From a6ef80371ed893cf93944c33dbc021edf0556caa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 04:49:00 +0000 Subject: [PATCH 1/2] Add org access control: members, roles, and per-user feature/agent access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01W9KkffhP2SqKC6NVphrZTG --- ai-company-brain/AGENTS.md | 6 +- ai-company-brain/specs/org_access_control.md | 279 ++++++++ apps/services/gateway/AGENTS.md | 10 +- apps/services/gateway/gateway/main.py | 13 + .../gateway/gateway/routes/admin/__init__.py | 17 + .../gateway/gateway/routes/admin/_common.py | 222 +++++++ .../gateway/gateway/routes/admin/me.py | 88 +++ .../gateway/gateway/routes/admin/members.py | 614 ++++++++++++++++++ .../gateway/gateway/routes/admin/roles.py | 359 ++++++++++ apps/services/gateway/gateway/routes/agent.py | 20 +- infra/AGENTS.md | 3 +- infra/postgres/128_org_access_control.sql | 307 +++++++++ packages/AGENTS.md | 2 +- packages/acb_auth/acb_auth/__init__.py | 58 +- packages/acb_auth/acb_auth/access.py | 263 ++++++++ packages/acb_auth/acb_auth/deps.py | 161 ++++- packages/acb_auth/acb_auth/permissions.py | 371 +++++++++++ packages/acb_auth/acb_auth/roles.py | 85 ++- packages/acb_auth/pyproject.toml | 3 + tests/unit/test_org_access_control.py | 359 ++++++++++ uv.lock | 2 + workbench/AGENTS.md | 10 +- .../src/app/api/admin/[...path]/route.ts | 44 ++ .../src/app/api/auth/me/route.ts | 30 + .../src/app/settings/members/[email]/page.tsx | 528 +++++++++++++++ .../src/app/settings/members/page.tsx | 410 ++++++++++++ .../src/app/settings/members/types.ts | 93 +++ .../src/app/settings/roles/page.tsx | 374 +++++++++++ .../src/components/AccessGate.tsx | 59 ++ .../src/components/AccessProvider.tsx | 84 +++ .../control_plane/src/components/AppShell.tsx | 19 +- .../src/components/Providers.tsx | 7 +- .../control_plane/src/components/Sidebar.tsx | 10 +- workbench/control_plane/src/lib/access.ts | 132 ++++ workbench/control_plane/src/lib/gateway.ts | 82 +++ workbench/control_plane/src/lib/nav.ts | 39 ++ 36 files changed, 5118 insertions(+), 45 deletions(-) create mode 100644 ai-company-brain/specs/org_access_control.md create mode 100644 apps/services/gateway/gateway/routes/admin/__init__.py create mode 100644 apps/services/gateway/gateway/routes/admin/_common.py create mode 100644 apps/services/gateway/gateway/routes/admin/me.py create mode 100644 apps/services/gateway/gateway/routes/admin/members.py create mode 100644 apps/services/gateway/gateway/routes/admin/roles.py create mode 100644 infra/postgres/128_org_access_control.sql create mode 100644 packages/acb_auth/acb_auth/access.py create mode 100644 packages/acb_auth/acb_auth/permissions.py create mode 100644 tests/unit/test_org_access_control.py create mode 100644 workbench/control_plane/src/app/api/admin/[...path]/route.ts create mode 100644 workbench/control_plane/src/app/api/auth/me/route.ts create mode 100644 workbench/control_plane/src/app/settings/members/[email]/page.tsx create mode 100644 workbench/control_plane/src/app/settings/members/page.tsx create mode 100644 workbench/control_plane/src/app/settings/members/types.ts create mode 100644 workbench/control_plane/src/app/settings/roles/page.tsx create mode 100644 workbench/control_plane/src/components/AccessGate.tsx create mode 100644 workbench/control_plane/src/components/AccessProvider.tsx create mode 100644 workbench/control_plane/src/lib/access.ts create mode 100644 workbench/control_plane/src/lib/gateway.ts diff --git a/ai-company-brain/AGENTS.md b/ai-company-brain/AGENTS.md index ec6dc5b8..2cf45bee 100644 --- a/ai-company-brain/AGENTS.md +++ b/ai-company-brain/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md — Planning Folder Navigation Guide > **For AI agents:** Read this file first. It tells you what this project is, what has been built, and which file to read for each concern. -> **Organisation:** Fracktal Works · **Project:** CommandCenter · **Last updated:** 2026-07-07 +> **Organisation:** Fracktal Works · **Project:** CommandCenter · **Last updated:** 2026-07-29 --- @@ -56,6 +56,7 @@ Operators interact via a thin **Control Plane** (Next.js browser UI) with chat Q | **Email app — multi-account client (Gmail/Outlook/IMAP) + AI assistant** | 🔄 In progress | M2.9 — `workbench/control_plane/src/app/email/`, gateway `routes/email/` (layered pkg), `apps/services/email_ingestion/`. Consolidated plan + roadmap: [`specs/email_app_master_plan.md`](specs/email_app_master_plan.md) | | **App Workshop / Custom Apps — chat-built small software deployed in-platform** | 🔄 Phase 0–3b + T2 built | RFC + mockups: `docs/app-workshop/`. Gateway `routes/apps/` (lifecycle/files/publish+conformance-scan/runtime incl. budgeted `ai/complete`, sharing+consent, manifest `actions`), migration `114_custom_apps.sql`, `agent-app-builder` + executor `allow_session_workspace` binding, workbench `/build/apps` (gallery · Workshop · run page · `window.cc` bridge), granted apps registered as orchestrator agent tools (`orchestrator/app_tools.py`), live per-app token tracking in `/observability`. **T2 (real React apps)** built: `agent-app-builder/build/build_t2.mjs` (esbuild against a shared, deploy-provisioned react/react-dom/esbuild vendor cache — zero per-app `npm install`), manifest `entry`/`tier` already-existing dynamic resolution now covers `dist/bundle.html`. **T3 (server-side backend compute) scoped, not built** — gated behind BO-7 (platform-wide sandbox hardening, still ✖/absent); RFC's already-decided T3 substrate is Deno subprocesses, not containers. Platform contract is binding (§4.0): CommandCenter is the only backbone | | **Task Manager app — GTD-philosophy client + `task-manager` agent (PM-agnostic: any tool via API or MCP)** | 🔄 capture/clarify live end-to-end | Frontend slices 1–2.5 + the capture/clarify **backend**: migration `48_task_manager_gtd.sql`, gateway `routes/tasks/` (20 endpoints), provider interface layer + **ClickUp connector** (multi-workspace `task_accounts`, encrypted tokens), `apps/skill-task-gtd/` + rewritten `apps/agent-task-manager/`, frontend wired live with mock fallback; **org-knowledge layer live** (`gtd_people` from agent-project-manager HR data → capability-aware delegation, §6.1). Resume: Slice 3 (Engage) · `/tasks/sync` pull. See [`specs/task_manager_app.md`](specs/task_manager_app.md) §9.1 | +| **Org access control — multi-user members, roles, per-user access** | 🔄 Phase 1 built | `infra/postgres/128_org_access_control.sql`, `packages/acb_auth/` (permissions.py + access.py + `require_permission`), gateway `routes/admin/` + `/auth/me`, workbench `/settings/members` + `/settings/roles` + nav/route gating. Spec: [`specs/org_access_control.md`](specs/org_access_control.md). Deliberately NOT yet done: modules/teams, memory + credential scoping, entity-graph RLS | | `agent-sales` + `skill-zoho-ingest` | 🔲 Phase 2 | Phase 2 (WBS 2.2) | | `agent-triage` + `skill-gmail-capture` | 🔲 Phase 2 | Phase 2 (WBS 2.3) | | Meeting bot (Vexa + WhisperX) | 🔲 Phase 3 | Phase 3 (WBS 3.1) | @@ -111,7 +112,8 @@ to it and to `competitive_hardening_2026-07.md` (CH-*) rather than re-describe t | [`task_manager_harness_2026-07.md`](specs/task_manager_harness_2026-07.md) | Task-manager × harness engineering (app-layer sibling) | 🔄 Tier 1 shipped (2026-07-03); Tier 2 planned | | [`llm_caching_memory.md`](specs/llm_caching_memory.md) | Prompt caching (ADR-008) + session memory | 🔄 caching **shipped & wired**; session-memory shipped but **inert by default** (→ BO-21); Phase 7 open | | [`mcp_plugin_integration.md`](specs/mcp_plugin_integration.md) | MCP servers vs Claude plugins vs REST | 🔄 MCP half **built** (`_inject_mcp_servers`); plugin store not started | -| [`multi_user_organization_research.md`](specs/multi_user_organization_research.md) | Multi-user / org account research — identity, roles, tenancy | 🔲 research done, not implemented | +| [`org_access_control.md`](specs/org_access_control.md) | **Org access control (implementation)** — members, roles, per-user allow/deny overrides, feature + agent gating, five enforcement seams. The buildable subset of the research doc below | 🔄 Phase 1 shipped (schema 128, `acb_auth` permission engine, `/admin` + `/auth/me`, nav/route/agent-run gating, Members + Roles UI) | +| [`multi_user_organization_research.md`](specs/multi_user_organization_research.md) | Multi-user / org account research — identity, roles, tenancy, memory + credential scoping, SaaS multi-tenancy | 🔄 §4 identity/roles now implemented via `org_access_control.md`; §5 modules, §7 memory scoping, §8 credential scoping, §9 entity-graph RLS and §17 SaaS remain research | | [`chat_agent_framework_review_2026-07.md`](specs/chat_agent_framework_review_2026-07.md) | **Chat + agent framework review** — dual-runtime verdict (MAF framework, Copilot as coding engine), orchestration/memory/artifact/HITL/co-authoring gaps, prioritized plan | 🟢 review complete | | [`single_agent_chat_bug_audit_2026-07.md`](specs/single_agent_chat_bug_audit_2026-07.md) | **Single-agent chat bug audit** — past issues verified fixed; 7 confirmed live bugs (Tier-1 loop-trip crash, Copilot retry duplication, unbounded resumed-session context, relay truncation/ack holes) + fix plan | 🟢 audit complete | | [`generative_ui_2.md`](specs/generative_ui_2.md) | **Generative UI 2.0** — immersive HITL UI: surface(panel)/hitl(blocking) on emit_generative_ui, 11-template library (recipe/flight/train/form/optionPicker…), side-panel genUI tabs, scenario→element map | 🔄 Phase 1 shipped | diff --git a/ai-company-brain/specs/org_access_control.md b/ai-company-brain/specs/org_access_control.md new file mode 100644 index 00000000..1ef7b829 --- /dev/null +++ b/ai-company-brain/specs/org_access_control.md @@ -0,0 +1,279 @@ +# Organization Access Control — implementation spec + +> **Status:** 🔄 Phase 1 shipping. +> **Created:** 2026-07-29 +> **Scope:** Turning the single-tenant deployment into a real multi-user organization: named members, roles, per-user feature/agent access, and one enforcement path the whole platform shares. +> **Parent research:** [`multi_user_organization_research.md`](multi_user_organization_research.md) — the *why* and the long-horizon (SaaS, memory scoping, entity-graph RLS) design. This document is the *what we are building now*, and it deliberately implements a subset. +> **Companion:** [`permissions_sandbox_b6.md`](permissions_sandbox_b6.md) (tool-level risk gating — orthogonal: that answers "may this *tool call* proceed", this answers "may this *person* reach this feature at all"). + +--- + +## 1. What this spec covers + +The research doc scopes a 22–32 week programme across identity, memory, credentials, the entity graph, and the Action Broker. That is the right destination, but nothing in it is enforceable until there is a **principal with a resolvable set of permissions**. Everything else hangs off that. + +So Phase 1 is deliberately narrow and load-bearing: + +**In scope** +- One organization row; every member belongs to it. Schema carries `organization_id` so a second org is a data change, not a migration. +- A member lifecycle: invited → active → suspended → removed. +- Roles as named permission bundles, seeded with five system roles, extendable with custom roles. +- **Per-user grants and denials on top of roles** — this is the specific ask: "some users don't get WhatsApp, don't get the app creator, but do get certain agents." +- One resolution algorithm (deny-wins) in one package, used by the gateway, the Next.js proxy layer, and the UI. +- Admin screens to do all of the above without SQL. + +**Explicitly out of scope for Phase 1** (tracked in the research doc, not regressed by anything here) +- Memory scoping (`personal` / `team` / `organization`) — §7 of the research doc. +- Per-user integration credential scoping — §8. +- Entity-graph row visibility + Postgres RLS — §9, §16.5. +- Modules/teams as a first-class container — §5. Phase 1 uses flat org membership; the `module` concept is what Phase 2 adds when "the sales team" needs to mean something. +- Everything SaaS (§17): subdomains, billing, tenant isolation of agent runtimes. + +**Why this order.** Modules, memory scoping, and credential scoping are all *consumers* of a resolved principal. Building them first means building three private half-implementations of the same permission check. Phase 1 is that check. + +--- + +## 2. Current state (verified against code, 2026-07-29) + +| Concern | Today | File | +|---|---|---| +| Sign-in | NextAuth v5 + Microsoft Entra ID, tenant-gated. Session carries `email` and `name` only. | `workbench/control_plane/src/auth.ts` | +| Role assignment | **An env var.** `EXECUTIVE_EMAILS` is parsed into a `Set` and compared against the session email. | ~50 copies of `buildGatewayHeaders()` across `src/app/api/**/route.ts` | +| Role transport | `X-User-Email` + `X-User-Role` headers alongside the internal bearer token. | `packages/acb_auth/acb_auth/deps.py` | +| Roles | `executive` \| `employee` \| `agent` (StrEnum, no DB backing). | `packages/acb_auth/acb_auth/roles.py` | +| Enforcement | `require_role(UserRole.EXECUTIVE)` — a binary gate. | `deps.py:189` | +| User table | `app_user(id, email, display_name, avatar_url, role, last_login_at, …)`. No org, no status, no invite trail. | `infra/postgres/09_app_user.sql` | +| Feature visibility | **None.** `NAV_SECTIONS` is a static array; every signed-in user sees every pane. | `workbench/control_plane/src/lib/nav.ts` | +| Agent visibility | **None.** Any registered agent is listable and runnable by anyone who can reach the gateway. | `agent_registry.json`, gateway `_AGENT_REGISTRY` | +| Nearest working precedent | Custom Apps: `apps.visibility ∈ (private, people, org)` + `app_grants(app_id, subject, role)` where subject is an email / `agent:` / `agents:*`. | `infra/postgres/114_custom_apps.sql` | + +Two observations drive the design: + +1. **The app-grants model already works and users already understand it.** Phase 1 generalises its shape (a subject, a resource, a role) rather than inventing a second vocabulary. `app_grants` stays as-is; it is the per-app rung below org-level access. +2. **`EXECUTIVE_EMAILS` duplicated across ~50 route files is the actual blocker.** Adding a role today means editing an env var and redeploying, and any route that forgets the helper silently downgrades the caller to `employee`. Phase 1 collapses this to one DB-backed helper. + +--- + +## 3. Model + +### 3.1 Principals + +A **principal** is whoever a request acts as. Three kinds: + +| Kind | Identified by | Source | +|---|---|---| +| Member | `app_user.id`, addressed by email | Entra ID sign-in | +| Service | the internal bearer token | `GATEWAY_INTERNAL_TOKEN` | +| Agent | `agent:` | agent runtime, acting **on behalf of** a member | + +**Email stays the wire identity.** `app_user.id` is the FK target, but every existing table that scopes by user does so by email (`app_tool_grants.user_email`, `app_grants.subject`, `app_audit.user_email`, `apps.owner_email`). Re-keying them all to UUID is a large, risky, and — for Phase 1 — pointless migration. `UserContext` carries both. + +**An agent never gains authority by existing.** An agent run inherits the invoking member's resolved permission set, intersected with the agent's own declared scope. It cannot exceed its caller. This is the same rule the research doc states in §11.1 (sub-agent inheritance) and it is why the permission set — not just the role name — has to travel with the request. + +### 3.2 Roles + +A role is a named bundle of permissions, scoped to an organization. Five are seeded as `is_system` (not deletable, not editable) and admins can create more. + +| Role | Intent | Grants | +|---|---|---| +| `owner` | The person who owns the deployment. | `*` | +| `admin` | Runs the platform day to day. | `admin:*`, `feature:*`, `agents:*`, `apps:*`, `integrations:*`, `data:org:read` | +| `manager` | Sees org-wide data, cannot change platform config. | all `feature:*` except `build.*`, `agents:run:*`, `apps:use:*`, `data:org:read`, `admin:members:read` | +| `member` | Default for a new employee. | `feature:` chat, email, tasks, notes, memory, artifacts, dashboard; `agents:run:*`; `apps:use:*` | +| `guest` | Contractor / external collaborator. | `feature:chat`, `apps:use:*` | + +Plus one non-assignable service role, `agent_service`, for the internal bearer path. + +A member may hold several roles; grants are the **union**. Note what `member` deliberately omits: WhatsApp, Approvals, Integrations, Models, and both Build panes. The default is the conservative one, and access is added rather than taken away. + +### 3.3 Permission vocabulary + +Colon-separated, with `*` legal **only as the final segment**, where it matches any non-empty suffix. + +```text +# Feature / module access — drives nav, route guard, and API gating +feature:chat feature:email feature:whatsapp +feature:tasks feature:notes feature:memory +feature:dashboard feature:observability feature:artifacts +feature:agents feature:approvals feature:integrations +feature:models feature:build.agents feature:build.apps + +# Agents +agents:run:* # run any agent +agents:run: # run one named agent +agents:manage # register / update / remove + +# Custom Apps (org level; app_grants remains the per-app rung) +apps:use:* apps:create apps:publish + +# Administration +admin:members:read admin:members:invite admin:members:manage +admin:roles:manage admin:access:manage admin:settings:manage +admin:audit:read + +# Data + platform +data:org:read integrations:manage +``` + +`feature:*` matches `feature:whatsapp`; `agents:run:*` matches `agents:run:agent-sales`; a bare `*` matches everything. `*` in a non-final position is rejected at write time — wildcards that match *inward* (`feature:*:read`) make the grant set impossible to reason about, and nothing needs them. + +The two examples from the original ask land like this: + +- *"no WhatsApp"* → deny `feature:whatsapp`. +- *"no app creator"* → deny `feature:build.apps`. +- *"but these agents"* → the role's `agents:run:*` is denied, and `agents:run:agent-email-assistant` is allowed per-user. + +### 3.4 Overrides and the resolution rule + +Per-user overrides sit in `user_permission_override(user_id, permission, effect)` where effect is `allow` or `deny`. + +Resolution is **two layers**. Roles are the baseline; overrides are exceptions layered on top, and only overrides compete with one another: + +``` +has(user, required): + if membership is not active → False + + # Layer 1 — overrides. Most specific wins; a tie goes to deny. + A := most specific override pattern matching required with effect=allow + D := most specific override pattern matching required with effect=deny + if A or D: + return False if D and (no A or specificity(D) >= specificity(A)) else True + + # Layer 2 — the role baseline. + if any role-granted pattern matches required → True + otherwise → False # default deny +``` + +Specificity: an exact match outranks every wildcard; among wildcards the longer literal prefix wins; bare `*` is the floor. + +**Why specificity rather than flat deny-wins.** The product's actual request — "only these two agents" — is expressed as a blanket deny plus named allows: + +``` +role: agents:run:* the baseline: every agent +override: agents:run:* deny take the blanket away +override: agents:run:email-assistant allow hand two back +``` + +Under AWS IAM's flat "deny always wins" (research doc §3.3), the specific allow could never surface, and an admin would have to clone a role per person — the failure mode roles exist to prevent. + +**Why roles stay out of that comparison.** An override of `feature:*` = deny must switch *everything* off. If a role's exact `feature:chat` could out-specify it, "revoke this person's access" would silently leave holes. An admin's explicit exception always outranks the role; specificity only orders exceptions against each other. + +A suspended or removed membership resolves to the empty set regardless of roles held. + +Every override row records `reason`, `set_by`, `set_at`. A permission model nobody can explain six months later gets switched off, so the *why* is a column, not tribal knowledge. + +--- + +## 4. Schema + +`infra/postgres/128_org_access_control.sql`, idempotent per `infra/postgres/README.md`. + +```sql +organization(id, slug UNIQUE, display_name, domain, settings JSONB, created_at, updated_at) + +-- app_user gains, without dropping the legacy `role` column (back-compat, §7): +app_user + organization_id, status ∈ (invited|active|suspended|removed), + invited_by, invited_at, joined_at, last_active_at + +org_role(id, organization_id, slug, display_name, description, + is_system BOOL, rank INT, created_at, updated_at, + UNIQUE(organization_id, slug)) + +org_role_permission(role_id, permission, granted_at, PRIMARY KEY(role_id, permission)) + +user_role(user_id, role_id, assigned_by, assigned_at, PRIMARY KEY(user_id, role_id)) + +user_permission_override(user_id, permission, effect ∈ (allow|deny), + reason, set_by, set_at, PRIMARY KEY(user_id, permission)) + +feature_catalog(slug PK, label, description, nav_href, category, sort_order, is_default) +``` + +`feature_catalog` exists so the admin UI can render a checklist of real features without importing `nav.ts` into the gateway, and so a new pane is one seeded row rather than a code change in three places. + +The migration seeds the default organization from `ALLOWED_EMAIL_DOMAIN`, seeds the five system roles with their permission sets, backfills every existing `app_user` into the org as `active`, and maps the legacy column: `role='executive'` → `admin`, `role='employee'` → `member`. Existing users keep working; nobody is locked out by deploying this. + +**Ownership bootstrap:** if no member holds `owner` after backfill, the first address in `EXECUTIVE_EMAILS` (else the oldest `app_user`) is promoted. A deployment with no owner is one where nobody can grant themselves access back. + +--- + +## 5. Enforcement + +Five seams. The first three are security, the last two are UX — a hidden nav item is not a permission check. + +| # | Seam | Where | Enforces | +|---|---|---|---| +| 1 | `require_permission(...)` | gateway route dependencies | API access — **the boundary of record** | +| 2 | Agent-run gate | `/agent/run/stream` + `agents:run:` | which agents a member may invoke | +| 3 | Agent context assembly | orchestrator | an agent run cannot exceed its caller's set | +| 4 | Route guard | Next.js middleware / layout | direct URL navigation | +| 5 | Nav filter | `NAV_SECTIONS` filtered by effective access | what the sidebar shows | + +Phase 1 ships 1, 2, 4, 5 and the plumbing for 3. Seam 3's full form (per-user credential and memory scoping) is research-doc §7–§8 and stays queued. + +`require_role()` is **kept and reimplemented** on top of the permission engine — `UserRole.EXECUTIVE` resolves as `admin:*`-equivalent — so no existing route changes behaviour on deploy. New routes use `require_permission()`. + +### Resolution and caching + +The gateway resolves `(roles, permissions)` from Postgres on first use per email and caches for 60s (in-process, invalidated on any admin write). Permissions are **not** put in the NextAuth JWT: a JWT outlives an access change, and "I revoked WhatsApp an hour ago and they still have it" is exactly the failure that makes people distrust the whole system. The session carries identity; access is resolved server-side per request. + +--- + +## 6. API + UI + +**Gateway** (`apps/services/gateway/gateway/routes/admin/`): + +``` +GET /auth/me effective access for the caller (no admin perm needed) +GET /admin/members list + roles + status +POST /admin/members invite +PATCH /admin/members/{email} status, display name +DELETE /admin/members/{email} remove (soft) +PUT /admin/members/{email}/roles set role assignment +GET /admin/members/{email}/access resolved set + overrides + provenance +PUT /admin/members/{email}/overrides set allow/deny overrides +GET /admin/roles list with permissions +POST /admin/roles create custom role +PATCH /admin/roles/{slug} edit (system roles: 403) +DELETE /admin/roles/{slug} delete custom role +GET /admin/features feature catalog +``` + +**Control Plane** — `/settings/members` (roster, invite, status, role), `/settings/members/[email]` (per-user access editor: feature toggles, agent list, effective-permission preview showing *why* each is on or off), `/settings/roles` (role editor). + +The per-user editor shows the resolved outcome and its provenance — "WhatsApp: **off** — denied for this user, overriding role `member`". Admins should never have to simulate the resolution algorithm in their heads. + +--- + +## 7. Back-compat and rollout + +The change is additive and reversible: + +1. `app_user.role` is retained and dual-written. Rollback is redeploying the previous build. +2. `require_role()` semantics are preserved exactly. +3. `EXECUTIVE_EMAILS` continues to work as the bootstrap path but stops being the source of truth once roles are assigned. +4. If the access tables are missing or unreachable, the resolver falls back to the legacy `executive`/`employee` mapping and logs a warning — a migration that has not run yet must not brick sign-in. + +**Fail-open is a deliberate, bounded choice here** and worth stating plainly: it applies only to the *resolver's* fallback when the tables are absent, i.e. before the migration lands. It does not apply to a member whose row exists — an unknown user resolves to the empty set and gets nothing. This matches the fail-open contract `deps.py` already documents for an unprovisioned gateway, and it is the same trade-off flagged in `FOUNDATION_BUILDOUT_CHECKLIST.md` BO-2 (enforce auth: never-reject → require). When BO-2 lands, this fallback should be removed with it. + +--- + +## 8. Phases + +| Phase | Content | State | +|---|---|---| +| **1** | Schema, permission engine, admin API, member/role/access UI, nav + route gating, agent-run gate | 🔄 this spec | +| **2** | Modules/teams (research §5) — team-scoped visibility; shared mailboxes; `email_account_member` | 🔲 | +| **3** | Memory + credential scoping (research §7–§8) — the real seam-3 work | 🔲 | +| **4** | Entity-graph visibility + RLS safety net (research §9, §16.5) | 🔲 | +| **5** | Consent records, access reviews, audit completeness (research §11.3) | 🔲 | + +--- + +## 9. Open questions + +1. **Agent visibility vs. runnability.** Phase 1 gates *running* an agent. Should a member also be unable to *see* that an agent exists? Listing is currently a weaker signal than running, but agent names leak org structure. +2. **Approval routing.** When a member lacking `admin:*` triggers an action needing approval, who is asked? Phase 1 routes to anyone with `feature:approvals`; per-module approvers is a Phase 2 question. +3. **Departure.** A removed member's private apps, chat sessions, and agent workspaces currently persist unowned. Transfer-on-removal is unbuilt (research §13 Q4). +4. **Guest scope.** Does `guest` ever need email or tasks, or is chat-plus-shared-apps the whole product surface for externals? +5. **Custom role ceiling.** Clerk caps at 10. Unbounded custom roles tend to produce one role per person, which is what overrides are for. diff --git a/apps/services/gateway/AGENTS.md b/apps/services/gateway/AGENTS.md index 89e92cdd..a3c8ce89 100644 --- a/apps/services/gateway/AGENTS.md +++ b/apps/services/gateway/AGENTS.md @@ -14,7 +14,7 @@ webhook receivers, OAuth callbacks, and the Control Plane API. ## Local Contracts 1. main.py -- FastAPI app factory, lifespan (key loading, model cache warmup, aiosmtpd inbound SMTP startup, background email sync scheduler), /copilot/chat AG-UI endpoint (relayed through stream_relay.run_detached when thread_id present) -2. routes/agent.py -- /agent/run, /agent/run/stream (detached: agent survives client disconnect), /agent/run/{thread_id}/reconnect (replay + live follow), /agent/webhook, agent CRUD, mutation inbox (approve/reject) +2. routes/agent.py -- /agent/run, /agent/run/stream (detached: agent survives client disconnect), /agent/run/{thread_id}/reconnect (replay + live follow), /agent/webhook, agent CRUD, mutation inbox (approve/reject). Org access control: `GET /agent` filters the registry to agents the caller may run (unless they hold `agents:manage`, who see everything), and both run endpoints call `assert_can_run_agent` — the filtered picker is UX, the endpoint check is the boundary of record 3. routes/chat.py -- Chat history CRUD (Postgres-backed sessions and messages) + GET /chat/active-sessions (Redis cc:active:* scan for running agents) 4. routes/oauth.py -- OAuth authorize->callback->refresh for Zoho/ClickUp/Google 5. routes/integrations.py — Integration Registry management, MCP server CRUD, Plugin install/remove @@ -26,14 +26,18 @@ webhook receivers, OAuth callbacks, and the Control Plane API. 11. routes/observability.py -- E2 LIVE observability over the global activity bus (cc:activity): GET /observability/activity/recent (backfill), /observability/activity/stream (SSE, agent+model activations across chat and ALL apps), /observability/active (runs in flight), /observability/roster (all agents + working/idle status for the office view), /observability/cost (daily LLM $ rollup by model/app). EXECUTIVE/AGENT-gated. Publish side: acb_common.activity + the executor run boundary + acb_llm._emit_usage (which also prices each call via litellm). App attribution is automatic — acb_llm.context._infer_app_source() reads the caller's gateway.routes. module, so any new app is observable with zero wiring. 12. routes/notes/ -- AI Note Taker /notes API (spec: ai-company-brain/specs/note_taker_app.md): meeting CRUD + library search, recording upload (multipart -> NOTES_MEDIA_DIR) + audio playback, background transcription pipeline over the pluggable acb_stt provider layer (BYOK Groq/OpenAI/Deepgram; summary_run rows carry per-stage status/errors), notes generation on acb_llm (templates.py prompt compiler + summaries.py grounded map-reduce -> meeting_note + draft action_item; auto-chained after transcription), and a per-meeting SSE progress stream (events.py). Same core/feature-module layout as routes/tasks. 13. routes/apps/ -- Custom Apps / App Workshop `/apps` API (RFC: docs/app-workshop/README.md, Phase 0+2a): app CRUD + workspace scaffold (apps_root() = CUSTOM_APPS_ROOT or {agents_clone_dir}/custom_apps, git-inited, app.json manifest per RFC §4.1), edit-gated workspace file passthrough (containment-guarded), publish → immutable app_versions rows + rollback + sandbox-CSP bundle serving, and the App Runtime API the `cc` bridge calls (/me, /data app-scoped JSON storage with shared/per-user partitions, /ai/complete on acb_llm tier aliases with a monthly per-app token budget from app_audit, /usage). Phase 1 draft durability (durability.py): eligible workspace text files mirror into `app_files` (write-through on file PUT, full sync on create/publish/POST /{slug}/sync) and a missing workspace lazily rehydrates from the store via `ensure_workspace` (the read-path choke point in files._edit_workspace + publish's draft reads); per-edit git checkpoints on the workspace repo (POST /{slug}/sync, GET /{slug}/checkpoints, POST /{slug}/restore — additive `checkout -- .` + restore commit, best-effort/never-500). Phase 2a `cc.tools` (tools.py): POST /{slug}/tools/{tool} — a small local `_TOOL_REGISTRY` (currently just `clickup.create_task`) checked against the LIVE manifest's `tool:?constraints` scopes (`parse_tool_scope`/`find_declared_tool_scope`; request args can never override a frozen constraint, `merge_tool_args`); read-only tools execute inline, destructive ones need a per-use confirm or a remembered `app_tool_grants` row ("always allow for this app") and then flow through the Action Broker (`action_broker.propose/submit`, namespaced action names `app.` to avoid colliding with `routes/tasks/broker_handlers.py`'s bare `clickup.create_task` registration — same flat `_HANDLERS` dict, different owners). Publish-time admin review (publish.py): an org-visibility publish declaring a non-read-only tool scope queues an `app.publish_review` proposal (SUGGEST → always NEEDS_APPROVAL) instead of going live, unless the same `scope_set_hash` was already approved on a prior version; approval flips `app_versions.review_status` and repoints `apps.live_version` (tools.py's `_apply_publish_review`, the registered handler). A rejected review has no reconciliation path — republish a new version. Phase 2a manifest `actions` (actions.py): POST /{slug}/actions/{name} + in-process `execute_app_action(slug, action_name, args, user)` (the orchestrator's agent-tool wrapper calls this directly, no HTTP loopback) — the reverse direction from `cc.tools`: named, typed capabilities OTHER callers (API clients, platform agents with `UserContext(role=AGENT)`) invoke INTO an app, checked against the LIVE manifest's `actions` array (four kinds: `storage.list`/`storage.get`/`storage.set` over `app_data`'s shared partition, `tool.call` wrapping a tool the app already declared a `tool:` scope for — reuses tools.py's `find_declared_tool_scope`/`merge_tool_args`/`_TOOL_REGISTRY`/`_broker_action_name`/`propose`/`submit` wholesale). `readonly` is derived, never manifest-trusted (hardcoded for the three storage kinds; `_TOOL_REGISTRY[...].read_only` for `tool.call`). Readonly actions and `storage.set` auto-apply for any viewer (person or agent) — `storage.set` only touches the app's own already-publish-reviewed storage; non-readonly `tool.call` auto-applies via the broker (`AuthorityTier.AUTONOMOUS`) only for a person who can also edit the app, else unconditionally `AuthorityTier.SUGGEST` (`NEEDS_APPROVAL`, no bypass — no confirm-toast is possible for an unattended API/agent caller). Tables: infra/postgres/114_custom_apps.sql + 115_app_files.sql + 116_app_tool_grants.sql. Same core/feature-module layout as routes/tasks (`_common.py` is the leaf). -14. agents.json -- Dynamic agent registry (persisted alongside pyproject.toml) +14. routes/admin/ -- Org access control `/admin` API + `/auth/me` (spec: ai-company-brain/specs/org_access_control.md, Phase 1): member roster and lifecycle (invite/suspend/remove — soft, because ~every user-scoped table keys people by email), role assignment, custom role CRUD, per-user allow/deny overrides, and the feature catalog the admin UI renders from. `GET /auth/me` is deliberately NOT admin-gated — every signed-in member calls it to resolve their own feature/agent access, and it returns resolved OUTCOMES (allowed feature slugs, runnable agent names) rather than raw permission patterns, so the matching rule has exactly one implementation. `GET /admin/members/{email}/access` returns each decision WITH its provenance (which role granted it, which override took it away) — the admin UI shows that verbatim rather than re-deriving it. Invariants enforced in `_common.py`: the org always keeps an owner, nobody assigns a role above their own rank, and system roles are immutable. Every write calls `invalidate_access` so a change lands immediately instead of after the resolver's 60s TTL. Tables: infra/postgres/128_org_access_control.sql. Same `_common.py`-is-the-leaf layout as routes/apps and routes/tasks. +15. agents.json -- Dynamic agent registry (persisted alongside pyproject.toml) ## Work Guidance ### Adding a new endpoint 1. Create or extend a route file in routes/ 2. Register the router in main.py -3. Use acb_auth.get_current_user for authentication +3. Use acb_auth.get_current_user for authentication; gate access with + `require_permission("feature:")` (preferred for new routes) or the + legacy `require_role(...)`. `get_current_user` already resolves the caller's + org roles + overrides onto `UserContext.access`, so no second lookup is needed 4. Follow FastAPI patterns: Pydantic models, dependency injection 5. Audit all write operations via acb_audit.record() diff --git a/apps/services/gateway/gateway/main.py b/apps/services/gateway/gateway/main.py index 0e146a11..cb3ca839 100644 --- a/apps/services/gateway/gateway/main.py +++ b/apps/services/gateway/gateway/main.py @@ -867,6 +867,19 @@ async def relayed_generator(): except Exception: # pragma: no cover pass +try: + # Org access control (ai-company-brain/specs/org_access_control.md) — + # member roster + lifecycle, roles, per-user overrides (prefix /admin), + # plus /auth/me, which every signed-in member calls to resolve their own + # feature and agent access. + from gateway.routes.admin import me_router as _me_router + from gateway.routes.admin import router as _admin_router + + app.include_router(_admin_router) + app.include_router(_me_router) +except Exception: # pragma: no cover + pass + # ---------- Health ---------- class Health(BaseModel): diff --git a/apps/services/gateway/gateway/routes/admin/__init__.py b/apps/services/gateway/gateway/routes/admin/__init__.py new file mode 100644 index 00000000..f903baa8 --- /dev/null +++ b/apps/services/gateway/gateway/routes/admin/__init__.py @@ -0,0 +1,17 @@ +"""Org administration routes (prefix ``/admin``) plus ``/auth/me``. + +Members, roles, and per-user access overrides for the multi-user organization +model. Spec: ``ai-company-brain/specs/org_access_control.md``. + +Layout mirrors ``routes/apps/``: ``_common`` is the leaf (DB, org lookup, +invariants) and the feature modules import from it, never from each other. +""" + +from gateway.routes.admin._common import router + +# Importing the modules attaches their routes to the shared `router`. +from gateway.routes.admin import members as _members # noqa: F401,E402 +from gateway.routes.admin import roles as _roles # noqa: F401,E402 +from gateway.routes.admin.me import me_router # noqa: E402 + +__all__ = ["router", "me_router"] diff --git a/apps/services/gateway/gateway/routes/admin/_common.py b/apps/services/gateway/gateway/routes/admin/_common.py new file mode 100644 index 00000000..b7ef58fc --- /dev/null +++ b/apps/services/gateway/gateway/routes/admin/_common.py @@ -0,0 +1,222 @@ +"""Org administration routes — shared kernel. + +DB access, the org lookup, and the invariants every write path in this package +has to respect. Spec: ``ai-company-brain/specs/org_access_control.md``. + +The three invariants, stated once here because they are the difference between +an access model and an outage: + +1. **The org always has an owner.** Every path that could remove the last + `owner` assignment refuses. A deployment with no owner is one where nobody + can grant access back, and the only recovery is SQL on the production box. +2. **Nobody grants above themselves.** Role assignment is checked against the + caller's own lowest rank, so an `admin` cannot mint an `owner`. +3. **System roles are immutable.** The five seeded roles are the floor the + bootstrap path depends on; custom roles are where admins express local + policy. +""" + +from __future__ import annotations + +import os +from typing import Any + +from acb_auth import UserContext, get_current_user, invalidate_access +from acb_common import get_logger, get_settings +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import text + +_log = get_logger("gateway.admin") + +router = APIRouter(prefix="/admin", tags=["admin"]) + +#: Slug of the single organization this deployment serves. The column exists on +#: every table so a second org is a data change; resolving it through one +#: constant keeps that future honest without shipping an org switcher today. +DEFAULT_ORG_SLUG = "default" + +#: Never assignable to a person — it is the internal service principal. +NON_ASSIGNABLE_ROLES = frozenset({"agent_service"}) + + +# ── DB (shared pooled async engine, same recipe as routes/apps/_common.py) ─── + +_ENGINE = None +_SESSION_FACTORY = None + + +def _get_session_factory() -> Any: + global _ENGINE, _SESSION_FACTORY + if _SESSION_FACTORY is None: + from sqlalchemy.ext.asyncio import ( # noqa: PLC0415 + async_sessionmaker, + create_async_engine, + ) + + settings = get_settings() + db_url = os.environ.get("DATABASE_URL", settings.database_url) + if "postgresql+psycopg" in db_url: + db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg") + elif db_url.startswith("postgresql://"): + db_url = db_url.replace("postgresql://", "postgresql+asyncpg://") + _ENGINE = create_async_engine( + db_url, echo=False, pool_pre_ping=True, + pool_size=5, max_overflow=10, pool_recycle=1800, + ) + _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False) + return _SESSION_FACTORY + + +async def get_db() -> Any: + """Return a new async session from the shared, pooled engine.""" + return _get_session_factory()() + + +# ── Auth gate ─────────────────────────────────────────────────────────────── + +async def require_admin_user( + user: UserContext = Depends(get_current_user), +) -> UserContext: + """Any admin surface: 401 anonymous, 403 without ``admin:members:read``. + + Read access is the floor for the whole package; individual write routes + add their own narrower `require_permission`. + """ + if not user.email: + raise HTTPException(status_code=401, detail="Authentication required") + if not user.has_permission("admin:members:read"): + raise HTTPException( + status_code=403, detail="Forbidden: missing permission 'admin:members:read'." + ) + return user + + +# ── Org + role lookups ────────────────────────────────────────────────────── + +async def get_org_id(db: Any) -> str: + """Resolve the deployment's organization id, or 503 if unprovisioned.""" + row = ( + await db.execute( + text("SELECT id::text AS id FROM organization WHERE slug = :slug"), + {"slug": DEFAULT_ORG_SLUG}, + ) + ).mappings().first() + if row is None: + raise HTTPException( + status_code=503, + detail=( + "Organization not provisioned. Apply " + "infra/postgres/128_org_access_control.sql." + ), + ) + return row["id"] + + +async def get_member(db: Any, email: str) -> dict[str, Any]: + """Fetch one member row by email, or 404.""" + row = ( + await db.execute( + text( + "SELECT id::text AS id, email, display_name, avatar_url, status, " + " role AS legacy_role, invited_by, invited_at, joined_at, " + " last_login_at, last_active_at, created_at " + " FROM app_user WHERE lower(email) = :email" + ), + {"email": email.lower().strip()}, + ) + ).mappings().first() + if row is None: + raise HTTPException(status_code=404, detail=f"No member '{email}'.") + return dict(row) + + +async def get_role(db: Any, org_id: str, slug: str) -> dict[str, Any]: + """Fetch one role row by slug, or 404.""" + row = ( + await db.execute( + text( + "SELECT id::text AS id, slug, display_name, description, " + " is_system, rank " + " FROM org_role WHERE organization_id = CAST(:org AS uuid) AND slug = :slug" + ), + {"org": org_id, "slug": slug}, + ) + ).mappings().first() + if row is None: + raise HTTPException(status_code=404, detail=f"No role '{slug}'.") + return dict(row) + + +async def roles_for_user(db: Any, user_id: str) -> list[str]: + rows = ( + await db.execute( + text( + "SELECT r.slug FROM user_role ur " + " JOIN org_role r ON r.id = ur.role_id " + " WHERE ur.user_id = CAST(:uid AS uuid) ORDER BY r.rank" + ), + {"uid": user_id}, + ) + ).scalars().all() + return list(rows) + + +async def caller_rank(db: Any, org_id: str, user: UserContext) -> int: + """The caller's most-privileged rank (lower = more privileged). + + The internal service principal and anyone holding ``*`` rank as owner; + everyone else is bounded by the roles actually on their row. + """ + if user.has_permission("*"): + return 0 + rows = ( + await db.execute( + text( + "SELECT MIN(r.rank) AS rank " + " FROM app_user u " + " JOIN user_role ur ON ur.user_id = u.id " + " JOIN org_role r ON r.id = ur.role_id " + " WHERE lower(u.email) = :email AND r.organization_id = CAST(:org AS uuid)" + ), + {"email": (user.email or "").lower(), "org": org_id}, + ) + ).mappings().first() + rank = rows["rank"] if rows else None + return int(rank) if rank is not None else 1000 + + +async def owner_count(db: Any, org_id: str, *, excluding_user_id: str | None = None) -> int: + """How many active members would still hold `owner`.""" + sql = ( + "SELECT count(*) FROM user_role ur " + " JOIN org_role r ON r.id = ur.role_id " + " JOIN app_user u ON u.id = ur.user_id " + " WHERE r.organization_id = CAST(:org AS uuid) AND r.slug = 'owner' " + " AND u.status = 'active'" + ) + params: dict[str, Any] = {"org": org_id} + if excluding_user_id: + sql += " AND u.id <> CAST(:uid AS uuid)" + params["uid"] = excluding_user_id + return int((await db.execute(text(sql), params)).scalar() or 0) + + +async def assert_owner_survives( + db: Any, org_id: str, *, excluding_user_id: str +) -> None: + """Refuse a change that would leave the organization ownerless.""" + if await owner_count(db, org_id, excluding_user_id=excluding_user_id) == 0: + raise HTTPException( + status_code=409, + detail=( + "This would leave the organization with no owner. " + "Assign another owner first." + ), + ) + + +def invalidate_for(*emails: str | None) -> None: + """Drop cached access so an admin change lands immediately, not in 60s.""" + for email in emails: + if email: + invalidate_access(email) diff --git a/apps/services/gateway/gateway/routes/admin/me.py b/apps/services/gateway/gateway/routes/admin/me.py new file mode 100644 index 00000000..b99cf33b --- /dev/null +++ b/apps/services/gateway/gateway/routes/admin/me.py @@ -0,0 +1,88 @@ +"""``GET /auth/me`` — the caller's own identity and effective access. + +Separate router (no ``/admin`` prefix, no admin permission) because every +signed-in member needs this: it is what the Control Plane filters the sidebar +and guards routes with. Asking for your own access is not an administrative +action. + +It returns *resolved outcomes* — a list of allowed feature slugs and runnable +agents — rather than raw permission patterns for the client to evaluate. Two +implementations of the matching rule (one Python, one TypeScript) is one +implementation too many; the server decides and the client renders. +""" + +from __future__ import annotations + +from typing import Any + +from acb_auth import UserContext, get_current_user +from fastapi import APIRouter, Depends + +from gateway.routes.admin._common import get_db, get_org_id + +me_router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _agent_names() -> list[str]: + try: + from gateway.routes.agent import ( # noqa: PLC0415 + _AGENT_REGISTRY, + _load_dynamic_agents, + ) + + names = {a["name"] for a in _AGENT_REGISTRY} + try: + names |= {a["name"] for a in _load_dynamic_agents()} + except Exception: # noqa: BLE001 + pass + return sorted(names) + except Exception: # noqa: BLE001 + return [] + + +@me_router.get("/me", summary="Current user's identity and effective access") +async def get_me(user: UserContext = Depends(get_current_user)) -> dict[str, Any]: + access = user.access + + organization: dict[str, str] = {} + try: + db = await get_db() + async with db: + org_id = await get_org_id(db) + from sqlalchemy import text # noqa: PLC0415 + + row = ( + await db.execute( + text( + "SELECT slug, display_name FROM organization " + " WHERE id = CAST(:id AS uuid)" + ), + {"id": org_id}, + ) + ).mappings().first() + if row: + organization = { + "id": org_id, + "slug": row["slug"], + "display_name": row["display_name"], + } + except Exception: # noqa: BLE001 + # An unprovisioned org must not stop a member from loading the app — + # the access set is authoritative either way. + organization = {} + + return { + "email": user.email or "", + "user_id": user.user_id or "", + "authenticated": bool(user.email), + "is_active": access.is_active, + "organization": organization, + "roles": sorted(access.roles), + # Legacy coarse role, still consumed by pre-migration UI checks. + "legacy_role": user.role.value, + "features": list(access.allowed_features()), + "agents": [n for n in _agent_names() if access.can_run_agent(n)], + "permissions": sorted(access.granted), + "denied": sorted(access.denied), + "is_admin": access.has("admin:members:read"), + } diff --git a/apps/services/gateway/gateway/routes/admin/members.py b/apps/services/gateway/gateway/routes/admin/members.py new file mode 100644 index 00000000..ac267f8a --- /dev/null +++ b/apps/services/gateway/gateway/routes/admin/members.py @@ -0,0 +1,614 @@ +"""Org administration — member roster, lifecycle, roles, and per-user access. + +Spec: ``ai-company-brain/specs/org_access_control.md`` §6. + +The interesting endpoint here is ``GET /admin/members/{email}/access``: it +returns not just *what* the member can reach but *why* — which role granted it, +or which override took it away. An admin who has to simulate the resolution +algorithm in their head to answer "why can Priya still see WhatsApp?" will stop +trusting the model, so provenance is part of the API, not a debugging aid. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from acb_auth import ( + CAPABILITIES, + FEATURES, + EffectiveAccess, + InvalidPermission, + UserContext, + agent_run_permission, + build_access, + feature_permission, + require_permission, + validate_permission, +) +from acb_auth.permissions import matched_by +from fastapi import Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import text + +from gateway.routes.admin._common import ( + NON_ASSIGNABLE_ROLES, + _log, + assert_owner_survives, + caller_rank, + get_db, + get_member, + get_org_id, + invalidate_for, + require_admin_user, + roles_for_user, + router, +) + +VALID_STATUSES = ("invited", "active", "suspended", "removed") + + +# ── Models ────────────────────────────────────────────────────────────────── + +class MemberEntry(BaseModel): + email: str + display_name: str = "" + avatar_url: str = "" + status: str = "active" + roles: list[str] = Field(default_factory=list) + invited_by: str = "" + joined_at: str = "" + last_login_at: str = "" + + +class InviteRequest(BaseModel): + email: str + display_name: str = "" + #: Role slugs to assign. Empty means the seeded default, `member`. + roles: list[str] = Field(default_factory=list) + + +class MemberPatch(BaseModel): + status: str | None = None + display_name: str | None = None + + +class RoleAssignment(BaseModel): + roles: list[str] + + +class OverrideEntry(BaseModel): + permission: str + effect: str # allow | deny + reason: str = "" + + +class OverrideRequest(BaseModel): + """Full replacement of a member's overrides — PUT semantics. + + Replace rather than merge so the admin UI can send exactly what the + checkboxes show. A merge API makes "unset this deny" a second verb nobody + remembers to call. + """ + + overrides: list[OverrideEntry] + + +def _iso(value: Any) -> str: + if isinstance(value, datetime): + return value.astimezone(timezone.utc).isoformat() + return "" if value is None else str(value) + + +# ── Roster ────────────────────────────────────────────────────────────────── + +@router.get("/members", summary="List organization members") +async def list_members( + include_removed: bool = False, + admin: UserContext = Depends(require_admin_user), +) -> list[MemberEntry]: + db = await get_db() + async with db: + org_id = await get_org_id(db) + sql = ( + "SELECT u.id::text AS id, u.email, u.display_name, u.avatar_url, " + " u.status, u.invited_by, u.joined_at, u.last_login_at, " + " COALESCE(" + " (SELECT array_agg(r.slug ORDER BY r.rank)" + " FROM user_role ur JOIN org_role r ON r.id = ur.role_id" + " WHERE ur.user_id = u.id), ARRAY[]::text[]) AS roles " + " FROM app_user u " + " WHERE u.organization_id = CAST(:org AS uuid) " + ) + if not include_removed: + sql += " AND u.status <> 'removed' " + sql += " ORDER BY u.email" + rows = (await db.execute(text(sql), {"org": org_id})).mappings().all() + + return [ + MemberEntry( + email=r["email"], + display_name=r["display_name"] or "", + avatar_url=r["avatar_url"] or "", + status=r["status"], + roles=list(r["roles"] or []), + invited_by=r["invited_by"] or "", + joined_at=_iso(r["joined_at"]), + last_login_at=_iso(r["last_login_at"]), + ) + for r in rows + ] + + +@router.post("/members", summary="Invite a member", + dependencies=[require_permission("admin:members:invite")]) +async def invite_member( + req: InviteRequest, + admin: UserContext = Depends(require_admin_user), +) -> MemberEntry: + """Create (or re-activate) a member row in the `invited` state. + + No email is sent — sign-in is Entra ID SSO, so "inviting" means + provisioning the row that turns a directory identity into a member with + access. Until that row exists, an authenticated stranger resolves to no + access (``resolve_access`` returns inactive for an unknown email). + """ + email = (req.email or "").strip().lower() + if "@" not in email or len(email) > 254: + raise HTTPException(status_code=400, detail="A valid email is required.") + + db = await get_db() + async with db: + org_id = await get_org_id(db) + wanted = req.roles or ["member"] + role_ids = await _resolve_assignable_roles(db, org_id, wanted, admin) + + await db.execute( + text( + "INSERT INTO app_user (email, display_name, organization_id, " + " status, invited_by, invited_at) " + "VALUES (:email, :name, CAST(:org AS uuid), 'invited', :by, now()) " + "ON CONFLICT (email) DO UPDATE " + " SET organization_id = EXCLUDED.organization_id, " + " display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), " + " app_user.display_name), " + " status = CASE WHEN app_user.status = 'removed' " + " THEN 'invited' ELSE app_user.status END, " + " updated_at = now()" + ), + {"email": email, "name": req.display_name or "", "org": org_id, + "by": admin.email}, + ) + member = await get_member(db, email) + await _set_roles(db, member["id"], role_ids, admin.email) + await db.commit() + roles = await roles_for_user(db, member["id"]) + + invalidate_for(email) + _log.info("member_invited", email=email, by=admin.email, roles=roles) + return MemberEntry( + email=email, + display_name=req.display_name or "", + status="invited", + roles=roles, + invited_by=admin.email or "", + ) + + +@router.patch("/members/{email}", summary="Update a member's status or name", + dependencies=[require_permission("admin:members:manage")]) +async def update_member( + email: str, + patch: MemberPatch, + admin: UserContext = Depends(require_admin_user), +) -> MemberEntry: + if patch.status is not None and patch.status not in VALID_STATUSES: + raise HTTPException( + status_code=400, + detail=f"status must be one of {list(VALID_STATUSES)}.", + ) + + db = await get_db() + async with db: + org_id = await get_org_id(db) + member = await get_member(db, email) + + # Suspending or removing the last owner locks everyone out of admin. + if patch.status in ("suspended", "removed"): + await assert_owner_survives(db, org_id, excluding_user_id=member["id"]) + + if patch.status is not None: + await db.execute( + text( + "UPDATE app_user SET status = :status, updated_at = now(), " + " joined_at = CASE WHEN :status = 'active' " + " THEN COALESCE(joined_at, now()) ELSE joined_at END " + " WHERE id = CAST(:uid AS uuid)" + ), + {"status": patch.status, "uid": member["id"]}, + ) + if patch.display_name is not None: + await db.execute( + text( + "UPDATE app_user SET display_name = :name, updated_at = now() " + " WHERE id = CAST(:uid AS uuid)" + ), + {"name": patch.display_name, "uid": member["id"]}, + ) + await db.commit() + member = await get_member(db, email) + roles = await roles_for_user(db, member["id"]) + + invalidate_for(member["email"]) + _log.info("member_updated", email=member["email"], by=admin.email, + status=member["status"]) + return MemberEntry( + email=member["email"], + display_name=member["display_name"] or "", + avatar_url=member["avatar_url"] or "", + status=member["status"], + roles=roles, + invited_by=member["invited_by"] or "", + joined_at=_iso(member["joined_at"]), + last_login_at=_iso(member["last_login_at"]), + ) + + +@router.delete("/members/{email}", summary="Remove a member", + dependencies=[require_permission("admin:members:manage")]) +async def remove_member( + email: str, + admin: UserContext = Depends(require_admin_user), +) -> dict[str, str]: + """Soft-remove: status → `removed`, role assignments dropped. + + The row is kept because ~every user-scoped table in the schema references + people by email (`apps.owner_email`, `app_audit.user_email`, chat sessions, + GTD items). Hard-deleting the identity would orphan all of it; what + actually matters for access is that the member resolves to nothing, which + the `removed` status guarantees. + """ + db = await get_db() + async with db: + org_id = await get_org_id(db) + member = await get_member(db, email) + if (member["email"] or "").lower() == (admin.email or "").lower(): + raise HTTPException( + status_code=409, detail="You cannot remove yourself." + ) + await assert_owner_survives(db, org_id, excluding_user_id=member["id"]) + + await db.execute( + text("DELETE FROM user_role WHERE user_id = CAST(:uid AS uuid)"), + {"uid": member["id"]}, + ) + await db.execute( + text( + "UPDATE app_user SET status = 'removed', updated_at = now() " + " WHERE id = CAST(:uid AS uuid)" + ), + {"uid": member["id"]}, + ) + await db.commit() + + invalidate_for(member["email"]) + _log.info("member_removed", email=member["email"], by=admin.email) + return {"status": "removed", "email": member["email"]} + + +# ── Role assignment ───────────────────────────────────────────────────────── + +async def _resolve_assignable_roles( + db: Any, org_id: str, slugs: list[str], admin: UserContext +) -> list[tuple[str, str]]: + """Validate role slugs and return ``[(role_id, slug)]``. + + Enforces invariant 2: an admin cannot assign a role more privileged than + their own, so `admin` cannot mint an `owner` and escalate laterally. + """ + if not slugs: + raise HTTPException(status_code=400, detail="At least one role is required.") + + rows = ( + await db.execute( + text( + "SELECT id::text AS id, slug, rank FROM org_role " + " WHERE organization_id = CAST(:org AS uuid) AND slug = ANY(:slugs)" + ), + {"org": org_id, "slugs": list(slugs)}, + ) + ).mappings().all() + + found = {r["slug"]: r for r in rows} + missing = [s for s in slugs if s not in found] + if missing: + raise HTTPException(status_code=400, detail=f"Unknown role(s): {missing}.") + + blocked = [s for s in slugs if s in NON_ASSIGNABLE_ROLES] + if blocked: + raise HTTPException( + status_code=400, + detail=f"Role(s) {blocked} are internal and cannot be assigned to people.", + ) + + my_rank = await caller_rank(db, org_id, admin) + too_high = [s for s in slugs if int(found[s]["rank"]) < my_rank] + if too_high: + raise HTTPException( + status_code=403, + detail=f"You cannot assign role(s) {too_high} — they outrank you.", + ) + return [(found[s]["id"], s) for s in slugs] + + +async def _set_roles( + db: Any, user_id: str, role_ids: list[tuple[str, str]], assigned_by: str | None +) -> None: + await db.execute( + text("DELETE FROM user_role WHERE user_id = CAST(:uid AS uuid)"), + {"uid": user_id}, + ) + for role_id, _slug in role_ids: + await db.execute( + text( + "INSERT INTO user_role (user_id, role_id, assigned_by) " + "VALUES (CAST(:uid AS uuid), CAST(:rid AS uuid), :by) " + "ON CONFLICT DO NOTHING" + ), + {"uid": user_id, "rid": role_id, "by": assigned_by}, + ) + + +@router.put("/members/{email}/roles", summary="Set a member's roles", + dependencies=[require_permission("admin:members:manage")]) +async def set_member_roles( + email: str, + req: RoleAssignment, + admin: UserContext = Depends(require_admin_user), +) -> MemberEntry: + db = await get_db() + async with db: + org_id = await get_org_id(db) + member = await get_member(db, email) + role_ids = await _resolve_assignable_roles(db, org_id, req.roles, admin) + + # Demoting the last owner is the same lockout as removing them. + if "owner" not in req.roles: + await assert_owner_survives(db, org_id, excluding_user_id=member["id"]) + + await _set_roles(db, member["id"], role_ids, admin.email) + + # Keep the legacy coarse column truthful so require_role() and any + # not-yet-migrated route agree with the new model (spec §7). + legacy = "executive" if any( + s in ("owner", "admin") for _rid, s in role_ids + ) else "employee" + await db.execute( + text( + "UPDATE app_user SET role = :role, updated_at = now() " + " WHERE id = CAST(:uid AS uuid)" + ), + {"role": legacy, "uid": member["id"]}, + ) + await db.commit() + roles = await roles_for_user(db, member["id"]) + + invalidate_for(member["email"]) + _log.info("member_roles_set", email=member["email"], by=admin.email, roles=roles) + return MemberEntry( + email=member["email"], + display_name=member["display_name"] or "", + status=member["status"], + roles=roles, + ) + + +# ── Per-user access + overrides ───────────────────────────────────────────── + +async def _load_overrides(db: Any, user_id: str) -> list[dict[str, Any]]: + rows = ( + await db.execute( + text( + "SELECT permission, effect, reason, set_by, set_at " + " FROM user_permission_override " + " WHERE user_id = CAST(:uid AS uuid) ORDER BY permission" + ), + {"uid": user_id}, + ) + ).mappings().all() + return [dict(r) for r in rows] + + +async def _role_permission_map(db: Any, user_id: str) -> dict[str, list[str]]: + """``{role_slug: [permission, ...]}`` for the member's assigned roles.""" + rows = ( + await db.execute( + text( + "SELECT r.slug AS slug, rp.permission AS permission " + " FROM user_role ur " + " JOIN org_role r ON r.id = ur.role_id " + " JOIN org_role_permission rp ON rp.role_id = r.id " + " WHERE ur.user_id = CAST(:uid AS uuid)" + ), + {"uid": user_id}, + ) + ).mappings().all() + out: dict[str, list[str]] = {} + for r in rows: + out.setdefault(r["slug"], []).append(r["permission"]) + return out + + +def _agent_names() -> list[str]: + """Registered agent names, static + dynamic. Best-effort. + + Imported lazily: the admin package must not fail to load because the agent + registry is unavailable — an access screen that 500s is worse than one + that shows only feature toggles. + """ + try: + from gateway.routes.agent import ( # noqa: PLC0415 + _AGENT_REGISTRY, + _load_dynamic_agents, + ) + + names = {a["name"] for a in _AGENT_REGISTRY} + try: + names |= {a["name"] for a in _load_dynamic_agents()} + except Exception: # noqa: BLE001 + pass + return sorted(names) + except Exception: # noqa: BLE001 + return [] + + +def _explain( + access: EffectiveAccess, permission: str, role_perms: dict[str, list[str]] +) -> dict[str, Any]: + """Resolve one permission and name what decided it.""" + decision = access.decide(permission) + via_role = "" + if decision.source == "role" and decision.pattern: + # Name the role that contributed the deciding pattern, so the UI can + # say "granted by role member" rather than "granted by feature:chat". + for slug, perms in role_perms.items(): + if decision.pattern in perms: + via_role = slug + break + return { + "permission": permission, + "allowed": decision.allowed, + "source": decision.source, + "pattern": decision.pattern or "", + "via_role": via_role, + } + + +@router.get("/members/{email}/access", summary="Resolved access for one member") +async def get_member_access( + email: str, + admin: UserContext = Depends(require_admin_user), +) -> dict[str, Any]: + """The member's effective access, with provenance for every decision.""" + db = await get_db() + async with db: + await get_org_id(db) + member = await get_member(db, email) + roles = await roles_for_user(db, member["id"]) + role_perms = await _role_permission_map(db, member["id"]) + overrides = await _load_overrides(db, member["id"]) + + access = build_access( + [p for perms in role_perms.values() for p in perms], + [(o["permission"], o["effect"]) for o in overrides], + roles=roles, + is_active=member["status"] == "active", + ) + + return { + "email": member["email"], + "display_name": member["display_name"] or "", + "status": member["status"], + "roles": roles, + "granted": sorted(access.granted), + "denied": sorted(access.denied), + "features": [ + _explain(access, feature_permission(f), role_perms) | {"slug": f} + for f in FEATURES + ], + "capabilities": [ + _explain(access, c, role_perms) for c in CAPABILITIES + ], + "agents": [ + _explain(access, agent_run_permission(n), role_perms) | {"name": n} + for n in _agent_names() + ], + "overrides": [ + { + "permission": o["permission"], + "effect": o["effect"], + "reason": o["reason"] or "", + "set_by": o["set_by"] or "", + "set_at": _iso(o["set_at"]), + } + for o in overrides + ], + } + + +@router.put("/members/{email}/overrides", summary="Replace a member's overrides", + dependencies=[require_permission("admin:access:manage")]) +async def set_member_overrides( + email: str, + req: OverrideRequest, + admin: UserContext = Depends(require_admin_user), +) -> dict[str, Any]: + """Replace the member's allow/deny overrides wholesale. + + This is the endpoint behind "no WhatsApp, no app creator, but these two + agents": deny ``feature:whatsapp`` and ``feature:build.apps``, allow + ``agents:run:``. + """ + cleaned: list[tuple[str, str, str]] = [] + seen: set[str] = set() + for entry in req.overrides: + if entry.effect not in ("allow", "deny"): + raise HTTPException( + status_code=400, + detail=f"effect must be 'allow' or 'deny', got '{entry.effect}'.", + ) + try: + perm = validate_permission(entry.permission) + except InvalidPermission as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if perm in seen: + raise HTTPException( + status_code=400, + detail=f"Duplicate override for '{perm}' — one effect per permission.", + ) + seen.add(perm) + cleaned.append((perm, entry.effect, entry.reason or "")) + + db = await get_db() + async with db: + org_id = await get_org_id(db) + member = await get_member(db, email) + + # An owner who denies themselves admin cannot undo it from the UI. + if (member["email"] or "").lower() == (admin.email or "").lower(): + self_denied = [ + p for p, effect, _ in cleaned + if effect == "deny" and matched_by([p], "admin:access:manage") + ] + if self_denied: + raise HTTPException( + status_code=409, + detail=( + "This would revoke your own access management " + "permission. Ask another admin to make this change." + ), + ) + + await db.execute( + text( + "DELETE FROM user_permission_override WHERE user_id = CAST(:uid AS uuid)" + ), + {"uid": member["id"]}, + ) + for perm, effect, reason in cleaned: + await db.execute( + text( + "INSERT INTO user_permission_override " + " (user_id, permission, effect, reason, set_by) " + "VALUES (CAST(:uid AS uuid), :perm, :effect, :reason, :by)" + ), + {"uid": member["id"], "perm": perm, "effect": effect, + "reason": reason, "by": admin.email}, + ) + await db.commit() + _ = org_id + + invalidate_for(member["email"]) + _log.info("member_overrides_set", email=member["email"], by=admin.email, + count=len(cleaned)) + return await get_member_access(member["email"], admin) # type: ignore[arg-type] diff --git a/apps/services/gateway/gateway/routes/admin/roles.py b/apps/services/gateway/gateway/routes/admin/roles.py new file mode 100644 index 00000000..db2505b3 --- /dev/null +++ b/apps/services/gateway/gateway/routes/admin/roles.py @@ -0,0 +1,359 @@ +"""Org administration — role definitions and the feature catalog. + +Spec: ``ai-company-brain/specs/org_access_control.md`` §3.2–§3.3. + +Roles are the reusable half of the model: a bundle of permission patterns with +a name. Per-user overrides (``members.py``) are the exception half. Reaching +for a new role when one person needs one thing removed is how organizations end +up with a role per employee, so the admin UI should steer toward overrides and +this API keeps role creation deliberate. +""" + +from __future__ import annotations + +from typing import Any + +from acb_auth import ( + CAPABILITIES, + FEATURES, + InvalidPermission, + UserContext, + feature_permission, + require_permission, + validate_permission, +) +from fastapi import Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import text + +from gateway.routes.admin._common import ( + _log, + get_db, + get_org_id, + get_role, + invalidate_for, + require_admin_user, + router, +) + +MAX_CUSTOM_ROLES = 20 +_SLUG_MAX = 48 + + +class RoleEntry(BaseModel): + slug: str + display_name: str + description: str = "" + is_system: bool = False + rank: int = 100 + permissions: list[str] = Field(default_factory=list) + member_count: int = 0 + + +class RoleCreate(BaseModel): + slug: str + display_name: str + description: str = "" + permissions: list[str] = Field(default_factory=list) + + +class RolePatch(BaseModel): + display_name: str | None = None + description: str | None = None + permissions: list[str] | None = None + + +def _clean_slug(raw: str) -> str: + slug = (raw or "").strip().lower().replace(" ", "_") + if not slug or len(slug) > _SLUG_MAX: + raise HTTPException( + status_code=400, detail=f"slug must be 1–{_SLUG_MAX} characters." + ) + if not all(c.isalnum() or c in "_-" for c in slug): + raise HTTPException( + status_code=400, + detail="slug may contain only letters, digits, '_' and '-'.", + ) + return slug + + +def _clean_permissions(raw: list[str]) -> list[str]: + out: list[str] = [] + for entry in raw: + try: + perm = validate_permission(entry) + except InvalidPermission as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if perm not in out: + out.append(perm) + return out + + +async def _permissions_for(db: Any, role_id: str) -> list[str]: + rows = ( + await db.execute( + text( + "SELECT permission FROM org_role_permission " + " WHERE role_id = CAST(:rid AS uuid) ORDER BY permission" + ), + {"rid": role_id}, + ) + ).scalars().all() + return list(rows) + + +@router.get("/roles", summary="List roles with their permissions") +async def list_roles( + admin: UserContext = Depends(require_admin_user), +) -> list[RoleEntry]: + db = await get_db() + async with db: + org_id = await get_org_id(db) + rows = ( + await db.execute( + text( + "SELECT r.id::text AS id, r.slug, r.display_name, r.description, " + " r.is_system, r.rank, " + " COALESCE(" + " (SELECT array_agg(rp.permission ORDER BY rp.permission)" + " FROM org_role_permission rp WHERE rp.role_id = r.id)," + " ARRAY[]::text[]) AS permissions, " + " (SELECT count(*) FROM user_role ur WHERE ur.role_id = r.id) " + " AS member_count " + " FROM org_role r " + " WHERE r.organization_id = CAST(:org AS uuid) " + " ORDER BY r.rank, r.slug" + ), + {"org": org_id}, + ) + ).mappings().all() + + return [ + RoleEntry( + slug=r["slug"], + display_name=r["display_name"], + description=r["description"] or "", + is_system=bool(r["is_system"]), + rank=int(r["rank"]), + permissions=list(r["permissions"] or []), + member_count=int(r["member_count"] or 0), + ) + for r in rows + ] + + +@router.post("/roles", summary="Create a custom role", + dependencies=[require_permission("admin:roles:manage")]) +async def create_role( + req: RoleCreate, + admin: UserContext = Depends(require_admin_user), +) -> RoleEntry: + slug = _clean_slug(req.slug) + permissions = _clean_permissions(req.permissions) + + db = await get_db() + async with db: + org_id = await get_org_id(db) + existing = ( + await db.execute( + text( + "SELECT count(*) FROM org_role " + " WHERE organization_id = CAST(:org AS uuid) AND is_system = false" + ), + {"org": org_id}, + ) + ).scalar() or 0 + if int(existing) >= MAX_CUSTOM_ROLES: + raise HTTPException( + status_code=409, + detail=( + f"Custom role limit ({MAX_CUSTOM_ROLES}) reached. Per-user " + "overrides are the right tool for one-off exceptions." + ), + ) + + clash = ( + await db.execute( + text( + "SELECT 1 FROM org_role " + " WHERE organization_id = CAST(:org AS uuid) AND slug = :slug" + ), + {"org": org_id, "slug": slug}, + ) + ).first() + if clash: + raise HTTPException(status_code=409, detail=f"Role '{slug}' already exists.") + + # Custom roles always rank below the seeded system roles: a custom role + # must not be able to out-rank `admin` and defeat the assignment check. + row = ( + await db.execute( + text( + "INSERT INTO org_role (organization_id, slug, display_name, " + " description, is_system, rank) " + "VALUES (CAST(:org AS uuid), :slug, :name, :desc, false, 50) " + "RETURNING id::text AS id" + ), + {"org": org_id, "slug": slug, "name": req.display_name or slug, + "desc": req.description or ""}, + ) + ).mappings().first() + role_id = row["id"] + for perm in permissions: + await db.execute( + text( + "INSERT INTO org_role_permission (role_id, permission) " + "VALUES (CAST(:rid AS uuid), :perm) ON CONFLICT DO NOTHING" + ), + {"rid": role_id, "perm": perm}, + ) + await db.commit() + + _log.info("role_created", slug=slug, by=admin.email, permissions=permissions) + return RoleEntry( + slug=slug, + display_name=req.display_name or slug, + description=req.description or "", + rank=50, + permissions=permissions, + ) + + +@router.patch("/roles/{slug}", summary="Edit a custom role", + dependencies=[require_permission("admin:roles:manage")]) +async def update_role( + slug: str, + patch: RolePatch, + admin: UserContext = Depends(require_admin_user), +) -> RoleEntry: + db = await get_db() + async with db: + org_id = await get_org_id(db) + role = await get_role(db, org_id, slug) + if role["is_system"]: + raise HTTPException( + status_code=403, + detail=( + f"'{slug}' is a system role and cannot be edited. Create a " + "custom role, or use per-user overrides." + ), + ) + + if patch.display_name is not None or patch.description is not None: + await db.execute( + text( + "UPDATE org_role SET " + " display_name = COALESCE(:name, display_name), " + " description = COALESCE(:desc, description), " + " updated_at = now() " + " WHERE id = CAST(:rid AS uuid)" + ), + {"name": patch.display_name, "desc": patch.description, + "rid": role["id"]}, + ) + + if patch.permissions is not None: + permissions = _clean_permissions(patch.permissions) + await db.execute( + text("DELETE FROM org_role_permission WHERE role_id = CAST(:rid AS uuid)"), + {"rid": role["id"]}, + ) + for perm in permissions: + await db.execute( + text( + "INSERT INTO org_role_permission (role_id, permission) " + "VALUES (CAST(:rid AS uuid), :perm) ON CONFLICT DO NOTHING" + ), + {"rid": role["id"], "perm": perm}, + ) + await db.commit() + permissions = await _permissions_for(db, role["id"]) + role = await get_role(db, org_id, slug) + + # Editing a role changes access for everyone holding it — the per-email + # cache cannot be targeted, so clear it wholesale. + invalidate_for(None) + _log.info("role_updated", slug=slug, by=admin.email) + return RoleEntry( + slug=role["slug"], + display_name=role["display_name"], + description=role["description"] or "", + is_system=bool(role["is_system"]), + rank=int(role["rank"]), + permissions=permissions, + ) + + +@router.delete("/roles/{slug}", summary="Delete a custom role", + dependencies=[require_permission("admin:roles:manage")]) +async def delete_role( + slug: str, + admin: UserContext = Depends(require_admin_user), +) -> dict[str, str]: + db = await get_db() + async with db: + org_id = await get_org_id(db) + role = await get_role(db, org_id, slug) + if role["is_system"]: + raise HTTPException( + status_code=403, detail=f"'{slug}' is a system role and cannot be deleted." + ) + holders = ( + await db.execute( + text("SELECT count(*) FROM user_role WHERE role_id = CAST(:rid AS uuid)"), + {"rid": role["id"]}, + ) + ).scalar() or 0 + if int(holders): + # Silently unassigning N people is a bigger action than the admin + # asked for; make them reassign first. + raise HTTPException( + status_code=409, + detail=f"{holders} member(s) still hold '{slug}'. Reassign them first.", + ) + await db.execute( + text("DELETE FROM org_role WHERE id = CAST(:rid AS uuid)"), + {"rid": role["id"]}, + ) + await db.commit() + + invalidate_for(None) + _log.info("role_deleted", slug=slug, by=admin.email) + return {"status": "deleted", "slug": slug} + + +@router.get("/features", summary="Feature catalog + permission vocabulary") +async def list_features( + admin: UserContext = Depends(require_admin_user), +) -> dict[str, Any]: + """What the admin UI renders its checklists from. + + Falls back to the vocabulary compiled into ``acb_auth`` if the catalog + table is absent, so the access editor still works on a deployment that has + not applied migration 128 yet. + """ + features: list[dict[str, Any]] = [] + try: + db = await get_db() + async with db: + rows = ( + await db.execute( + text( + "SELECT slug, label, description, nav_href, category, " + " sort_order, is_default " + " FROM feature_catalog ORDER BY category, sort_order" + ) + ) + ).mappings().all() + features = [ + dict(r) | {"permission": feature_permission(r["slug"])} for r in rows + ] + except Exception: # noqa: BLE001 + features = [ + {"slug": f, "label": f, "description": "", "nav_href": "", + "category": "apps", "sort_order": 100, "is_default": False, + "permission": feature_permission(f)} + for f in FEATURES + ] + + return {"features": features, "capabilities": list(CAPABILITIES)} diff --git a/apps/services/gateway/gateway/routes/agent.py b/apps/services/gateway/gateway/routes/agent.py index 25fb998b..d72b34e8 100644 --- a/apps/services/gateway/gateway/routes/agent.py +++ b/apps/services/gateway/gateway/routes/agent.py @@ -24,7 +24,12 @@ from pathlib import Path from typing import Any -from acb_auth import UserContext, get_current_user, require_internal_auth +from acb_auth import ( + UserContext, + assert_can_run_agent, + get_current_user, + require_internal_auth, +) from acb_common import get_logger, get_settings from fastapi import (APIRouter, BackgroundTasks, Depends, HTTPException, Request, status) @@ -773,6 +778,14 @@ async def list_agents( for a in merged: a["display_name"] = aliases.get(a["name"], "") + # ── Access filter (org access control, enforcement seam 2) ──────────── + # This list feeds both the chat agent picker and the /agents management + # pane. Anyone who can manage agents sees the whole registry; everyone + # else sees only what they may actually run, so the picker never offers a + # choice that would 403 on use. + if not user.has_permission("agents:manage"): + merged = [a for a in merged if user.can_run_agent(a["name"])] + return merged @@ -1248,6 +1261,10 @@ async def run_agent_stream_endpoint( from orchestrator.executor import run_agent_stream # noqa: PLC0415 agent_name = _resolve_agent_for_run(req.agent, req.thread_id) + # Org access control, enforcement seam 2: the picker is filtered, but the + # endpoint is the boundary of record — a hand-crafted request naming an + # agent the member cannot run is refused here, not in the UI. + assert_can_run_agent(user, agent_name) run_id = req.run_id or str(uuid.uuid4()) user_id: str = getattr(user, "email", "") or "anonymous" @@ -1723,6 +1740,7 @@ async def run_agent_sync( from orchestrator.executor import AgentRunError, run_agent # noqa: PLC0415 agent = _resolve_agent_for_run(req.agent, req.thread_id) + assert_can_run_agent(user, agent) run_id = req.run_id or str(uuid.uuid4()) try: diff --git a/infra/AGENTS.md b/infra/AGENTS.md index 6c77a629..3bb46bcc 100644 --- a/infra/AGENTS.md +++ b/infra/AGENTS.md @@ -5,10 +5,11 @@ Docker Compose, Postgres schema, LiteLLM tier config. LLM routing is via the gat ## Key Files - docker-compose.yml -- core services (Postgres 16 + pgvector, Redis 7) -- postgres/ -- schema files (00-10) + 09_app_user.sql (NextAuth users) + 11_integration_credentials.sql (unified credential store) +- postgres/ -- schema files (00-10) + 09_app_user.sql (NextAuth users) + 11_integration_credentials.sql (unified credential store) + 128_org_access_control.sql (organization, membership lifecycle on app_user, org_role/org_role_permission/user_role, user_permission_override, feature_catalog — spec: ai-company-brain/specs/org_access_control.md) ## Conventions - Postgres migrations are numbered SQL files +- Access control: `app_user.role` (executive/employee) is RETAINED and dual-written alongside the org role model, so `require_role()` and any not-yet-migrated route keep working. Do not drop it without migrating every `require_role` call site first - **Provider keys table (08) stores ALL credentials encrypted at rest: LLM provider keys (credential_type='llm') AND business integration keys (credential_type='integration')** — migrated in 11_integration_credentials.sql - LiteLLM model names follow tier1/tier2/tier3/copilot/ patterns - **LiteLLM uses Prisma internally → `DATABASE_URL` MUST be `postgresql://` (plain, no `+psycopg` suffix)** diff --git a/infra/postgres/128_org_access_control.sql b/infra/postgres/128_org_access_control.sql new file mode 100644 index 00000000..2fb776dc --- /dev/null +++ b/infra/postgres/128_org_access_control.sql @@ -0,0 +1,307 @@ +-- ============================================================================ +-- 128_org_access_control.sql — Organization membership, roles, permissions +-- ============================================================================ +-- Phase 1 of ai-company-brain/specs/org_access_control.md. Turns the +-- single-tenant deployment into a real multi-user organization: +-- +-- organization the tenant boundary (one row today; the column is +-- carried everywhere so a second org is data, not a +-- migration) +-- app_user + org columns membership lifecycle on the EXISTING user table — +-- deliberately not a rename to `user_account`, +-- because every user-scoped table in the schema keys +-- by email (app_grants.subject, app_tool_grants +-- .user_email, apps.owner_email, app_audit +-- .user_email), so re-keying to UUID would be a +-- large migration that buys nothing in Phase 1 +-- org_role a named permission bundle (5 seeded system roles) +-- org_role_permission the bundle's contents +-- user_role assignment; a member may hold several (union) +-- user_permission_override per-user allow/deny ON TOP of roles — this is what +-- makes "member, but no WhatsApp and no app creator, +-- plus these two agents" expressible without cloning +-- a role per person. Deny wins (AWS IAM rule). +-- feature_catalog the nav-pane registry the admin UI renders from, +-- so adding a pane is a seeded row, not a code +-- change in the gateway AND the frontend +-- +-- Back-compat: app_user.role is KEPT and still honoured. This migration +-- backfills executive→admin and employee→member so no one's access changes on +-- deploy. See spec §7. +-- +-- Idempotent. Depends on: 09_app_user.sql. +-- ============================================================================ + +-- ── Organization ──────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS organization ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + domain TEXT, + settings JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- The single default organization. Display name/domain are editable from the +-- admin UI; the slug is the stable handle the resolver looks up. +INSERT INTO organization (slug, display_name, domain) +VALUES ('default', 'Fracktal Works', 'fracktal.in') +ON CONFLICT (slug) DO NOTHING; + +-- ── Membership lifecycle on app_user ──────────────────────────────────────── + +ALTER TABLE app_user + ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active', + ADD COLUMN IF NOT EXISTS invited_by TEXT, + ADD COLUMN IF NOT EXISTS invited_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS joined_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'app_user_status_check' + ) THEN + ALTER TABLE app_user ADD CONSTRAINT app_user_status_check + CHECK (status IN ('invited', 'active', 'suspended', 'removed')); + END IF; +END $$; + +-- Existing users join the default org as active members. +UPDATE app_user + SET organization_id = (SELECT id FROM organization WHERE slug = 'default'), + joined_at = COALESCE(joined_at, created_at) + WHERE organization_id IS NULL; + +CREATE INDEX IF NOT EXISTS app_user_org_status_idx + ON app_user (organization_id, status); + +-- ── Roles ─────────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS org_role ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + -- System roles are seeded here and are neither editable nor deletable from + -- the admin UI: they are the floor the bootstrap path depends on. + is_system BOOLEAN NOT NULL DEFAULT false, + -- Lower rank = more privileged. Used for display ordering and to stop an + -- admin from assigning a role above their own. + rank INT NOT NULL DEFAULT 100, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (organization_id, slug) +); + +CREATE TABLE IF NOT EXISTS org_role_permission ( + role_id UUID NOT NULL REFERENCES org_role(id) ON DELETE CASCADE, + permission TEXT NOT NULL, + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (role_id, permission) +); + +CREATE TABLE IF NOT EXISTS user_role ( + user_id UUID NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES org_role(id) ON DELETE CASCADE, + assigned_by TEXT, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, role_id) +); + +CREATE INDEX IF NOT EXISTS user_role_role_idx ON user_role (role_id); + +-- ── Per-user overrides (deny wins) ────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS user_permission_override ( + user_id UUID NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + permission TEXT NOT NULL, + effect TEXT NOT NULL CHECK (effect IN ('allow', 'deny')), + -- Why this exists. An access model nobody can explain gets switched off. + reason TEXT NOT NULL DEFAULT '', + set_by TEXT, + set_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, permission) +); + +-- ── Feature catalog ───────────────────────────────────────────────────────── +-- Mirrors workbench/control_plane/src/lib/nav.ts. The admin UI renders its +-- feature checklist from here so the gateway never imports frontend nav config. + +CREATE TABLE IF NOT EXISTS feature_catalog ( + slug TEXT PRIMARY KEY, + label TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + nav_href TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT 'apps' + CHECK (category IN ('apps', 'configure', 'build')), + sort_order INT NOT NULL DEFAULT 100, + -- Whether a brand-new `member` gets this by default. Kept in sync with the + -- seeded `member` role below. + is_default BOOLEAN NOT NULL DEFAULT false +); + +INSERT INTO feature_catalog (slug, label, description, nav_href, category, sort_order, is_default) VALUES + ('chat', 'Chat', 'AI conversations · sessions · memory', '/chat', 'apps', 10, true), + ('email', 'Email', 'AI-powered inbox', '/email', 'apps', 20, true), + ('whatsapp', 'WhatsApp', 'AI-powered WhatsApp inbox', '/whatsapp', 'apps', 30, false), + ('memory', 'Memories', 'Facts · episodic · knowledge graph', '/memory', 'apps', 40, true), + ('tasks', 'Tasks', 'AI task manager', '/tasks', 'apps', 50, true), + ('notes', 'Notes', 'AI note taker', '/notes', 'apps', 60, true), + ('dashboard', 'Dashboard', 'Company overview', '/dashboard', 'apps', 70, true), + ('observability', 'Live Activity', 'Agent & model activations in real time', '/observability', 'apps', 80, false), + ('artifacts', 'Artifacts', 'All agent files · inputs · outputs · data', '/artifacts', 'apps', 90, true), + ('models', 'Models', 'LLMs · tiers · providers', '/settings/models', 'configure', 10, false), + ('agents', 'Agents', 'Register · manage · commits · remove', '/agents', 'configure', 20, false), + ('approvals', 'Approvals', 'Action Broker · outward writes awaiting review', '/approvals', 'configure', 30, false), + ('integrations', 'Integrations', 'APIs · MCP servers · plugins', '/integrations', 'configure', 40, false), + ('build.agents', 'Agent Workbench', 'MAF agents & skills', '/build/agents', 'build', 10, false), + ('build.apps', 'Custom Apps', 'User-created applications', '/build/apps', 'build', 20, false) +ON CONFLICT (slug) DO UPDATE + SET label = EXCLUDED.label, + description = EXCLUDED.description, + nav_href = EXCLUDED.nav_href, + category = EXCLUDED.category, + sort_order = EXCLUDED.sort_order; + -- is_default is intentionally NOT overwritten: an admin may have + -- retuned the default set and a redeploy must not stomp that. + +-- ── Seed the system roles ─────────────────────────────────────────────────── + +DO $$ +DECLARE + org_id UUID; + rid UUID; +BEGIN + SELECT id INTO org_id FROM organization WHERE slug = 'default'; + + -- owner ------------------------------------------------------------------ + INSERT INTO org_role (organization_id, slug, display_name, description, is_system, rank) + VALUES (org_id, 'owner', 'Owner', + 'Full control of the organization, including roles and billing.', true, 0) + ON CONFLICT (organization_id, slug) DO NOTHING; + SELECT id INTO rid FROM org_role WHERE organization_id = org_id AND slug = 'owner'; + INSERT INTO org_role_permission (role_id, permission) + VALUES (rid, '*') + ON CONFLICT DO NOTHING; + + -- admin ------------------------------------------------------------------ + INSERT INTO org_role (organization_id, slug, display_name, description, is_system, rank) + VALUES (org_id, 'admin', 'Admin', + 'Runs the platform: members, agents, integrations, and settings.', true, 10) + ON CONFLICT (organization_id, slug) DO NOTHING; + SELECT id INTO rid FROM org_role WHERE organization_id = org_id AND slug = 'admin'; + INSERT INTO org_role_permission (role_id, permission) + SELECT rid, p FROM unnest(ARRAY[ + 'admin:members:read', 'admin:members:invite', 'admin:members:manage', + 'admin:roles:manage', 'admin:access:manage', 'admin:settings:manage', + 'admin:audit:read', + 'feature:*', 'agents:run:*', 'agents:manage', + 'apps:use:*', 'apps:create', 'apps:publish', + 'integrations:manage', 'data:org:read' + ]) AS p + ON CONFLICT DO NOTHING; + + -- manager ---------------------------------------------------------------- + INSERT INTO org_role (organization_id, slug, display_name, description, is_system, rank) + VALUES (org_id, 'manager', 'Manager', + 'Org-wide visibility across the apps; cannot change platform config.', true, 20) + ON CONFLICT (organization_id, slug) DO NOTHING; + SELECT id INTO rid FROM org_role WHERE organization_id = org_id AND slug = 'manager'; + INSERT INTO org_role_permission (role_id, permission) + SELECT rid, p FROM unnest(ARRAY[ + 'feature:chat', 'feature:email', 'feature:whatsapp', 'feature:tasks', + 'feature:notes', 'feature:memory', 'feature:dashboard', + 'feature:observability', 'feature:artifacts', 'feature:approvals', + 'agents:run:*', 'apps:use:*', 'apps:create', + 'data:org:read', 'admin:members:read' + ]) AS p + ON CONFLICT DO NOTHING; + + -- member (the default for a new employee) --------------------------------- + -- Deliberately omits WhatsApp, Approvals, Integrations, Models and both + -- Build panes: access is added, not taken away. + INSERT INTO org_role (organization_id, slug, display_name, description, is_system, rank) + VALUES (org_id, 'member', 'Member', + 'Day-to-day access to the core apps and shared agents.', true, 30) + ON CONFLICT (organization_id, slug) DO NOTHING; + SELECT id INTO rid FROM org_role WHERE organization_id = org_id AND slug = 'member'; + INSERT INTO org_role_permission (role_id, permission) + SELECT rid, p FROM unnest(ARRAY[ + 'feature:chat', 'feature:email', 'feature:tasks', 'feature:notes', + 'feature:memory', 'feature:dashboard', 'feature:artifacts', + 'agents:run:*', 'apps:use:*' + ]) AS p + ON CONFLICT DO NOTHING; + + -- guest ------------------------------------------------------------------ + INSERT INTO org_role (organization_id, slug, display_name, description, is_system, rank) + VALUES (org_id, 'guest', 'Guest', + 'External collaborator: chat and explicitly shared apps only.', true, 40) + ON CONFLICT (organization_id, slug) DO NOTHING; + SELECT id INTO rid FROM org_role WHERE organization_id = org_id AND slug = 'guest'; + INSERT INTO org_role_permission (role_id, permission) + SELECT rid, p FROM unnest(ARRAY['feature:chat', 'apps:use:*']) AS p + ON CONFLICT DO NOTHING; + + -- agent_service (service-to-service; never assigned to a person) ---------- + INSERT INTO org_role (organization_id, slug, display_name, description, is_system, rank) + VALUES (org_id, 'agent_service', 'Agent Service', + 'Internal service-to-service principal. Not assignable to people.', true, 90) + ON CONFLICT (organization_id, slug) DO NOTHING; + SELECT id INTO rid FROM org_role WHERE organization_id = org_id AND slug = 'agent_service'; + INSERT INTO org_role_permission (role_id, permission) + SELECT rid, p FROM unnest(ARRAY['agents:run:*', 'data:org:read']) AS p + ON CONFLICT DO NOTHING; +END $$; + +-- ── Backfill role assignments from the legacy app_user.role column ────────── +-- executive → admin, employee → member. Runs only for users who have no +-- assignment yet, so an admin's later changes are never re-stomped by a +-- redeploy of this idempotent migration. + +INSERT INTO user_role (user_id, role_id, assigned_by) +SELECT u.id, r.id, 'migration:128' + FROM app_user u + JOIN org_role r + ON r.organization_id = u.organization_id + AND r.slug = CASE WHEN u.role = 'executive' THEN 'admin' ELSE 'member' END + WHERE NOT EXISTS (SELECT 1 FROM user_role ur WHERE ur.user_id = u.id) +ON CONFLICT DO NOTHING; + +-- ── Ownership bootstrap ───────────────────────────────────────────────────── +-- A deployment with no owner is one where nobody can grant access back. If the +-- backfill left the org ownerless, promote the oldest admin (else the oldest +-- member) so there is always exactly one way back in. + +DO $$ +DECLARE + org_id UUID; + owner_rid UUID; + candidate UUID; +BEGIN + SELECT id INTO org_id FROM organization WHERE slug = 'default'; + SELECT id INTO owner_rid FROM org_role + WHERE organization_id = org_id AND slug = 'owner'; + + IF NOT EXISTS (SELECT 1 FROM user_role WHERE role_id = owner_rid) THEN + SELECT u.id INTO candidate + FROM app_user u + LEFT JOIN user_role ur ON ur.user_id = u.id + LEFT JOIN org_role r ON r.id = ur.role_id + WHERE u.organization_id = org_id + AND u.status = 'active' + ORDER BY (r.slug = 'admin') DESC NULLS LAST, u.created_at ASC + LIMIT 1; + + IF candidate IS NOT NULL THEN + INSERT INTO user_role (user_id, role_id, assigned_by) + VALUES (candidate, owner_rid, 'migration:128') + ON CONFLICT DO NOTHING; + END IF; + END IF; +END $$; diff --git a/packages/AGENTS.md b/packages/AGENTS.md index d31a5e38..aabaa1ff 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -10,7 +10,7 @@ Reusable Python packages shared across all CommandCenter services. - acb_graph/ -- Postgres entity graph (SQLAlchemy sessions) - acb_common/ -- Shared settings, logging, activity/cost feed, utilities - acb_audit/ -- Audit event recording -- acb_auth/ -- Authentication and role-based access +- acb_auth/ -- Authentication, roles, and org access control. Two guard styles coexist: the original coarse `require_role(UserRole.EXECUTIVE)` (unchanged) and `require_permission("feature:whatsapp")`, backed by DB roles + per-user allow/deny overrides (`permissions.py` is pure and testable; `access.py` does the I/O with a 60s cache). Spec: ai-company-brain/specs/org_access_control.md ## Conventions - Each package has its own pyproject.toml diff --git a/packages/acb_auth/acb_auth/__init__.py b/packages/acb_auth/acb_auth/__init__.py index 1c703ab9..59092a0c 100644 --- a/packages/acb_auth/acb_auth/__init__.py +++ b/packages/acb_auth/acb_auth/__init__.py @@ -1,11 +1,65 @@ -"""RBAC roles, user context, FastAPI dependency helpers (WBS 1.7).""" +"""RBAC roles, user context, FastAPI dependency helpers (WBS 1.7). + +Two guard styles coexist: + +* ``require_role(UserRole.EXECUTIVE)`` — the original coarse gate. Unchanged. +* ``require_permission("feature:whatsapp")`` — org access control: DB-backed + roles plus per-user allow/deny overrides. See + ``ai-company-brain/specs/org_access_control.md``. +""" +from acb_auth.access import invalidate as invalidate_access +from acb_auth.access import resolve_access +from acb_auth.deps import ( + assert_can_run_agent, + get_current_user, + require_any_permission, + require_feature, + require_internal_auth, + require_permission, + require_role, +) +from acb_auth.permissions import ( + ASSIGNABLE_SYSTEM_ROLES, + CAPABILITIES, + FEATURES, + SYSTEM_ROLES, + AccessDecision, + EffectiveAccess, + InvalidPermission, + agent_run_permission, + build_access, + feature_permission, + permission_matches, + validate_permission, +) from acb_auth.roles import UserContext, UserRole -from acb_auth.deps import get_current_user, require_internal_auth, require_role __all__ = [ + # identity "UserRole", "UserContext", "get_current_user", + # guards "require_role", + "require_permission", + "require_any_permission", + "require_feature", "require_internal_auth", + "assert_can_run_agent", + # permission model + "EffectiveAccess", + "AccessDecision", + "InvalidPermission", + "FEATURES", + "CAPABILITIES", + "SYSTEM_ROLES", + "ASSIGNABLE_SYSTEM_ROLES", + "build_access", + "feature_permission", + "agent_run_permission", + "permission_matches", + "validate_permission", + # resolution + "resolve_access", + "invalidate_access", ] diff --git a/packages/acb_auth/acb_auth/access.py b/packages/acb_auth/acb_auth/access.py new file mode 100644 index 00000000..b47fb2c4 --- /dev/null +++ b/packages/acb_auth/acb_auth/access.py @@ -0,0 +1,263 @@ +"""DB-backed resolution of a member's effective access. + +Reads the org/role/override tables from ``infra/postgres/128_org_access_control.sql`` +and turns an email into an :class:`~acb_auth.permissions.EffectiveAccess`. +Pure matching logic lives in :mod:`acb_auth.permissions`; this module is the +I/O half. + +Spec: ``ai-company-brain/specs/org_access_control.md`` §5. + +Why resolve per request instead of stuffing permissions in the session JWT: a +JWT outlives an access change. "I revoked WhatsApp an hour ago and they still +have it" is the failure that makes people stop trusting the whole model, so +the session carries identity only and access is resolved server-side behind a +short TTL cache. +""" +from __future__ import annotations + +import os +import time +from typing import Any + +from acb_common import get_logger + +from acb_auth.permissions import ( + LEGACY_ROLE_MAP, + EffectiveAccess, + build_access, +) + +_log = get_logger("acb_auth.access") + +#: Short enough that a revocation lands within a minute, long enough that a +#: chatty page does not issue one query per API call. +CACHE_TTL_SECONDS = 60.0 + +#: Possession of the internal bearer token is already total authority (it can +#: assert any X-User-Email), so the service principal is granted everything +#: rather than pretending to a narrower set it could trivially escape. +SERVICE_ACCESS = EffectiveAccess( + roles=frozenset({"agent_service"}), + role_granted=frozenset({"*"}), +) + +_cache: dict[str, tuple[float, EffectiveAccess]] = {} +_ENGINE: Any = None +_SESSION_FACTORY: Any = None +#: Set once the access tables are confirmed missing, so we degrade to the +#: legacy mapping without re-querying a failing table on every request. +_tables_missing = False + + +# ── Engine (same recipe as gateway routes/apps/_common.py) ────────────────── + +def _get_session_factory() -> Any: + global _ENGINE, _SESSION_FACTORY + if _SESSION_FACTORY is None: + from sqlalchemy.ext.asyncio import ( # noqa: PLC0415 + async_sessionmaker, + create_async_engine, + ) + from acb_common import get_settings # noqa: PLC0415 + + settings = get_settings() + db_url = os.environ.get("DATABASE_URL", settings.database_url) + if "postgresql+psycopg" in db_url: + db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg") + elif db_url.startswith("postgresql://"): + db_url = db_url.replace("postgresql://", "postgresql+asyncpg://") + _ENGINE = create_async_engine( + db_url, echo=False, pool_pre_ping=True, + pool_size=5, max_overflow=10, pool_recycle=1800, + ) + _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False) + return _SESSION_FACTORY + + +# ── Cache ─────────────────────────────────────────────────────────────────── + +def invalidate(email: str | None = None) -> None: + """Drop cached access for one member, or everyone when email is None. + + Every admin write path calls this. Without it the 60s TTL becomes the + latency of a permission change, which is fine for revocation-by-timeout + but infuriating for an admin watching a toggle appear to do nothing. + """ + if email: + _cache.pop(email.lower().strip(), None) + else: + _cache.clear() + + +def _cache_get(email: str) -> EffectiveAccess | None: + hit = _cache.get(email) + if hit is None: + return None + expires_at, access = hit + if expires_at < time.monotonic(): + _cache.pop(email, None) + return None + return access + + +def _cache_put(email: str, access: EffectiveAccess) -> None: + _cache[email] = (time.monotonic() + CACHE_TTL_SECONDS, access) + + +# ── Legacy fallback ───────────────────────────────────────────────────────── + +def legacy_access(role: str | None) -> EffectiveAccess: + """Approximate the pre-128 world from the legacy ``executive``/``employee``. + + Used only when the access tables are absent (migration not yet applied) — + see spec §7. A member whose row *does* exist never lands here; an unknown + user resolves to nothing. + """ + slug = LEGACY_ROLE_MAP.get((role or "employee").lower(), "member") + if slug in ("admin", "agent_service"): + return build_access( + ["feature:*", "agents:run:*", "agents:manage", "apps:use:*", + "apps:create", "apps:publish", "admin:members:read", + "admin:members:invite", "admin:members:manage", + "admin:roles:manage", "admin:access:manage", + "admin:settings:manage", "admin:audit:read", + "integrations:manage", "data:org:read"], + roles=[slug], + ) + return build_access( + ["feature:chat", "feature:email", "feature:tasks", "feature:notes", + "feature:memory", "feature:dashboard", "feature:artifacts", + "agents:run:*", "apps:use:*"], + roles=[slug], + ) + + +# ── Resolution ────────────────────────────────────────────────────────────── + +_ACCESS_SQL = """ + SELECT u.id::text AS user_id, + u.organization_id::text AS organization_id, + u.status AS status, + u.role AS legacy_role, + COALESCE( + (SELECT array_agg(DISTINCT r.slug) + FROM user_role ur + JOIN org_role r ON r.id = ur.role_id + WHERE ur.user_id = u.id), + ARRAY[]::text[] + ) AS roles, + COALESCE( + (SELECT array_agg(DISTINCT rp.permission) + FROM user_role ur + JOIN org_role_permission rp ON rp.role_id = ur.role_id + WHERE ur.user_id = u.id), + ARRAY[]::text[] + ) AS role_permissions, + COALESCE( + (SELECT array_agg(o.permission || '=' || o.effect) + FROM user_permission_override o + WHERE o.user_id = u.id), + ARRAY[]::text[] + ) AS overrides + FROM app_user u + WHERE lower(u.email) = :email + LIMIT 1 +""" + + +async def resolve_access( + email: str | None, + *, + legacy_role: str | None = None, + use_cache: bool = True, +) -> EffectiveAccess: + """Resolve a member's effective access by email. + + An unknown email resolves to no access. A suspended or removed member + resolves to no access regardless of the roles still on their row — the + status check is not a filter on the query but a property of the result, so + a stale cache entry can never outlive a suspension by more than the TTL. + """ + global _tables_missing + + if not email: + return EffectiveAccess(is_active=False) + key = email.lower().strip() + + if use_cache: + cached = _cache_get(key) + if cached is not None: + return cached + + if _tables_missing: + return legacy_access(legacy_role) + + try: + from sqlalchemy import text # noqa: PLC0415 + + factory = _get_session_factory() + async with factory() as session: + row = ( + await session.execute(text(_ACCESS_SQL), {"email": key}) + ).mappings().first() + except Exception as exc: # noqa: BLE001 + # Distinguish "migration hasn't run" (degrade to legacy, permanently) + # from a transient DB blip (degrade for this request only). + message = str(exc).lower() + if "does not exist" in message or "undefinedtable" in message: + _tables_missing = True + _log.warning( + "access_tables_missing", + detail="falling back to legacy executive/employee mapping; " + "apply infra/postgres/128_org_access_control.sql", + ) + else: + _log.warning("access_resolve_failed", error=str(exc)) + return legacy_access(legacy_role) + + if row is None: + # Authenticated by the IdP but not provisioned here. No access, and + # deliberately not auto-provisioned: an admin invites people. + return EffectiveAccess(is_active=False) + + overrides: list[tuple[str, str]] = [] + for entry in row["overrides"] or []: + perm, _, effect = str(entry).rpartition("=") + if perm and effect in ("allow", "deny"): + overrides.append((perm, effect)) + + access = build_access( + row["role_permissions"] or [], + overrides, + roles=row["roles"] or [], + is_active=row["status"] == "active", + ) + + if use_cache: + _cache_put(key, access) + return access + + +async def resolve_identity(email: str | None) -> tuple[str | None, str | None]: + """Return ``(user_id, organization_id)`` for an email, or ``(None, None)``.""" + if not email: + return None, None + try: + from sqlalchemy import text # noqa: PLC0415 + + factory = _get_session_factory() + async with factory() as session: + row = ( + await session.execute( + text( + "SELECT id::text AS id, organization_id::text AS org " + "FROM app_user WHERE lower(email) = :email LIMIT 1" + ), + {"email": email.lower().strip()}, + ) + ).mappings().first() + except Exception: # noqa: BLE001 + return None, None + if row is None: + return None, None + return row["id"], row["org"] diff --git a/packages/acb_auth/acb_auth/deps.py b/packages/acb_auth/acb_auth/deps.py index 9476874a..cc8cc391 100644 --- a/packages/acb_auth/acb_auth/deps.py +++ b/packages/acb_auth/acb_auth/deps.py @@ -2,14 +2,19 @@ Usage in routes --------------- - from acb_auth import get_current_user, require_role, UserRole + from acb_auth import get_current_user, require_permission, require_role, UserRole # Any authenticated user: @app.post("/pull") async def pull(req: PullRequest, user=Depends(get_current_user)): ... - # Executive-only: + # Permission-gated (preferred for new routes): + @app.get("/whatsapp/chats", dependencies=[require_permission("feature:whatsapp")]) + async def list_chats(): + ... + + # Executive-only (legacy coarse role — still honoured, see roles.py): @app.post("/pull/sales", dependencies=[require_role(UserRole.EXECUTIVE)]) async def pull_sales(req: PullRequest): ... @@ -33,6 +38,8 @@ async def pull_sales(req: PullRequest): from fastapi import Depends, Header, HTTPException +from acb_auth.access import SERVICE_ACCESS, resolve_access, resolve_identity +from acb_auth.permissions import NO_ACCESS from acb_auth.roles import UserContext, UserRole, _coerce_role # --------------------------------------------------------------------------- @@ -75,6 +82,42 @@ def _trust_unverified_sso_headers() -> bool: +async def _with_resolved_access(user: UserContext) -> UserContext: + """Attach the member's DB-resolved permission set to a UserContext. + + Best-effort by construction: :func:`acb_auth.access.resolve_access` never + raises, degrading to the legacy executive/employee mapping when the access + tables are absent and to no-access when the member is unknown. Results are + cached for 60s, so this costs one indexed query per member per minute + rather than one per request. + """ + if not user.email: + return user + access = await resolve_access(user.email, legacy_role=user.role.value) + user_id, organization_id = await resolve_identity(user.email) + enriched = user.with_access( + access, user_id=user_id, organization_id=organization_id + ) + + # Keep the legacy coarse role consistent with the org model, so a member + # promoted to `admin` in the members UI immediately passes the + # require_role(EXECUTIVE) routes that have not migrated to permissions yet. + # + # Upgrade-only, deliberately: the Next.js proxy still derives X-User-Role + # from EXECUTIVE_EMAILS, and an admin listed there who has never signed in + # has no app_user row to resolve. Letting the DB *downgrade* the header + # would lock exactly that person out during rollout. + if enriched.role is not UserRole.EXECUTIVE and access.has("admin:settings:manage"): + return UserContext( + email=enriched.email, + role=UserRole.EXECUTIVE, + user_id=enriched.user_id, + organization_id=enriched.organization_id, + access=access, + ) + return enriched + + async def get_current_user( x_user_email: Annotated[str | None, Header(alias="X-User-Email")] = None, x_user_role: Annotated[str | None, Header(alias="X-User-Role")] = None, @@ -88,8 +131,13 @@ async def get_current_user( 2. If ``X-User-Email`` is set → resolve from SSO headers (normal user flow). 3. Otherwise → anonymous ``UserContext(email=None, role=EMPLOYEE)``. - Never raises — missing/wrong headers resolve to the lowest-privilege role. - Enforcement is done by require_role(). + Every branch also resolves the caller's effective access (org roles + + per-user overrides) onto the context, so routes can call + ``user.has_permission(...)`` without a second lookup. + + Never raises — missing/wrong headers resolve to the lowest-privilege role + and an empty permission set. Enforcement is done by require_role() / + require_permission(). """ # 1. Internal Bearer token (Next.js proxy, cron jobs, CI) bearer_ok = False @@ -110,16 +158,25 @@ async def get_current_user( email = x_user_email if not email.lower().endswith("@" + allowed_domain): email = None - return UserContext( - email=email or x_user_email, # still trust Next.js but flag domain mismatch - role=_coerce_role(x_user_role), + return await _with_resolved_access( + UserContext( + email=email or x_user_email, # still trust Next.js but flag domain mismatch + role=_coerce_role(x_user_role), + ) ) # 1b. Bearer-matched call WITHOUT user headers → internal service call. # Used by cron jobs, CI pipelines, and legacy LangGraph batch mode # that predates the identity-forwarding fix. + # + # Granted everything: whoever holds the internal token can already + # assert any X-User-Email, so a narrower set would be theatre. if bearer_ok: - return UserContext(email="system:internal", role=UserRole.AGENT) + return UserContext( + email="system:internal", + role=UserRole.AGENT, + access=SERVICE_ACCESS, + ) # 2. SSO headers WITHOUT a valid Bearer token. # X-User-Email is only trustworthy when it arrives WITH the internal Bearer @@ -136,7 +193,7 @@ async def get_current_user( and _get_internal_token() and not _trust_unverified_sso_headers() ): - return UserContext(email=None, role=UserRole.EMPLOYEE) + return UserContext(email=None, role=UserRole.EMPLOYEE, access=NO_ACCESS) email = x_user_email if email: @@ -147,9 +204,8 @@ async def get_current_user( # Treat as anonymous rather than raising — callers use require_role() to enforce. email = None - return UserContext( - email=email, - role=_coerce_role(x_user_role), + return await _with_resolved_access( + UserContext(email=email, role=_coerce_role(x_user_role)) ) @@ -187,7 +243,12 @@ async def require_internal_auth( def require_role(*allowed: UserRole) -> Depends: - """Return a FastAPI Depends that 403s if the caller role is not in allowed.""" + """Return a FastAPI Depends that 403s if the caller role is not in allowed. + + Unchanged from the pre-org-access-control behaviour on purpose: every route + written against the coarse executive/employee split keeps working exactly + as before. New routes should prefer :func:`require_permission`. + """ allowed_set = frozenset(allowed) async def _check(user: Annotated[UserContext, Depends(get_current_user)]) -> UserContext: @@ -201,4 +262,76 @@ async def _check(user: Annotated[UserContext, Depends(get_current_user)]) -> Use ) return user - return Depends(_check) \ No newline at end of file + return Depends(_check) + + +def require_permission(*permissions: str) -> Depends: + """Return a FastAPI Depends that 403s unless the caller holds **all** of them. + + Anonymous callers get 401 rather than 403 — "you are not signed in" and + "you are signed in but not allowed" are different problems and the + frontend routes them differently (sign-in redirect vs. access-denied page). + + The 403 body names the missing permission. That is a deliberate trade: + a signed-in member learning the *name* of a permission they lack leaks + nothing an admin screen wouldn't tell them, and without it every access + bug becomes a support ticket. + + @router.get("/chats", dependencies=[require_permission("feature:whatsapp")]) + """ + required = tuple(permissions) + + async def _check(user: Annotated[UserContext, Depends(get_current_user)]) -> UserContext: + if not user.email: + raise HTTPException(status_code=401, detail="Authentication required") + missing = [p for p in required if not user.has_permission(p)] + if missing: + raise HTTPException( + status_code=403, + detail=f"Forbidden: missing permission(s) {sorted(missing)}.", + ) + return user + + return Depends(_check) + + +def require_feature(slug: str) -> Depends: + """Sugar for ``require_permission(f"feature:{slug}")``.""" + return require_permission(f"feature:{slug}") + + +def require_any_permission(*permissions: str) -> Depends: + """Like :func:`require_permission` but any single match suffices. + + For endpoints that legitimately serve two audiences — e.g. an approvals + feed readable by both the Approvals pane and platform admins. + """ + required = tuple(permissions) + + async def _check(user: Annotated[UserContext, Depends(get_current_user)]) -> UserContext: + if not user.email: + raise HTTPException(status_code=401, detail="Authentication required") + if not any(user.has_permission(p) for p in required): + raise HTTPException( + status_code=403, + detail=f"Forbidden: requires one of {sorted(required)}.", + ) + return user + + return Depends(_check) + + +def assert_can_run_agent(user: UserContext, agent_name: str) -> None: + """Raise 403 unless ``user`` may run ``agent_name``. + + A function rather than a dependency because the agent name arrives in the + request body, not the path — the run endpoints call this after parsing. + Seam 2 of the spec's enforcement table. + """ + if user.role is UserRole.AGENT and user.has_permission("*"): + return # internal service principal + if not user.can_run_agent(agent_name): + raise HTTPException( + status_code=403, + detail=f"Forbidden: no access to agent '{agent_name}'.", + ) \ No newline at end of file diff --git a/packages/acb_auth/acb_auth/permissions.py b/packages/acb_auth/acb_auth/permissions.py new file mode 100644 index 00000000..749d8bc5 --- /dev/null +++ b/packages/acb_auth/acb_auth/permissions.py @@ -0,0 +1,371 @@ +"""Permission vocabulary and the resolution rule. + +This module is **pure** — no DB, no FastAPI, no I/O. It defines what a +permission string looks like, how a granted pattern matches a required +permission, and how grants + per-user overrides combine into a yes/no. The +DB-backed side lives in :mod:`acb_auth.access`; the request-time side in +:mod:`acb_auth.deps`. + +Spec: ``ai-company-brain/specs/org_access_control.md`` §3.3–§3.4. + +Grammar +------- +Colon-separated lowercase segments, e.g. ``feature:whatsapp``, +``agents:run:agent-sales``, ``admin:members:invite``. ``*`` is legal **only as +the final segment**, where it matches any non-empty suffix: + + "*" matches everything + "feature:*" matches "feature:whatsapp", "feature:build.apps" + "agents:run:*" matches "agents:run:agent-sales" + "feature:*:read" REJECTED — see validate_permission() + +Inward-matching wildcards are refused on purpose. A grant set you cannot read +top-to-bottom is a grant set nobody audits, and nothing in the product needs +them. + +Resolution +---------- +Two layers. Roles are the baseline; per-user overrides are exceptions layered +on top, and only overrides compete with each other: + + 1. Among the OVERRIDE patterns matching `required`, the most specific one + decides. A tie goes to deny. + 2. If no override matches, the role grants decide. + 3. Otherwise, deny. + +Specificity, not a flat "deny always wins", is what makes the product's real +request expressible. "Give them `member`, but only these two agents" is: + + 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 the specific allow could never surface, so an admin would +have to clone a role per person — which is the failure mode roles exist to +prevent. Under specificity the exact allow beats the wildcard deny, while a +same-specificity conflict still resolves to deny. + +Keeping role grants OUT of that comparison is equally deliberate: an override +of `feature:*` = deny must switch everything off, and it would not if a role's +exact `feature:chat` could out-specify it. An admin's explicit exception +outranks the role, always; specificity only orders exceptions against each +other. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Iterable + +# ── Vocabulary ────────────────────────────────────────────────────────────── + +#: Nav panes / product surfaces. Kept in sync with the `feature_catalog` table +#: (infra/postgres/128_org_access_control.sql) and the frontend's nav.ts. +FEATURES: tuple[str, ...] = ( + "chat", + "email", + "whatsapp", + "memory", + "tasks", + "notes", + "dashboard", + "observability", + "artifacts", + "models", + "agents", + "approvals", + "integrations", + "build.agents", + "build.apps", +) + +#: Non-feature permissions, listed so the admin UI can offer a closed set and +#: so a typo in a role definition is catchable rather than silently inert. +CAPABILITIES: tuple[str, ...] = ( + "agents:run:*", + "agents:manage", + "apps:use:*", + "apps:create", + "apps:publish", + "admin:members:read", + "admin:members:invite", + "admin:members:manage", + "admin:roles:manage", + "admin:access:manage", + "admin:settings:manage", + "admin:audit:read", + "integrations:manage", + "data:org:read", +) + +#: System role slugs seeded by migration 128. `agent_service` is never +#: assignable to a person. +SYSTEM_ROLES: tuple[str, ...] = ( + "owner", + "admin", + "manager", + "member", + "guest", + "agent_service", +) + +ASSIGNABLE_SYSTEM_ROLES: tuple[str, ...] = tuple( + r for r in SYSTEM_ROLES if r != "agent_service" +) + +#: Legacy `app_user.role` → seeded role slug. Preserves everyone's access +#: across the migration and keeps `require_role()` meaningful (spec §7). +LEGACY_ROLE_MAP: dict[str, str] = { + "executive": "admin", + "employee": "member", + "agent": "agent_service", +} + +_SEGMENT_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$") +MAX_PERMISSION_LEN = 128 + + +def feature_permission(slug: str) -> str: + """``"whatsapp"`` → ``"feature:whatsapp"``.""" + return f"feature:{slug}" + + +def agent_run_permission(agent_name: str) -> str: + """``"agent-sales"`` → ``"agents:run:agent-sales"``.""" + return f"agents:run:{agent_name}" + + +# ── Validation ────────────────────────────────────────────────────────────── + +class InvalidPermission(ValueError): + """A permission string that would be unauditable or is simply malformed.""" + + +def validate_permission(raw: str) -> str: + """Normalise and validate a permission string, or raise. + + Called on every write path (role editing, override editing) so malformed + grants never reach the database — an unmatched permission is invisible + rather than loud, so the check belongs at the boundary. + """ + p = (raw or "").strip().lower() + if not p: + raise InvalidPermission("permission must not be empty") + if len(p) > MAX_PERMISSION_LEN: + raise InvalidPermission(f"permission exceeds {MAX_PERMISSION_LEN} chars") + if p == "*": + return p + + segments = p.split(":") + for i, seg in enumerate(segments): + is_last = i == len(segments) - 1 + if seg == "*": + if not is_last: + raise InvalidPermission( + f"'{raw}': '*' is only allowed as the final segment" + ) + continue + if not _SEGMENT_RE.match(seg): + raise InvalidPermission(f"'{raw}': invalid segment '{seg}'") + return p + + +# ── Matching ──────────────────────────────────────────────────────────────── + +def permission_matches(pattern: str, required: str) -> bool: + """Does a granted/denied ``pattern`` cover the ``required`` permission?""" + if pattern == required: + return True + if pattern == "*": + return True + if pattern.endswith(":*"): + prefix = pattern[:-1] # "feature:*" → "feature:" + return required.startswith(prefix) and len(required) > len(prefix) + return False + + +def matched_by(patterns: Iterable[str], required: str) -> str | None: + """Return the first pattern covering ``required``, else None. + + Returning the *pattern* rather than a bool is what lets the admin UI + explain itself ("denied by `feature:*`" beats a bare "no"). + """ + for pattern in patterns: + if permission_matches(pattern, required): + return pattern + return None + + +def specificity(pattern: str, required: str) -> int: + """How precisely ``pattern`` names ``required``. Higher is more specific. + + An exact match outranks every wildcard; among wildcards, the longer literal + prefix wins; bare ``*`` is the floor. Only meaningful for a pattern that + actually matches. + """ + if pattern == required: + return 1_000_000 + if pattern == "*": + return 0 + return len(pattern) - 1 # literal prefix length, minus the '*' + + +def most_specific(patterns: Iterable[str], required: str) -> str | None: + """The matching pattern that names ``required`` most precisely.""" + best: str | None = None + best_score = -1 + for pattern in patterns: + if not permission_matches(pattern, required): + continue + score = specificity(pattern, required) + if score > best_score: + best, best_score = pattern, score + return best + + +# ── Resolved access ───────────────────────────────────────────────────────── + +@dataclass(frozen=True, slots=True) +class AccessDecision: + """Why a permission resolved the way it did. Powers the admin UI preview.""" + + permission: str + allowed: bool + #: "deny-override" | "allow-override" | "role" | "default-deny" + source: str + #: The pattern that decided it, if any. + pattern: str | None = None + + +@dataclass(frozen=True, slots=True) +class EffectiveAccess: + """A principal's fully-resolved permission set. + + Three pattern sets, kept separate because the resolution rule treats them + differently (see the module docstring): ``role_granted`` is the baseline, + ``allowed`` and ``denied`` are the per-user exceptions that compete with + each other by specificity. + + All three hold *patterns*, not expanded permissions, so a wildcard grant + stays a wildcard and a newly-registered agent is covered by + ``agents:run:*`` without anyone re-saving a role. + """ + + roles: frozenset[str] = frozenset() + role_granted: frozenset[str] = frozenset() + #: allow-overrides + allowed: frozenset[str] = frozenset() + #: deny-overrides + denied: frozenset[str] = frozenset() + #: False for suspended/removed/unknown members — they resolve to nothing. + is_active: bool = True + + @property + def granted(self) -> frozenset[str]: + """Everything that grants, for display and export. Not the decision.""" + return self.role_granted | self.allowed + + def decide(self, required: str) -> AccessDecision: + if not self.is_active: + return AccessDecision(required, False, "inactive") + + # Layer 1 — overrides, most specific wins, ties go to deny. + allow = most_specific(self.allowed, required) + deny = most_specific(self.denied, required) + if allow is not None or deny is not None: + if deny is not None and ( + allow is None + or specificity(deny, required) >= specificity(allow, required) + ): + return AccessDecision(required, False, "deny-override", deny) + return AccessDecision(required, True, "allow-override", allow) + + # Layer 2 — the role baseline. + role = matched_by(self.role_granted, required) + if role is not None: + return AccessDecision(required, True, "role", role) + return AccessDecision(required, False, "default-deny") + + def has(self, required: str) -> bool: + return self.decide(required).allowed + + def can_use_feature(self, slug: str) -> bool: + return self.has(feature_permission(slug)) + + def can_run_agent(self, agent_name: str) -> bool: + return self.has(agent_run_permission(agent_name)) + + def allowed_features(self) -> tuple[str, ...]: + """The feature slugs this principal may reach, in catalog order.""" + return tuple(f for f in FEATURES if self.can_use_feature(f)) + + def intersect(self, other: "EffectiveAccess") -> "EffectiveAccess": + """Narrow this access by ``other`` — used for agent runs. + + An agent acts on behalf of a member and must never exceed them, so + grants intersect (a pattern survives only if the other side also covers + it) while denials union. Widening is impossible by construction. + + Both sides' grant sets are flattened to the baseline layer: an + allow-override is an exception *for a person*, and carrying it into the + combined set would let it out-specify a denial from the other side — + the one thing this method exists to prevent. + """ + mine, theirs = self.granted, other.granted + granted = frozenset( + p for p in mine if matched_by(theirs, p) is not None + ) | frozenset( + p for p in theirs if matched_by(mine, p) is not None + ) + return EffectiveAccess( + roles=self.roles | other.roles, + role_granted=granted, + denied=self.denied | other.denied, + is_active=self.is_active and other.is_active, + ) + + +def build_access( + role_permissions: Iterable[str], + overrides: Iterable[tuple[str, str]] = (), + roles: Iterable[str] = (), + *, + is_active: bool = True, +) -> EffectiveAccess: + """Combine role grants with ``(permission, effect)`` overrides. + + Invalid strings are dropped rather than raised on: this runs on the read + path for every request, and one bad row written before validation existed + must not lock a member out of the product. + """ + role_granted: set[str] = set() + allowed: set[str] = set() + denied: set[str] = set() + + for raw in role_permissions: + try: + role_granted.add(validate_permission(raw)) + except InvalidPermission: + continue + + for raw, effect in overrides: + try: + perm = validate_permission(raw) + except InvalidPermission: + continue + if effect == "deny": + denied.add(perm) + elif effect == "allow": + allowed.add(perm) + + return EffectiveAccess( + roles=frozenset(roles), + role_granted=frozenset(role_granted), + allowed=frozenset(allowed), + denied=frozenset(denied), + is_active=is_active, + ) + + +#: What an unknown / signed-out / suspended principal gets. +NO_ACCESS = EffectiveAccess(is_active=False) diff --git a/packages/acb_auth/acb_auth/roles.py b/packages/acb_auth/acb_auth/roles.py index 4972aa87..8c66c942 100644 --- a/packages/acb_auth/acb_auth/roles.py +++ b/packages/acb_auth/acb_auth/roles.py @@ -1,21 +1,28 @@ -"""User roles and context for the AI Company Brain RBAC scaffold (WBS 1.7). +"""User roles and the per-request identity context. -Two user-facing roles exist in Phase 1: - EXECUTIVE -- full access, including sensitive sales/pipeline data. - EMPLOYEE -- general internal access; sales pipeline is gated. +Two layers live here, and the split matters: -A third internal role, AGENT, is reserved for service-to-service calls -(e.g. the orchestrator calling itself via the gateway). +**Legacy coarse role** (``UserRole``) — ``executive`` / ``employee`` / ``agent``, +derived from the ``X-User-Role`` header. Every route written before the org +access-control work guards on this via ``require_role()``, so it is kept +verbatim: deploying migration 128 changes nobody's access. -The role is derived from the X-User-Role header set by the Next.js SSO proxy. -No DB lookup happens here. The Next.js layer reads Person.role from Postgres -once at session creation and stamps it on every downstream request header. +**Effective access** (``UserContext.access``) — the DB-backed permission set +resolved from the member's roles and per-user overrides. New code guards on +this via ``require_permission()``. See +``ai-company-brain/specs/org_access_control.md``. + +The two coexist by design. `app_user.role` is dual-written and the migration +maps executive→admin / employee→member, so a route can move from one to the +other independently. """ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import StrEnum +from acb_auth.permissions import NO_ACCESS, EffectiveAccess + class UserRole(StrEnum): EXECUTIVE = "executive" @@ -35,10 +42,22 @@ def _coerce_role(raw: str | None) -> "UserRole": @dataclass(slots=True, frozen=True) class UserContext: - """Resolved identity for one request.""" + """Resolved identity for one request. + + ``email`` and ``role`` are the original, still-load-bearing fields. The + rest default to "nothing", so any code path that builds a UserContext + without resolving access gets default-deny rather than accidental + authority. + """ email: str | None role: UserRole + #: `app_user.id`. None until the member is provisioned in the org. + user_id: str | None = None + organization_id: str | None = None + access: EffectiveAccess = field(default=NO_ACCESS) + + # ── Legacy coarse role ────────────────────────────────────────────────── @property def is_executive(self) -> bool: @@ -50,4 +69,46 @@ def is_employee(self) -> bool: @property def is_agent(self) -> bool: - return self.role is UserRole.AGENT \ No newline at end of file + return self.role is UserRole.AGENT + + # ── Effective access ──────────────────────────────────────────────────── + + @property + def roles(self) -> frozenset[str]: + """Org role slugs held by this member (e.g. ``{"admin"}``).""" + return self.access.roles + + @property + def permissions(self) -> frozenset[str]: + """Granted permission *patterns* — wildcards are not expanded.""" + return self.access.granted + + def has_permission(self, permission: str) -> bool: + return self.access.has(permission) + + def can_use_feature(self, slug: str) -> bool: + """``can_use_feature("whatsapp")`` → ``feature:whatsapp``.""" + return self.access.can_use_feature(slug) + + def can_run_agent(self, agent_name: str) -> bool: + """``can_run_agent("agent-sales")`` → ``agents:run:agent-sales``.""" + return self.access.can_run_agent(agent_name) + + def with_access( + self, + access: EffectiveAccess, + *, + user_id: str | None = None, + organization_id: str | None = None, + ) -> "UserContext": + """Return a copy carrying resolved access (the context is frozen).""" + return UserContext( + email=self.email, + role=self.role, + user_id=user_id if user_id is not None else self.user_id, + organization_id=( + organization_id if organization_id is not None + else self.organization_id + ), + access=access, + ) diff --git a/packages/acb_auth/pyproject.toml b/packages/acb_auth/pyproject.toml index d63cabed..74f99881 100644 --- a/packages/acb_auth/pyproject.toml +++ b/packages/acb_auth/pyproject.toml @@ -6,6 +6,9 @@ requires-python = ">=3.12,<3.14" dependencies = [ "fastapi>=0.115", "acb-common", + # Org access control resolves roles/overrides from Postgres (acb_auth.access). + # Imported lazily so the pure permission model stays usable without a DB. + "sqlalchemy>=2.0", ] [build-system] diff --git a/tests/unit/test_org_access_control.py b/tests/unit/test_org_access_control.py new file mode 100644 index 00000000..250d4837 --- /dev/null +++ b/tests/unit/test_org_access_control.py @@ -0,0 +1,359 @@ +"""Org access control — permission model and guard behaviour. + +Spec: ai-company-brain/specs/org_access_control.md + +These cover the pure resolution layer (acb_auth.permissions) and the FastAPI +guards, which is where a mistake is silent: a permission that matches nothing +is invisible rather than loud, and a wildcard that matches too much looks +identical to one that matches correctly until someone reaches data they +shouldn't. +""" +from __future__ import annotations + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from acb_auth import ( + ASSIGNABLE_SYSTEM_ROLES, + CAPABILITIES, + FEATURES, + SYSTEM_ROLES, + EffectiveAccess, + InvalidPermission, + UserContext, + UserRole, + agent_run_permission, + assert_can_run_agent, + build_access, + feature_permission, + get_current_user, + permission_matches, + require_any_permission, + require_permission, + validate_permission, +) +from acb_auth.access import SERVICE_ACCESS, legacy_access + + +# ── Matching ──────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize( + ("pattern", "required", "expected"), + [ + ("feature:whatsapp", "feature:whatsapp", True), + ("feature:whatsapp", "feature:email", False), + ("*", "anything:at:all", True), + ("feature:*", "feature:whatsapp", True), + ("feature:*", "feature:build.apps", True), + ("feature:*", "agents:run:x", False), + ("agents:run:*", "agents:run:agent-sales", True), + ("agents:*", "agents:run:agent-sales", True), + ("agents:run:*", "agents:manage", False), + # A prefix wildcard must not match the bare prefix itself: holding + # "feature:*" is not holding a permission literally named "feature:". + ("feature:*", "feature:", False), + # Prefix must land on a segment boundary, or "feature:*" would cover an + # unrelated "featureflags:x". + ("feature:*", "featureflags:x", False), + ], +) +def test_permission_matches(pattern: str, required: str, expected: bool) -> None: + assert permission_matches(pattern, required) is expected + + +# ── Validation ────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize( + "raw", + ["feature:whatsapp", "*", "agents:run:*", "AGENTS:RUN:X", " feature:chat "], +) +def test_validate_accepts(raw: str) -> None: + assert validate_permission(raw) == raw.strip().lower() + + +@pytest.mark.parametrize( + "raw", + [ + "", + " ", + "feature:*:read", # inward wildcard — unauditable, refused + "*:read", + "feature:what sapp", # whitespace inside a segment + "feature:" + "x" * 200, # over the length ceiling + ], +) +def test_validate_rejects(raw: str) -> None: + with pytest.raises(InvalidPermission): + validate_permission(raw) + + +# ── Resolution ────────────────────────────────────────────────────────────── + +def test_default_is_deny() -> None: + access = build_access(["feature:chat"]) + assert access.has("feature:chat") + assert not access.has("feature:whatsapp") + assert not access.has("admin:members:read") + + +def test_deny_override_beats_a_wildcard_role_grant() -> None: + """The headline case: `admin`-style blanket access, minus two things.""" + access = build_access( + ["feature:*", "agents:run:*"], + [("feature:whatsapp", "deny"), ("feature:build.apps", "deny")], + roles=["admin"], + ) + assert access.can_use_feature("email") + assert not access.can_use_feature("whatsapp") + assert not access.can_use_feature("build.apps") + assert access.can_run_agent("agent-sales") + + +def test_allow_override_adds_a_single_agent_on_top_of_a_denied_wildcard() -> None: + """"No agents except this one" — deny the blanket, allow one by name.""" + access = build_access( + ["feature:chat", "agents:run:*"], + [ + ("agents:run:*", "deny"), + ("agents:run:email-assistant", "allow"), + ], + roles=["member"], + ) + assert access.can_run_agent("email-assistant") + assert not access.can_run_agent("whatsapp-assistant") + assert not access.can_run_agent("app-builder") + + +def test_deny_wins_when_two_overrides_are_equally_specific() -> None: + access = build_access( + ["feature:whatsapp"], + [("feature:whatsapp", "allow"), ("feature:whatsapp", "deny")], + ) + assert not access.can_use_feature("whatsapp") + + +def test_a_wildcard_deny_override_beats_an_exact_role_grant() -> None: + """Specificity orders overrides against each other — never against roles. + + If a role's exact `feature:chat` could out-specify an admin's explicit + `feature:*` deny, "switch everything off for this person" would silently + leave holes. + """ + access = build_access( + ["feature:chat", "feature:email"], + [("feature:*", "deny")], + roles=["member"], + ) + assert not access.can_use_feature("chat") + assert not access.can_use_feature("email") + assert access.allowed_features() == () + + +def test_more_specific_deny_beats_a_broader_allow_override() -> None: + access = build_access( + [], + [("agents:run:*", "allow"), ("agents:run:app-builder", "deny")], + ) + assert access.can_run_agent("email-assistant") + assert not access.can_run_agent("app-builder") + + +def test_bare_star_is_the_least_specific_pattern() -> None: + access = build_access([], [("*", "deny"), ("feature:chat", "allow")]) + assert access.can_use_feature("chat") + assert not access.can_use_feature("email") + + +def test_granted_exposes_roles_and_allow_overrides_together() -> None: + """`granted` is for display/export; the decision uses the layers.""" + access = build_access(["feature:chat"], [("feature:email", "allow")]) + assert access.granted == frozenset({"feature:chat", "feature:email"}) + assert access.role_granted == frozenset({"feature:chat"}) + assert access.allowed == frozenset({"feature:email"}) + + +def test_suspended_member_resolves_to_nothing() -> None: + access = build_access(["*"], roles=["owner"], is_active=False) + assert not access.has("feature:chat") + assert not access.has("admin:members:read") + assert access.allowed_features() == () + + +def test_malformed_stored_permissions_are_skipped_not_fatal() -> None: + """A bad row written before validation existed must not lock anyone out.""" + access = build_access(["feature:chat", "feature:*:read", ""]) + assert access.can_use_feature("chat") + + +def test_decision_explains_its_provenance() -> None: + access = build_access( + ["feature:*"], [("feature:whatsapp", "deny")], roles=["admin"] + ) + granted = access.decide("feature:email") + assert granted.allowed and granted.source == "role" + assert granted.pattern == "feature:*" + + denied = access.decide("feature:whatsapp") + assert not denied.allowed and denied.source == "deny-override" + assert denied.pattern == "feature:whatsapp" + + missing = access.decide("admin:roles:manage") + assert not missing.allowed and missing.source == "default-deny" + + +def test_allowed_features_follows_catalog_order() -> None: + access = build_access(["feature:*"]) + assert access.allowed_features() == FEATURES + + +# ── Agent inheritance ─────────────────────────────────────────────────────── + +def test_intersect_never_widens_access() -> None: + """An agent run inherits the caller's set; it can only narrow.""" + caller = build_access(["feature:chat", "agents:run:email-assistant"]) + agent = build_access(["*"]) + combined = agent.intersect(caller) + assert combined.can_run_agent("email-assistant") + assert not combined.can_run_agent("app-builder") + assert not combined.has("admin:members:read") + + +def test_intersect_unions_denials() -> None: + a = build_access(["feature:*"], [("feature:whatsapp", "deny")]) + b = build_access(["feature:*"], [("feature:email", "deny")]) + combined = a.intersect(b) + assert not combined.can_use_feature("whatsapp") + assert not combined.can_use_feature("email") + assert combined.can_use_feature("chat") + + +# ── Vocabulary invariants ─────────────────────────────────────────────────── + +def test_every_declared_permission_is_valid() -> None: + for slug in FEATURES: + validate_permission(feature_permission(slug)) + for cap in CAPABILITIES: + validate_permission(cap) + + +def test_agent_service_is_not_assignable_to_people() -> None: + assert "agent_service" in SYSTEM_ROLES + assert "agent_service" not in ASSIGNABLE_SYSTEM_ROLES + + +def test_legacy_roles_map_onto_the_new_model() -> None: + """Deploying the migration must not change anyone's access.""" + exec_access = legacy_access("executive") + assert exec_access.can_use_feature("whatsapp") + assert exec_access.has("admin:members:manage") + + employee = legacy_access("employee") + assert employee.can_use_feature("chat") + assert not employee.has("admin:members:manage") + # The seeded `member` role deliberately withholds these. + assert not employee.can_use_feature("whatsapp") + assert not employee.can_use_feature("build.apps") + + +def test_unknown_legacy_role_falls_back_to_member() -> None: + assert legacy_access("nonsense").roles == frozenset({"member"}) + + +# ── UserContext ───────────────────────────────────────────────────────────── + +def test_user_context_defaults_to_no_access() -> None: + """Any construction path that skips resolution must not grant anything.""" + user = UserContext(email="a@fracktal.in", role=UserRole.EXECUTIVE) + assert not user.has_permission("feature:chat") + assert user.permissions == frozenset() + assert user.roles == frozenset() + + +def test_with_access_preserves_identity() -> None: + user = UserContext(email="a@fracktal.in", role=UserRole.EMPLOYEE) + enriched = user.with_access( + build_access(["feature:chat"], roles=["member"]), + user_id="u-1", + organization_id="o-1", + ) + assert enriched.email == "a@fracktal.in" + assert enriched.user_id == "u-1" + assert enriched.organization_id == "o-1" + assert enriched.can_use_feature("chat") + # Frozen dataclass: the original is untouched. + assert not user.can_use_feature("chat") + + +# ── Guards ────────────────────────────────────────────────────────────────── + +def _client(access: EffectiveAccess, *, email: str | None = "a@fracktal.in") -> TestClient: + app = FastAPI() + + async def _fake_user() -> UserContext: + return UserContext(email=email, role=UserRole.EMPLOYEE, access=access) + + @app.get("/whatsapp", dependencies=[require_permission("feature:whatsapp")]) + async def _whatsapp() -> dict[str, bool]: + return {"ok": True} + + @app.get( + "/either", + dependencies=[require_any_permission("feature:whatsapp", "admin:members:read")], + ) + async def _either() -> dict[str, bool]: + return {"ok": True} + + @app.get("/run/{name}") + async def _run(name: str, user: UserContext = Depends(_fake_user)) -> dict[str, bool]: + assert_can_run_agent(user, name) + return {"ok": True} + + app.dependency_overrides[get_current_user] = _fake_user + return TestClient(app) + + +def test_require_permission_allows_the_holder() -> None: + client = _client(build_access(["feature:whatsapp"])) + assert client.get("/whatsapp").status_code == 200 + + +def test_require_permission_403s_without_it() -> None: + client = _client(build_access(["feature:chat"])) + res = client.get("/whatsapp") + assert res.status_code == 403 + # The body names what is missing so an access bug is self-diagnosing. + assert "feature:whatsapp" in res.json()["detail"] + + +def test_require_permission_401s_when_anonymous() -> None: + """401 and 403 are different problems: sign in vs. ask an admin.""" + client = _client(build_access(["feature:whatsapp"]), email=None) + assert client.get("/whatsapp").status_code == 401 + + +def test_require_any_permission_needs_only_one() -> None: + client = _client(build_access(["admin:members:read"])) + assert client.get("/either").status_code == 200 + assert client.get("/whatsapp").status_code == 403 + + +def test_agent_run_gate() -> None: + client = _client( + build_access( + ["agents:run:*"], [("agents:run:app-builder", "deny")] + ) + ) + assert client.get("/run/email-assistant").status_code == 200 + res = client.get("/run/app-builder") + assert res.status_code == 403 + assert "app-builder" in res.json()["detail"] + + +def test_service_principal_runs_any_agent() -> None: + """Internal-token callers already hold total authority; no theatre.""" + user = UserContext( + email="system:internal", role=UserRole.AGENT, access=SERVICE_ACCESS + ) + assert_can_run_agent(user, "anything") # must not raise + assert user.has_permission(agent_run_permission("anything")) diff --git a/uv.lock b/uv.lock index 4beb7166..b3dfd791 100644 --- a/uv.lock +++ b/uv.lock @@ -62,12 +62,14 @@ source = { editable = "packages/acb_auth" } dependencies = [ { name = "acb-common" }, { name = "fastapi" }, + { name = "sqlalchemy" }, ] [package.metadata] requires-dist = [ { name = "acb-common", editable = "packages/acb_common" }, { name = "fastapi", specifier = ">=0.115" }, + { name = "sqlalchemy", specifier = ">=2.0" }, ] [[package]] diff --git a/workbench/AGENTS.md b/workbench/AGENTS.md index d4f99d80..935daebd 100644 --- a/workbench/AGENTS.md +++ b/workbench/AGENTS.md @@ -9,7 +9,13 @@ Control Plane (Next.js browser UI) and local development tools. - control_plane/src/app/email/components/ContactCard.tsx -- People card (Outlook parity). `ContactTrigger` wraps any avatar/name/recipient to open it; `RecipientList` renders a clickable To:/Cc: line; every field carries a `CopyButton`. Backed by GET /email/contacts/card, which also files what it learns into the server-side contacts directory — pass the display name you already have so recipients you only ever write TO are filed under a name, not a bare address. The trigger renders a real + + + {(error || notice) && ( +
+ {error || notice} + +
+ )} + +
+ {/* Role */} +
} + title="Role" + subtitle="The baseline. Everything below adjusts it for this person only." + > +
+ {assignable.map((r) => { + const held = data.roles.includes(r.slug); + return ( + + ); + })} +
+
+ + {/* Features */} +
} + title="Apps and features" + subtitle="Which parts of CommandCenter this person can open." + > + d.slug ?? d.permission} + overrides={overrides} + reasons={reasons} + onEffect={setEffect} + onReason={(p, v) => setReasons((r) => ({ ...r, [p]: v }))} + /> +
+ + {/* Agents */} +
} + title="Agents" + subtitle="Which agents this person can run. Denying the role's blanket access and allowing individual agents is the usual pattern." + > + {data.agents.length === 0 ? ( +

+ No agents registered yet. +

+ ) : ( + <> +
+ + + To restrict someone to specific agents, set{" "} + agents:run:* below to{" "} + Deny, then allow the individual agents. + +
+ setReasons((r) => ({ ...r, [p]: v }))} + /> + d.name ?? d.permission} + overrides={overrides} + reasons={reasons} + onEffect={setEffect} + onReason={(p, v) => setReasons((r) => ({ ...r, [p]: v }))} + /> + + )} +
+ + {/* Capabilities */} +
} + title="Administration and capabilities" + subtitle="Platform-level permissions, separate from which apps are visible." + > + d.permission} + mono + overrides={overrides} + reasons={reasons} + onEffect={setEffect} + onReason={(p, v) => setReasons((r) => ({ ...r, [p]: v }))} + /> +
+
+ + ); +} + +// ── Presentational pieces ─────────────────────────────────────────────────── + +function Section({ + icon, + title, + subtitle, + children, +}: { + icon: React.ReactNode; + title: string; + subtitle: string; + children: React.ReactNode; +}) { + return ( +
+
+

