Skip to content

feat: user & permission management with per-user keys and grant enforcement - #75

Merged
jaymeklein merged 20 commits into
mainfrom
docs/claude-rules-and-standards-check
Jul 24, 2026
Merged

feat: user & permission management with per-user keys and grant enforcement#75
jaymeklein merged 20 commits into
mainfrom
docs/claude-rules-and-standards-check

Conversation

@jaymeklein

Copy link
Copy Markdown
Owner

What & why

The store had no notion of a user: authentication was a master key plus per-collection eb_ keys, and the embedded MCP server ran every tool as the master principal (any MCP client had full access). This PR introduces users with one API key each and hierarchical read/write grants over the workspace → collection → document tree, and makes both the REST API and the MCP server authenticate the caller and enforce those grants.

Requested capabilities — all delivered:

  • Register permissions — grant CRUD over workspaces/collections/documents.
  • Activate / deactivate usersis_active; an inactive user's key is rejected everywhere (REST, MCP, WebSocket).
  • One API key per user — mint / rotate / revoke; raw key shown once.
  • Choose who may access which resources — read/write grants at any level of the tree.
  • MCP uses the user's API key — the caller's Principal is resolved from their key and bound per request.
  • Unauthorized MCP actions are blocked server-side — each tool enforces the caller's grants and returns a 403 tool error when denied.

Key decisions

  • Full-stack — backend + MCP/REST enforcement + a master-key React admin console.
  • Hierarchical read/write grants — a grant cascades to descendants; write implies read. api/services/permissions.py is the single authorization authority (routers/tools call it and let it raise 403 — no ad-hoc checks).
  • Replaced per-collection keys with per-user keys (breaking) — the old collection-key flow is retired entirely.

Changes (by commit)

  1. feat(db)users + permissions tables; api_keys repointed from collection_id to user_id (UNIQUE(user_id)); Alembic 0008 creates the tables and rebuilds api_keys (verified up/down/up).
  2. feat(auth)Principal(is_master, user_id, api_key_id); inactive users → 403; record_key_use wired in (best-effort) so last_used_at is live; permissions.py authority (collection/document authorizers, readable-id narrowing, workspace-tree filter, grant CRUD).
  3. refactor(collections)! — retire the collection-key mint/list/revoke service, routes, and schema.
  4. feat(api) — grant enforcement on documents/search/indexing/ws; /search narrows to readable collections; delete/download authorize before resolving (no forbidden-vs-nonexistent oracle); ingest_local_path is now master-only.
  5. feat(users) — master-only /users API: user CRUD, activate/deactivate, key mint/rotate/revoke, and grant CRUD.
  6. feat(mcp) — middleware resolves the caller's Principal and binds it per request via a ContextVar; every tool enforces grants; rate-limit runs before the DB-backed auth.
  7. feat(ui) — Users admin console (list + active toggle, key modal with reveal-once/rotate/revoke, permissions editor over the resource tree); removes the collection-keys UI.
  8. test — authorizer/users/permissions unit coverage + /users and per-user MCP/REST integration coverage; shared fixtures migrated to user keys.

⚠️ Breaking / migration

  • Per-collection API key endpoints are removed — mint a user key and grant the user access to the collection instead.
  • Migration 0008 rebuilds api_keys, dropping any existing collection keys. After upgrading, re-mint keys as user keys.

Security

  • ingest_document (MCP) → ingest_local_path references an arbitrary container-local path, so it is master-only (an operator capability): a scoped write grant must not become an arbitrary server-file read or a cross-tenant copy. Covered by a regression test (test_ingest_document_requires_master).
  • Auth errors stay coarse (401 missing/invalid vs 403 inactive/unauthorized); key_hash and raw keys are never logged or returned (raw returned once at mint).
  • /security-review run: 1 HIGH found and fixed (the above); no other findings at or above High.

Verification

  • Gate green at the tip: ruff + mypy clean, 744 passed / 13 skipped, UI tsc clean, Alembic up/down/up round-trip.
  • Required skills run: /standards-check (clean), /code-review (6 findings fixed), /security-review (1 HIGH fixed).

