Skip to content

Org access control: members, roles, and per-user feature/agent access - #286

Merged
vjvarada merged 2 commits into
mainfrom
claude/multi-tenant-user-management-1cb3ub
Jul 29, 2026
Merged

Org access control: members, roles, and per-user feature/agent access#286
vjvarada merged 2 commits into
mainfrom
claude/multi-tenant-user-management-1cb3ub

Conversation

@vjvarada

Copy link
Copy Markdown
Contributor

Phase 1 of specs/org_access_control.md — the buildable subset of the existing multi_user_organization_research.md proposal.

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:

priya@fracktal.in   roles: [member]
  features : chat, email, memory, tasks, notes, dashboard, artifacts   ← no whatsapp, no build.apps
  agents   : email-assistant, task-manager                             ← not whatsapp-assistant, not app-builder

GET /admin/members/{email}/access returns each decision with its provenance ("denied for this person, overriding role member"), 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

  • 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

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:

role:     agents:run:*                        baseline: every agent
override: agents:run:*                 deny   take the blanket away
override: agents:run:email-assistant   allow  hand two back

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 exact feature:chat could out-specify it.

Enforcement, in order of authority

Seam Where Status
require_permission() gateway route dependencies boundary of record
assert_can_run_agent() both /agent/run* endpoints enforced
GET /agent registry filter picker never offers a 403 enforced
AccessGate + nav filtering control plane presentation only

Agent-context intersection (an agent run can never exceed its caller) is implemented in EffectiveAccess.intersect and 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.role is retained and dual-written; require_role() is unchanged, so no existing route changes behaviour.
  • The migration backfills executiveadmin, employeemember, and bootstraps an owner — 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.
  • Invariants enforced server-side: the org always keeps an owner; nobody assigns a role above their own rank; system roles are immutable; you cannot revoke your own access-management permission.
  • Permissions are deliberately not in the NextAuth JWT — a JWT outlives an access change, and "I revoked WhatsApp an hour ago and they still have it" is what makes people distrust the whole model. Identity in the session, access resolved per request behind a 60s cache that every admin write invalidates.

⚠️ One fail-open to note: if the access tables are absent (migration not yet applied), the resolver degrades to the legacy executive/employee mapping rather than bricking sign-in. It does not apply to a member whose row exists — an unknown user gets nothing. This mirrors the contract deps.py already documents and should be removed when BO-2 lands; flagged in spec §7.

Verification

  • Migration applied twice against a real Postgres 16 — idempotent; backfill and owner bootstrap confirmed correct.
  • Resolver checked end-to-end against that database for owner / restricted member / unrestricted member / suspended / unknown.
  • 2584 unit tests pass (48 new covering matching, validation, both resolution layers, agent inheritance, and the guards).
  • Control plane builds and typechecks clean; new files lint clean. (The Sidebar.tsx static-components lint errors are pre-existing on main — 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_EMAILS copy. lib/gateway.ts is 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

claude added 2 commits July 29, 2026 04:49
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants