Skip to content

feat(portal): portal permissions, external users, access requests (#2229) - #2252

Merged
cjimti merged 1 commit into
mainfrom
feat/2229-portal-permissions
Oct 22, 2026
Merged

cjimti merged 1 commit into
mainfrom
feat/2229-portal-permissions

Conversation

@cjimti

@cjimti cjimti commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Adds per-persona portal permissions, external personas and accounts signed in by an emailed link, shares addressed to a persona, internal and shared feedback threads, and requests for access to an item a person cannot open. Five migrations (000212-000216), new routes under /api/v1/admin/external-users, /api/v1/admin/settings/external-accounts, /api/v1/admin/settings/access-requests, /api/v1/portal/access-requests, /api/v1/portal/share-personas and /portal/auth/email-link, and three gate tickets. Fixes 3 access bugs found in review:

  1. manage_asset get and provenance read an asset by id without a read check; any caller who knew an id could read it. Both now go through the same read check as the other read actions.
  2. The emailed /portal/view/<token> link of a share addressed to a persona refused the persona's members: shareaccess.Authorize matched only a recipient id or email. A share now carries RecipientPersona and the viewer's persona is matched against it.
  3. An agent never matched a share addressed to its persona: the asset toolkit built the share identity without the caller's persona. It now carries it (not for a managed-script run, whose identity is the author's).

Closes #2229
Closes #2225
Closes #2092
Closes #2089
Closes #2090
Closes #2227
Closes #2186
Closes #2248
Closes #2249
Closes #2232

