Skip to content

Auth unification - #76

Merged
tekrajchhetri merged 36 commits into
improve-ingestion-query-servicefrom
auth-unification
Aug 11, 2026
Merged

Auth unification#76
tekrajchhetri merged 36 commits into
improve-ingestion-query-servicefrom
auth-unification

Conversation

@tekrajchhetri

@tekrajchhetri tekrajchhetri commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

This is a PR from the hackathon that improves the auth for MCP. This adds features such as PAT.

…ntial

Web_user_profile becomes the single user of record; Web_jwtuser is demoted to
a 1:1 credential linked via a new profile_id FK (email backfill for existing
rows). Per-service token isolation is preserved (each service keeps its own
secret) — this unifies identity, not tokens.

- schema: add Web_jwtuser.profile_id (FK -> Web_user_profile, SET NULL, indexed)
  in the ORM + an idempotent inline migration with case-insensitive email
  backfill (usermanagement bootstrap).
- usermanagement: new provision_identity() as the single path that ensures
  profile + linked credential + default role (Curator) + bootstrap-superadmin;
  OAuth callback refactored onto it (drops _ensure_jwt_user_shell + duplicated
  default-role/bootstrap blocks) and now sets profile_id.
- query_service: /api/register now provisions a canonical profile, assigns the
  default role, and links the credential (best-effort so it never blocks
  signup) — fixes password users having no roles.
- query_service tokens now carry sub/scopes/user_id/profile_id/roles/auth_source
  to match usermanagement's v2 token shape, still signed with query_service's
  own secret. Roles stay informational; rbac re-reads them from the DB.
- docs: AUTH_UNIFICATION.md design + Phase 1 implementation status.

Verified live: migration + backfill, fresh register -> profile+link+Curator,
both services' /api/token return the same claim shape, protected endpoints OK.
usermanagement becomes the sole token issuer; a single login mints a
short-lived refresh token, exchanged for narrow per-service access tokens
(aud=<service>). Services verify via the published JWKS and require their own
audience, so a token minted for one service can't be replayed against another
(containment enforced by aud, not shared secrets). Additive: legacy HS256
tokens still validate, so this is a safe migration rather than a cutover.

usermanagement:
- tokens_rs256.py: RS256 key load from env PEM/FILE, else a process-shared
  ephemeral key persisted to a file (all uvicorn workers agree — per-worker
  ephemeral keys break cross-worker verification). JWKS builder, refresh/access
  minting, refresh verification.
- routers/sso.py: GET /.well-known/jwks.json, POST /api/auth/login (refresh),
  POST /api/auth/exchange (per-audience access; roles/scopes re-read fresh from
  the DB, active + ban checks enforced here).
- configuration.py: issuer, private key, TTLs, allowed audiences.

query_service:
- jwks.py: sync JWKS fetch/cache + RS256 verification requiring iss + aud.
- security.py: decode_token_any() tries RS256 (SSO, aud-checked) then legacy
  HS256; wired into get_current_user(_optional), verify_scopes/require_scopes,
  and websocket auth.
- configuration.py: SSO JWKS URL, issuer, audience.

docs: AUTH_UNIFICATION.md Phase 2 status + deployment env + remaining rollout
(ml_service/chat_service/MCP, then retire legacy HS256).
Make the RS256 SSO key zero-touch for deployment: the unified container's
start.sh generates a persistent key at /app/secrets/um_jwt_private.pem on first
boot (only if no key is configured), so the JWKS kid stays stable across the 4
usermanagement gunicorn workers and across redeploys. An explicit
USERMANAGEMENT_JWT_PRIVATE_KEY_PEM/_FILE still takes precedence.

- Dockerfile.unified: openssl key-gen block in start.sh; exports
  USERMANAGEMENT_JWT_PRIVATE_KEY_FILE (inherited by supervised processes).
- docker-compose.unified.yml: mount ./secrets:/app/secrets so the key persists.
- .gitignore: ignore secrets/.
- env.template: document that the key is auto-provisioned; override is optional.
- AUTH_UNIFICATION.md: update deploy notes.
genpkey already emits PKCS#8; the -pkcs8 flag is invalid and made key
generation fail, so the container silently fell back to the /tmp ephemeral
key (still shared across workers, but not on the persistent ./secrets volume).
Drop -pkcs8 so the key lands at /app/secrets/um_jwt_private.pem and survives
redeploys with a stable JWKS kid. Same fix in the env.template hint.
…oped)

Extend single-issuer SSO verification beyond query_service so per-audience
tokens work across services. Additive — legacy HS256 tokens still validate.

usermanagement (now accepts its own SSO tokens):
- verify_token() tries an RS256 access token minted for aud=usermanagement
  (verified with our OWN public key — we are the issuer, no network) before the
  legacy HS256 v2 token. Flows through get_current_user / require_admin /
  scopes / ban-check unchanged.
