Skip to content

Improve ingestion & query service - #75

Open
tekrajchhetri wants to merge 80 commits into
mainfrom
improve-ingestion-query-service
Open

Improve ingestion & query service#75
tekrajchhetri wants to merge 80 commits into
mainfrom
improve-ingestion-query-service

Conversation

@tekrajchhetri

Copy link
Copy Markdown
Collaborator

This PR makes several improvements to existing implementations, such as introducing private, public graph/space. Some major changes are:

  • native PROV-O provenance + triple-level deltas
  • owner-controlled containers of named graphs, individual or team, visibility private/public (public = anonymous read). Membership owner/editor/viewer. Enforcement in Postgres with an RDF mirror. registered-named-graphs now filtered by visibility so private graph IRIs don't leak.
  • updated RBAC to support space capabilities.
  • access-filtered; anonymous/public searches see public only. Indexing moved off the ingest path to a background task queue (durable index_tasks + atomic claim + startup recovery); backfill/reindex async too.
  • per-worker ingest concurrency cap (semaphore) so bulk ingest can't crash the service.

Track all mutating actions (ingestion, named-graph registration, crash
recovery) as W3C PROV-O triples in a dedicated provenance named graph
(https://brainkb.org/provenance/), queryable via SPARQL. Postgres keeps
job execution state; Oxigraph is the provenance source of truth.

- core/provenance.py: PROV-O builders (IngestionActivity/RegistrationActivity/
  RecoveryActivity) with typed user/system agents, plus Graph Store HTTP write
  and CONSTRUCT->JSON-LD retrieval helpers. Writes are best-effort.
- insert.py: emit provenance from run_ingest_job (all terminal states),
  create_named_graph, and recover_stuck_jobs; stop embedding PROV into domain
  data (files upload unmodified); add GET /provenance/job and
  /provenance/named-graph (application/ld+json).
- PROVENANCE_MODEL.md: design and reference.
- query_provenance_jsonld: Oxigraph returns HTTP 406 for Accept
  application/ld+json (it serializes Turtle/N-Triples/N-Quads/RDF-XML only),
  so request Turtle and convert to JSON-LD locally with rdflib. Keeps the
  JSON-LD API contract independent of the triplestore's output formats.
- construct_for_job: also traverse inbound prov:used so a job's provenance
  bundle includes the recovery activity (and its system agent) that acted
  on the job, not just forward links.
Track which triples each ingestion job adds, not just activity-level metadata.
Validated end-to-end against a live Oxigraph.

- Each job stages triples in a per-job delta graph
  (https://brainkb.org/provenance/delta/{job_id}), then merges into the target
  via SPARQL ADD (idempotent set union). The delta graph is preserved as the
  exact change record; a brainkb:IngestionDelta PROV-O entity records the
  derivation, target, delta graph, and added triple count.
- Gated by TRACK_TRIPLE_DELTAS (default on); disable to upload directly to the
  target (delta graphs persist, roughly doubling stored triples).
- New endpoints: GET /provenance/delta (added triples as JSON-LD),
  /provenance/delta/history (change history for a graph),
  /provenance/delta/compare (diff two jobs' deltas: A-only/B-only/shared).
- provenance.py: delta_graph_for, merge_delta_into_target, count_graph_triples,
  construct_delta_content, delta_history_for_graph, compare_deltas; job CONSTRUCT
  now includes the delta entity.
- PROVENANCE_MODEL.md: document the delta model and endpoints.
Registration was recorded twice: once in the named-graph registry graph
(metadata/named-graph, via named_graph_metadata) and again as a separate
RegistrationActivity in the provenance graph. Both asserted the registration
timestamp and made the graph IRI a subject.

Keep the registry graph as the single home for registration facts and attribute
it to the registering user there (prov:wasAttributedTo); drop the duplicate
RegistrationActivity from the provenance graph.

- shared.py: named_graph_metadata takes an optional agent_uri and adds
  prov:wasAttributedTo on the registry entry.
- insert.py: create_named_graph passes the agent URI; remove the duplicate
  build_registration_provenance write (and its import).
- provenance.py: remove build_registration_provenance (now unused).
- query.py: /query/registered-named-graphs returns registered_by.
- PROVENANCE_MODEL.md: document registration living in the registry graph.
Scopes (require_scopes) — GET reads require 'read', mutations require 'write':
- read added: GET /insert/jobs, /insert/user/jobs/detail,
  /insert/jobs/check-recoverable, /provenance/job, /provenance/named-graph,
  /provenance/delta, /provenance/delta/history, /provenance/delta/compare,
  /query/registered-named-graphs
- write added: POST /insert/jobs/recover, POST /register-named-graph
- unchanged: /insert/{raw,files}/knowledge-graph-triples (write),
  /query/taxonomy (read), /query/sparql/ (write+admin, arbitrary query),
  /register + /token (public)

Docstrings: document the difference between /query/registered-named-graphs
(the registry/catalog of graphs) and /provenance/named-graph (the PROV-O
ingestion/activity history of a graph); expand descriptions on all provenance
and delta endpoints.
Arbitrary SPARQL is a powerful, unrestricted capability; require the 'admin'
scope (dropping the redundant 'write'), and keep it off the default 'read'
scope used by fixed-shape read endpoints.
Users/teams create owner-controlled spaces, keep them private, or publish them
publicly for anyone (incl. unauthenticated clients) to read. Supports a
decentralized, IRI-addressable model (https://brainkb.org/space/{slug}).

Storage split (confirmed): Postgres holds identity/teams/enforcement
(spaces, space_members, space_graphs); Oxigraph holds all KG data + provenance
plus a best-effort RDF mirror of each space manifest (metadata/spaces graph).

- core/spaces.py: CRUD, per-request authorization (public=anonymous read;
  private=members; write=owner/editor), and SPARQL-Update RDF mirror. Legacy
  unmapped graphs fall through to existing scope checks (backward compatible).
- core/routers/spaces.py: POST/GET /spaces, GET /spaces/{slug},
  PATCH visibility, POST/DELETE members, POST graphs, GET /spaces/{slug}/data
  (public = anonymous).
- security.py: get_current_user_optional (token used if present, never 401)
  for anonymous public reads.
- insert.py: ingestion now enforces space write-authorization on the target
  graph, in addition to the write scope and user-identity check.
- main.py: create spaces tables on startup; mount spaces router.
- SPACES_MODEL.md: design + endpoints.

Validated end-to-end against the live stack (15/15): private owner ingest/read,
outsider ingest/read denied (403), anonymous read denied while private, public
flip enables anonymous read + listing, public grants read-not-write, RDF mirror.
Stop leaking private graph existence via the registry listing: graphs in a
private space the caller is not a member of are omitted. Public-space and legacy
(unmapped) graphs remain listed. The endpoint now loads the caller identity
(get_current_user) and excludes hidden graphs via spaces.hidden_graphs_for().

Also clarify in SPACES_MODEL.md that job-scoped provenance is intentionally
owner-only and ingestion is always restricted to activated JWT users with valid
credentials + space owner/editor membership (never anonymous); only reads of
public spaces are anonymous.

Validated live: owner sees private+public graphs; non-member sees only public.
- query_service/README.md: document auth/scopes, ingestion + jobs, PROV-O
  provenance, triple-level delta endpoints, and private/public spaces; add
  architecture note (Postgres = identity/enforcement, Oxigraph = graph data +
  provenance).
- readme.md: expand the Query Service bullet to mention provenance, deltas, and
  spaces, with pointers to the model docs.
Search over the knowledge graphs that respects space visibility. Hybrid design:
Postgres holds a full-text locator index (graph_search_index: subject, text,
named graph, owning space) populated at ingest; a query runs in Postgres
(fast, filtered by space visibility/membership) to locate subjects, then the
matched triples are fetched from Oxigraph (the source of truth for KG data).

- core/search.py: index_graph_subjects (indexes a graph's/-delta's subject
  literals), reindex_graph_space, and search() with the access filter
  (anonymous -> public spaces only; authenticated -> public + member spaces +
  legacy). Data for the located subjects is fetched from Oxigraph as JSON-LD.
- main.py: create graph_search_index (GIN full-text) on startup; mount router.
- run_ingest_job: index the target graph's subjects after merge (best-effort).
- spaces.attach_graph: point existing index rows at the space so search picks up
  the workspace immediately (inline SQL to avoid an import cycle).
- routers/search.py: GET /search (optional auth; space-scoped or full).
- READMEs updated.

Validated live (9/9): anon finds public term + data from Oxigraph; anon/outsider
cannot see private term; owner finds private; scoped search respects membership;
anon scoped to a private space returns nothing.
Indexing a large graph inline was slow and delayed ingest jobs. Move it off the
ingest path into an in-process async task queue with durable status.

- core/indexing.py: asyncio queue + single background consumer, durable
  index_tasks table, atomic queued->running claim (safe across gunicorn workers),
  and startup recovery (re-queue tasks left over from a crash). Supports 'ingest'
  (one graph) and 'backfill' (reindex every user graph, with progress).
- main.py: create index_tasks table; start the consumer on startup.
- insert.py: ingest now ENQUEUES indexing (non-blocking) instead of awaiting it,
  so jobs finish without waiting on indexing.
- routers/search.py: POST /search/reindex (admin, background backfill) and
  GET /search/index-tasks (status).
- README updated.

Validated live: ingest job completes in ~1s while indexing runs in background;
background task indexes the subject and it becomes searchable; backfill reindexed
3/3 graphs in the background.
Ingestion stays submit-and-forget/background, but a burst of concurrent
submissions could previously run unbounded and exhaust memory / the DB pool /
Oxigraph and crash the worker. Add a per-worker asyncio.Semaphore limiter
(MAX_CONCURRENT_INGEST_JOBS, default 3): run_ingest_job now acquires a slot
before processing; excess jobs return immediately and wait as 'pending' until a
slot frees (backpressure without a queue). Effective global cap ~= cap x workers.

Validated live: 6 concurrent submissions all accepted in ~0.07s (submit-and-
forget intact) and all completed 'done', throttled, no crash.
SPARQL (Oxigraph) + SQL (Postgres) queries to verify ingested graphs,
provenance, per-job deltas, spaces manifest/membership, and the search index,
with instructions to run via the API or directly against Oxigraph/Postgres.
…I access only

Authorization now comes from the user's roles (joined by email to
Web_user_profile -> Web_user_role), mapped to capabilities, layered with space
membership. JWT scopes remain only an API-access gate.

- core/rbac.py: roles->capabilities policy; delegated grants
  (user_capability_grants); SuperAdmin>=Admin>write>read>none hierarchy;
  admin-intrinsic caps (grant, sparql_admin) are NOT delegatable (no escalation);
  query_service never assigns roles (role assignment stays Django-owned).
- Space types: 'individual' (any write-capable user) vs 'team' (Admin/SuperAdmin
  or granted create_team_space).
- Enforcement: create space (by type), ingest, recover, arbitrary SPARQL, space
  management, and reads (no-role -> public content only).
- Admin endpoints: GET/POST /admin/capabilities[/grant|/revoke].
- main.py: space_type column + user_capability_grants table.
- RBAC_MODEL.md: full model.

Validated live (14/14): no-role denied (public read only); Lab Member creates
private + ingests but not team; Admin creates team + SPARQL; delegated grant
upgrades Lab Member to create team spaces; non-admins can't grant; admin-intrinsic
caps rejected for delegation.
Within a space, restrict an action to a global role, a space role, or specific
members — e.g. 'only Admins may write here', 'only these Lab Members may read',
'let this member manage'. Layers on top of capabilities + owner/editor/viewer
membership; owner and global Admin/SuperAdmin always bypass (no lockout).

- space_access_rules table (action, subject_type[global_role|member|space_role],
  subject_value).
- spaces.py: rule CRUD, matches_access_rule (pure match) and
  space_action_permitted (owner/admin bypass; no-rules -> allow for read/write).
- Enforced on: reads (get space / space data), ingest (insert raw+files), and
  manage (members/visibility/graphs, via _can_manage grant).
- Endpoints: GET/POST/DELETE /spaces/{slug}/access-rules (manager only; GET
  member/manager).
- RBAC_MODEL.md updated.

Validated live (9/9): write Admin-only rule blocks a Lab Member editor but owner
bypasses; member rule then allows the Lab Member; read Admin-only rule blocks a
member read (owner bypass); manage rule grants a member management.
POST /api/admin/users/activate and /deactivate (by email), Admin-gated, using the
existing jwt_user_repo.activate_user/deactivate_user. Enables admins to activate
accounts via API (e.g. after password self-registration) instead of only the UI/DB.
…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.
tekrajchhetri and others added 30 commits July 24, 2026 17:00
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.
…session

The OAuth callback handed the UI a 30-min HS256 token that NextAuth stores and
never refreshes, so after 30 min /api/users/me (and the profile/activity routes
that reuse the session token) returned 401/404. Make create_access_token_v2 take
an expires_minutes override and have the OAuth callback mint the web-session token
with USERMANAGEMENT_WEB_SESSION_TTL_MIN (default 720 = 12h). Password-login and
per-service tokens keep their short defaults.
…enew

The OAuth callback now also mints a longer-lived SSO refresh token (aud=brainkb-auth,
USERMANAGEMENT_WEB_REFRESH_TTL_MIN, default 7d) and returns it to the UI as
?refresh=. The UI exchanges it at /api/auth/exchange (audience=usermanagement)
to renew its short access token without re-login. create_refresh_token gains an
expires_minutes override. Verified: refresh -> exchange -> /api/users/me 200.
Add longer web-session and issue a web refresh token
/api/auth/exchange looked the credential row up with the active-only
jwt_user_repo.get_by_email, so it 401'd "Account inactive" for every
Globus/ORCID/GitHub user. OAuth onboarding creates that row as a SHELL with
is_active=False on purpose — an OAuth user has no usable password, and the shell
exists only to supply a stable user_id claim (see the get_by_email_any_status
docstring, which already says OAuth flows must use it). The result: an OAuth user
could log in and mint a refresh token that nothing would ever accept.

That broke two flows on the same line. The MCP/skill paste-code login
(cli/start -> cli/exchange -> exchange) dead-ended at the last hop, so
brainkb_whoami read authenticated:false right after a successful login and a PAT
could never be minted — minting needs a session token, and the only way to one
from a refresh token is this endpoint. The UI's silent renew goes through the
same call (oauth.py mints web_refresh precisely "exchanged by the UI at
/api/auth/exchange"), so web sessions died at TTL instead of renewing.

Not a bare swap to get_by_email_any_status, because is_active is overloaded:
POST /api/admin/users/deactivate flips the same column, so dropping the check
would make deactivation a no-op here. The refresh token records how it was
issued — auth_source="password" from /auth/login, the provider name from OAuth —
so the check now applies only to password credentials, where is_active really is
the deactivation switch. OAuth accounts are removed by banning, which the
is_banned -> 403 check below already enforces.

Also distinguishes a missing row ("Unknown account") from a switched-off one
("Account inactive"), which were previously the same message.
usermanagement: let OAuth accounts exchange a refresh token
get_current_user verified the token, then looked the caller up with
get_user(email), whose SQL filters `AND is_active = True`. An OAuth caller's
credential row is the SHELL usermanagement provisions with is_active=False — they
have no usable password, the row exists only to carry a stable user_id — so the
lookup found nothing and raised 401 "Could not validate credentials" for every
Globus/ORCID/GitHub user, with a valid, correctly-audienced, correctly-signed
token. Same root cause as the /api/auth/exchange fix in the previous commit, one
service further down.

Worse than a plain error on the optional path: get_current_user_optional swallows
the failure and returns None, so a signed-in OAuth user read those endpoints as
ANONYMOUS. list_spaces answered {"spaces": []} — indistinguishable from "you own
no spaces", and it was reported to a user as exactly that, while authenticated
endpoints 401'd alongside. That combination reads like a token/issuer mismatch and
sent debugging after the wrong thing entirely.

get_user takes include_inactive (default False, so nothing else changes) and the
three token-verification call sites pass it based on the token's own auth_source
claim: relaxed for OAuth, strict for password credentials where is_active is the
switch POST /api/admin/users/deactivate flips. authenticate_user, the actual
password path, is untouched and still refuses inactive rows. Banned accounts are
unaffected — that is enforced separately on the profile.
…cope table

A Globus SuperAdmin got 403 "Insufficient scopes" from every query_service admin
route, including /api/admin/capabilities, while usermanagement's own admin routes
worked. Setting USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS did not help, and it was
never going to: promote_bootstrap_superadmins assigns the Admin and SuperAdmin ROLES
to a UserProfile and writes nothing else.

Four paths mint tokens, and until now they disagreed about where scopes come from:

  /api/auth/session-exchange   scopes from roles  ("RBAC is authoritative")
  /api/pat/exchange            scopes from roles
  /api/auth/login              scopes from Web_jwtuser_scopes
  /api/auth/exchange           scopes from Web_jwtuser_scopes   <- the MCP path

Web_jwtuser_scopes is the legacy Django table, populated only for accounts created
through the old admin. An OAuth account has no rows in it: its Web_jwtuser row is
the shell created to supply a stable user_id claim. So the refresh-token exchange
minted `roles: ["Admin", "SuperAdmin"]` alongside `scopes: ["read"]`.

query_service gates its admin routes on the scope claim — require_scopes(["admin"])
runs as a dependency, before the rbac.is_admin() check inside the handler that would
have passed — and it has no bootstrap-email allowlist of its own
(config.bootstrap_superadmin_emails appears nowhere in query_service). That
combination is why the symptom looked like a missing audience: the same identity
could list users through usermanagement, which honours the allowlist, and could not
read capabilities through query_service, which trusts the token.

Both refresh-token paths now union the stored scopes with the ones the user's roles
imply. Union rather than replacement, because a legacy account may hold an
explicitly granted scope that no role implies and dropping it would be a silent
downgrade. This also removes the need for a PAT as a workaround — the PAT exchange
only worked because it already derived scopes from roles.

Verified on the pure functions: a Globus SuperAdmin with no scope rows now yields
["admin", "read", "write"], a Curator ["read", "write"], a user with no roles
["read"], and a legacy account keeps a scope no role implies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
usermanagement: derive token scopes from roles, not just the legacy scope table
Nothing could read a review its author marked Public. Every synth-scholar route
depends on get_current_user, GET /reviews is owner-scoped, and _session_or_404
404s for non-owners — so the public web pages at /knowledge-base/synth-scholar
failed for anonymous visitors and showed signed-in ones their own reviews. The
is_public flag was write-only.

Add /api/synth-scholar/public/reviews{,/{id},/{id}/log,/{id}/export}. Three rules
hold across all four:

  * no get_current_user, so an anonymous browser can read them;
  * every lookup goes through store.list_public / get_public, which require
    is_public AND completed — the filter is in SQL, not the caller, so other
    people's drafts are never shipped to a browser to be filtered there;
  * an unpublished id answers 404, not 403, so the status code does not confirm
    it exists.

list_public skips the runtime-state merge: a completed review has none, and
consulting it would leak progress for a row being re-run.

Export shares one _export_session helper with the authenticated route (the two
were identical past the access check) and one _EXPORT_FORMAT_PATTERN constant, so
a format added for signed-in users cannot silently 400 on a public review.

No behaviour change to the authenticated routes. Nothing new is exposed: the
detail payload already excludes openrouter_api_key from run_request, and these
routes serve only what an author explicitly published.
add an unauthenticated read surface for published reviews
ml_service dies at boot on EC2 — gunicorn exit 3 (WORKER_BOOT_ERROR) after 2.7s,
four times, then supervisor gives up and 8007 stays dead while the container
reports healthy on 8000/8004/8010.

Exit 3 with no bind means the workers raised before the app object existed, and
`from structsense import kickoff` is the only unguarded heavy import on that path
(core/main.py already guards the SynthScholar one for exactly this reason). It was
imported in two places:

  * core/routers/structsense.py — never used. This module reaches structsense only
    through core.shared.run_kickoff_with_config. Removed.
  * core/shared.py — the real one. Now guarded, with kickoff = None on failure and
    a 503 raised per-request from run_kickoff_with_config.

So a broken structsense install now costs the extraction endpoints only. Everything
that never touches kickoff keeps serving: GET /api/ner and the saved-annotation
surface behind /knowledge-base/ner, and the whole /api/synth-scholar tree.

Why the install can be broken while the image builds green (Dockerfile.unified):
the structsense step falls back to `--no-deps` when the legacy resolver fails, so
crewai/litellm never arrive — and ml_service's requirements.txt lists neither, so
nothing fills the gap. The RUN still exits 0. Added an import check to that step so
this fails the build. (Not a precedence bug: `A || B && C` already groups as
`(A || B) && C`; the parentheses added are only for the reader.)

Diagnosis note for next time: gunicorn's traceback goes to
/var/log/supervisor/ml_service.err.log, not the container's stdout, so
`docker logs brainkb-unified` shows only supervisord's exit codes.
…tion

The traceback names the cause exactly:

  structsense -> crewai -> litellm -> openai
  openai/_vendor/httpx_aiohttp/transport.py:17  aiohttp.SocketTimeoutError
  AttributeError: module aiohttp has no attribute SocketTimeoutError

requirements.txt pinned aiohttp==3.8.6, and Dockerfile.unified installs that file
AFTER structsense — so the pin downgraded the aiohttp that crewai/litellm/openai
had just pulled in. Recent openai vendors httpx_aiohttp, which needs
aiohttp.SocketTimeoutError (added in 3.10). Import raised, all 6 workers died
before the app object existed, supervisor gave up, 8007 stayed dead while the
container looked healthy on 8000/8004/8010.

Changed to `aiohttp>=3.10,<4` — a floor, not a pin, so pip can reconcile with
whatever openai/litellm require. chat_service and usermanagement_service keep
3.9.1; neither imports openai.

Also: both import guards caught the wrong exception. This failure is an
AttributeError, so `except ImportError` does not catch it — the guard added in
c970966 would not have prevented this outage, and main.py's SynthScholar guard had
the same hole since it was written. Both now catch Exception: a four-package import
chain can fail in any number of ways, and each must cost one feature rather than
the process.

Verified the widening matters rather than assuming it: `except ImportError` lets an
AttributeError through, `except Exception` does not.
…override

Each service kept its own hardcoded list and they had drifted. chat_service was
actually broken: it was missing https://brainkb.org and https://www.brainkb.org
entirely, so the production UI was blocked from it on the main domain while beta and
sandbox worked. It also carried "http://127.0.0.1:300" — a typo for :3000.

Dropped the schemeless "localhost:3000" entries from usermanagement and chat: the
browser's Origin header always carries a scheme, so they could never match. Dropped
ml_service's "http://localhost" and its two :3001 entries — nothing in the repo
serves the UI on 3001, and CORS_ALLOWED_ORIGINS covers anyone who does.

All four now share one 6-entry default, plus CORS_ALLOWED_ORIGINS (comma-separated)
so a new domain does not mean editing four files. Documented in env.template.

Not a fix for the reported failure. The synth-scholar public route reporting "No
Access-Control-Allow-Origin" from brainkb.org is ml_service being down: the ALB
answers 502 (server: awselb/2.0) and its error page has no CORS headers, so an
outage is indistinguishable from a CORS misconfiguration in the browser console.
https://brainkb.org was already in ml_service's list. Noted in both the code and
env.template, since this will mislead again.
…ards, CORS

Brings the three commits added after PR #81 was merged. The aiohttp one is the
reason ml_service could not boot (gunicorn exit 3), so deploying this branch
without it would reintroduce that crash on the next --no-cache rebuild:

  c970966  survive an unimportable structsense; fail the build instead
  be4eac8  unpin aiohttp 3.8.6, widen the import guards to Exception
  29c1fb0  make the four CORS origin lists identical, add CORS_ALLOWED_ORIGINS
This is why ml_service worked in local Docker and died on the unified/EC2 build.
The two files installed the same requirements with different resolvers:

  ml_service/Dockerfile : pip install -r requirements.txt                  (works)
  Dockerfile.unified    : pip install --use-deprecated=legacy-resolver -r  (broken)

The legacy resolver does not backtrack and does not check consistency, so a later
requirement silently downgrades an earlier one. `aiohttp>=3.10` in requirements.txt
was therefore not enough on the unified build: structsense==0.0.4 drags an old
crewai/litellm that tolerates aiohttp 3.8.x, that won, and openai's vendored
httpx_aiohttp failed at import with

  AttributeError: module aiohttp has no attribute SocketTimeoutError

That is why the deployed build had the new code (confirmed: the ml_service CORS list
no longer allows http://localhost:3001) yet still hit the original error.

The legacy resolver is kept for the structsense install alone, which needs it.
requirements.txt now resolves the same way it does locally. The explicit aiohttp
upgrade after it is a safety net for this one regression, not the fix — nothing in
the tree wants aiohttp <3.10 (litellm requires >=3.14.2).

Also adds ml_service/scripts/verify_imports.py, run at the end of the build. It
checks aiohttp.SocketTimeoutError and imports both structsense and synthscholar,
failing the build with the real traceback if either is broken. Both are guarded at
runtime so one bad dependency cannot kill the process — correct, but it means a
broken install is invisible: the container starts, /api/health returns 200, and the
routers are simply never mounted. That is how the synthscholar breakage stayed
hidden until /api/synth-scholar/health started 404-ing.
The build failed at the ml_service pip step: with the modern resolver, this tree
cannot be satisfied. structsense==0.0.4 drags an old crewai/litellm that holds
aiohttp below 3.10, and openai's vendored httpx_aiohttp references
aiohttp.SocketTimeoutError at import time. The legacy resolver hid that by
installing an inconsistent set; the modern one refuses.

That conflict was never confined to structsense. synthscholar imports openai too, so
the same old aiohttp broke it, which is why every /api/synth-scholar route 404'd
including the pre-existing ones — the router was never mounted. One dead pin cost
two features.

So structsense is no longer installed, and its three extraction WebSocket endpoints
are no longer registered:

  /ws/ner/{client_id}
  /ws/extract-resources/{client_id}
  /ws/pdf2reproschema/{client_id}

Registration is conditional on STRUCTSENSE_AVAILABLE (new, exported from
core/shared.py) via a small _extraction_ws decorator, so the paths simply do not
exist rather than accepting a WebSocket upgrade and failing after the client has
uploaded a PDF. GET /api/ws-info now reports extraction_available: false and names
what is disabled, instead of advertising paths that 404.

Everything that only touches stored data keeps working, which is the point:
GET /ner, GET /structured-resource, both save endpoints, GET /job/{task_id}.
/knowledge-base/ner depends on GET /ner.

Nothing is deleted. The install is commented in place with what re-enabling needs:
a structsense release whose crewai/litellm accept aiohttp>=3.10, or the two stacks
will keep fighting over it. verify_imports.py checks synthscholar only for now, with
a note not to re-add structsense without restoring the install.
/knowledge-base/ner and /knowledge-base/resources are public pages, but they read
GET /api/ner and GET /api/structured-resource, which require a credential. An
anonymous visitor got 403 {"detail":"Not authenticated"} — get_current_user rejects
for having no credential at all, before require_scopes(["read"]) is consulted. So
those pages showed an error to everyone not signed in.

Adds GET /api/public/ner and GET /api/public/structured-resource. They serve the
same documents as the authenticated routes, which is safe here rather than by
accident: saved annotations carry documentName, processedAt, sourceType,
sourceContent and the extracted entities, and the write path
(upsert_ner_annotations / upsert_structured_resources) records no submitter
identity — so there is nothing per-user to leak and no visibility flag to honour.
A comment marks these as the place to filter if per-record visibility is ever added.

`limit` is capped at 200 (the authenticated routes document 1000 and enforce
nothing). These are open to the internet and the payload includes sourceContent,
which can be a whole paper.

Same shape as the synth-scholar public routes added in 8fc6b5c: no
get_current_user, and read-only.
Build failed with:

  error: resolution-too-deep
  × Dependency resolution exceeded maximum depth

Not a structsense problem — structsense was already removed in 21d543d, and the
failing step does not mention it. synthscholar drags pydantic-ai -> openai and
sentence-transformers -> torch, and solved together with the exact `==` pins in
requirements.txt on an empty base image the graph exceeds what pip will search. The
hundreds of "psycopg 3.1.18 does not provide the extra 'async'" lines are noise, not
the cause: no psycopg release has ever had an `async` extra (checked 3.1.18, 3.2.x,
3.3.4), pip ignores unknown extras, and the repetition just shows how much
backtracking was happening.

So synthscholar moves out of requirements.txt into its own pip step. Two smaller
solves succeed where one large one does not, and requirements.txt goes first so its
pins are established before synthscholar resolves against them.

This also explains local-vs-EC2, which I had wrong earlier: the standalone
ml_service/Dockerfile does it in one solve because tiangolo/uvicorn-gunicorn-fastapi
preinstalls fastapi/uvicorn/pydantic, constraining the search. python:3.11-slim
starts empty, so pip explores everything. The resolver change in b622f5f surfaced
this rather than causing it — the legacy resolver was silently accepting an
inconsistent set.

Separately, fixes the layer ordering that made every deploy slow. Each service did
`COPY <service>/` BEFORE `pip install`, so editing any .py file invalidated that
service's pip layer and reinstalled everything — including torch, ~8 minutes on
ml_service alone. Now each copies only requirements.txt, installs, then copies source,
so pip layers are keyed on requirements.txt alone. verify_imports.py moves to its own
RUN after the source copy, since it needs the code.

`--no-cache` is no longer needed to pick up a dependency change; a requirements.txt
edit invalidates the layer on its own.
ml_service died on boot with

  File "/app/ml_service/core/shared.py", line 31
    from bs4 import BeautifulSoup
  ModuleNotFoundError: No module named 'bs4'

bs4 was never declared — it arrived as a transitive dependency of structsense.
Dropping structsense in 21d543d took it away, and core/shared.py imports it directly.

Rather than fix that one and wait for the next crash, parsed every import in core/ and
diffed against requirements.txt. Four were undeclared and genuinely needed:

  beautifulsoup4  core/shared.py
  python-dotenv   core/configuration.py
  rdflib          core/shared.py       (currently survives via synthscholar)
  PyYAML          core/shared.py

Deliberately not added: pydantic and starlette (guaranteed by the fastapi pin, and
unbounded entries risk resolution-too-deep again), pytest (test-only), structsense
(guarded, intentionally absent), synthscholar (its own pip step).

The deeper problem was the build gate. verify_imports.py checked the two optional AI
stacks but never the app itself, so a missing dependency in ml_service's OWN code
sailed through — bs4 broke via core/shared.py, a module the script never touched. It
now imports core.main, the same module gunicorn loads, and treats only
ModuleNotFoundError as fatal: missing env or database at build time is expected and
says nothing about the image, since importing core.main mounts routers but opens no
connections.

Needed sys.path fixing too — `python scripts/verify_imports.py` puts scripts/ on
sys.path, not the service root, so `import core.main` would have failed misleadingly
whatever was installed.
…ceback

ml_service is up and NER/structured-resource work, but /api/synth-scholar/* is still
absent from the OpenAPI spec. So `import synthscholar` succeeds (the build's check
passed) while `core.synth_scholar.routes` does not — and core/main.py mounts that
router inside a try/except, so the failure is swallowed, the service starts, and
/api/health returns 200 with an entire feature missing.

Two gaps, both closed here:

  * The gate checked the PACKAGE, not the router module. Importing synthscholar tells
    you nothing about whether the router that uses it can import. Added
    core.synth_scholar.routes as a fatal check — synthscholar is deliberately
    installed, so if it is present and the router still cannot import, that is a
    defect rather than a configuration choice.

  * Failures printed only str(exc). Diagnosing one meant a docker exec into a running
    container to reproduce the import by hand, because the frame that fails is the
    only thing identifying the bad dependency. Now prints the full traceback.

Note this makes the next build FAIL until the router imports. That is intended: a
green build shipping a silently disabled feature is what produced the last several
hours of 404s.

Also a correction to my own analysis, recorded so nobody repeats it: I reported
ROB_DOMAINS as missing from synthscholar 0.0.11's agents.py. It is present, at line
327, as an annotated assignment (`ROB_DOMAINS: dict[str, list[str]] = {...}`). The
checker only walked ast.Assign, not ast.AnnAssign. Every name core/synth_scholar/
imports does exist in 0.0.11 — verified against the wheel.
Root cause of /api/synth-scholar/* being absent, found at last. The four services
share ONE Python environment: docker-compose.unified.yml defines a single
brainkb-unified container and supervisor runs api_tokenmanager, query_service,
ml_service, usermanagement_service and oxigraph as programs inside it. So the last
pip install wins, and the install order was:

  line 129  ml_service            aiohttp>=3.10   installed
  line 137  verify_imports.py     PASSES          (aiohttp is 3.10+ at this moment)
  line 142  usermanagement        aiohttp==3.9.1  DOWNGRADES it

At runtime ml_service then sees 3.9.1, and openai's vendored httpx_aiohttp fails on
aiohttp.SocketTimeoutError (added in 3.10 — verified against the 3.9.5/3.10.11/
3.11.18/3.12.15/3.14.3 wheels). core/main.py catches it, the router never mounts, and
the service reports healthy with a whole feature missing.

That also explains why the build kept passing: verification ran inside the ml_service
block, i.e. before the downgrade. It now runs LAST, after every pip step, so it checks
the environment the container actually runs. The aiohttp upgrade moved there too, as a
backstop rather than the fix.

Audited every requirements.txt for the same class of problem — 12 packages are required
by more than one service with differing specs. Most are last-wins on patch versions and
harmless; two were real violations of another service's constraint:

  aiohttp     usermanagement/chat ==3.9.1  vs ml_service >=3.10   (this outage)
  sqlalchemy  usermanagement ==2.0.23      vs ml_service >=2.0.30 (latent)

Both relaxed, with a note in each file explaining that an exact pin here overrides what
another service needs. Not touched: fastapi, gunicorn, rich, pydantic-settings and the
rest, where the differing specs are all mutually satisfiable.
…_service instead

The push in oxigraph_push.py writes review RDF straight into Oxigraph over GSP.
It is the fastest way to get triples into the store and the worst way to get
them into BrainKB: no ingest job, no PROV-O provenance, no search-index row,
and the named graph belongs to no space, so nothing but an Admin SPARQL query
can see it. Reviews now reach BrainKB the way all other RDF does — the TTL
export goes through query_service, which records a job, a delta and a real user
as the agent.

Leaving both paths on gives every review two graphs rather than one. Review ids
are unique, so reviews never collide with each other; the problem is that the
two writers disagree about the IRI by a single character. _named_graph_for
returns prefix + review_id with no trailing slash, while the ingest path
registers and writes .../<review_id>/ — query_service normalises the registry
lookup (check_named_graph_exists appends a slash) but writes the IRI verbatim
(create_job(graph=named_graph_iri)). So the store ends up holding a governed
graph and an unregistered shadow copy, with search and provenance describing
only one of them. That is worse than either outcome alone.

backfill_oxigraph_push.py now refuses to run rather than reporting success
against a no-op: with the flag off, _make_config returns None for every review
and the script would have logged a clean backfill while nothing left the
process. Worst possible outcome for a one-shot repair tool.

The env-var table and module docstrings say which path is current, so the next
person reading either file finds the ingest route instead of re-enabling this
one.
Route Review RDF Through BrainKB Ingest and Disable Direct Oxigraph Push
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