Auth unification - #76
Merged
tekrajchhetri merged 36 commits intoAug 11, 2026
Merged
Conversation
…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.
…, no-delete (ban) policy
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.
…) in usermanagement README + env.template
…s) + status update
…al vs per-space, full admin action catalog
…; 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.
… scoped (owned/assigned, not all)
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.
…rement is gated on all-clients-on-SSO
…ice, usermanagement
…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).
…now unblocked (do after deploy test)
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.
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.
This is a PR from the hackathon that improves the auth for MCP. This adds features such as PAT.