+ {icon} + {title} +

+

{subtitle}

+
+ {children} +
+ ); +} + +function DecisionTable({ + decisions, + labelOf, + mono = false, + overrides, + reasons, + onEffect, + onReason, +}: { + decisions: Decision[]; + labelOf: (d: Decision) => string; + mono?: boolean; + overrides: Overrides; + reasons: Record; + onEffect: (permission: string, effect: Effect) => void; + onReason: (permission: string, value: string) => void; +}) { + return ( +
+ {decisions.map((d) => ( + + ))} +
+ ); +} + +function DecisionRow({ + permission, + label, + decision, + mono = false, + overrides, + reasons, + onEffect, + onReason, +}: { + permission: string; + label: string; + decision: Decision; + mono?: boolean; + overrides: Overrides; + reasons: Record; + onEffect: (permission: string, effect: Effect) => void; + onReason: (permission: string, value: string) => void; +}) { + const effect: Effect = overrides[permission] ?? "inherit"; + // The server's decision reflects the SAVED state; while an override is + // pending it would contradict the control the admin just clicked, so the + // pending choice wins for display. + const allowed = + effect === "allow" ? true : effect === "deny" ? false : decision.allowed; + + return ( +
+
+
+
+ {label} +
+
+ {effect === "inherit" + ? explainSource(decision) + : effect === "allow" + ? "allowed for this person specifically" + : "denied for this person specifically"} +
+
+ + + + {allowed ? "on" : "off"} + + +
+ {(["inherit", "allow", "deny"] as Effect[]).map((e) => ( + + ))} +
+
+ + {effect !== "inherit" && ( + onReason(permission, ev.target.value)} + placeholder="Why? (recorded alongside the exception)" + className="mt-2 w-full rounded-lg border border-border bg-background px-2.5 py-1.5 text-[11px] text-foreground outline-none focus:border-primary/50" + /> + )} +
+ ); +} diff --git a/workbench/control_plane/src/app/settings/members/page.tsx b/workbench/control_plane/src/app/settings/members/page.tsx new file mode 100644 index 00000000..35470acd --- /dev/null +++ b/workbench/control_plane/src/app/settings/members/page.tsx @@ -0,0 +1,410 @@ +"use client"; + +/** + * Settings → Members — the organization roster. + * + * Spec: ai-company-brain/specs/org_access_control.md §6. + * + * Invite, suspend, change roles, and drill into one person's access. The + * per-person editor is where the interesting work happens (./[email]); this + * page is the list that gets you there. + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { + Loader2, + Plus, + RefreshCw, + ShieldOff, + UserPlus, + X, +} from "lucide-react"; +import FilterPills from "@/components/FilterPills"; +import { useAccess } from "@/components/AccessProvider"; +import type { Member, Role } from "./types"; + +const STATUS_STYLES: Record = { + active: "text-success", + invited: "text-warning", + suspended: "text-destructive", + removed: "text-muted-foreground", +}; + +export default function MembersPage() { + const { access, refresh: refreshAccess } = useAccess(); + const [members, setMembers] = useState([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [filter, setFilter] = useState("all"); + const [inviting, setInviting] = useState(false); + + const load = useCallback(async () => { + try { + // The fetch goes first so no setState runs synchronously in the mount + // effect — clearing the error afterwards is equivalent and avoids a + // cascading render. + const [m, r] = await Promise.all([ + fetch("/api/admin/members", { cache: "no-store" }), + fetch("/api/admin/roles", { cache: "no-store" }), + ]); + setError(""); + if (!m.ok) { + const body = await m.json().catch(() => ({})); + throw new Error(body.detail ?? `Failed to load members (${m.status})`); + } + setMembers(await m.json()); + if (r.ok) setRoles(await r.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load members."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + // Wrapped rather than called directly: the effect must not reach a + // setState synchronously (react-hooks/set-state-in-effect), and `load` is + // also invoked from refresh buttons, so it stays a useCallback. + const run = async () => { + await load(); + }; + void run(); + }, [load]); + + const counts = useMemo( + () => ({ + all: members.length, + active: members.filter((m) => m.status === "active").length, + invited: members.filter((m) => m.status === "invited").length, + suspended: members.filter((m) => m.status === "suspended").length, + }), + [members] + ); + + const shown = useMemo( + () => (filter === "all" ? members : members.filter((m) => m.status === filter)), + [members, filter] + ); + + const setStatus = async (email: string, status: Member["status"]) => { + setError(""); + const res = await fetch(`/api/admin/members/${encodeURIComponent(email)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setError(body.detail ?? "Could not update this member."); + return; + } + await load(); + // The admin may have just changed their own access. + await refreshAccess(); + }; + + if (!access.is_admin && !loading) { + return ( +
+
+ +

+ Members is admin-only +

+

+ You need the admin:members:read{" "} + permission to manage the organization roster. +

+
+
+ ); + } + + return ( +
+
+
+

Members

+

+ {access.organization?.display_name ?? "Organization"} ·{" "} + {counts.active} active of {counts.all} +

+
+
+ + + Roles + + +
+
+ +
+ +
+ + {error && ( +
+ {error} + +
+ )} + +
+ {loading ? ( +
+ Loading members… +
+ ) : shown.length === 0 ? ( +

No members here yet.

+ ) : ( +
+ {shown.map((m) => ( +
+
+ + {m.display_name || m.email} + +
+ {m.email} +
+
+ +
+ {m.roles.length === 0 ? ( + + no role + + ) : ( + m.roles.map((r) => ( + + {roles.find((x) => x.slug === r)?.display_name ?? r} + + )) + )} +
+ +
+ + + {m.status} + +
+ +
+ + Manage access + + {m.status === "suspended" || m.status === "invited" ? ( + + ) : ( + + )} +
+
+ ))} +
+ )} +
+ + {inviting && ( + setInviting(false)} + onDone={async () => { + setInviting(false); + await load(); + }} + /> + )} +
+ ); +} + +function InviteDialog({ + roles, + onClose, + onDone, +}: { + roles: Role[]; + onClose: () => void; + onDone: () => Promise; +}) { + const [email, setEmail] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [role, setRole] = useState("member"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const assignable = roles.filter((r) => r.slug !== "agent_service"); + + const submit = async () => { + setBusy(true); + setError(""); + try { + const res = await fetch("/api/admin/members", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: email.trim(), + display_name: displayName.trim(), + roles: [role], + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.detail ?? "Invite failed."); + } + await onDone(); + } catch (e) { + setError(e instanceof Error ? e.message : "Invite failed."); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+
+
+

+ Invite a member +

+

+ Sign-in is Microsoft SSO — this provisions their access, it does + not send an email. +

+
+ +
+ + + setEmail(e.target.value)} + placeholder="person@fracktal.in" + className="mb-3 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary/50" + /> + + + setDisplayName(e.target.value)} + className="mb-3 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary/50" + /> + + + +

+ {assignable.find((r) => r.slug === role)?.description ?? ""} +

+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/workbench/control_plane/src/app/settings/members/types.ts b/workbench/control_plane/src/app/settings/members/types.ts new file mode 100644 index 00000000..8e2cc949 --- /dev/null +++ b/workbench/control_plane/src/app/settings/members/types.ts @@ -0,0 +1,93 @@ +// Wire types for the org administration API (gateway routes/admin/*). +// Spec: ai-company-brain/specs/org_access_control.md §6. + +export type Member = { + email: string; + display_name: string; + avatar_url?: string; + status: "invited" | "active" | "suspended" | "removed"; + roles: string[]; + invited_by?: string; + joined_at?: string; + last_login_at?: string; +}; + +export type Role = { + slug: string; + display_name: string; + description: string; + is_system: boolean; + rank: number; + permissions: string[]; + member_count: number; +}; + +export type Feature = { + slug: string; + label: string; + description: string; + nav_href: string; + category: "apps" | "configure" | "build"; + sort_order: number; + is_default: boolean; + permission: string; +}; + +/** + * One resolved permission plus why it resolved that way. `source` is the whole + * point of this screen: an admin should never have to replay the resolution + * algorithm to understand what they're looking at. + */ +export type Decision = { + permission: string; + allowed: boolean; + /** "role" | "deny-override" | "allow-override" | "default-deny" | "inactive" */ + source: string; + /** The granted/denied pattern that decided it, e.g. "feature:*". */ + pattern: string; + /** Which role contributed `pattern`, when source is "role". */ + via_role: string; + /** Present on feature decisions. */ + slug?: string; + /** Present on agent decisions. */ + name?: string; +}; + +export type MemberAccess = { + email: string; + display_name: string; + status: Member["status"]; + roles: string[]; + granted: string[]; + denied: string[]; + features: Decision[]; + capabilities: Decision[]; + agents: Decision[]; + overrides: { + permission: string; + effect: "allow" | "deny"; + reason: string; + set_by: string; + set_at: string; + }[]; +}; + +/** Human wording for a decision's provenance. */ +export function explainSource(d: Decision): string { + switch (d.source) { + case "role": + return d.via_role + ? `granted by role “${d.via_role}”${d.pattern !== d.permission ? ` via ${d.pattern}` : ""}` + : `granted by ${d.pattern}`; + case "allow-override": + return "allowed for this person specifically"; + case "deny-override": + return d.pattern === d.permission + ? "denied for this person specifically" + : `denied for this person via ${d.pattern}`; + case "inactive": + return "no access — membership is not active"; + default: + return "not granted by any role"; + } +} diff --git a/workbench/control_plane/src/app/settings/roles/page.tsx b/workbench/control_plane/src/app/settings/roles/page.tsx new file mode 100644 index 00000000..6c965575 --- /dev/null +++ b/workbench/control_plane/src/app/settings/roles/page.tsx @@ -0,0 +1,374 @@ +"use client"; + +/** + * Settings → Roles — the permission bundles members are assigned. + * + * Spec: ai-company-brain/specs/org_access_control.md §3.2. + * + * The five system roles are read-only: they are the floor the bootstrap path + * depends on, and an org whose `admin` role has been edited into uselessness + * has no way back. Custom roles are where local policy lives — but note the + * copy nudging toward per-user overrides, because the failure mode of any + * role system is one role per employee. + */ + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { ArrowLeft, Loader2, Lock, Plus, Trash2, X } from "lucide-react"; +import { useAccess } from "@/components/AccessProvider"; +import type { Feature, Role } from "../members/types"; + +export default function RolesPage() { + const { access } = useAccess(); + const [roles, setRoles] = useState([]); + const [features, setFeatures] = useState([]); + const [capabilities, setCapabilities] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [creating, setCreating] = useState(false); + + const load = useCallback(async () => { + try { + // Fetch first: no synchronous setState inside the mount effect. + const [r, f] = await Promise.all([ + fetch("/api/admin/roles", { cache: "no-store" }), + fetch("/api/admin/features", { cache: "no-store" }), + ]); + setError(""); + if (!r.ok) { + const body = await r.json().catch(() => ({})); + throw new Error(body.detail ?? `Could not load roles (${r.status})`); + } + setRoles(await r.json()); + if (f.ok) { + const payload = await f.json(); + setFeatures(payload.features ?? []); + setCapabilities(payload.capabilities ?? []); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Could not load roles."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + // Wrapped rather than called directly: the effect must not reach a + // setState synchronously (react-hooks/set-state-in-effect), and `load` is + // also invoked from refresh buttons, so it stays a useCallback. + const run = async () => { + await load(); + }; + void run(); + }, [load]); + + const remove = async (slug: string) => { + setError(""); + const res = await fetch(`/api/admin/roles/${encodeURIComponent(slug)}`, { + method: "DELETE", + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setError(body.detail ?? "Could not delete this role."); + return; + } + await load(); + }; + + if (!access.is_admin && !loading) { + return ( +
+

Roles is admin-only.

+
+ ); + } + + return ( +
+
+
+ + + +
+

Roles

+

+ Permission bundles you assign to members +

+
+
+ +
+ + {error && ( +
+ {error} + +
+ )} + +
+

+ Need one person to have a little more or less than their role gives + them? Use per-user access on their member page instead of creating a + role for them — that is what it is for. +

+ + {loading ? ( +
+ Loading roles… +
+ ) : ( +
+ {roles.map((r) => ( +
+
+
+
+ + {r.display_name} + + + {r.slug} + + {r.is_system && ( + + system + + )} +
+

{r.description}

+
+
+ + {r.member_count} member{r.member_count === 1 ? "" : "s"} + + {!r.is_system && ( + + )} +
+
+
+ {r.permissions.map((p) => ( + + {p} + + ))} +
+
+ ))} +
+ )} +
+ + {creating && ( + setCreating(false)} + onDone={async () => { + setCreating(false); + await load(); + }} + /> + )} +
+ ); +} + +function CreateRoleDialog({ + features, + capabilities, + onClose, + onDone, +}: { + features: Feature[]; + capabilities: string[]; + onClose: () => void; + onDone: () => Promise; +}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const toggle = (permission: string) => + setSelected((prev) => { + const next = new Set(prev); + if (next.has(permission)) next.delete(permission); + else next.add(permission); + return next; + }); + + const submit = async () => { + setBusy(true); + setError(""); + try { + const res = await fetch("/api/admin/roles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + slug: name.trim().toLowerCase().replace(/\s+/g, "_"), + display_name: name.trim(), + description: description.trim(), + permissions: [...selected], + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.detail ?? "Could not create the role."); + } + await onDone(); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not create the role."); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+
+
+

New role

+

+ Custom roles always rank below the built-in ones. +

+
+ +
+ +
+ + setName(e.target.value)} + placeholder="Support Team" + className="mb-3 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary/50" + /> + + + setDescription(e.target.value)} + placeholder="What this role is for" + className="mb-4 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary/50" + /> + +

Apps

+
+ {features.map((f) => ( + toggle(f.permission)} + /> + ))} +
+ +