- tokens_rs256: add verify_access_token(token, audience) + user_id claim in
  access tokens; exchange now stamps jwt_user_id.
- add "usermanagement" to the exchangeable audiences (config + env.template).

ml_service:
- new core/jwks.py (httpx, sync) verifies RS256 via the issuer's JWKS and
  requires aud=ml_service.
- decode_token_any() tries RS256 then legacy HS256; wired into get_current_user,
  verify_scopes/require_scopes, decode_jwt (covers SSE), and the websocket path.
- SSO config (JWKS URL, issuer, audience) in configuration.py.

Verified live (hot-swap): usermanagement-aud and ml-aud tokens accepted (200);
a query_service-aud token is rejected at each (401, containment holds); legacy
HS256 tokens still work. chat_service deferred (not in use).
- query_service/README.md: Auth section now documents dual verification
  (RS256/JWKS SSO with aud=query_service + legacy HS256), and that /register
  provisions a canonical profile + default role.
- usermanagement_service/README.md: document the SSO endpoints
  (/.well-known/jwks.json, /api/auth/login, /api/auth/exchange), auto-provisioned
  signing key, and that it accepts aud=usermanagement SSO tokens on its routes.
- top-level readme.md / README.md: describe usermanagement as the identity + SSO
  issuer and add an Authentication section pointing to AUTH_UNIFICATION.md.
… a group)

Previously per-space access rules could only *restrict*, and ingest required
owner/editor membership — so there was no way to let a whole group ingest into a
team space without adding each user individually. Now a write access rule GRANTS
write:

- spaces.can_write_space(space, email): write allowed if global Admin, owner/
  editor membership, OR a matching write access rule (global_role / member /
  space_role). Returns a reason for clear 403s.
- insert.py: both ingest endpoints use can_write_space instead of the old
  membership-only authorize() + restrict-only space_action_permitted() combo
  (drops the now-unused authorize import). The INGEST capability (write-capable
  role) is still required separately, so a read-only group can't ingest.

So an admin/space-manager can add {action=write, subject_type=global_role,
subject_value="Lab Member"} and every Lab Member can ingest into that space; remove
the rule to revoke.

Docs: query_service/README.md gains a "Capabilities & roles (RBAC)" section
(capability meanings, role→capability mapping, delegation, SuperAdmin vs Admin,
and giving a group ingest access to a team space).

Verified live: rule present → Lab Member ingest 200, non-group 403; rule removed
→ 403.
Adds role/group-level capability grants so an admin can give a custom group
(e.g. "uk_collaborator") a global KG capability without per-user grants — the
missing piece next to per-user grants and per-space access rules.

- new role_capability_grants table (role, capability), created at startup.
- rbac: role_granted_capabilities(roles); capabilities(email) now = role-derived
  caps ∪ role/group grants ∪ per-user grants. grant/revoke/list_role_capability.
- spaces admin router: GET /admin/capabilities/available (catalog + which are
  delegatable), GET /admin/capabilities/role, POST grant-role / revoke-role.
  Admin+SuperAdmin only; only GRANTABLE_CAPS delegatable (grant/sparql_admin
  stay admin-intrinsic — no escalation).

Verified live: uk_collaborator [read_private] -> grant ingest -> [ingest,
read_private]; sparql_admin refused (400).
…ban only)

Enforce SuperAdmin > Admin and make ban (not delete) the removal mechanism.

- Only a SuperAdmin may assign/remove the Admin (or SuperAdmin) role and ban an
  Admin account; regular Admins manage non-admin users only. SuperAdmin role
  stays fully protected (no strip/ban). Added _is_superadmin/_require_superadmin
  (honors the bootstrap-superadmin allowlist).
- User deletion is DISABLED (DELETE /users/{id} -> 405): we don't delete
  accounts — ban instead (reversible, preserves provenance/audit history).
- Fix a latent MissingGreenlet in ban_user: build the response from locals
  captured before commit instead of touching expired ORM attributes.

Verified live: Admin assign/remove Admin + ban Admin -> 403; SuperAdmin -> 200;
delete -> 405.
Lets the MCP/skill complete an OAuth login without the web UI. The browser
sign-in (user consent) is unavoidable, but the result is picked up out-of-band
via a short paste-code instead of a frontend redirect.

- Web_oauth_state gains a `mode` ('web'|'cli'); new Web_oauth_cli_result table
  (code -> SSO refresh token, single-use, short-lived) + repo.
- POST /api/auth/cli/start {provider} -> authorize URL (state marked cli).
- OAuth callback branches on mode: for cli it provisions as usual, mints an SSO
  refresh token, stores it behind a short code, and renders a minimal
  "copy this code" page (no SPA).