Reviewer notes

  • MCP per-user identity via ContextVar — verified against mcp==1.28.1 + anyio==4.14.1 that stateless streamable HTTP spawns the handler task per request after the middleware sets the contextvar, so the principal propagates with no cross-request leak. This depends on stateless_http=True (noted in server.py).
  • The 8 commits are layered by concern and ordered by dependency; the cumulative tip is what passed the gate — intermediate commits are logical slices, not each independently green.
  • Rule docs were updated alongside the code they govern: database.md, permissions.md, api.md, mcp.md.

Jayme Klein added 20 commits July 21, 2026 09:08
Introduce the users table (one row per person, with is_active) and the
permissions table (per-user read/write grants over workspace, collection,
and document; polymorphic resource_id with no FK, like job_records).
Repoint api_keys from collection_id to user_id with UNIQUE(user_id) so each
user owns exactly one key. Alembic 0008 creates the new tables and rebuilds
api_keys; verified on an up/down/up round-trip.
Rework Principal to carry user_id/api_key_id and drop collection_id and
can_access. authenticate_api_key now loads the key's user and rejects an
inactive user with 403; record_key_use is wired in (best-effort) so
last_used_at stays live. Add permissions.py as the single authorization
authority: hierarchical read/write grants (write implies read, a grant
cascades to descendants), collection/document authorizers, readable-id
narrowing, workspace-tree filtering, and grant CRUD.
Remove the collection-key mint/list/revoke service functions, their
/{collection_id}/keys routes, and the now-unused APIKeyCreate schema (and
the secrets/bcrypt imports they pulled in). API keys are owned by users
going forward.