Capabilities

+
+ {capabilities.map((c) => ( + toggle(c)} + /> + ))} +
+
+ +
+ {error ? ( + {error} + ) : ( + + {selected.size} permission{selected.size === 1 ? "" : "s"} selected + + )} +
+ + +
+
+
+
+ ); +} + +function PermissionChip({ + label, + on, + mono = false, + onClick, +}: { + label: string; + on: boolean; + mono?: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/workbench/control_plane/src/components/AccessGate.tsx b/workbench/control_plane/src/components/AccessGate.tsx new file mode 100644 index 00000000..abd9533f --- /dev/null +++ b/workbench/control_plane/src/components/AccessGate.tsx @@ -0,0 +1,59 @@ +"use client"; + +/** + * AccessGate — blocks direct navigation to a route the member cannot reach. + * + * Enforcement seam 4 (spec §5). Complements the sidebar filter: hiding a link + * does nothing about someone typing the URL, and a page that renders its shell + * and then fills with 403s is a confusing way to learn you lack access. + * + * This is presentation. The data behind every one of these pages is gated at + * the gateway; this just replaces a wall of failed requests with one clear + * sentence. + */ + +import { usePathname } from "next/navigation"; +import { ShieldOff } from "lucide-react"; +import Link from "next/link"; +import { useAccess } from "@/components/AccessProvider"; +import { canSeePath } from "@/lib/access"; + +export default function AccessGate({ children }: { children: React.ReactNode }) { + const pathname = usePathname() ?? "/"; + const { access, loading } = useAccess(); + + // Don't flash "no access" before the first resolution lands. + if (loading) return <>{children}; + + // An unauthenticated viewer is middleware's problem (sign-in redirect), not + // ours — showing them "access denied" would misdescribe the situation. + if (!access.authenticated) return <>{children}; + + if (canSeePath(access, pathname)) return <>{children}; + + const suspended = !access.is_active; + + return ( +
+
+
+ +
+

+ {suspended ? "Your account is not active" : "You don't have access to this"} +

+

+ {suspended + ? "Your membership is suspended or pending. An organization admin can reactivate it." + : "This part of CommandCenter isn't enabled for your account. An organization admin can grant access from Settings → Members."} +

+ + Back to Chat + +
+
+ ); +} diff --git a/workbench/control_plane/src/components/AccessProvider.tsx b/workbench/control_plane/src/components/AccessProvider.tsx new file mode 100644 index 00000000..72300c24 --- /dev/null +++ b/workbench/control_plane/src/components/AccessProvider.tsx @@ -0,0 +1,84 @@ +"use client"; + +/** + * AccessProvider — the signed-in member's effective access, fetched once and + * shared by the whole shell. + * + * Spec: ai-company-brain/specs/org_access_control.md §5 (seams 4 and 5). + * + * Two consumers: the Sidebar filters nav panes with it, and AccessGate blocks + * direct navigation to a route the member cannot reach. Neither is a security + * boundary — the gateway re-authorizes every request — but a sidebar full of + * links that 403 is a worse product than one that shows what you actually have. + * + * `loading` matters: rendering the nav before access resolves would flash the + * full sidebar and then remove items, which reads as a bug. Consumers hold + * their layout until it clears. + */ + +import { + createContext, + useContext, + useCallback, + useEffect, + useState, + type ReactNode, +} from "react"; +import { NO_ACCESS, fetchAccess, type Access } from "@/lib/access"; + +type AccessCtx = { + access: Access; + loading: boolean; + /** Re-fetch after an admin change so the sidebar updates without a reload. */ + refresh: () => Promise; +}; + +const Ctx = createContext({ + access: NO_ACCESS, + loading: true, + refresh: async () => {}, +}); + +export function useAccess(): AccessCtx { + return useContext(Ctx); +} + +export default function AccessProvider({ children }: { children: ReactNode }) { + const [access, setAccess] = useState(NO_ACCESS); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + const next = await fetchAccess(); + setAccess(next); + setLoading(false); + }, []); + + useEffect(() => { + const controller = new AbortController(); + let alive = true; + (async () => { + const next = await fetchAccess(controller.signal); + if (alive) { + setAccess(next); + setLoading(false); + } + })(); + + // Re-resolve periodically so a revoked permission disappears from a + // long-lived tab. The gateway caches for 60s, so polling faster than that + // buys nothing. + const interval = setInterval(() => { + void refresh(); + }, 120_000); + + return () => { + alive = false; + controller.abort(); + clearInterval(interval); + }; + }, [refresh]); + + return ( + {children} + ); +} diff --git a/workbench/control_plane/src/components/AppShell.tsx b/workbench/control_plane/src/components/AppShell.tsx index 8c96192c..dd74c70d 100644 --- a/workbench/control_plane/src/components/AppShell.tsx +++ b/workbench/control_plane/src/components/AppShell.tsx @@ -28,7 +28,10 @@ import { X, Monitor, Smartphone, LogOut, Command, Mail, Zap, Inbox, ListChecks, import Sidebar from "@/components/Sidebar"; import { useViewMode } from "@/components/ViewModeProvider"; import { useActiveSessions } from "@/hooks/useActiveSessions"; -import { NAV_SECTIONS } from "@/lib/nav";import { ThemeToggleMenuItem } from "@/components/ThemeToggle"; +import { visibleSections } from "@/lib/nav"; +import AccessGate from "@/components/AccessGate"; +import { useAccess } from "@/components/AccessProvider"; +import { ThemeToggleMenuItem } from "@/components/ThemeToggle"; // The task manager's Focus Mode session (room + minimizable timer dock). Lives // in the SHELL so the running timer stays visible across every app in the // control plane; renders nothing when no focus session is active. @@ -82,7 +85,9 @@ export default function AppShell({ children }: { children: React.ReactNode }) { return (
-
{children}
+
+ {children} +
@@ -112,7 +117,9 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
{/* Page content — pb-nav reserves the fixed bottom bar's FULL height (content + safe-area inset), so nothing hides under it */} -
{children}
+
+ {children} +
{/* Bottom navigation bar — fixed at viewport bottom, never scrolls. pb-safe lifts it above the iOS home indicator */}
@@ -169,6 +176,10 @@ function MobileBottomNavInner({ const { data: session } = useSession(); const activeRunIds = useActiveSessions(); const activeCount = activeRunIds.size; + // Same access filter as the desktop Sidebar — the two navs must agree, or a + // pane hidden on desktop reappears in the phone drawer. + const { access, loading: accessLoading } = useAccess(); + const navSections = visibleSections(accessLoading ? null : access.features); const menuContent = ( <> @@ -192,7 +203,7 @@ function MobileBottomNavInner({