- POST /api/auth/cli/exchange {code} -> refresh token (reads it inside the
  session to avoid MissingGreenlet), single-use.

Verified: cli/start (globus) 200 with authorize URL; exchange of a seeded code
returns the token and reuse is refused (400); success page renders the code.
The real Globus click-through is verified on deploy.
…ogin; auth-flow PNGs

- No self-registration: query_service and ml_service /api/register now return 405.
  Users are created on first Globus/ORCID/GitHub login (OAuth auto-provisions the
  profile + default role). The MCP self-register tool is removed too.
- Login endpoint renamed to /api/login on query_service, usermanagement, and
  ml_service; /api/token kept as a deprecated (hidden) alias for compatibility.
- AUTH_UNIFICATION.md: current auth-flow rendered as PNGs (query_service/docs/auth/
  flow-a|b|c.png) with the Mermaid source kept collapsed; READMEs updated.

Verified live: /api/register -> 405; /api/login and the /api/token alias both
authenticate (401 on bad creds, not 404) on query_service and usermanagement.
…; Admin/SuperAdmin still manage all

Previously any manage_team_space holder could manage EVERY team space. Now a
non-admin manages a team space only if they own it (created), are matched by a
per-space 'manage' rule, or hold manage_team_space AND are a member of that
space. Admin/SuperAdmin still manage all. Verified: non-member holder 403; owner
200; admin 200; member holder 200.
require_admin no longer trusts the token 'roles' claim — it re-reads active roles
from the DB (by profile_id/email), so a revoked/demoted admin loses access
immediately without waiting for token expiry. Same for the SuperAdmin gate on
admin-tier actions (_is_superadmin). Bootstrap-superadmin allowlist still honored
for first sign-in.

Verified: old token claiming roles=[Admin] -> 200 while Admin in DB; after the
Admin role is removed in the DB, the same token -> 403.
…ess token, role-derived scopes)

Lets the web UI swap its usermanagement session JWT (v2 or SSO) for a short-lived
aud-scoped access token for query_service/ml_service, with scopes derived from the
user's roles (RBAC authoritative). Removes the UI's need for a shared
service-account password on those services. Verified: Curator session token ->
aud=query_service token (scopes read,write) -> accepted at query_service (200).
Mint an opaque, revocable, time-bounded token once while logged in, set it
as BRAINKB_TOKEN in the MCP/skill config, and authenticate with it thereafter
with no browser or password. The PAT is stored hashed and validated at
usermanagement, then exchanged for the same short-lived per-service access
token the login flow issues, so downstream services are unchanged and aud
containment is preserved. Roles are re-read live on exchange (instant
ban/demotion/revoke).

- Web_personal_access_token model + repository
- POST/GET/DELETE /api/auth/tokens (session-auth) + POST /api/auth/pat/exchange
- env: USERMANAGEMENT_PAT_DEFAULT_DAYS/_MAX_DAYS/_MAX_PER_USER
- AUTH_UNIFICATION.md: PAT decision (9.11) + RS256-vs-shared-secret rationale (9.12)
Shorter default PAT lifetime; users may still request up to PAT_MAX_DAYS.
Updated code default, env.template, and .env (untracked).
Return your_role / is_owner / access (owner|member|public) / can_write for each
visible space so callers can see what they may do in each, not just its
existence. (Slug + named-graph IRI uniqueness and no-hard-delete were already
enforced.)
…alid user

get_user returns False (not None) when there is no active user row, but the
callers checked 'is None' — so False slipped through as the authenticated user,
breaking _agent/role lookup and yielding a misleading 403 (and, for the optional
path, a bogus False instead of anonymous None). Check falsiness / normalize to
None in get_current_user, get_current_user_optional, and verify_and_get_user.
Each successful PAT exchange pushes expires_at to now + PAT_DEFAULT_DAYS (the
idle window), capped at created_at + PAT_MAX_DAYS, and only ever extends. So an
actively-used token never re-prompts, while an unused one lapses after the
window. Controlled by USERMANAGEMENT_PAT_SLIDING (default on). Verified: a
1-day token rolled to the 3-day window after one use.
The one-time login paste-code was 8 chars (~39 bits). Raise it to 20 chars over
the 30-symbol unambiguous alphabet (~98 bits), grouped in 4s, clamped to fit the
String(32) code column, env-configurable via USERMANAGEMENT_CLI_CODE_LEN. It
stays short-lived (~10 min) + single-use; the extra entropy is defense-in-depth
against brute force in the window.
…d dummy example)

Show that USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS supports multiple emails and
note the runtime path (an existing SuperAdmin can grant the role). Use dummy
placeholder emails in the template.
@tekrajchhetri
tekrajchhetri merged commit 016d50b into improve-ingestion-query-service Aug 11, 2026
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