BREAKING CHANGE: the per-collection API key endpoints are removed. Mint a
user key and grant the user access to the collection instead.
Route document, indexing, search, and websocket access through the
permissions authority. /search drops from require_master to require_auth and
narrows the requested collections to those the caller may read (403 when
none survive). Document delete and download authorize before resolving, so
"forbidden" and "nonexistent" are indistinguishable (no existence oracle).
ingest_local_path is now master-only: referencing an arbitrary container
path is an operator capability, not something a scoped write grant confers.
Add the /users router (gated by require_master) for user CRUD,
activate/deactivate via is_active, minting/rotating/revoking the user's
single key (raw key returned once, mint replaces any existing key), and
grant CRUD delegated to the permissions service. Register the router before
the MCP mount so /mcp never shadows it.
The MCP middleware resolves the caller's Principal (master or active user
key) and binds it per request through a ContextVar; the tool wrappers read
it and each tool enforces that caller's grants — list_workspaces is filtered
to readable nodes, search and list require read, delete requires write.
ingest_document stays master-only. Rate limiting now runs before the
DB-backed auth so an unauthenticated flood can't drive query cost.
Add the Users page (user list with an active toggle and key status), the
create/edit form, the key modal (mint/rotate/reveal-once/revoke), and the
permissions editor over the workspace/collection/document tree, backed by
new API client methods, hooks, and types. Wire up the sidebar entry and
route, and remove the per-collection API keys UI now that keys belong to
users.
Add unit coverage for the authorizer (hierarchy, read vs write, master
bypass, inactive-user rejection) and the users/permissions services, plus
integration coverage for the /users router and per-user MCP/REST
enforcement — including ingest_document requiring the master key. Update the
shared fixtures to mint user keys and migrate the existing suites off
collection keys.
Type the user email field as pydantic EmailStr so malformed addresses are
rejected with 422 on both create and update, and add the email-validator
dependency it requires. Canonicalise the address (EmailStr lower-cases the
domain; an AfterValidator folds the local part) so the existing uniqueness
check and the UNIQUE(email) constraint treat Jane@Example.com and
jane@example.com as the same user. The create/edit form validates inline and
blocks submit until the address is well-formed.
Integrate the live MCP rate-limit (#70), sticky pagination (#71), and
indexing retry-failed (#72) work that landed on main.

Resolved two conflicts in the MCP layer: build_mcp_middleware keeps #70's
live rate_limit_rpm callable alongside this branch's per-user
resolve_principal, dropping the now-obsolete master_key param (auth is
DB-backed via authenticate_api_key, which already handles the master key).
The middleware tests construct TokenBucketRateLimiter with a callable to
match #70's new signature.
Add username/password console login and extend per-user grant enforcement
to the console's browse, ingestion-queue, and indexing surfaces (PR #75,
user & permission management).

Login / sessions
- POST /auth/login, /auth/change-password, /auth/me; JWT signed with a key
  derived from the master key (no new secret; MCP stays API-key-only).
- Forced password change on first login; admin-resettable passwords.
  Migration 0009 adds username/password_hash/is_admin/password_changed_at.
- resolve_bearer accepts a session JWT or an API key; is_admin/is_active
  are re-checked live, and a reset/change invalidates prior sessions.

Grant-scoped reads
- /workspaces and /collections list/get, the ingestion queue
  (/ingestion/jobs, /ingestion/jobs/stats) and /indexing/status are now
  require_auth + scoped via permissions.readable_collection_scope; the
  ingestion-queue WebSocket is filtered per event (fail-closed).
- Management writes, user/grant CRUD, and bulk retry-failed stay admin-only.

Console UI
- Login, change-password, and unlock screens; role-aware sidebar and routes.
- Ingestion queue and indexing pages are visible to non-admins as read-only,
  grant-scoped views (retry actions gated to admins).
- Users admin: per-row actions menu; grant list shows resource names with
  click-to-copy ids; one-time password/key reveal.

Tests and .claude/rules docs updated to cover the access matrix.
Rework the authorization authority (api/services/permissions.py) so permissions
SCOPE A USER DOWN rather than granting access from nothing:

- No permissions at all → unrestricted: the user reads and writes everything.
  Master and admin users are always unrestricted.
- Any permission scopes the user to the workspace(s) it touches; a collection or
  document permission scopes them to that resource's workspace.
- Per-level narrowing: a workspace-only permission leaves all its collections
  visible; a collection permission narrows to just the permitted ones.
- Write requires an explicit write permission on the resource or an ancestor;
  a scoped user is otherwise view-only (a read grant never confers write).
- A document permission is direct-access to that one document — it does not open
  its collection for browsing or search, so it can't leak the collection's other
  documents through the list / search / jobs / indexing readers.

A user's permissions resolve once into a `_Scope` (parent links pre-fetched) that
answers readability/writability per level; router and MCP entrypoints are
unchanged. Access-matrix tests updated; permissions.md rewritten to match.
Add an inline note to the permissions editor: permissions scope a user down
(none = full access; a workspace limits them to it; add a collection to narrow
further), so the model is clear at the point of use.
…reation

Open resource creation to non-admins, gated by permission (was master-only):

- Create a COLLECTION: require_auth + write on the parent workspace
  (authorize_workspace(ws, "write")). An unrestricted or workspace-write user may;
  a read-only / otherwise-scoped user gets 403.
- Create a WORKSPACE: require_auth + a new grantable "capability". A workspace has
  no parent to hold a write grant, so creation needs the create_workspace capability
  (authorize_workspace_creation). A scoped creator is auto-granted write on the new
  workspace (grant_creator_access) so they can use what they made.

Capabilities are grantable permissions NOT tied to a resource: a permissions row with
resource_type="capability", resource_id="create_workspace". They are ignored by
data-scope resolution (never scope a user's read/write), checked via has_capability,
and granted through the same POST /users/{id}/permissions API.

GET /workspaces(/{id}) now reports can_write and GET /auth/me reports
can_create_workspaces so the console can gate its create actions. Collection edit and
delete stay admin-only. Tests cover the creation matrix + auto-grant; rule docs updated.
- Permissions editor: add "Create workspaces" as a grantable capability.
- Show "New workspace" only to users who may create workspaces (from /auth/me),
  and "New collection" only where the workspace reports can_write.
- Thread can_create_workspaces through the auth context and can_write on the
  workspace types.
Editing or deleting a workspace/collection required the master key; it now needs
a write grant on the resource (or an ancestor) under the scope-down model, so a
non-admin manages exactly what their grants cover. Write implies read, so a user
can only edit what they can already see; a user with no permissions stays
unrestricted, and master/admin are unrestricted.

- workspaces/collections update+delete: require_master -> require_auth +
  authorize_*(..., "write"), authorized before the existence check so a scoped
  user gets a uniform 403 (no 404 existence oracle)
- add writable_collection_ids(), returning a collection only when it is both
  readable and writable (write never leaks past visibility)
- collection list/get now report can_write for the caller

Documents delete/reprocess/index already enforced authorize_document(write).
Tests cover the matrix (no-perm allowed, read-scoped 403, write-scoped ok,
out-of-scope 403), can_write reporting, and the write-past-visibility edge.
Hide edit/delete/upload/retry/index affordances unless the caller may write the
resource (workspace/collection can_write); read affordances (open, download,
status, failure reason) stay visible to everyone. Tag pickers are gated on admin
since tag assignment is master-only, and the admin-only Tags button is hidden for
non-admins (previously a dead-end).
require_operator already raises 403 when the session carries no user
identity, so the route-level asserts were pure mypy narrowing that
python -O would strip. cast(str, ...) keeps the narrowing with no
runtime statement to lose; enforcement stays in the dependency.
Introduce api/services/access.py: an AccessPolicy Protocol (async
apply() that raises to deny, returns None to allow), concrete policies
wrapping the authorization authority (AuthorizeWorkspace /
AuthorizeCollection / AuthorizeDocument) and the domain existence check
(CollectionInWorkspace), and a CompositePolicy that applies policies in
order (fail-closed on an empty list).

Routers now authorize via policies instead of hand-sequencing an
authorize_* call with a require_*/resolve_* existence check. Order is a
security property: authorization runs before existence, so a scoped
caller gets a uniform 403 whether or not the resource exists - the 404
can no longer serve as an existence oracle (the nested document and
indexing routes previously checked existence first). apply() raises
rather than returning bool because the policies surface different
status codes (403 vs 404) and the firing order is what closes the
oracle.

create_collection drops its redundant explicit require_workspace (the
service already raises the 404). Upload and raw-download keep their
in-service authorization by design; the residual is documented in the
permissions rule file.
Deactivating a user left their console "signed in" while every request
errored with the backend's 403 "User is inactive" (only 401 triggered
sign-out). The client now treats that 403 like any dead credential:
clear the stored credentials, reset auth state, drop the query cache,
and return to the login screen.

The three duplicated response-error blocks (request / openDocument /
downloadDocument) collapse into one raiseForStatus helper. A new
integration test pins the 403 detail string the console matches on, so
a backend reword cannot silently strand deactivated users again.
@jaymeklein
jaymeklein merged commit 12530e7 into main Jul 24, 2026
8 checks passed
@jaymeklein
jaymeklein deleted the docs/claude-rules-and-standards-check branch July 24, 2026 11:21
jaymeklein added a commit that referenced this pull request Jul 24, 2026
Add username/password console login and extend per-user grant enforcement
to the console's browse, ingestion-queue, and indexing surfaces (PR #75,
user & permission management).