Portal permissions per persona (#2225)

A persona's portal block sets one level per portal area: assets (shared, own), resources (none, shared, own, persona), knowledge and catalog (none, read, write), apis (none, browse), and the switches feedback, prompts, automations, search, memory, download. Set in file config, the persona_definitions.portal column (migration 000212) and the persona editor's new Portal tab.

  • internal/portal/portalperm maps every portal route to its area; the persona gate (internal/httpserver/accessgate) answers a route of an area the persona lacks with 403 Problem Details naming the area. Administrators have every area.
  • An unset area resolves to what the persona reached before: assets own, resources persona, apis browse, every switch on, knowledge write with apply_knowledge else read, catalog by apply_knowledge/fetch. Resolution is at load (persona.PortalAccess.Resolve), so existing personas behave exactly as before.
  • Resource levels narrow the resources API's claims (internal/httpserver/resourcelevel); shared reads the own folder, the persona folder and Global and uploads nothing.
  • Knowledge-page writes and the portal catalog routes follow the levels rather than the tools.
  • GET /api/v1/portal/me reports portal resolved; the navigation hides what a persona lacks and an address in a hidden area shows a notice (SectionGate).
  • Migration 000212 folds hide_automations into portal.automations and drops the column; a file persona's hide_automations: true still reads as automations: false. The admin persona API no longer accepts hide_automations (a hard cut; the feat(personas): choose per persona whether the portal shows Automations #2109 acceptance criteria now send portal.automations).
  • Download off removes the download actions and routes. It cannot stop a person saving content they are allowed to view.

External personas and accounts (#2092)

  • external: true on a persona makes it a persona for people outside the organization: no tools, viewer-only access to what is shared with them, no sharing, keys or directory.
  • An administrator creates an external account under such a persona (Admin > Users > External users: create, extend, suspend, resume, remove and revoke what is shared with them). The invitation and every later sign-in are an emailed one-time link; only its hash is stored (migration 000213, internal/extaccount).
  • Session 12 hours, invitation 7 days, sign-in link 15 minutes by default, adjustable at /api/v1/admin/settings/external-accounts and on the settings page. Links are rate limited per account per hour; the request route reports nothing about whether an address exists.
  • Every cookie session of an external account is checked per request: a suspended, expired or removed account, or a persona no longer external, is signed out.
  • A sign-in through the identity provider by the same address converts the account: it stops being external and the person keeps what was shared with them.

Shares addressed to a persona (#2089)

  • The share dialog offers People or Personas; a persona share reaches whoever is in the persona at the moment they open the item, through the portal, the emailed link and an agent's manage_asset (migration 000215). GET /api/v1/portal/share-personas lists the personas a share can name with their members.
  • Persona matching uses the caller's persona as resolved now (portaldomain.WithCallerPersona), so leaving the persona ends the access.

Internal and shared feedback threads (#2227)

A feedback thread is Internal or Shared (migration 000214). An internal thread is for the organization: a person of an external persona never sees it in a list, a count, the sign-off or by its address, and a mention or notification of it reaches nobody external. A shared thread is seen by everyone who can open the item, external people included. The composer opens on Internal; the item's owner, an editor or an administrator can change it later, and the change is recorded on the timeline. A thread an external person opens is always shared.

Requests for access (#2090)

  • Opening an asset, collection or prompt the person cannot open shows who owns it and a Request access panel instead of a bare not-found (/api/v1/portal/{assets,collections,prompts}/{id}/access-request(s)).
  • The owner is emailed and answers in the share dialog or under Access requests at the top of the Inbox (/api/v1/portal/access-requests, approve as viewer or editor, or decline). An approval creates a person share recorded with origin access_request; the requester is emailed the answer. A second open request from the same person notifies nobody.
  • Settings at /api/v1/admin/settings/access-requests: whether the request page names the item, and how many days a request stays open (migration 000216, internal/portal/accessreq, wired by internal/httpserver/accessreqwire).

Gates (#2186, #2248, #2249)

  • make preverify runs three gates only verify ran: make security-diff (verify's standalone gosec over packages with a changed production Go file), make route-template-diff (TestEveryRouteReportsItsTemplate_RealDB alone when the diff adds or removes a mux pattern) and make route-conformance-check (the MSW route conformance test), read from the diff by scripts/preverify-diff.py. Pinned by TestPreverifyRunsTheGatesOnlyVerifyRan.
  • A diff under dev/ makes make preverify run make dev-smoke: one call through the platform per service the dev stack runs, in under a minute. Pinned by TestPreverifySmokesAChangedDevStack.
  • The RealDB lane's server is started with REALDB_MAX_CONNECTIONS (100), and every run prints its peak connections and the packages holding the most (scripts/realdb-connections.py). The migration runner now returns its connection when released; before it, pkg/oauth/postgres alone held 268 connections and exhausted a 400 limit under verify's load.

Acceptance suite measurement (#2232)

One full serial make acceptance against the local two-replica stack took 6155 s; 1468 criteria passed. Five criteria in code this PR does not touch failed on that long-lived dev database and are #2251.

Acceptance

test/acceptance/issue_2089_test.go, issue_2090_test.go, issue_2092_test.go (including an identity-provider sign-in through Keycloak converting an account), issue_2225_test.go, issue_2227_test.go, and the updated issue_2109_test.go, each through the real portal, admin API, MCP client and mail delivery on the local stack. Transcripts under build/<n>/acceptance.md; make acceptance-check passes for all six.

Readers of this state

  • Persona JSON (portal, external, hide_automations removed): ui/src/pages/settings/PersonaEditor.tsx, persona/PortalTab.tsx, persona/BehaviorTab.tsx, persona/types.ts, PersonasPanel.tsx, ui/src/api/admin/types/tools.ts, ui/src/mocks/data/personas.ts.
  • /api/v1/portal/me portal: ui/src/hooks/usePortalAccess.ts, ui/src/lib/portalAccess.ts, Sidebar.tsx, AppShell.tsx, SectionGate.tsx, AssetViewer.tsx, AssetViewerActions.tsx, ContentRenderer.tsx, KnowledgeHub.tsx, KnowledgePagesPage.tsx, pages/resources/scopes.ts, stores/auth.ts.
  • Share JSON (recipient persona): ShareDialog.tsx, share/ShareSections.tsx, share/ActiveShares.tsx, lib/sharedLine.ts, the asset and collection grids and tables, PromptViewerHeader.tsx.
  • Thread visibility: api/portal/feedback.ts, hooks/threads.ts, NewThreadForm.tsx, ThreadBadges.tsx, ThreadList.tsx, ThreadHeader.tsx, FeedbackButton.tsx.
  • External users and their settings: UsersPanel.tsx (External users section), ExternalAccountsSettingsCard.tsx, api/admin/hooks/externalUsers.ts, LoginForm.tsx (email link).
  • Access requests and their settings: components/access/RequestAccessPanel.tsx, AccessRequestsList.tsx, api/portal/hooks/accessRequests.ts, AccessRequestsSettingsCard.tsx, api/admin/hooks/accessRequests.ts, the asset, collection and prompt viewer pages, FeedbackPage.tsx.

New internal imports

test/structure/testdata/allowed_internal_imports.txt gains 46 edges, all from the new packages (internal/extaccount, internal/admin/extaccountapi, internal/admin/personarules, internal/portal/accessreq, internal/portal/portalperm, internal/httpserver/{accessreqwire,extaccountwire,personashare,resourcelevel}) or from existing packages to the persona and permission checks they now call, plus internal/httpserver/accessreqwire -> pkg/prompt for the prompt an access request names.

Docs

docs/personas/overview.md, docs/portal/admin-access.md, docs/portal/admin-settings.md, docs/portal/assets.md, docs/portal/feedback.md, docs/server/configuration.md, README.md, CLAUDE.md, docs/llms.txt, docs/llms-full.txt; swagger regenerated with the "Access requests" and "External users" tags grouped.

)

Closes #2229, #2225, #2092, #2089, #2090, #2227, #2186, #2248, #2249, #2232.

A persona's portal block sets one level per portal area (#2225), enforced on every route of the area and reported on /api/v1/portal/me; hide_automations is folded into portal.automations by migration 000212. External personas and accounts signed in by an emailed one-time link, with session, invite and link lifetimes in admin settings, converted by an identity-provider sign-in (#2092). Shares addressed to a persona follow its membership (#2089). Requests for access to an item a person cannot open, answered by its owner, with emails and settings (#2090).

Three access bugs fixed: manage_asset get and provenance had no read check; a persona share's emailed view link refused the persona's members; an agent never matched a share addressed to its persona.

The #2109 acceptance criteria now send portal.automations.
@codecov

codecov Bot commented Oct 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.81641% with 72 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.84%. Comparing base (e475648) to head (e032684).

Files with missing lines Patch % Lines
internal/extaccount/service.go 95.45% 6 Missing ⚠️
internal/httpserver/accessreqwire/accessreqwire.go 92.77% 5 Missing and 1 partial ⚠️
internal/notification/notifyrender/render.go 80.00% 6 Missing ⚠️
internal/portal/accessreq/accessreq.go 93.25% 6 Missing ⚠️
internal/admin/extaccountapi/extaccountapi.go 94.93% 4 Missing ⚠️
internal/httpserver/extaccountwire/platform.go 84.61% 4 Missing ⚠️
pkg/portal/persona_share.go 90.24% 4 Missing ⚠️
internal/extaccount/store.go 97.05% 3 Missing ⚠️
internal/httpserver/mounts.go 50.00% 3 Missing ⚠️
internal/portal/portaldomain/callerpersona.go 0.00% 3 Missing ⚠️
... and 19 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2252      +/-   ##
==========================================
- Coverage   94.84%   94.84%   -0.01%     
==========================================
  Files        1381     1398      +17     
  Lines      112010   113176    +1166     
==========================================
+ Hits       106237   107337    +1100     
- Misses       5756     5821      +65     
- Partials       17       18       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cjimti
cjimti merged commit b1def68 into main Oct 22, 2026
12 checks passed
@cjimti
cjimti deleted the feat/2229-portal-permissions branch October 22, 2026 06:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment