Org access control: members, roles, and per-user feature/agent access - #286
Merged
Conversation
…cess Phase 1 of ai-company-brain/specs/org_access_control.md — the buildable subset of the multi_user_organization_research.md proposal. Nothing in that research is enforceable until there is a principal with a resolvable permission set; everything else (modules, memory scoping, credential scoping, entity-graph RLS) is a consumer of that check. This is that check. What changes for an admin: they can invite people, give them a role, and then add or remove individual pieces on top of it — "member, but no WhatsApp and no app creator, and only these two agents" — without SQL and without cloning a role per person. Model (spec §3): - One organization; organization_id is carried everywhere so a second org is a data change, not a migration. - Five seeded system roles (owner/admin/manager/member/guest) plus a non-assignable agent_service. `member` deliberately omits WhatsApp, Approvals, Integrations, Models and both Build panes: access is added, not taken away. - Per-user allow/deny overrides layered on top, each recording its reason. Resolution is two layers, not flat deny-wins. Roles are the baseline; overrides are exceptions and only compete with each other, most specific winning and ties going to deny. Flat deny-wins cannot express "deny the blanket agents:run:*, allow two by name" — the specific allow could never surface — which is exactly the case this feature exists for. Role grants stay out of that comparison so an override of `feature:*` = deny switches everything off rather than leaving holes where a role happens to name a feature exactly. Enforcement, in order of authority: - require_permission() on gateway routes — the boundary of record. - assert_can_run_agent() on both run endpoints; GET /agent filters the registry so the picker never offers a choice that would 403. - AccessGate + nav filtering in the UI — presentation, not security. Back-compat is the reason this is safe to deploy: - app_user.role is retained and dual-written; require_role() is unchanged, so no existing route changes behaviour. - The migration backfills executive→admin, employee→member and bootstraps an owner, so nobody is locked out on deploy. - get_current_user upgrades the coarse role from the DB but never downgrades it, so an EXECUTIVE_EMAILS admin who has never signed in keeps working. - A missing access table degrades to the legacy mapping rather than bricking sign-in. That fallback should be removed with BO-2. Verified: migration applied twice against a real Postgres 16 (idempotent, backfill and owner bootstrap correct); resolver checked end-to-end against that database for owner / restricted member / unrestricted member / suspended / unknown; 2584 unit tests pass; control plane builds and typechecks clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W9KkffhP2SqKC6NVphrZTG
The header still described flat deny-wins, which the specificity rule replaced — and it sat directly above the control that implements it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W9KkffhP2SqKC6NVphrZTG
This was referenced Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 1 of
specs/org_access_control.md— the buildable subset of the existingmulti_user_organization_research.mdproposal.Why this slice
The research doc scopes 22–32 weeks across identity, memory, credentials, the entity graph, and the Action Broker. None of it is enforceable until there is a principal with a resolvable permission set — modules, memory scoping and credential scoping are all consumers of that one check. Building them first means building three private half-implementations of it. This PR is that check, plus the admin surface to drive it.
The starting state was thinner than the research doc implies: roles were an env var (
EXECUTIVE_EMAILS) parsed in ~50 copy-pasted proxy files, two roles total, and zero feature or agent gating — every signed-in user saw every pane and could run every agent.What an admin can now do
Invite someone, give them a role, then add or remove individual pieces on top of it — without SQL and without cloning a role per person:
GET /admin/members/{email}/accessreturns each decision with its provenance ("denied for this person, overriding rolemember"), and the UI renders that verbatim. An admin who has to replay the resolution algorithm in their head to answer "why can Priya still see WhatsApp?" stops trusting the model.The model
organization_idis carried everywhere so a second org is a data change, not a migration.owner/admin/manager/member/guest) plus a non-assignableagent_service.memberdeliberately omits WhatsApp, Approvals, Integrations, Models and both Build panes — access is added, not taken away.reason.Resolution is two layers, not flat deny-wins
This is the one design call worth reviewing. I first implemented the AWS IAM rule the research doc recommends — explicit deny always wins — and a test caught that it cannot express the requirement:
Under flat deny-wins that resolves to no agents — the wildcard deny swallows the specific allow, so an admin would have to clone a role per person, the exact failure roles exist to prevent. So overrides compete by specificity (exact beats wildcard; ties go to deny).
Role grants stay out of that comparison on purpose: an override of
feature:*= deny must switch everything off, and it wouldn't if a role's exactfeature:chatcould out-specify it.Enforcement, in order of authority
require_permission()assert_can_run_agent()/agent/run*endpointsGET /agentregistry filterAccessGate+ nav filteringAgent-context intersection (an agent run can never exceed its caller) is implemented in
EffectiveAccess.intersectand unit-tested; its full form — per-user credential and memory scoping — is research-doc §7–§8 and stays queued.Why this is safe to deploy
app_user.roleis retained and dual-written;require_role()is unchanged, so no existing route changes behaviour.executive→admin,employee→member, and bootstraps an owner — nobody is locked out on deploy.get_current_userupgrades the coarse role from the DB but never downgrades it, so anEXECUTIVE_EMAILSadmin who has never signed in keeps working.executive/employeemapping rather than bricking sign-in. It does not apply to a member whose row exists — an unknown user gets nothing. This mirrors the contractdeps.pyalready documents and should be removed when BO-2 lands; flagged in spec §7.Verification
Sidebar.tsxstatic-componentslint errors are pre-existing onmain— verified against the baseline.)Deliberately not in this PR
Modules/teams, memory scoping, per-user credential scoping, entity-graph visibility + RLS, and everything SaaS — all still tracked in the research doc and phased in spec §8. Also: ~50 proxy routes still carry their own
EXECUTIVE_EMAILScopy.lib/gateway.tsis the single replacement and new routes use it, but the sweep is cosmetic now (permissions resolve server-side from the email regardless), so it is left as follow-up.Generated by Claude Code