Login / sessions
- POST /auth/login, /auth/change-password, /auth/me; JWT signed with a key
  derived from the master key (no new secret; MCP stays API-key-only).
- Forced password change on first login; admin-resettable passwords.
  Migration 0009 adds username/password_hash/is_admin/password_changed_at.
- resolve_bearer accepts a session JWT or an API key; is_admin/is_active
  are re-checked live, and a reset/change invalidates prior sessions.

Grant-scoped reads
- /workspaces and /collections list/get, the ingestion queue
  (/ingestion/jobs, /ingestion/jobs/stats) and /indexing/status are now
  require_auth + scoped via permissions.readable_collection_scope; the
  ingestion-queue WebSocket is filtered per event (fail-closed).
- Management writes, user/grant CRUD, and bulk retry-failed stay admin-only.

Console UI
- Login, change-password, and unlock screens; role-aware sidebar and routes.
- Ingestion queue and indexing pages are visible to non-admins as read-only,
  grant-scoped views (retry actions gated to admins).
- Users admin: per-row actions menu; grant list shows resource names with
  click-to-copy ids; one-time password/key reveal.

Tests and .claude/rules docs updated to cover the access matrix.
jaymeklein added a commit that referenced this pull request Jul 24, 2026
…s-check

feat: user & permission management with per-user keys and grant enforcement
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant