feat: user & permission management with per-user keys and grant enforcement - #75
Merged
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 theworkspace → collection → documenttree, and makes both the REST API and the MCP server authenticate the caller and enforce those grants.Requested capabilities — all delivered:
is_active; an inactive user's key is rejected everywhere (REST, MCP, WebSocket).Principalis resolved from their key and bound per request.Key decisions
writeimpliesread.api/services/permissions.pyis the single authorization authority (routers/tools call it and let it raise 403 — no ad-hoc checks).Changes (by commit)
feat(db)—users+permissionstables;api_keysrepointed fromcollection_idtouser_id(UNIQUE(user_id)); Alembic 0008 creates the tables and rebuildsapi_keys(verified up/down/up).feat(auth)—Principal(is_master, user_id, api_key_id); inactive users → 403;record_key_usewired in (best-effort) solast_used_atis live;permissions.pyauthority (collection/document authorizers, readable-id narrowing, workspace-tree filter, grant CRUD).refactor(collections)!— retire the collection-key mint/list/revoke service, routes, and schema.feat(api)— grant enforcement on documents/search/indexing/ws;/searchnarrows to readable collections; delete/download authorize before resolving (no forbidden-vs-nonexistent oracle);ingest_local_pathis now master-only.feat(users)— master-only/usersAPI: user CRUD, activate/deactivate, key mint/rotate/revoke, and grant CRUD.feat(mcp)— middleware resolves the caller'sPrincipaland binds it per request via aContextVar; every tool enforces grants; rate-limit runs before the DB-backed auth.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.test— authorizer/users/permissions unit coverage +/usersand per-user MCP/REST integration coverage; shared fixtures migrated to user keys.api_keys, dropping any existing collection keys. After upgrading, re-mint keys as user keys.Security
ingest_document(MCP) →ingest_local_pathreferences an arbitrary container-local path, so it is master-only (an operator capability): a scopedwritegrant must not become an arbitrary server-file read or a cross-tenant copy. Covered by a regression test (test_ingest_document_requires_master).key_hashand raw keys are never logged or returned (raw returned once at mint)./security-reviewrun: 1 HIGH found and fixed (the above); no other findings at or above High.Verification
ruff+mypyclean, 744 passed / 13 skipped, UItscclean, Alembic up/down/up round-trip./standards-check(clean),/code-review(6 findings fixed),/security-review(1 HIGH fixed).Reviewer notes
mcp==1.28.1+anyio==4.14.1that 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 onstateless_http=True(noted inserver.py).database.md,permissions.md,api.md,mcp.md.