From 3b5dc24e66260fb2d0f82d8993250efb95c30f10 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Wed, 22 Jul 2026 12:17:25 -0400 Subject: [PATCH 01/70] Add native PROV-O provenance tracking in Oxigraph 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_service/PROVENANCE_MODEL.md | 137 +++++++++++++ query_service/core/provenance.py | 290 +++++++++++++++++++++++++++ query_service/core/routers/insert.py | 163 ++++++++++----- 3 files changed, 544 insertions(+), 46 deletions(-) create mode 100644 query_service/PROVENANCE_MODEL.md create mode 100644 query_service/core/provenance.py diff --git a/query_service/PROVENANCE_MODEL.md b/query_service/PROVENANCE_MODEL.md new file mode 100644 index 0000000..9641843 --- /dev/null +++ b/query_service/PROVENANCE_MODEL.md @@ -0,0 +1,137 @@ +# BrainKB Provenance Model (PROV-O in Oxigraph) + +Status: implemented on branch `improve-ingestion-query-service`. + +BrainKB stores knowledge in Oxigraph (a triplestore), so provenance is tracked +**natively as W3C PROV-O triples in the graph database** and queried with SPARQL — +not in a relational side-table. Postgres continues to hold job *execution state* +(`jobs`, `job_results`, `job_processing_log`); Oxigraph is the provenance +*source of truth*. + +This replaces the previous approach, which embedded PROV triples directly into +each uploaded file's domain data via `attach_provenance()`. That polluted domain +graphs, generated per-file provenance with random UUIDs unlinked to the job, and +forced a full parse+re-serialize of every file (a memory/latency bottleneck). + +## Where provenance lives + +All provenance is written to one dedicated named graph: + +``` +https://brainkb.org/provenance/ +``` + +Domain data uploaded during ingestion is **no longer modified** — files land in +their target named graph exactly as provided. + +## Vocabulary + +| Prefix | IRI | +|-----------|------------------------------------------------| +| `prov` | `http://www.w3.org/ns/prov#` | +| `dcterms` | `http://purl.org/dc/terms/` | +| `xsd` | `http://www.w3.org/2001/XMLSchema#` | +| `brainkb` | `https://brainkb.org/vocab/` (custom terms) | + +Instance IRIs are minted under `https://brainkb.org/prov/`: + +- Activity: `…/prov/activity/{job_id}` +- Ingested bundle (entity): `…/prov/bundle/{job_id}` +- Per-file entity: `…/prov/file/{job_id}/{urlencoded filename}` +- Agent: `…/prov/agent/{urlencoded id}`; system agent: `…/prov/agent/system` + +## Tracked activities + +Every mutating action becomes a `prov:Activity` with a typed agent. + +### 1. Ingestion — `brainkb:IngestionActivity` (agent: user) + +Written when a job reaches a terminal state (`done` / `error` / partial). + +```turtle +GRAPH { + <…/prov/activity/{job_id}> + a prov:Activity, brainkb:IngestionActivity ; + prov:startedAtTime "…"^^xsd:dateTime ; + prov:endedAtTime "…"^^xsd:dateTime ; + prov:wasAssociatedWith <…/prov/agent/{user}> ; + brainkb:targetGraph <{named_graph_iri}> ; + brainkb:jobStatus "done" ; + brainkb:totalFiles 20 ; + brainkb:successCount 19 ; + brainkb:failCount 1 . + + <…/prov/bundle/{job_id}> + a prov:Entity ; + prov:wasGeneratedBy <…/prov/activity/{job_id}> ; + prov:wasAttributedTo <…/prov/agent/{user}> ; + prov:generatedAtTime "…"^^xsd:dateTime ; + dcterms:isPartOf <{named_graph_iri}> . + + <…/prov/file/{job_id}/data_009.ttl> + a prov:Entity ; + prov:wasGeneratedBy <…/prov/activity/{job_id}> ; + dcterms:isPartOf <…/prov/bundle/{job_id}> ; + brainkb:fileName "data_009.ttl" ; + brainkb:uploadStatus "success" ; + brainkb:httpStatus 204 ; + brainkb:sizeBytes 41943040 . + + <…/prov/agent/{user}> a prov:Agent, prov:Person . +} +``` + +### 2. Named-graph registration — `brainkb:RegistrationActivity` (agent: user) + +Written after a graph is registered via `POST /register-named-graph`. + +```turtle +<…/prov/activity/reg-{uuid}> + a prov:Activity, brainkb:RegistrationActivity ; + prov:startedAtTime "…"^^xsd:dateTime ; + prov:wasAssociatedWith <…/prov/agent/{user}> ; + brainkb:targetGraph <{named_graph_url}> . +<{named_graph_url}> prov:wasGeneratedBy <…/prov/activity/reg-{uuid}> . +``` + +### 3. Crash recovery — `brainkb:RecoveryActivity` (agent: system) + +Written when `recover_stuck_jobs()` marks a stuck job as `error`. + +```turtle +<…/prov/activity/rec-{job_id}-{ts}> + a prov:Activity, brainkb:RecoveryActivity ; + prov:startedAtTime "…"^^xsd:dateTime ; + prov:wasAssociatedWith <…/prov/agent/system> ; + prov:used <…/prov/activity/{job_id}> ; + dcterms:description "{cause}" . +<…/prov/agent/system> a prov:Agent, prov:SoftwareAgent . +``` + +## Writing + +Provenance triples are serialized to Turtle and appended to the provenance graph +via Oxigraph's Graph Store HTTP protocol (`POST {endpoint}/store?graph=…`, which +*merges* rather than replaces). Writes are best-effort: a provenance failure is +logged but never fails the underlying job/registration. + +## Retrieval (JSON-LD) + +Two read endpoints return a PROV-O bundle as `application/ld+json` via SPARQL +`CONSTRUCT` against the provenance graph: + +- `GET /api/provenance/job?job_id=…&user_id=…` — provenance for one job + (access-controlled with `verify_user_access`). +- `GET /api/provenance/named-graph?iri=…` — all ingestion/registration activity + that targeted a given named graph. + +Because everything is in Oxigraph, arbitrary provenance questions can also be +asked directly over SPARQL, e.g. "all graphs ingested by agent X since T". + +## Backward compatibility + +- The `skip_provenance` query parameter on the ingestion endpoints is retained + but no longer controls domain-data embedding (which is removed). Graph-level + PROV-O tracking always happens. +- `attach_provenance()` / `process_file_with_provenance()` remain in the codebase + but are no longer on the ingestion hot path. diff --git a/query_service/core/provenance.py b/query_service/core/provenance.py new file mode 100644 index 0000000..fae0b78 --- /dev/null +++ b/query_service/core/provenance.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# DISCLAIMER: This software is provided "as is" without any warranty, +# express or implied, including but not limited to the warranties of +# merchantability, fitness for a particular purpose, and non-infringement. +# +# In no event shall the authors or copyright holders be liable for any +# claim, damages, or other liability, whether in an action of contract, +# tort, or otherwise, arising from, out of, or in connection with the +# software or the use or other dealings in the software. +# ----------------------------------------------------------------------------- + +# @Author : Tek Raj Chhetri +# @Email : tekraj@mit.edu +# @Web : https://tekrajchhetri.com/ +# @File : provenance.py +# @Software: PyCharm + +""" +Native PROV-O provenance tracking in Oxigraph. + +BrainKB stores knowledge in a triplestore, so provenance is tracked as W3C +PROV-O triples written to a dedicated provenance named graph and queried with +SPARQL. See PROVENANCE_MODEL.md for the full design. + +This module is intentionally self-contained and best-effort: helpers here build +and persist provenance, but callers must ensure a provenance failure never fails +the underlying operation (wrap calls in try/except). +""" + +from __future__ import annotations + +import datetime +import logging +import uuid +from typing import Any, Dict, List, Optional +from urllib.parse import quote + +import httpx +from rdflib import Graph, Literal, Namespace, URIRef, RDF, XSD, DCTERMS + +from core.shared import get_oxigraph_endpoint, get_oxigraph_auth +from core.graph_database_connection_manager import _get_endpoint + +logger = logging.getLogger(__name__) + +# Dedicated named graph holding all provenance. +PROVENANCE_GRAPH = "https://brainkb.org/provenance/" + +# Namespaces +PROV = Namespace("http://www.w3.org/ns/prov#") +BRAINKB = Namespace("https://brainkb.org/vocab/") # custom vocabulary +PROV_BASE = Namespace("https://brainkb.org/prov/") # instance IRIs + + +def _now_iso() -> str: + """Current UTC time as an ISO-8601 string.""" + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +def _new_graph() -> Graph: + g = Graph() + g.bind("prov", PROV) + g.bind("brainkb", BRAINKB) + g.bind("dcterms", DCTERMS) + return g + + +def agent_ref(agent_id: str) -> URIRef: + """Mint the agent IRI for a user/system identifier.""" + return URIRef(PROV_BASE[f"agent/{quote(str(agent_id), safe='')}"]) + + +def activity_ref(job_id: str) -> URIRef: + return URIRef(PROV_BASE[f"activity/{quote(str(job_id), safe='')}"]) + + +# --------------------------------------------------------------------------- +# Builders — each returns an rdflib Graph of PROV-O triples +# --------------------------------------------------------------------------- + +def build_ingestion_provenance( + *, + job_id: str, + agent_id: str, + named_graph_iri: str, + started_at: str, + ended_at: str, + status: str, + total_files: int, + success_count: int, + fail_count: int, + results: Optional[List[Dict[str, Any]]] = None, + agent_type: str = "user", +) -> Graph: + """Build the PROV-O bundle for a completed (terminal) ingestion job.""" + g = _new_graph() + + activity = activity_ref(job_id) + bundle = URIRef(PROV_BASE[f"bundle/{quote(str(job_id), safe='')}"]) + agent = agent_ref(agent_id) + + # Agent + g.add((agent, RDF.type, PROV.Agent)) + g.add((agent, RDF.type, PROV.SoftwareAgent if agent_type == "system" else PROV.Person)) + + # Activity + g.add((activity, RDF.type, PROV.Activity)) + g.add((activity, RDF.type, BRAINKB.IngestionActivity)) + g.add((activity, PROV.startedAtTime, Literal(started_at, datatype=XSD.dateTime))) + g.add((activity, PROV.endedAtTime, Literal(ended_at, datatype=XSD.dateTime))) + g.add((activity, PROV.wasAssociatedWith, agent)) + g.add((activity, BRAINKB.targetGraph, URIRef(named_graph_iri))) + g.add((activity, BRAINKB.jobStatus, Literal(status))) + g.add((activity, BRAINKB.totalFiles, Literal(int(total_files), datatype=XSD.integer))) + g.add((activity, BRAINKB.successCount, Literal(int(success_count), datatype=XSD.integer))) + g.add((activity, BRAINKB.failCount, Literal(int(fail_count), datatype=XSD.integer))) + + # Ingested bundle entity + g.add((bundle, RDF.type, PROV.Entity)) + g.add((bundle, PROV.wasGeneratedBy, activity)) + g.add((bundle, PROV.wasAttributedTo, agent)) + g.add((bundle, PROV.generatedAtTime, Literal(ended_at, datatype=XSD.dateTime))) + g.add((bundle, DCTERMS.isPartOf, URIRef(named_graph_iri))) + + # Per-file entities + for r in results or []: + fname = str(r.get("file", "unknown")) + file_entity = URIRef(PROV_BASE[f"file/{quote(str(job_id), safe='')}/{quote(fname, safe='')}"]) + g.add((file_entity, RDF.type, PROV.Entity)) + g.add((file_entity, PROV.wasGeneratedBy, activity)) + g.add((file_entity, DCTERMS.isPartOf, bundle)) + g.add((file_entity, BRAINKB.fileName, Literal(fname))) + g.add((file_entity, BRAINKB.uploadStatus, Literal("success" if r.get("success") else "failed"))) + if r.get("http_status") is not None: + g.add((file_entity, BRAINKB.httpStatus, Literal(int(r["http_status"]), datatype=XSD.integer))) + if r.get("size_bytes") is not None: + g.add((file_entity, BRAINKB.sizeBytes, Literal(int(r["size_bytes"]), datatype=XSD.integer))) + + return g + + +def build_registration_provenance( + *, + named_graph_url: str, + agent_id: str, + at: Optional[str] = None, +) -> Graph: + """Build PROV-O for a named-graph registration (agent: user).""" + g = _new_graph() + at = at or _now_iso() + activity = URIRef(PROV_BASE[f"activity/reg-{uuid.uuid4().hex}"]) + agent = agent_ref(agent_id) + + g.add((agent, RDF.type, PROV.Agent)) + g.add((agent, RDF.type, PROV.Person)) + g.add((activity, RDF.type, PROV.Activity)) + g.add((activity, RDF.type, BRAINKB.RegistrationActivity)) + g.add((activity, PROV.startedAtTime, Literal(at, datatype=XSD.dateTime))) + g.add((activity, PROV.wasAssociatedWith, agent)) + g.add((activity, BRAINKB.targetGraph, URIRef(named_graph_url))) + g.add((URIRef(named_graph_url), PROV.wasGeneratedBy, activity)) + return g + + +def build_recovery_provenance( + *, + job_id: str, + at: Optional[str] = None, + cause: str = "", +) -> Graph: + """Build PROV-O for an automated crash-recovery action (agent: system).""" + g = _new_graph() + at = at or _now_iso() + ts = at.replace(":", "").replace("-", "").replace(".", "") + activity = URIRef(PROV_BASE[f"activity/rec-{quote(str(job_id), safe='')}-{quote(ts, safe='')}"]) + system_agent = agent_ref("system") + + g.add((system_agent, RDF.type, PROV.Agent)) + g.add((system_agent, RDF.type, PROV.SoftwareAgent)) + g.add((activity, RDF.type, PROV.Activity)) + g.add((activity, RDF.type, BRAINKB.RecoveryActivity)) + g.add((activity, PROV.startedAtTime, Literal(at, datatype=XSD.dateTime))) + g.add((activity, PROV.wasAssociatedWith, system_agent)) + # Link the recovery activity to the ingestion activity it acted upon + g.add((activity, PROV.used, activity_ref(job_id))) + if cause: + g.add((activity, DCTERMS.description, Literal(cause))) + return g + + +# --------------------------------------------------------------------------- +# Persistence + retrieval +# --------------------------------------------------------------------------- + +async def write_provenance(graph: Graph) -> bool: + """ + Append PROV-O triples to the provenance named graph via Oxigraph's Graph + Store HTTP protocol (POST merges into the graph). Best-effort: returns True + on success, False on failure (never raises). + """ + if graph is None or len(graph) == 0: + return True + try: + payload = graph.serialize(format="turtle") + endpoint = get_oxigraph_endpoint() # .../store + url = f"{endpoint}?graph={quote(PROVENANCE_GRAPH, safe='')}" + auth = get_oxigraph_auth() + async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=15.0)) as client: + resp = await client.post( + url, + content=payload.encode("utf-8"), + headers={"Content-Type": "text/turtle"}, + auth=auth, + ) + if resp.status_code in (200, 201, 204): + return True + logger.warning( + "[provenance] Failed to write provenance (HTTP %s): %s", + resp.status_code, (resp.text or "")[:500], + ) + return False + except Exception as e: + logger.warning(f"[provenance] Error writing provenance: {e}", exc_info=True) + return False + + +async def query_provenance_jsonld(construct_query: str) -> Optional[str]: + """ + Execute a SPARQL CONSTRUCT against Oxigraph and return JSON-LD text. + Returns None on error. + """ + try: + endpoint = _get_endpoint("get") # .../query for OXIGRAPH + auth = get_oxigraph_auth() + async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) as client: + resp = await client.post( + endpoint, + data={"query": construct_query}, + headers={"Accept": "application/ld+json"}, + auth=auth, + ) + if resp.status_code == 200: + return resp.text + logger.warning( + "[provenance] CONSTRUCT query failed (HTTP %s): %s", + resp.status_code, (resp.text or "")[:500], + ) + return None + except Exception as e: + logger.warning(f"[provenance] Error querying provenance: {e}", exc_info=True) + return None + + +def construct_for_job(job_id: str) -> str: + """SPARQL CONSTRUCT for all provenance about a single job (activity, bundle, + per-file entities, and the connected agent).""" + activity = str(activity_ref(job_id)) + bundle = str(URIRef(PROV_BASE[f"bundle/{quote(str(job_id), safe='')}"])) + file_prefix = f"{str(PROV_BASE)}file/{quote(str(job_id), safe='')}/" + return f""" + CONSTRUCT {{ ?s ?p ?o }} + WHERE {{ + GRAPH <{PROVENANCE_GRAPH}> {{ + {{ <{activity}> ?p ?o . BIND(<{activity}> AS ?s) }} + UNION + {{ <{bundle}> ?p ?o . BIND(<{bundle}> AS ?s) }} + UNION + {{ ?s ?p ?o . FILTER(STRSTARTS(STR(?s), "{file_prefix}")) }} + UNION + {{ <{activity}> (<{PROV.wasAssociatedWith}>|<{PROV.used}>) ?s . ?s ?p ?o }} + UNION + {{ <{bundle}> <{PROV.wasAttributedTo}> ?s . ?s ?p ?o }} + }} + }} + """ + + +def construct_for_named_graph(named_graph_iri: str) -> str: + """SPARQL CONSTRUCT for all activity that targeted a given named graph.""" + return f""" + CONSTRUCT {{ ?activity ?p ?o . ?ent ?ep ?eo }} + WHERE {{ + GRAPH <{PROVENANCE_GRAPH}> {{ + ?activity <{BRAINKB.targetGraph}> <{named_graph_iri}> ; + ?p ?o . + OPTIONAL {{ ?ent <{PROV.wasGeneratedBy}> ?activity ; ?ep ?eo . }} + }} + }} + """ diff --git a/query_service/core/routers/insert.py b/query_service/core/routers/insert.py index d765c52..410ae01 100644 --- a/query_service/core/routers/insert.py +++ b/query_service/core/routers/insert.py @@ -18,7 +18,7 @@ from fastapi import APIRouter, Request, HTTPException, status, UploadFile, File, Form, BackgroundTasks, Query, Body -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, Response from core.graph_database_connection_manager import insert_data_gdb, insert_data_gdb_async import logging from core.pydantic_schema import InputKGTripleSchema, NamedGraphSchema @@ -48,6 +48,15 @@ batch_insert_job_results, ) from core.configuration import load_environment +from core.provenance import ( + build_ingestion_provenance, + build_registration_provenance, + build_recovery_provenance, + write_provenance, + query_provenance_jsonld, + construct_for_job, + construct_for_named_graph, +) import datetime import uuid import asyncio @@ -279,29 +288,13 @@ async def upload_single_file_path( total_files=total_files, ) - # Process file (attach provenance if text-based RDF format) - if job_id and not skip_provenance: - from core.database import insert_processing_log - status_msg = f"Attaching provenance to {filename}" - await update_job_processing_state( - job_id=job_id, - current_stage="attaching_provenance", - status_message=status_msg - ) - # Log to history - await insert_processing_log( - job_id=job_id, - file_name=filename, - stage="attaching_provenance", - status_message=status_msg, - file_index=file_index, - total_files=total_files, - ) - - processed_filepath, provenance_success = await process_file_with_provenance( - filepath, user_id, ext, skip_provenance=skip_provenance - ) - + # Provenance is tracked natively as PROV-O in Oxigraph's dedicated provenance + # graph (see core/provenance.py and PROVENANCE_MODEL.md), NOT embedded into the + # domain data. The uploaded file is therefore sent to Oxigraph unmodified. + # `skip_provenance` is retained on the API for backward compatibility but no + # longer controls domain-data embedding (which has been removed). + processed_filepath = filepath + # Update processing state: Uploading file if job_id: from core.database import insert_processing_log @@ -440,23 +433,6 @@ async def upload_single_file_path( if resp_text and len(resp_text) > max_len: resp_text = resp_text[:max_len] + "... [truncated]" - # Surface provenance-attachment failures. process_file_with_provenance returns - # success=False when it was asked to attach provenance but rdflib parsing failed; - # in that case the ORIGINAL (un-provenanced) file was uploaded. Previously this - # was silently swallowed and the job still reported success, hiding a data-integrity - # gap. We now flag it explicitly so the job result records it. - provenance_requested = not skip_provenance and ext in [ - "ttl", "turtle", "nt", "nq", "jsonld", "json", "rdf", "owl" - ] - provenance_failed = provenance_requested and not provenance_success - if provenance_failed: - warning = ( - f"WARNING: provenance could not be attached to {filename} " - f"(RDF parsing failed); the original file was uploaded WITHOUT provenance metadata. " - ) - logger.warning(f"[upload_single_file_path] {warning.strip()}") - resp_text = warning + (resp_text or "") - return { "file": filename, "ext": ext, @@ -464,8 +440,6 @@ async def upload_single_file_path( "elapsed_s": elapsed, "http_status": resp.status_code, "success": success, - "provenance_attached": provenance_requested and provenance_success, - "provenance_requested": provenance_requested, "bps": bps, "response_body": resp_text, } @@ -488,7 +462,35 @@ async def run_ingest_job( # This prevents jobs from running indefinitely MAX_JOB_TIMEOUT = 2 * 60 * 60 # 2 hours job_start_time = time.time() - + + async def _write_ingestion_prov(status_label: str, results): + """Best-effort: record this job as a PROV-O IngestionActivity in Oxigraph. + Never raises — a provenance failure must not fail the job.""" + try: + details = await get_job_details(job_id) + named_graph = details.get("graph") if details else None + if not named_graph: + return + results = results or [] + succ = sum(1 for r in results if r.get("success")) + fail = len(results) - succ + prov_graph = build_ingestion_provenance( + job_id=job_id, + agent_id=user_id, + named_graph_iri=named_graph, + started_at=datetime.datetime.fromtimestamp(job_start_time, datetime.timezone.utc).isoformat(), + ended_at=datetime.datetime.now(datetime.timezone.utc).isoformat(), + status=status_label, + total_files=len(results), + success_count=succ, + fail_count=fail, + results=results, + agent_type="user", + ) + await write_provenance(prov_graph) + except Exception as _pe: + logger.warning(f"[run_ingest_job] Provenance write failed for {job_id}: {_pe}") + try: # Mark job as running (start_time was already set when job was created) from core.database import update_job_processing_state, insert_processing_log @@ -615,8 +617,13 @@ async def worker(fi: Dict[str, Any], index: int): status_message=status_msg, ) await update_job_status(job_id, "done", end_time=time.time()) + # Record PROV-O ingestion provenance in Oxigraph (source of truth) + _succ = sum(1 for r in all_results if r.get("success")) + _fail = len(all_results) - _succ + _status_label = "done" if _fail == 0 else ("failed" if _succ == 0 else "partial") + await _write_ingestion_prov(_status_label, all_results) logger.info(f"[run_ingest_job] Job {job_id} completed successfully") - + except asyncio.TimeoutError as e: # Mark job as errored due to timeout from core.database import update_job_processing_state, insert_processing_log @@ -632,6 +639,7 @@ async def worker(fi: Dict[str, Any], index: int): status_message=status_msg, ) await update_job_status(job_id, "error", end_time=time.time()) + await _write_ingestion_prov("error", []) logger.error(f"[run_ingest_job] Job {job_id} timed out: {e}", exc_info=True) except Exception as e: # Mark job as errored @@ -648,6 +656,7 @@ async def worker(fi: Dict[str, Any], index: int): status_message=status_msg, ) await update_job_status(job_id, "error", end_time=time.time()) + await _write_ingestion_prov("error", []) logger.error(f"[run_ingest_job] Job {job_id} failed: {e}", exc_info=True) finally: # Ensure job status is always updated, even if something goes wrong @@ -980,7 +989,15 @@ async def recover_stuck_jobs( f"The job was marked as 'error' to prevent it from running indefinitely." ), ) - + + # Record the automated recovery as a PROV-O RecoveryActivity (system agent) + try: + await write_provenance( + build_recovery_provenance(job_id=job_id, cause=cause) + ) + except Exception as _pe: + logger.warning(f"[recover_stuck_jobs] Provenance write failed for {job_id}: {_pe}") + logger.info(f"[recover_stuck_jobs] Recovered {len(stuck_jobs)} stuck job(s) from server crash/restart") return len(stuck_jobs) else: @@ -1749,6 +1766,20 @@ async def create_named_graph( description=description, ) ) + # Record registration as a PROV-O RegistrationActivity (user agent) + try: + try: + agent_id = user["email"] or user["id"] + except (KeyError, TypeError, IndexError): + agent_id = "unknown" + await write_provenance( + build_registration_provenance( + named_graph_url=named_graph_url, + agent_id=str(agent_id), + ) + ) + except Exception as _pe: + logger.warning(f"[create_named_graph] Provenance write failed: {_pe}") return response else: return JSONResponse( @@ -1765,3 +1796,43 @@ async def create_named_graph( detail=f"An error occurred processing the request {e}", ) + +@router.get("/provenance/job", include_in_schema=True) +async def get_job_provenance( + user: Annotated[LoginUserIn, Depends(get_current_user)], + user_id: Annotated[str, Query(..., description="User identifier (must match the authenticated user)")], + job_id: Annotated[str, Query(..., description="Job identifier to fetch provenance for")], +): + """ + GET /provenance/job + Return the W3C PROV-O provenance bundle (JSON-LD) for a single ingestion job, + reconstructed from the dedicated provenance graph in Oxigraph. + """ + verify_user_access(user_id, user) + jsonld = await query_provenance_jsonld(construct_for_job(job_id)) + if jsonld is None: + return JSONResponse( + {"error": "Failed to retrieve provenance from the graph database"}, + status_code=502, + ) + return Response(content=jsonld, media_type="application/ld+json") + + +@router.get("/provenance/named-graph", include_in_schema=True) +async def get_named_graph_provenance( + user: Annotated[LoginUserIn, Depends(get_current_user)], + iri: Annotated[str, Query(..., description="Named graph IRI to fetch provenance for")], +): + """ + GET /provenance/named-graph + Return the W3C PROV-O provenance (JSON-LD) for every activity (ingestion, + registration) that targeted the given named graph. + """ + jsonld = await query_provenance_jsonld(construct_for_named_graph(iri)) + if jsonld is None: + return JSONResponse( + {"error": "Failed to retrieve provenance from the graph database"}, + status_code=502, + ) + return Response(content=jsonld, media_type="application/ld+json") + From 73eac510272de8fd44b67a719711afb2f924d095 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Wed, 22 Jul 2026 12:41:26 -0400 Subject: [PATCH 02/70] Fix provenance retrieval validated against live Oxigraph - 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. --- query_service/core/provenance.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/query_service/core/provenance.py b/query_service/core/provenance.py index fae0b78..24b148f 100644 --- a/query_service/core/provenance.py +++ b/query_service/core/provenance.py @@ -229,6 +229,11 @@ async def query_provenance_jsonld(construct_query: str) -> Optional[str]: """ Execute a SPARQL CONSTRUCT against Oxigraph and return JSON-LD text. Returns None on error. + + Oxigraph does not serialize CONSTRUCT results as JSON-LD (it offers + Turtle / N-Triples / N-Quads / RDF-XML), so we request Turtle and convert to + JSON-LD locally with rdflib. This keeps the JSON-LD API contract independent + of the triplestore's supported output formats. """ try: endpoint = _get_endpoint("get") # .../query for OXIGRAPH @@ -237,16 +242,21 @@ async def query_provenance_jsonld(construct_query: str) -> Optional[str]: resp = await client.post( endpoint, data={"query": construct_query}, - headers={"Accept": "application/ld+json"}, + headers={"Accept": "text/turtle"}, auth=auth, ) - if resp.status_code == 200: - return resp.text - logger.warning( - "[provenance] CONSTRUCT query failed (HTTP %s): %s", - resp.status_code, (resp.text or "")[:500], - ) - return None + if resp.status_code != 200: + logger.warning( + "[provenance] CONSTRUCT query failed (HTTP %s): %s", + resp.status_code, (resp.text or "")[:500], + ) + return None + g = Graph() + g.parse(data=resp.text, format="turtle") + g.bind("prov", PROV) + g.bind("brainkb", BRAINKB) + g.bind("dcterms", DCTERMS) + return g.serialize(format="json-ld", auto_compact=True) except Exception as e: logger.warning(f"[provenance] Error querying provenance: {e}", exc_info=True) return None @@ -271,6 +281,10 @@ def construct_for_job(job_id: str) -> str: {{ <{activity}> (<{PROV.wasAssociatedWith}>|<{PROV.used}>) ?s . ?s ?p ?o }} UNION {{ <{bundle}> <{PROV.wasAttributedTo}> ?s . ?s ?p ?o }} + UNION + {{ ?s <{PROV.used}> <{activity}> . ?s ?p ?o }} + UNION + {{ ?rec <{PROV.used}> <{activity}> ; <{PROV.wasAssociatedWith}> ?s . ?s ?p ?o }} }} }} """ From 52993ba2e5e82a4b5c68d21cf2fbed2d992767ea Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Wed, 22 Jul 2026 14:43:49 -0400 Subject: [PATCH 03/70] Add triple-level delta tracking and delta query/compare endpoints 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. --- query_service/PROVENANCE_MODEL.md | 52 ++++++- query_service/core/provenance.py | 201 ++++++++++++++++++++++++++- query_service/core/routers/insert.py | 101 +++++++++++++- 3 files changed, 348 insertions(+), 6 deletions(-) diff --git a/query_service/PROVENANCE_MODEL.md b/query_service/PROVENANCE_MODEL.md index 9641843..8d08d75 100644 --- a/query_service/PROVENANCE_MODEL.md +++ b/query_service/PROVENANCE_MODEL.md @@ -115,9 +115,48 @@ via Oxigraph's Graph Store HTTP protocol (`POST {endpoint}/store?graph=…`, whi *merges* rather than replaces). Writes are best-effort: a provenance failure is logged but never fails the underlying job/registration. +## Incremental change tracking (triple-level deltas) + +Provenance above records *who/what/when* at the activity level. To also track +*which triples changed*, each ingestion job stages its data in a **per-job delta +graph** before it reaches the target: + +``` +https://brainkb.org/provenance/delta/{job_id} +``` + +Flow (when `TRACK_TRIPLE_DELTAS=true`, the default): + +1. Each uploaded file is written to the job's delta graph (not the target). +2. When the job finalizes, the delta graph is merged into the target graph + server-side via SPARQL `ADD SILENT TO ` (a set union, so + re-ingesting identical triples is idempotent — no duplicates). +3. The delta graph is **preserved** as the immutable record of exactly what that + job added, and a PROV-O `brainkb:IngestionDelta` entity is written: + +```turtle +<…/prov/delta/{job_id}> + a prov:Entity, brainkb:IngestionDelta ; + prov:wasGeneratedBy <…/prov/activity/{job_id}> ; + prov:wasDerivedFrom <{target_graph}> ; + brainkb:changeType "addition" ; + brainkb:targetGraph <{target_graph}> ; + brainkb:deltaGraph ; + brainkb:addedTripleCount 1234 ; + prov:generatedAtTime "…"^^xsd:dateTime ; + dcterms:isPartOf <…/prov/bundle/{job_id}> . +``` + +Set `TRACK_TRIPLE_DELTAS=false` to upload directly to the target graph (no delta +graphs). Trade-off: delta graphs persist, so this roughly doubles stored triples +for ingested data — the cost of full triple-level history and diffing. + +Current scope is **additions** (ingestion is append-only). Removals/updates would +add a `removed` delta graph per activity; that is a future extension. + ## Retrieval (JSON-LD) -Two read endpoints return a PROV-O bundle as `application/ld+json` via SPARQL +Read endpoints return a PROV-O bundle as `application/ld+json` via SPARQL `CONSTRUCT` against the provenance graph: - `GET /api/provenance/job?job_id=…&user_id=…` — provenance for one job @@ -125,6 +164,17 @@ Two read endpoints return a PROV-O bundle as `application/ld+json` via SPARQL - `GET /api/provenance/named-graph?iri=…` — all ingestion/registration activity that targeted a given named graph. +Delta / change endpoints: + +- `GET /api/provenance/delta?job_id=…&user_id=…` — the exact triples a job added + (its delta graph) as JSON-LD (access-controlled). +- `GET /api/provenance/delta/history?iri=…` — the ordered change history of a + named graph: one entry per delta (job, added triple count, timestamp, status), + newest first. +- `GET /api/provenance/delta/compare?job_id_a=…&job_id_b=…&user_id=…` — compare + the triples added by two jobs; returns counts (A-only / B-only / shared) and the + differing triples as JSON-LD (access-controlled). + Because everything is in Oxigraph, arbitrary provenance questions can also be asked directly over SPARQL, e.g. "all graphs ingested by agent X since T". diff --git a/query_service/core/provenance.py b/query_service/core/provenance.py index 24b148f..8da275f 100644 --- a/query_service/core/provenance.py +++ b/query_service/core/provenance.py @@ -31,6 +31,7 @@ from __future__ import annotations import datetime +import json import logging import uuid from typing import Any, Dict, List, Optional @@ -47,6 +48,11 @@ # Dedicated named graph holding all provenance. PROVENANCE_GRAPH = "https://brainkb.org/provenance/" +# Per-job delta graphs live under this base. Each ingestion job stages its +# triples in its own delta graph, giving an exact, queryable record of what that +# job added (triple-level change tracking) before/after merging into the target. +PROVENANCE_DELTA_BASE = "https://brainkb.org/provenance/delta/" + # Namespaces PROV = Namespace("http://www.w3.org/ns/prov#") BRAINKB = Namespace("https://brainkb.org/vocab/") # custom vocabulary @@ -75,6 +81,11 @@ def activity_ref(job_id: str) -> URIRef: return URIRef(PROV_BASE[f"activity/{quote(str(job_id), safe='')}"]) +def delta_graph_for(job_id: str) -> str: + """The per-job delta graph IRI (holds exactly the triples this job added).""" + return f"{PROVENANCE_DELTA_BASE}{quote(str(job_id), safe='')}" + + # --------------------------------------------------------------------------- # Builders — each returns an rdflib Graph of PROV-O triples # --------------------------------------------------------------------------- @@ -92,8 +103,15 @@ def build_ingestion_provenance( fail_count: int, results: Optional[List[Dict[str, Any]]] = None, agent_type: str = "user", + delta_graph: Optional[str] = None, + added_triple_count: Optional[int] = None, ) -> Graph: - """Build the PROV-O bundle for a completed (terminal) ingestion job.""" + """Build the PROV-O bundle for a completed (terminal) ingestion job. + + When ``delta_graph`` is given, a ``brainkb:IngestionDelta`` entity is added + describing the incremental change: which named graph it derives from, the + delta graph holding the exact added triples, and (optionally) the count. + """ g = _new_graph() activity = activity_ref(job_id) @@ -137,6 +155,23 @@ def build_ingestion_provenance( if r.get("size_bytes") is not None: g.add((file_entity, BRAINKB.sizeBytes, Literal(int(r["size_bytes"]), datatype=XSD.integer))) + # Incremental-change (delta) entity: an isolated, queryable record of exactly + # the triples this job contributed to the target graph. + if delta_graph: + delta = URIRef(PROV_BASE[f"delta/{quote(str(job_id), safe='')}"]) + g.add((delta, RDF.type, PROV.Entity)) + g.add((delta, RDF.type, BRAINKB.IngestionDelta)) + g.add((delta, PROV.wasGeneratedBy, activity)) + # The change derives from (is applied to) the target named graph + g.add((delta, PROV.wasDerivedFrom, URIRef(named_graph_iri))) + g.add((delta, BRAINKB.changeType, Literal("addition"))) + g.add((delta, BRAINKB.targetGraph, URIRef(named_graph_iri))) + g.add((delta, BRAINKB.deltaGraph, URIRef(delta_graph))) + g.add((delta, DCTERMS.isPartOf, bundle)) + g.add((delta, PROV.generatedAtTime, Literal(ended_at, datatype=XSD.dateTime))) + if added_triple_count is not None: + g.add((delta, BRAINKB.addedTripleCount, Literal(int(added_triple_count), datatype=XSD.integer))) + return g @@ -225,6 +260,52 @@ async def write_provenance(graph: Graph) -> bool: return False +async def merge_delta_into_target(delta_graph: str, target_graph: str) -> bool: + """ + Copy all triples from a per-job delta graph into the target named graph via + SPARQL Update ADD (server-side; the delta graph is preserved as the change + record). Best-effort: returns True on success, False otherwise. + """ + try: + update = f"ADD SILENT <{delta_graph}> TO <{target_graph}>" + endpoint = _get_endpoint("post") # .../update for OXIGRAPH + auth = get_oxigraph_auth() + async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=15.0)) as client: + resp = await client.post(endpoint, data={"update": update}, auth=auth) + if resp.status_code in (200, 204): + return True + logger.warning( + "[provenance] Delta merge failed (HTTP %s): %s", + resp.status_code, (resp.text or "")[:500], + ) + return False + except Exception as e: + logger.warning(f"[provenance] Error merging delta graph: {e}", exc_info=True) + return False + + +async def count_graph_triples(graph_iri: str) -> Optional[int]: + """Count triples in a named graph. Returns None on error.""" + try: + query = f"SELECT (COUNT(*) AS ?n) WHERE {{ GRAPH <{graph_iri}> {{ ?s ?p ?o }} }}" + endpoint = _get_endpoint("get") # .../query for OXIGRAPH + auth = get_oxigraph_auth() + async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) as client: + resp = await client.post( + endpoint, + data={"query": query}, + headers={"Accept": "application/sparql-results+json"}, + auth=auth, + ) + if resp.status_code != 200: + return None + bindings = resp.json().get("results", {}).get("bindings", []) + return int(bindings[0]["n"]["value"]) if bindings else 0 + except Exception as e: + logger.warning(f"[provenance] Error counting triples in {graph_iri}: {e}", exc_info=True) + return None + + async def query_provenance_jsonld(construct_query: str) -> Optional[str]: """ Execute a SPARQL CONSTRUCT against Oxigraph and return JSON-LD text. @@ -267,6 +348,7 @@ def construct_for_job(job_id: str) -> str: per-file entities, and the connected agent).""" activity = str(activity_ref(job_id)) bundle = str(URIRef(PROV_BASE[f"bundle/{quote(str(job_id), safe='')}"])) + delta = str(URIRef(PROV_BASE[f"delta/{quote(str(job_id), safe='')}"])) file_prefix = f"{str(PROV_BASE)}file/{quote(str(job_id), safe='')}/" return f""" CONSTRUCT {{ ?s ?p ?o }} @@ -276,6 +358,8 @@ def construct_for_job(job_id: str) -> str: UNION {{ <{bundle}> ?p ?o . BIND(<{bundle}> AS ?s) }} UNION + {{ <{delta}> ?p ?o . BIND(<{delta}> AS ?s) }} + UNION {{ ?s ?p ?o . FILTER(STRSTARTS(STR(?s), "{file_prefix}")) }} UNION {{ <{activity}> (<{PROV.wasAssociatedWith}>|<{PROV.used}>) ?s . ?s ?p ?o }} @@ -290,6 +374,121 @@ def construct_for_job(job_id: str) -> str: """ +def construct_delta_content(job_id: str) -> str: + """SPARQL CONSTRUCT returning the exact triples a job added (its delta graph).""" + dg = delta_graph_for(job_id) + return f"CONSTRUCT {{ ?s ?p ?o }} WHERE {{ GRAPH <{dg}> {{ ?s ?p ?o }} }}" + + +async def _fetch_construct_graph(construct_query: str) -> Optional[Graph]: + """Run a CONSTRUCT and return the result parsed into an rdflib Graph.""" + try: + endpoint = _get_endpoint("get") + auth = get_oxigraph_auth() + async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0)) as client: + resp = await client.post( + endpoint, + data={"query": construct_query}, + headers={"Accept": "text/turtle"}, + auth=auth, + ) + if resp.status_code != 200: + logger.warning( + "[provenance] CONSTRUCT fetch failed (HTTP %s): %s", + resp.status_code, (resp.text or "")[:500], + ) + return None + g = Graph() + g.parse(data=resp.text, format="turtle") + return g + except Exception as e: + logger.warning(f"[provenance] Error fetching graph: {e}", exc_info=True) + return None + + +async def delta_history_for_graph(named_graph_iri: str) -> Optional[List[Dict[str, Any]]]: + """ + Return the change history for a named graph: one entry per ingestion delta + (job, added triple count, timestamp, status), newest first. + """ + query = f""" + PREFIX prov: <{str(PROV)}> + PREFIX brainkb: <{str(BRAINKB)}> + SELECT ?delta ?activity ?count ?time ?status ?deltaGraph + WHERE {{ + GRAPH <{PROVENANCE_GRAPH}> {{ + ?delta a brainkb:IngestionDelta ; + brainkb:targetGraph <{named_graph_iri}> ; + prov:wasGeneratedBy ?activity . + OPTIONAL {{ ?delta brainkb:addedTripleCount ?count }} + OPTIONAL {{ ?delta prov:generatedAtTime ?time }} + OPTIONAL {{ ?delta brainkb:deltaGraph ?deltaGraph }} + OPTIONAL {{ ?activity brainkb:jobStatus ?status }} + }} + }} + ORDER BY DESC(?time) + """ + try: + endpoint = _get_endpoint("get") + auth = get_oxigraph_auth() + async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) as client: + resp = await client.post( + endpoint, + data={"query": query}, + headers={"Accept": "application/sparql-results+json"}, + auth=auth, + ) + if resp.status_code != 200: + return None + out = [] + for b in resp.json().get("results", {}).get("bindings", []): + out.append({ + "delta": b.get("delta", {}).get("value"), + "activity": b.get("activity", {}).get("value"), + "added_triple_count": int(b["count"]["value"]) if "count" in b else None, + "generated_at": b.get("time", {}).get("value"), + "status": b.get("status", {}).get("value"), + "delta_graph": b.get("deltaGraph", {}).get("value"), + }) + return out + except Exception as e: + logger.warning(f"[provenance] Error fetching delta history: {e}", exc_info=True) + return None + + +async def compare_deltas(job_id_a: str, job_id_b: str) -> Optional[Dict[str, Any]]: + """ + Compare the triples added by two jobs. Returns counts and the differing + triples (A-only / B-only) as JSON-LD, plus the shared-triple count. + """ + ga = await _fetch_construct_graph(construct_delta_content(job_id_a)) + gb = await _fetch_construct_graph(construct_delta_content(job_id_b)) + if ga is None or gb is None: + return None + + only_a = ga - gb # in A, not in B + only_b = gb - ga # in B, not in A + common = ga & gb # in both + + def _jsonld(graph: Graph): + graph.bind("prov", PROV) + graph.bind("brainkb", BRAINKB) + graph.bind("dcterms", DCTERMS) + return json.loads(graph.serialize(format="json-ld", auto_compact=True)) + + return { + "job_a": job_id_a, + "job_b": job_id_b, + "a_total": len(ga), + "b_total": len(gb), + "only_in_a_count": len(only_a), + "only_in_b_count": len(only_b), + "common_count": len(common), + "only_in_a": _jsonld(only_a), + "only_in_b": _jsonld(only_b), + } + + def construct_for_named_graph(named_graph_iri: str) -> str: """SPARQL CONSTRUCT for all activity that targeted a given named graph.""" return f""" diff --git a/query_service/core/routers/insert.py b/query_service/core/routers/insert.py index 410ae01..e2ed51b 100644 --- a/query_service/core/routers/insert.py +++ b/query_service/core/routers/insert.py @@ -56,6 +56,12 @@ query_provenance_jsonld, construct_for_job, construct_for_named_graph, + delta_graph_for, + merge_delta_into_target, + count_graph_triples, + construct_delta_content, + delta_history_for_graph, + compare_deltas, ) import datetime import uuid @@ -86,6 +92,12 @@ # Supported file extensions SUPPORTED_EXTS = {"ttl", "turtle", "nt", "nq", "trig", "rdf", "owl", "jsonld", "json"} +# Triple-level change tracking. When enabled, each job stages its triples in a +# per-job delta graph (an exact, queryable record of what the job added) which is +# then merged into the target graph. Costs extra storage (delta graphs persist); +# set TRACK_TRIPLE_DELTAS=false to upload directly to the target instead. +TRACK_TRIPLE_DELTAS = os.getenv("TRACK_TRIPLE_DELTAS", "true").strip().lower() in ("1", "true", "yes", "on") + # Ensure job directory exists os.makedirs(JOB_BASE_DIR, exist_ok=True) @@ -463,9 +475,12 @@ async def run_ingest_job( MAX_JOB_TIMEOUT = 2 * 60 * 60 # 2 hours job_start_time = time.time() + delta_graph = delta_graph_for(job_id) + async def _write_ingestion_prov(status_label: str, results): - """Best-effort: record this job as a PROV-O IngestionActivity in Oxigraph. - Never raises — a provenance failure must not fail the job.""" + """Best-effort finalizer: merge the per-job delta graph into the target, + then record this job as a PROV-O IngestionActivity (+ IngestionDelta) in + Oxigraph. Never raises — a provenance failure must not fail the job.""" try: details = await get_job_details(job_id) named_graph = details.get("graph") if details else None @@ -474,6 +489,16 @@ async def _write_ingestion_prov(status_label: str, results): results = results or [] succ = sum(1 for r in results if r.get("success")) fail = len(results) - succ + + added_count = None + effective_delta_graph = None + if TRACK_TRIPLE_DELTAS: + # Count what the job staged, then merge the delta into the target graph. + added_count = await count_graph_triples(delta_graph) + if added_count and added_count > 0: + await merge_delta_into_target(delta_graph, named_graph) + effective_delta_graph = delta_graph + prov_graph = build_ingestion_provenance( job_id=job_id, agent_id=user_id, @@ -486,6 +511,8 @@ async def _write_ingestion_prov(status_label: str, results): fail_count=fail, results=results, agent_type="user", + delta_graph=effective_delta_graph, + added_triple_count=added_count, ) await write_provenance(prov_graph) except Exception as _pe: @@ -515,7 +542,10 @@ async def _write_ingestion_prov(status_label: str, results): job_dir = job_details["job_dir"] graph = job_details["graph"] - + # When delta tracking is on, stage uploads in the per-job delta graph; + # _write_ingestion_prov merges it into the target graph at the end. + upload_graph = delta_graph if TRACK_TRIPLE_DELTAS else graph + # Collect files in job_dir (exclude .processed files) file_infos: List[Dict[str, Any]] = [] for name in os.listdir(job_dir): @@ -555,7 +585,7 @@ async def worker(fi: Dict[str, Any], index: int): client, filepath, size, - graph, + upload_graph, user_id, skip_provenance=skip_provenance, job_id=job_id, @@ -1836,3 +1866,66 @@ async def get_named_graph_provenance( ) return Response(content=jsonld, media_type="application/ld+json") + +@router.get("/provenance/delta", include_in_schema=True) +async def get_job_delta( + user: Annotated[LoginUserIn, Depends(get_current_user)], + user_id: Annotated[str, Query(..., description="User identifier (must match the authenticated user)")], + job_id: Annotated[str, Query(..., description="Job identifier whose added triples to return")], +): + """ + GET /provenance/delta + Return the exact set of triples a job added (its delta graph) as JSON-LD. + This is the incremental change that job contributed to the target graph. + """ + verify_user_access(user_id, user) + jsonld = await query_provenance_jsonld(construct_delta_content(job_id)) + if jsonld is None: + return JSONResponse( + {"error": "Failed to retrieve delta from the graph database"}, + status_code=502, + ) + return Response(content=jsonld, media_type="application/ld+json") + + +@router.get("/provenance/delta/history", include_in_schema=True) +async def get_delta_history( + user: Annotated[LoginUserIn, Depends(get_current_user)], + iri: Annotated[str, Query(..., description="Named graph IRI to list the change history for")], +): + """ + GET /provenance/delta/history + Return the ordered change history of a named graph: one entry per ingestion + delta (job, added triple count, timestamp, status), newest first. + """ + history = await delta_history_for_graph(iri) + if history is None: + return JSONResponse( + {"error": "Failed to retrieve delta history from the graph database"}, + status_code=502, + ) + return {"named_graph_iri": iri, "changes": history, "total": len(history)} + + +@router.get("/provenance/delta/compare", include_in_schema=True) +async def compare_job_deltas( + user: Annotated[LoginUserIn, Depends(get_current_user)], + user_id: Annotated[str, Query(..., description="User identifier (must match the authenticated user)")], + job_id_a: Annotated[str, Query(..., description="First job identifier")], + job_id_b: Annotated[str, Query(..., description="Second job identifier")], +): + """ + GET /provenance/delta/compare + Compare the triples added by two jobs. Returns counts (A-only, B-only, + shared) and the differing triples as JSON-LD, so users can see exactly how + two ingestion changes differ. + """ + verify_user_access(user_id, user) + result = await compare_deltas(job_id_a, job_id_b) + if result is None: + return JSONResponse( + {"error": "Failed to compare deltas (one or both delta graphs unavailable)"}, + status_code=502, + ) + return result + From 7e18aa48a4c024512fe651f2b11be3d896ce2ae9 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Wed, 22 Jul 2026 15:19:30 -0400 Subject: [PATCH 04/70] De-duplicate named-graph registration provenance 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. --- query_service/PROVENANCE_MODEL.md | 33 ++++++++++++++++++---------- query_service/core/provenance.py | 24 -------------------- query_service/core/routers/insert.py | 26 ++++++++++------------ query_service/core/routers/query.py | 8 ++++--- query_service/core/shared.py | 9 +++++++- 5 files changed, 46 insertions(+), 54 deletions(-) diff --git a/query_service/PROVENANCE_MODEL.md b/query_service/PROVENANCE_MODEL.md index 8d08d75..7dd3d60 100644 --- a/query_service/PROVENANCE_MODEL.md +++ b/query_service/PROVENANCE_MODEL.md @@ -42,7 +42,9 @@ Instance IRIs are minted under `https://brainkb.org/prov/`: ## Tracked activities -Every mutating action becomes a `prov:Activity` with a typed agent. +Data-mutation actions become a `prov:Activity` in the provenance graph. +Named-graph registration is **not** duplicated here — it is recorded on the +registry graph (see §2). ### 1. Ingestion — `brainkb:IngestionActivity` (agent: user) @@ -81,19 +83,27 @@ GRAPH { } ``` -### 2. Named-graph registration — `brainkb:RegistrationActivity` (agent: user) +### 2. Named-graph registration (recorded in the registry graph — no duplication) -Written after a graph is registered via `POST /register-named-graph`. +Registration is **not** written as a separate activity in the provenance graph, +because the named-graph **registry** graph +(`https://brainkb.org/metadata/named-graph`) already records each registration as +a PROV entity. We simply attribute it to the registering user on that same entry, +so registration facts live in exactly one place: ```turtle -<…/prov/activity/reg-{uuid}> - a prov:Activity, brainkb:RegistrationActivity ; - prov:startedAtTime "…"^^xsd:dateTime ; - prov:wasAssociatedWith <…/prov/agent/{user}> ; - brainkb:targetGraph <{named_graph_url}> . -<{named_graph_url}> prov:wasGeneratedBy <…/prov/activity/reg-{uuid}> . +GRAPH { + <{named_graph_url}> + a prov:Entity ; + prov:generatedAtTime "…"^^xsd:dateTime ; + dcterms:description "…" ; + prov:wasAttributedTo <…/prov/agent/{user}> . +} ``` +`GET /api/query/registered-named-graphs` returns `registered_by` alongside the +description and timestamp. + ### 3. Crash recovery — `brainkb:RecoveryActivity` (agent: system) Written when `recover_stuck_jobs()` marks a stuck job as `error`. @@ -161,8 +171,9 @@ Read endpoints return a PROV-O bundle as `application/ld+json` via SPARQL - `GET /api/provenance/job?job_id=…&user_id=…` — provenance for one job (access-controlled with `verify_user_access`). -- `GET /api/provenance/named-graph?iri=…` — all ingestion/registration activity - that targeted a given named graph. +- `GET /api/provenance/named-graph?iri=…` — all ingestion activity that targeted + a given named graph. (Registration attribution is on the registry graph; see + `GET /api/query/registered-named-graphs`.) Delta / change endpoints: diff --git a/query_service/core/provenance.py b/query_service/core/provenance.py index 8da275f..b8528bd 100644 --- a/query_service/core/provenance.py +++ b/query_service/core/provenance.py @@ -33,7 +33,6 @@ import datetime import json import logging -import uuid from typing import Any, Dict, List, Optional from urllib.parse import quote @@ -175,29 +174,6 @@ def build_ingestion_provenance( return g -def build_registration_provenance( - *, - named_graph_url: str, - agent_id: str, - at: Optional[str] = None, -) -> Graph: - """Build PROV-O for a named-graph registration (agent: user).""" - g = _new_graph() - at = at or _now_iso() - activity = URIRef(PROV_BASE[f"activity/reg-{uuid.uuid4().hex}"]) - agent = agent_ref(agent_id) - - g.add((agent, RDF.type, PROV.Agent)) - g.add((agent, RDF.type, PROV.Person)) - g.add((activity, RDF.type, PROV.Activity)) - g.add((activity, RDF.type, BRAINKB.RegistrationActivity)) - g.add((activity, PROV.startedAtTime, Literal(at, datatype=XSD.dateTime))) - g.add((activity, PROV.wasAssociatedWith, agent)) - g.add((activity, BRAINKB.targetGraph, URIRef(named_graph_url))) - g.add((URIRef(named_graph_url), PROV.wasGeneratedBy, activity)) - return g - - def build_recovery_provenance( *, job_id: str, diff --git a/query_service/core/routers/insert.py b/query_service/core/routers/insert.py index e2ed51b..8b03cdd 100644 --- a/query_service/core/routers/insert.py +++ b/query_service/core/routers/insert.py @@ -50,8 +50,8 @@ from core.configuration import load_environment from core.provenance import ( build_ingestion_provenance, - build_registration_provenance, build_recovery_provenance, + agent_ref, write_provenance, query_provenance_jsonld, construct_for_job, @@ -1790,26 +1790,22 @@ async def create_named_graph( """ named_graph_exists = await fetch_data_gdb_async(query) if not named_graph_exists.get("message", {}).get("boolean", False): + # Attribute the registration to the authenticated user (PROV-O). This is + # recorded on the registry entry itself (see named_graph_metadata) rather + # than duplicated as a separate activity in the provenance graph. + try: + agent_id = user["email"] or user["id"] + except (KeyError, TypeError, IndexError): + agent_id = "unknown" + agent_uri = str(agent_ref(str(agent_id))) + # Register the new named graph response = await insert_data_gdb_async(named_graph_metadata( named_graph_url=named_graph_url, description=description, + agent_uri=agent_uri, ) ) - # Record registration as a PROV-O RegistrationActivity (user agent) - try: - try: - agent_id = user["email"] or user["id"] - except (KeyError, TypeError, IndexError): - agent_id = "unknown" - await write_provenance( - build_registration_provenance( - named_graph_url=named_graph_url, - agent_id=str(agent_id), - ) - ) - except Exception as _pe: - logger.warning(f"[create_named_graph] Provenance write failed: {_pe}") return response else: return JSONResponse( diff --git a/query_service/core/routers/query.py b/query_service/core/routers/query.py index 88c521d..cb33361 100644 --- a/query_service/core/routers/query.py +++ b/query_service/core/routers/query.py @@ -35,12 +35,13 @@ async def get_named_graphs(): query_named_graph = """ PREFIX prov: PREFIX dcterms: - Select distinct ?graph ?description ?registered_at + Select distinct ?graph ?description ?registered_at ?registered_by WHERE { GRAPH { ?graph dcterms:description ?description; prov:generatedAtTime ?registered_at. - } + OPTIONAL { ?graph prov:wasAttributedTo ?registered_by. } + } } """ response = await fetch_data_gdb_async(query_named_graph) @@ -54,7 +55,8 @@ async def get_named_graphs(): response_graph[graphs_info["graph"]["value"]] = { "graph": graphs_info["graph"]["value"], "description": graphs_info["description"]["value"], - "registered_at": graphs_info["registered_at"]["value"] + "registered_at": graphs_info["registered_at"]["value"], + "registered_by": graphs_info.get("registered_by", {}).get("value"), } return response_graph diff --git a/query_service/core/shared.py b/query_service/core/shared.py index 2e8f8ac..200cf66 100644 --- a/query_service/core/shared.py +++ b/query_service/core/shared.py @@ -353,7 +353,7 @@ def chunk_ttl_to_named_graphs(ttl_str: str, named_graph_uri: str = "https://brai return chunks -def named_graph_metadata(named_graph_url, description): +def named_graph_metadata(named_graph_url, description, agent_uri=None): """ Generates metadata for a named graph using the PROV and DCTERMS ontologies. @@ -386,6 +386,13 @@ def named_graph_metadata(named_graph_url, description): g.add((prov_entity, RDF.type, PROV.Entity)) g.add((prov_entity,PROV.generatedAtTime, Literal(created_At, datatype=XSD.dateTime))) g.add((prov_entity,DCTERMS.description, Literal(description, datatype=XSD.string))) + # Record who registered the graph directly on the registry entry (PROV-O). + # This keeps registration provenance in one place (the registry graph) rather + # than duplicating it as a separate activity in the provenance graph. + if agent_uri: + agent = URIRef(agent_uri) + g.add((agent, RDF.type, PROV.Agent)) + g.add((prov_entity, PROV.wasAttributedTo, agent)) named_graph_metadata = convert_ttl_to_named_graph( ttl_str=g.serialize(format='turtle'), named_graph_uri="https://brainkb.org/metadata/named-graph" From 966de65441f24db33cddb545e506c6c7d770a5ca Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Wed, 22 Jul 2026 15:49:50 -0400 Subject: [PATCH 05/70] Enforce per-endpoint scopes and clarify registry vs provenance endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- query_service/core/routers/insert.py | 75 ++++++++++++++++++++++------ query_service/core/routers/query.py | 22 +++++++- 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/query_service/core/routers/insert.py b/query_service/core/routers/insert.py index 8b03cdd..d8b224d 100644 --- a/query_service/core/routers/insert.py +++ b/query_service/core/routers/insert.py @@ -1383,7 +1383,8 @@ async def run_and_track_job(): } @router.get("/insert/jobs", - include_in_schema=True + include_in_schema=True, + dependencies=[Depends(require_scopes(["read"]))], ) async def list_jobs( user: Annotated[LoginUserIn, Depends(get_current_user)], @@ -1409,7 +1410,8 @@ async def list_jobs( @router.get("/insert/user/jobs/detail", - include_in_schema=True + include_in_schema=True, + dependencies=[Depends(require_scopes(["read"]))], ) async def get_job_detail( user: Annotated[LoginUserIn, Depends(get_current_user)], @@ -1562,7 +1564,8 @@ async def get_job_detail( @router.get("/insert/jobs/check-recoverable", - include_in_schema=True + include_in_schema=True, + dependencies=[Depends(require_scopes(["read"]))], ) async def check_job_recoverable_endpoint( user: Annotated[LoginUserIn, Depends(get_current_user)], @@ -1601,7 +1604,8 @@ async def check_job_recoverable_endpoint( @router.post("/insert/jobs/recover", - include_in_schema=True + include_in_schema=True, + dependencies=[Depends(require_scopes(["write"]))], ) async def recover_stuck_jobs_endpoint( user: Annotated[LoginUserIn, Depends(get_current_user)], @@ -1761,7 +1765,9 @@ async def recover_stuck_jobs_endpoint( ) -@router.post("/register-named-graph") +@router.post("/register-named-graph", + dependencies=[Depends(require_scopes(["write"]))], + ) async def create_named_graph( user: Annotated[LoginUserIn, Depends(get_current_user)], request: NamedGraphSchema @@ -1823,16 +1829,31 @@ async def create_named_graph( ) -@router.get("/provenance/job", include_in_schema=True) +@router.get( + "/provenance/job", + include_in_schema=True, + dependencies=[Depends(require_scopes(["read"]))], + summary="PROV-O provenance bundle for one ingestion job", + description=( + "Returns the **W3C PROV-O bundle** (JSON-LD) for a single ingestion job: " + "the `IngestionActivity` (with agent, start/end time, status, file counts), " + "the generated bundle entity, each per-file entity (upload status, HTTP " + "status, size), the `IngestionDelta` entity, and any recovery activity that " + "acted on the job. Access-controlled — the `user_id` must match the " + "authenticated caller." + ), +) async def get_job_provenance( user: Annotated[LoginUserIn, Depends(get_current_user)], user_id: Annotated[str, Query(..., description="User identifier (must match the authenticated user)")], job_id: Annotated[str, Query(..., description="Job identifier to fetch provenance for")], ): """ - GET /provenance/job - Return the W3C PROV-O provenance bundle (JSON-LD) for a single ingestion job, + Return the full PROV-O provenance bundle (JSON-LD) for a single ingestion job, reconstructed from the dedicated provenance graph in Oxigraph. + + Scope: everything about ONE job (activity + bundle + files + delta + recovery). + For a whole graph's history use /provenance/named-graph. """ verify_user_access(user_id, user) jsonld = await query_provenance_jsonld(construct_for_job(job_id)) @@ -1844,15 +1865,34 @@ async def get_job_provenance( return Response(content=jsonld, media_type="application/ld+json") -@router.get("/provenance/named-graph", include_in_schema=True) +@router.get( + "/provenance/named-graph", + include_in_schema=True, + dependencies=[Depends(require_scopes(["read"]))], + summary="PROV-O activity history for a named graph", + description=( + "Returns the **W3C PROV-O provenance** (JSON-LD) describing how a named " + "graph's data came to be: every ingestion activity that targeted it, with " + "the agent, start/end times, per-file entities, and job status.\n\n" + "Difference from `GET /api/query/registered-named-graphs`:\n" + "- **registered-named-graphs** = the *registry/catalog* — which graphs " + "exist and their registration metadata (one row per graph).\n" + "- **provenance/named-graph** = the *activity history* — what was ingested " + "into a given graph, when, and by whom (a PROV-O bundle, potentially many " + "activities). Reads the provenance graph `https://brainkb.org/provenance/`." + ), +) async def get_named_graph_provenance( user: Annotated[LoginUserIn, Depends(get_current_user)], - iri: Annotated[str, Query(..., description="Named graph IRI to fetch provenance for")], + iri: Annotated[str, Query(..., description="Named graph IRI to fetch the ingestion/activity provenance for")], ): """ - GET /provenance/named-graph - Return the W3C PROV-O provenance (JSON-LD) for every activity (ingestion, - registration) that targeted the given named graph. + Return the PROV-O provenance (JSON-LD) for every ingestion activity that + targeted the given named graph. + + This is the *history of data mutations* on the graph, distinct from the + registry catalog returned by /api/query/registered-named-graphs. Registration + attribution lives on the registry entry (see that endpoint's `registered_by`). """ jsonld = await query_provenance_jsonld(construct_for_named_graph(iri)) if jsonld is None: @@ -1863,7 +1903,8 @@ async def get_named_graph_provenance( return Response(content=jsonld, media_type="application/ld+json") -@router.get("/provenance/delta", include_in_schema=True) +@router.get("/provenance/delta", include_in_schema=True, + dependencies=[Depends(require_scopes(["read"]))]) async def get_job_delta( user: Annotated[LoginUserIn, Depends(get_current_user)], user_id: Annotated[str, Query(..., description="User identifier (must match the authenticated user)")], @@ -1884,7 +1925,8 @@ async def get_job_delta( return Response(content=jsonld, media_type="application/ld+json") -@router.get("/provenance/delta/history", include_in_schema=True) +@router.get("/provenance/delta/history", include_in_schema=True, + dependencies=[Depends(require_scopes(["read"]))]) async def get_delta_history( user: Annotated[LoginUserIn, Depends(get_current_user)], iri: Annotated[str, Query(..., description="Named graph IRI to list the change history for")], @@ -1903,7 +1945,8 @@ async def get_delta_history( return {"named_graph_iri": iri, "changes": history, "total": len(history)} -@router.get("/provenance/delta/compare", include_in_schema=True) +@router.get("/provenance/delta/compare", include_in_schema=True, + dependencies=[Depends(require_scopes(["read"]))]) async def compare_job_deltas( user: Annotated[LoginUserIn, Depends(get_current_user)], user_id: Annotated[str, Query(..., description="User identifier (must match the authenticated user)")], diff --git a/query_service/core/routers/query.py b/query_service/core/routers/query.py index cb33361..66308b8 100644 --- a/query_service/core/routers/query.py +++ b/query_service/core/routers/query.py @@ -30,8 +30,28 @@ logger = logging.getLogger(__name__) -@router.get("/query/registered-named-graphs") +@router.get( + "/query/registered-named-graphs", + dependencies=[Depends(require_scopes(["read"]))], + summary="List registered named graphs (the registry/catalog)", + description=( + "Returns the **catalog of named graphs** that have been registered in " + "BrainKB — i.e. *which* graphs exist and may be ingested into. Each entry " + "carries its `description`, `registered_at` timestamp, and `registered_by` " + "(the user who registered it). Data is read from the registry graph " + "`https://brainkb.org/metadata/named-graph`.\n\n" + "This answers *\"what graphs are available?\"*. It is NOT a history of what " + "was ingested — for the ingestion/activity history of a specific graph, use " + "`GET /api/provenance/named-graph?iri=…`." + ), +) async def get_named_graphs(): + """List every registered named graph with its registration metadata. + + Registry (catalog) view: one row per graph with description, when it was + registered, and by whom. Contrast with /api/provenance/named-graph, which + returns the PROV-O activity history (ingestions) that targeted a graph. + """ query_named_graph = """ PREFIX prov: PREFIX dcterms: From 96506b5e1b354a91f4caf4c9fd69d247eb37b7aa Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Wed, 22 Jul 2026 15:52:48 -0400 Subject: [PATCH 06/70] Gate /query/sparql/ to admin scope only 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. --- query_service/core/routers/query.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/query_service/core/routers/query.py b/query_service/core/routers/query.py index 66308b8..f46ac1a 100644 --- a/query_service/core/routers/query.py +++ b/query_service/core/routers/query.py @@ -82,7 +82,15 @@ async def get_named_graphs(): @router.get("/query/sparql/", - dependencies=[Depends(require_scopes(["write","admin"]))], + dependencies=[Depends(require_scopes(["admin"]))], + summary="Run an arbitrary SPARQL query (admin only)", + description=( + "Executes a caller-supplied SPARQL query against the graph database. " + "This is a powerful, unrestricted capability, so it is gated to the " + "**admin** scope only — deliberately NOT the default 'read' scope that " + "the fixed-shape read endpoints (e.g. /query/taxonomy, /query/" + "registered-named-graphs) use." + ), ) async def sparql_query( user: Annotated[LoginUserIn, Depends(get_current_user)], sparql_query: str From 2b13b2e8d0b2b9ca6f8264c52e59948d98789138 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Wed, 22 Jul 2026 19:40:59 -0400 Subject: [PATCH 07/70] Add private/public spaces (team-owned containers of named graphs) 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. --- query_service/SPACES_MODEL.md | 89 ++++++++ query_service/core/main.py | 48 +++++ query_service/core/routers/insert.py | 37 +++- query_service/core/routers/spaces.py | 207 ++++++++++++++++++ query_service/core/security.py | 26 ++- query_service/core/spaces.py | 312 +++++++++++++++++++++++++++ 6 files changed, 714 insertions(+), 5 deletions(-) create mode 100644 query_service/SPACES_MODEL.md create mode 100644 query_service/core/routers/spaces.py create mode 100644 query_service/core/spaces.py diff --git a/query_service/SPACES_MODEL.md b/query_service/SPACES_MODEL.md new file mode 100644 index 0000000..7e83c14 --- /dev/null +++ b/query_service/SPACES_MODEL.md @@ -0,0 +1,89 @@ +# BrainKB Spaces (private/public, team-owned) + +Status: implemented on branch `improve-ingestion-query-service`. + +Spaces let a user or team create their own **owner-controlled container** of named +graphs, keep it **private**, or publish it **publicly** for anyone — including +unauthenticated clients — to read. This supports a decentralized model where each +space is a sovereign, IRI-addressable pod (`https://brainkb.org/space/{slug}`). + +## Storage split (confirmed architecture) + +- **Postgres** — identity/teams/enforcement only: JWT users, and the space tables + (`spaces`, `space_members`, `space_graphs`). This is the source of truth for + authorization and is what every request checks (fast, joinable with the JWT user). +- **Oxigraph (graph DB)** — all knowledge-graph data AND provenance AND a + best-effort **RDF mirror** of each space manifest (in the spaces metadata graph + `https://brainkb.org/metadata/spaces/`), so spaces are portable/queryable via SPARQL. + +The RDF mirror is best-effort: a mirror failure never fails the enforcing Postgres +write. + +## Model + +- `spaces(space_id, slug, name, description, owner, visibility, …)` — + `visibility ∈ {private, public}`. +- `space_members(space_id, member, role)` — `role ∈ {owner, editor, viewer}`; + `member` is the user email (= the PROV agent id). +- `space_graphs(space_id, named_graph_iri)` — a named graph belongs to exactly one + space. + +Roles: **owner** manages members/visibility/graphs; **editor** may ingest; +**viewer** may read a private space; **public** grants read to everyone. + +## Authorization (enforced on every request) + +`spaces.authorize(named_graph_iri, member, need)`: + +| Space state | read | write (ingest) | +|-------------|------|----------------| +| public | **anyone (even anonymous)** | owner/editor only | +| private | owner/editor/viewer | owner/editor | +| *unmapped (legacy graph)* | falls through to endpoint scope | falls through to endpoint scope | + +Backward-compat: graphs never attached to a space (e.g. pre-existing graphs) are +"unmapped" and keep their previous scope-only behavior; only space-mapped graphs +are governed by space ACL. + +**Public = anonymous**: public reads require no token. Read endpoints that serve +space data use an optional-auth dependency (`get_current_user_optional`) — a token +is used if present, but its absence is not an error for public spaces. + +## Endpoints (`/api`) + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| POST | `/spaces` | write | Create a space (caller = owner) | +| GET | `/spaces` | optional | List visible spaces (public + own; anon → public only) | +| GET | `/spaces/{slug}` | optional | Space manifest (public to anyone, private to members) | +| PATCH | `/spaces/{slug}/visibility` | owner | Flip private/public | +| POST | `/spaces/{slug}/members` | owner | Add/update a member (role) | +| DELETE | `/spaces/{slug}/members/{member}` | owner | Remove a member | +| POST | `/spaces/{slug}/graphs` | owner/editor | Register + bind a named graph to the space | +| GET | `/spaces/{slug}/data` | optional | Read the space's RDF (JSON-LD); public = anonymous | + +Ingestion (`/insert/{raw,files}/knowledge-graph-triples`) now also checks space +write-authorization for the target graph (in addition to the `write` scope and the +user-identity check). + +## RDF manifest (mirror) + +```turtle +GRAPH { + + a brainkb:Space ; + brainkb:slug "{slug}" ; schema:name "…" ; dcterms:description "…" ; + brainkb:visibility "public" ; + brainkb:owner ; + brainkb:editor <…/agent/{editor}> ; + brainkb:viewer <…/agent/{viewer}> ; + brainkb:containsGraph <{named_graph_iri}> . +} +``` + +## Notes / future + +- `registered-named-graphs` still lists all registered graph IRIs; filtering that + listing by space visibility is a follow-up (data reads are already protected). +- Job-scoped provenance endpoints remain owner-restricted (by `user_id`); making a + public space's ingestion provenance publicly readable is a follow-up. diff --git a/query_service/core/main.py b/query_service/core/main.py index 1978718..e6b204d 100644 --- a/query_service/core/main.py +++ b/query_service/core/main.py @@ -12,6 +12,7 @@ from core.routers.query import router as query_router from core.routers.rapid_release import router as rapid_release from core.routers.insert import router as insert_router +from core.routers.spaces import router as spaces_router from core.configuration import load_environment from core.database import init_db_pool from core.graph_database_connection_manager import initialize_metadata_graph @@ -50,6 +51,7 @@ app.include_router(jwt_router, prefix="/api") app.include_router(query_router, prefix="/api") app.include_router(insert_router,prefix="/api") +app.include_router(spaces_router, prefix="/api", tags=["Spaces"]) # rapid-release app.include_router(rapid_release, prefix="/api/rapid-release", tags=["Rapid release"]) @@ -153,6 +155,52 @@ async def startup_event(): except Exception: pass # Indexes may already exist logger.info("Job tracking tables initialized") + + # Spaces: owner-controlled containers of named graphs with + # private/public visibility and team membership (see spaces.py). + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS spaces ( + space_id TEXT PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT, + owner TEXT NOT NULL, + visibility TEXT NOT NULL DEFAULT 'private', + created_at DOUBLE PRECISION, + updated_at DOUBLE PRECISION + ) + """ + ) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS space_members ( + id SERIAL PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(space_id) ON DELETE CASCADE, + member TEXT NOT NULL, + role TEXT NOT NULL, + added_at DOUBLE PRECISION, + UNIQUE (space_id, member) + ) + """ + ) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS space_graphs ( + id SERIAL PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(space_id) ON DELETE CASCADE, + named_graph_iri TEXT NOT NULL UNIQUE, + added_at DOUBLE PRECISION + ) + """ + ) + try: + await conn.execute("CREATE INDEX IF NOT EXISTS idx_space_members_space ON space_members(space_id)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_space_members_member ON space_members(member)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_space_graphs_space ON space_graphs(space_id)") + except Exception: + pass + logger.info("Spaces tables initialized") break # Success, exit retry loop finally: await pool.release(conn) diff --git a/query_service/core/routers/insert.py b/query_service/core/routers/insert.py index d8b224d..d7f482d 100644 --- a/query_service/core/routers/insert.py +++ b/query_service/core/routers/insert.py @@ -48,6 +48,7 @@ batch_insert_job_results, ) from core.configuration import load_environment +from core.spaces import authorize as authorize_space_access from core.provenance import ( build_ingestion_provenance, build_recovery_provenance, @@ -76,6 +77,14 @@ router = APIRouter() logger = logging.getLogger(__name__) + +def _agent_email(user): + """Caller's identity string (email), used for space membership checks.""" + try: + return user["email"] + except (KeyError, TypeError, IndexError): + return None + # Global dictionary to track running background tasks # Maps job_id -> asyncio.Task for checking if job is actually running _running_job_tasks: Dict[str, asyncio.Task] = {} @@ -1124,12 +1133,22 @@ async def insert_knowledge_graph_triples( }, status_code=400, ) - + + # If the graph belongs to a space, enforce space write-authorization + # (owner/editor). Unmapped legacy graphs fall through (scope check applies). + _graph_key = named_graph_iri if named_graph_iri.endswith("/") else named_graph_iri + "/" + _allowed, _reason = await authorize_space_access(_graph_key, _agent_email(user), "write") + if not _allowed: + return JSONResponse( + {"error": f"Not authorized to ingest into this graph: {_reason}", "named_graph_iri": named_graph_iri}, + status_code=403, + ) + job_id = uuid.uuid4().hex - + # Get Oxigraph endpoint from configuration endpoint = get_oxigraph_endpoint() - + raw_bytes = data.encode("utf-8") if len(raw_bytes) > MAX_RAW_SIZE_BYTES: return JSONResponse( @@ -1236,7 +1255,17 @@ async def insert_file_knowledge_graph_triples( }, status_code=400, ) - + + # If the graph belongs to a space, enforce space write-authorization + # (owner/editor). Unmapped legacy graphs fall through (scope check applies). + _graph_key = named_graph_iri if named_graph_iri.endswith("/") else named_graph_iri + "/" + _allowed, _reason = await authorize_space_access(_graph_key, _agent_email(user), "write") + if not _allowed: + return JSONResponse( + {"error": f"Not authorized to ingest into this graph: {_reason}", "named_graph_iri": named_graph_iri}, + status_code=403, + ) + job_id = uuid.uuid4().hex # generate for job tracking # Get Oxigraph endpoint from configuration diff --git a/query_service/core/routers/spaces.py b/query_service/core/routers/spaces.py new file mode 100644 index 0000000..e3ce29e --- /dev/null +++ b/query_service/core/routers/spaces.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# DISCLAIMER: This software is provided "as is" without any warranty, +# express or implied, including but not limited to the warranties of +# merchantability, fitness for a particular purpose, and non-infringement. +# ----------------------------------------------------------------------------- + +# @Author : Tek Raj Chhetri +# @Email : tekraj@mit.edu +# @File : spaces.py (router) + +"""REST endpoints for spaces — private/public containers of named graphs.""" + +import logging +import re +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import JSONResponse, Response +from pydantic import BaseModel, HttpUrl + +from core.models.user import LoginUserIn +from core.security import get_current_user, get_current_user_optional, require_scopes +from core.shared import named_graph_metadata +from core.provenance import agent_ref, query_provenance_jsonld +from core.graph_database_connection_manager import insert_data_gdb_async, check_named_graph_exists +from core import spaces as sp + +router = APIRouter() +logger = logging.getLogger(__name__) + +_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$") + + +def _agent(user) -> str: + """Caller's identity string (email preferred), matching the PROV agent id.""" + try: + return str(user["email"] or user["id"]) + except (KeyError, TypeError, IndexError): + return "unknown" + + +class SpaceCreate(BaseModel): + slug: str + name: str + description: Optional[str] = None + visibility: str = "private" + + +class MemberIn(BaseModel): + member: str + role: str = "viewer" + + +class VisibilityIn(BaseModel): + visibility: str + + +class SpaceGraphIn(BaseModel): + named_graph_url: HttpUrl + description: str = "" + + +@router.post("/spaces", status_code=201, + dependencies=[Depends(require_scopes(["write"]))], + summary="Create a space", + description="Create an owner-controlled space (private by default). The " + "caller becomes its owner. Members can later be added and the " + "space flipped public for anonymous read access.") +async def create_space(body: SpaceCreate, user: Annotated[LoginUserIn, Depends(get_current_user)]): + if not _SLUG_RE.match(body.slug): + raise HTTPException(400, "slug must be lowercase alphanumeric/hyphen, 3-64 chars") + if body.visibility not in ("private", "public"): + raise HTTPException(400, "visibility must be 'private' or 'public'") + if await sp.get_space(body.slug): + raise HTTPException(409, f"space '{body.slug}' already exists") + space = await sp.create_space(body.slug, body.name, body.description, _agent(user), body.visibility) + await sp.mirror_space_to_rdf(space) + return space + + +@router.get("/spaces", + summary="List visible spaces", + description="Lists spaces the caller may see: all public spaces plus any " + "the caller is a member of. Anonymous callers see only public " + "spaces (no token required).") +async def list_spaces(user: Annotated[Optional[object], Depends(get_current_user_optional)]): + member = _agent(user) if user else None + return {"spaces": await sp.list_visible_spaces(member)} + + +@router.get("/spaces/{slug}", + summary="Get a space", + description="Returns a space's manifest (members, graphs, visibility) if the " + "caller may see it — public to anyone, private to members only.") +async def get_space(slug: str, user: Annotated[Optional[object], Depends(get_current_user_optional)]): + space = await sp.get_space(slug) + if not space: + return JSONResponse({"error": "space not found"}, status_code=404) + member = _agent(user) if user else None + if space["visibility"] != "public": + role = await sp.member_role(space["space_id"], member) + if role is None: + raise HTTPException(403, "private space — membership required") + return space + + +@router.patch("/spaces/{slug}/visibility", + dependencies=[Depends(require_scopes(["write"]))], + summary="Set space visibility (owner only)", + description="Flip a space between 'private' and 'public'. Public spaces are " + "readable by anyone, including unauthenticated clients.") +async def set_visibility(slug: str, body: VisibilityIn, user: Annotated[LoginUserIn, Depends(get_current_user)]): + space = await sp.get_space(slug) + if not space: + return JSONResponse({"error": "space not found"}, status_code=404) + if await sp.member_role(space["space_id"], _agent(user)) != "owner": + raise HTTPException(403, "only the space owner can change visibility") + if body.visibility not in ("private", "public"): + raise HTTPException(400, "visibility must be 'private' or 'public'") + await sp.set_visibility(slug, body.visibility) + space = await sp.get_space(slug) + await sp.mirror_space_to_rdf(space) + return space + + +@router.post("/spaces/{slug}/members", + dependencies=[Depends(require_scopes(["write"]))], + summary="Add or update a member (owner only)") +async def add_member(slug: str, body: MemberIn, user: Annotated[LoginUserIn, Depends(get_current_user)]): + space = await sp.get_space(slug) + if not space: + return JSONResponse({"error": "space not found"}, status_code=404) + if await sp.member_role(space["space_id"], _agent(user)) != "owner": + raise HTTPException(403, "only the space owner can manage members") + if body.role not in sp.ROLES: + raise HTTPException(400, f"role must be one of {sp.ROLES}") + await sp.add_member(space["space_id"], body.member, body.role) + space = await sp.get_space(slug) + await sp.mirror_space_to_rdf(space) + return space + + +@router.delete("/spaces/{slug}/members/{member}", + dependencies=[Depends(require_scopes(["write"]))], + summary="Remove a member (owner only)") +async def remove_member(slug: str, member: str, user: Annotated[LoginUserIn, Depends(get_current_user)]): + space = await sp.get_space(slug) + if not space: + return JSONResponse({"error": "space not found"}, status_code=404) + if await sp.member_role(space["space_id"], _agent(user)) != "owner": + raise HTTPException(403, "only the space owner can manage members") + await sp.remove_member(space["space_id"], member) + space = await sp.get_space(slug) + await sp.mirror_space_to_rdf(space) + return space + + +@router.post("/spaces/{slug}/graphs", + dependencies=[Depends(require_scopes(["write"]))], + summary="Register a named graph into a space (owner/editor)", + description="Registers a named graph and binds it to this space so that " + "ingestion and reads on that graph are governed by the space's " + "membership and visibility.") +async def add_graph(slug: str, body: SpaceGraphIn, user: Annotated[LoginUserIn, Depends(get_current_user)]): + space = await sp.get_space(slug) + if not space: + return JSONResponse({"error": "space not found"}, status_code=404) + if await sp.member_role(space["space_id"], _agent(user)) not in sp.WRITE_ROLES: + raise HTTPException(403, "only the space owner/editors can add graphs") + + named_graph_url = str(body.named_graph_url) + if not named_graph_url.endswith("/"): + named_graph_url += "/" + + # Register in the graph registry if not already there (idempotent-ish). + if not await check_named_graph_exists(named_graph_url): + await insert_data_gdb_async(named_graph_metadata( + named_graph_url=named_graph_url, + description=body.description, + agent_uri=str(agent_ref(_agent(user))), + )) + await sp.attach_graph(space["space_id"], named_graph_url) + space = await sp.get_space(slug) + await sp.mirror_space_to_rdf(space) + return space + + +@router.get("/spaces/{slug}/data", + summary="Read a space's data (public = anonymous)", + description="Returns the RDF (JSON-LD) across all named graphs in the space. " + "Public spaces are readable by anyone (no token); private spaces " + "require membership.") +async def read_space_data(slug: str, user: Annotated[Optional[object], Depends(get_current_user_optional)]): + space = await sp.get_space(slug) + if not space: + return JSONResponse({"error": "space not found"}, status_code=404) + member = _agent(user) if user else None + if space["visibility"] != "public": + if await sp.member_role(space["space_id"], member) is None: + raise HTTPException(403, "private space — membership required") + if not space["graphs"]: + return Response(content='{"@graph": []}', media_type="application/ld+json") + jsonld = await query_provenance_jsonld(await sp.construct_space_graphs(space)) + if jsonld is None: + return JSONResponse({"error": "failed to read space data"}, status_code=502) + return Response(content=jsonld, media_type="application/ld+json") diff --git a/query_service/core/security.py b/query_service/core/security.py index d6b7348..4e78f68 100644 --- a/query_service/core/security.py +++ b/query_service/core/security.py @@ -21,7 +21,7 @@ import asyncio from typing import Annotated, List, Optional, Dict -from fastapi import Depends, HTTPException, status, WebSocket +from fastapi import Depends, HTTPException, status, WebSocket, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import OAuth2PasswordBearer from jose import ExpiredSignatureError, JWTError, jwt @@ -107,6 +107,30 @@ async def get_current_user( return user +async def get_current_user_optional(request: Request): + """ + Return the authenticated user if a valid Bearer token is present, else None. + + Unlike get_current_user this NEVER raises on a missing/invalid token — it is + for endpoints that serve public resources anonymously but still want to know + the caller's identity when a token is supplied (e.g. public-space reads). + """ + auth = request.headers.get("authorization", "") + if not auth[:7].lower() == "bearer ": + return None + token = auth[7:].strip() + if not token: + return None + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + email = payload.get("sub") + if not email: + return None + return await get_user(email=email) + except (ExpiredSignatureError, JWTError, Exception): + return None + + def verify_scopes(required_scopes: List[str], token: str) -> bool: decoded_token = decode_jwt(token) token_scopes = decoded_token.get("scopes", []) diff --git a/query_service/core/spaces.py b/query_service/core/spaces.py new file mode 100644 index 0000000..fa2c85b --- /dev/null +++ b/query_service/core/spaces.py @@ -0,0 +1,312 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# DISCLAIMER: This software is provided "as is" without any warranty, +# express or implied, including but not limited to the warranties of +# merchantability, fitness for a particular purpose, and non-infringement. +# ----------------------------------------------------------------------------- + +# @Author : Tek Raj Chhetri +# @Email : tekraj@mit.edu +# @File : spaces.py + +""" +Spaces: owner-controlled containers of named graphs with private/public +visibility and team membership. + +A space is a sovereign, IRI-addressable container (think Solid-style pod) that a +user or team owns. Named graphs belong to a space; ingestion into a graph is +allowed only for the owning space's owner/editors, while reads are allowed to +members always and to ANYONE (even anonymous) when the space is public. + +Storage is hybrid (see SPACES_MODEL.md): + * Postgres (spaces / space_members / space_graphs) is the enforcement source of + truth — fast per-request authorization. + * A best-effort RDF mirror in the spaces metadata graph makes space manifests + portable/decentralizable and queryable via SPARQL. +""" + +from __future__ import annotations + +import logging +import time +import uuid +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import quote + +import httpx +from rdflib import Graph, Literal, Namespace, URIRef, RDF +from rdflib.namespace import DCTERMS + +from core.database import get_db_connection +from core.shared import get_oxigraph_auth +from core.graph_database_connection_manager import _get_endpoint +from core.provenance import agent_ref, BRAINKB, PROV + +logger = logging.getLogger(__name__) + +SPACES_METADATA_GRAPH = "https://brainkb.org/metadata/spaces/" +SPACE_BASE = "https://brainkb.org/space/" +SCHEMA = Namespace("https://schema.org/") + +ROLES = ("owner", "editor", "viewer") +READ_ROLES = ("owner", "editor", "viewer") +WRITE_ROLES = ("owner", "editor") + + +def space_iri(slug: str) -> str: + return f"{SPACE_BASE}{quote(str(slug), safe='')}" + + +# --------------------------------------------------------------------------- +# Postgres CRUD +# --------------------------------------------------------------------------- + +async def create_space(slug: str, name: str, description: Optional[str], owner: str, + visibility: str = "private") -> Dict[str, Any]: + """Create a space and register the owner as a member with role 'owner'.""" + if visibility not in ("private", "public"): + raise ValueError("visibility must be 'private' or 'public'") + space_id = uuid.uuid4().hex + now = time.time() + async with get_db_connection() as conn: + async with conn.transaction(): + await conn.execute( + """ + INSERT INTO spaces (space_id, slug, name, description, owner, visibility, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + """, + space_id, slug, name, description, owner, visibility, now, + ) + await conn.execute( + """ + INSERT INTO space_members (space_id, member, role, added_at) + VALUES ($1, $2, 'owner', $3) + """, + space_id, owner, now, + ) + return await get_space(slug) + + +async def get_space(slug: str) -> Optional[Dict[str, Any]]: + async with get_db_connection() as conn: + row = await conn.fetchrow("SELECT * FROM spaces WHERE slug = $1", slug) + if not row: + return None + members = await conn.fetch( + "SELECT member, role FROM space_members WHERE space_id = $1 ORDER BY role, member", + row["space_id"], + ) + graphs = await conn.fetch( + "SELECT named_graph_iri FROM space_graphs WHERE space_id = $1 ORDER BY named_graph_iri", + row["space_id"], + ) + return { + "space_id": row["space_id"], + "slug": row["slug"], + "name": row["name"], + "description": row["description"], + "owner": row["owner"], + "visibility": row["visibility"], + "iri": space_iri(row["slug"]), + "members": [{"member": m["member"], "role": m["role"]} for m in members], + "graphs": [g["named_graph_iri"] for g in graphs], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + +async def get_space_for_graph(named_graph_iri: str) -> Optional[Dict[str, Any]]: + """Return the space that owns a named graph, or None if the graph is unmapped.""" + async with get_db_connection() as conn: + row = await conn.fetchrow( + """ + SELECT s.* FROM spaces s + JOIN space_graphs g ON g.space_id = s.space_id + WHERE g.named_graph_iri = $1 + """, + named_graph_iri, + ) + if not row: + return None + return { + "space_id": row["space_id"], "slug": row["slug"], "name": row["name"], + "owner": row["owner"], "visibility": row["visibility"], + } + + +async def member_role(space_id: str, member: Optional[str]) -> Optional[str]: + if not member: + return None + async with get_db_connection() as conn: + return await conn.fetchval( + "SELECT role FROM space_members WHERE space_id = $1 AND member = $2", + space_id, member, + ) + + +async def list_visible_spaces(member: Optional[str]) -> List[Dict[str, Any]]: + """Spaces the caller may see: all public spaces plus any they are a member of. + Anonymous callers (member=None) see only public spaces.""" + async with get_db_connection() as conn: + if member: + rows = await conn.fetch( + """ + SELECT DISTINCT s.slug, s.name, s.description, s.owner, s.visibility, s.created_at + FROM spaces s + LEFT JOIN space_members m ON m.space_id = s.space_id AND m.member = $1 + WHERE s.visibility = 'public' OR m.member IS NOT NULL + ORDER BY s.created_at DESC + """, + member, + ) + else: + rows = await conn.fetch( + """ + SELECT s.slug, s.name, s.description, s.owner, s.visibility, s.created_at + FROM spaces s WHERE s.visibility = 'public' + ORDER BY s.created_at DESC + """, + ) + return [ + {"slug": r["slug"], "name": r["name"], "description": r["description"], + "owner": r["owner"], "visibility": r["visibility"], "iri": space_iri(r["slug"]), + "created_at": r["created_at"]} + for r in rows + ] + + +async def add_member(space_id: str, member: str, role: str) -> None: + if role not in ROLES: + raise ValueError(f"role must be one of {ROLES}") + async with get_db_connection() as conn: + await conn.execute( + """ + INSERT INTO space_members (space_id, member, role, added_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (space_id, member) DO UPDATE SET role = EXCLUDED.role + """, + space_id, member, role, time.time(), + ) + + +async def remove_member(space_id: str, member: str) -> None: + async with get_db_connection() as conn: + await conn.execute( + "DELETE FROM space_members WHERE space_id = $1 AND member = $2 AND role <> 'owner'", + space_id, member, + ) + + +async def set_visibility(slug: str, visibility: str) -> None: + if visibility not in ("private", "public"): + raise ValueError("visibility must be 'private' or 'public'") + async with get_db_connection() as conn: + await conn.execute( + "UPDATE spaces SET visibility = $1, updated_at = $2 WHERE slug = $3", + visibility, time.time(), slug, + ) + + +async def attach_graph(space_id: str, named_graph_iri: str) -> None: + async with get_db_connection() as conn: + await conn.execute( + """ + INSERT INTO space_graphs (space_id, named_graph_iri, added_at) + VALUES ($1, $2, $3) + ON CONFLICT (named_graph_iri) DO NOTHING + """, + space_id, named_graph_iri, time.time(), + ) + + +# --------------------------------------------------------------------------- +# Authorization (enforcement) +# --------------------------------------------------------------------------- + +async def authorize(named_graph_iri: str, member: Optional[str], need: str) -> Tuple[bool, str]: + """ + Decide whether ``member`` (a user email, or None if anonymous) may read/write + the given named graph, based on the owning space's visibility and membership. + + Backward-compat: a graph not mapped to any space is treated as legacy — access + falls through to whatever scope check the endpoint already applied (returns + allowed=True here). Only space-mapped graphs are governed by space ACL. + """ + space = await get_space_for_graph(named_graph_iri) + if space is None: + return True, "legacy (unmapped) graph — governed by endpoint scope only" + + if need == "read": + if space["visibility"] == "public": + return True, "public space" + role = await member_role(space["space_id"], member) + if role in READ_ROLES: + return True, f"member ({role})" + return False, "private space — membership required" + + if need == "write": + role = await member_role(space["space_id"], member) + if role in WRITE_ROLES: + return True, f"member ({role})" + return False, "write requires owner/editor membership of the space" + + return False, f"unknown access mode: {need}" + + +# --------------------------------------------------------------------------- +# RDF mirror (best-effort, for portability / decentralization) +# --------------------------------------------------------------------------- + +def _space_manifest_graph(space: Dict[str, Any]) -> Graph: + g = Graph() + g.bind("brainkb", BRAINKB) + g.bind("prov", PROV) + g.bind("dcterms", DCTERMS) + g.bind("schema", SCHEMA) + s = URIRef(space_iri(space["slug"])) + g.add((s, RDF.type, BRAINKB.Space)) + g.add((s, BRAINKB.slug, Literal(space["slug"]))) + g.add((s, SCHEMA.name, Literal(space["name"]))) + if space.get("description"): + g.add((s, DCTERMS.description, Literal(space["description"]))) + g.add((s, BRAINKB.visibility, Literal(space["visibility"]))) + g.add((s, BRAINKB.owner, agent_ref(space["owner"]))) + for m in space.get("members", []): + pred = {"owner": BRAINKB.owner, "editor": BRAINKB.editor, "viewer": BRAINKB.viewer}[m["role"]] + g.add((s, pred, agent_ref(m["member"]))) + for giri in space.get("graphs", []): + g.add((s, BRAINKB.containsGraph, URIRef(giri))) + return g + + +async def mirror_space_to_rdf(space: Dict[str, Any]) -> bool: + """Upsert a space's manifest into the spaces metadata graph via SPARQL Update. + Best-effort — a mirror failure never fails the enforcing Postgres operation.""" + try: + s = space_iri(space["slug"]) + triples = _space_manifest_graph(space).serialize(format="nt") + update = ( + f"DELETE {{ GRAPH <{SPACES_METADATA_GRAPH}> {{ <{s}> ?p ?o }} }} " + f"WHERE {{ GRAPH <{SPACES_METADATA_GRAPH}> {{ <{s}> ?p ?o }} }} ; " + f"INSERT DATA {{ GRAPH <{SPACES_METADATA_GRAPH}> {{ {triples} }} }}" + ) + endpoint = _get_endpoint("post") # .../update + auth = get_oxigraph_auth() + async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=15.0)) as client: + resp = await client.post(endpoint, data={"update": update}, auth=auth) + if resp.status_code in (200, 204): + return True + logger.warning("[spaces] RDF mirror failed (HTTP %s): %s", resp.status_code, (resp.text or "")[:400]) + return False + except Exception as e: + logger.warning(f"[spaces] Error mirroring space to RDF: {e}", exc_info=True) + return False + + +async def construct_space_graphs(space: Dict[str, Any]) -> str: + """SPARQL CONSTRUCT returning all triples across the space's named graphs.""" + graphs = space.get("graphs", []) + if not graphs: + return "CONSTRUCT {} WHERE {}" + unions = " UNION ".join(f"{{ GRAPH <{g}> {{ ?s ?p ?o }} }}" for g in graphs) + return f"CONSTRUCT {{ ?s ?p ?o }} WHERE {{ {unions} }}" From 788689a088aa4f8c1efd07a16715ab4d4d121c9c Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Wed, 22 Jul 2026 19:46:57 -0400 Subject: [PATCH 08/70] Filter registered-named-graphs by space visibility 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/SPACES_MODEL.md | 18 ++++++++++++------ query_service/core/routers/query.py | 19 +++++++++++++++++-- query_service/core/spaces.py | 24 ++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/query_service/SPACES_MODEL.md b/query_service/SPACES_MODEL.md index 7e83c14..b57434a 100644 --- a/query_service/SPACES_MODEL.md +++ b/query_service/SPACES_MODEL.md @@ -81,9 +81,15 @@ GRAPH { } ``` -## Notes / future - -- `registered-named-graphs` still lists all registered graph IRIs; filtering that - listing by space visibility is a follow-up (data reads are already protected). -- Job-scoped provenance endpoints remain owner-restricted (by `user_id`); making a - public space's ingestion provenance publicly readable is a follow-up. +## Notes + +- `registered-named-graphs` is **visibility-filtered**: graphs in a private space + the caller isn't a member of are omitted, so private graph existence is not + leaked. Public-space and legacy (unmapped) graphs remain listed. The endpoint + requires authentication (read scope); anonymous discovery of public spaces is via + `GET /api/spaces`. +- Job-scoped provenance endpoints are **intentionally owner-restricted** (by + `user_id` = authenticated identity) and stay that way — job provenance is not made + public even for public spaces. Ingestion is likewise restricted to activated JWT + users with valid credentials (and space owner/editor membership); it is never + anonymous. Only *reads* of public spaces are anonymous. diff --git a/query_service/core/routers/query.py b/query_service/core/routers/query.py index f46ac1a..0cb5e96 100644 --- a/query_service/core/routers/query.py +++ b/query_service/core/routers/query.py @@ -40,13 +40,17 @@ "carries its `description`, `registered_at` timestamp, and `registered_by` " "(the user who registered it). Data is read from the registry graph " "`https://brainkb.org/metadata/named-graph`.\n\n" + "Visibility-filtered: graphs that belong to a **private space** the caller " + "is not a member of are omitted (so private graph existence is not leaked). " + "Public-space graphs and legacy (unmapped) graphs are always listed.\n\n" "This answers *\"what graphs are available?\"*. It is NOT a history of what " "was ingested — for the ingestion/activity history of a specific graph, use " "`GET /api/provenance/named-graph?iri=…`." ), ) -async def get_named_graphs(): - """List every registered named graph with its registration metadata. +async def get_named_graphs(user: Annotated[LoginUserIn, Depends(get_current_user)]): + """List every registered named graph with its registration metadata, + filtered so private-space graphs the caller can't access are hidden. Registry (catalog) view: one row per graph with description, when it was registered, and by whom. Contrast with /api/provenance/named-graph, which @@ -78,6 +82,17 @@ async def get_named_graphs(): "registered_at": graphs_info["registered_at"]["value"], "registered_by": graphs_info.get("registered_by", {}).get("value"), } + + # Hide graphs belonging to private spaces the caller is not a member of, so + # private graph existence is not leaked via the registry listing. + from core.spaces import hidden_graphs_for + try: + member = user["email"] + except (KeyError, TypeError, IndexError): + member = None + hidden = await hidden_graphs_for(member) + if hidden: + response_graph = {k: v for k, v in response_graph.items() if k not in hidden} return response_graph diff --git a/query_service/core/spaces.py b/query_service/core/spaces.py index fa2c85b..4fb7ccd 100644 --- a/query_service/core/spaces.py +++ b/query_service/core/spaces.py @@ -134,6 +134,30 @@ async def get_space_for_graph(named_graph_iri: str) -> Optional[Dict[str, Any]]: } +async def hidden_graphs_for(member: Optional[str]) -> set: + """ + Return the set of named-graph IRIs the caller must NOT see in listings: graphs + belonging to a PRIVATE space the caller is not a member of. Public-space graphs + and legacy (unmapped) graphs are never hidden. Anonymous callers (member=None) + have every private-space graph hidden. + """ + async with get_db_connection() as conn: + rows = await conn.fetch( + """ + SELECT g.named_graph_iri + FROM space_graphs g + JOIN spaces s ON s.space_id = g.space_id + WHERE s.visibility = 'private' + AND NOT EXISTS ( + SELECT 1 FROM space_members m + WHERE m.space_id = s.space_id AND m.member = $1 + ) + """, + member, + ) + return {r["named_graph_iri"] for r in rows} + + async def member_role(space_id: str, member: Optional[str]) -> Optional[str]: if not member: return None From 54152e24aeed189559c36832552dfccf3dec62b8 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Wed, 22 Jul 2026 19:57:48 -0400 Subject: [PATCH 09/70] docs: update READMEs for provenance, delta tracking, and spaces - 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. --- query_service/README.md | 69 ++++++++++++++++++++++++++++++++++++++--- readme.md | 9 +++++- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/query_service/README.md b/query_service/README.md index 6a1dc22..14e123d 100644 --- a/query_service/README.md +++ b/query_service/README.md @@ -1,14 +1,73 @@ # Query Service -This service provides the endpoints and the functionalities necessary for querying (and updating) the knowledge graphs from the graph database. -## Features Implemented -- [x] Logging -- [ ] Endpoints to query the data from the graph database +FastAPI service for querying **and** ingesting BrainKB knowledge graphs in the +graph database (Oxigraph), with W3C PROV-O provenance and team-owned +private/public spaces. +## Features +- [x] Structured logging (with correlation IDs) +- [x] JWT auth with per-endpoint scopes (`read` / `write` / `admin`) +- [x] SPARQL query endpoints (registered graphs, taxonomy, arbitrary query — admin only) +- [x] Bulk RDF ingestion into named graphs (file + raw), run as background jobs +- [x] Job tracking: live status, processing history, progress, crash recovery +- [x] Native **PROV-O provenance** in Oxigraph (ingestion / recovery activities) +- [x] **Triple-level delta tracking** — per-job delta graphs + query/compare endpoints +- [x] **Spaces** — owner-controlled, private/public containers of named graphs +## Auth & scopes +Tokens are issued by the JWT/token manager and validated here. Scope policy: +- **GET (reads)** → `read` +- **Mutations** (ingest, register/attach graph, recover, create/modify space) → `write` +- **Arbitrary SPARQL** (`/query/sparql/`) → `admin` +- **Public-space reads** → no token required (anonymous), see Spaces below +- `/register`, `/token` → public + +Users may only act on their own `user_id` (enforced), and job-scoped endpoints are +owner-only. + +## Endpoints (prefix `/api`) + +### Query +- `GET /query/registered-named-graphs` — registry/catalog of graphs (visibility-filtered) +- `GET /query/taxonomy` — taxonomy view +- `GET /query/sparql/` — arbitrary SPARQL (**admin**) + +### Ingestion & jobs +- `POST /insert/raw/knowledge-graph-triples` — ingest raw triples (background job) +- `POST /insert/files/knowledge-graph-triples` — ingest uploaded RDF files +- `POST /register-named-graph` — register a named graph +- `GET /insert/jobs`, `GET /insert/user/jobs/detail` — job listing / detail +- `GET /insert/jobs/check-recoverable`, `POST /insert/jobs/recover` — crash recovery + +### Provenance (PROV-O, JSON-LD) +- `GET /provenance/job` — full bundle for one job +- `GET /provenance/named-graph` — ingestion/activity history of a graph +- `GET /provenance/delta` — exact triples a job added +- `GET /provenance/delta/history` — a graph's change history +- `GET /provenance/delta/compare` — diff two jobs' deltas + +See [PROVENANCE_MODEL.md](PROVENANCE_MODEL.md). + +### Spaces (private/public) +- `POST /spaces`, `GET /spaces`, `GET /spaces/{slug}` +- `PATCH /spaces/{slug}/visibility` — flip private/public (owner) +- `POST/DELETE /spaces/{slug}/members[/{member}]` — membership (owner) +- `POST /spaces/{slug}/graphs` — register + bind a graph to a space (owner/editor) +- `GET /spaces/{slug}/data` — read space RDF (public = anonymous) + +**public** = readable by anyone, including unauthenticated clients; **private** = +members only; **write/ingest** = space owner/editor only. See +[SPACES_MODEL.md](SPACES_MODEL.md). + +## Architecture notes + +- **Postgres** holds identity/teams/enforcement (JWT users, jobs, spaces/members/graphs). +- **Oxigraph** holds all knowledge-graph data, PROV-O provenance, per-job delta + graphs, and a mirror of each space manifest — the graph database is the source of + truth for graph data and provenance. ### Acknowledgements Special thanks to the authors of the resources below who helped with some best practices. @@ -17,4 +76,4 @@ Special thanks to the authors of the resources below who helped with some best p - FastAPI official documentation ### License -[MIT](https://github.com/git/git-scm.com/blob/main/MIT-LICENSE.txt) \ No newline at end of file +[MIT](https://github.com/git/git-scm.com/blob/main/MIT-LICENSE.txt) diff --git a/readme.md b/readme.md index 1ac77a9..3c21a81 100644 --- a/readme.md +++ b/readme.md @@ -47,7 +47,14 @@ Once started, services are accessible at: - **API Token Manager (Django)**: `http://localhost:8000/` - Once you register JWT user you need to activate it using token manager. You can also assign permission. - **Query Service (FastAPI)**: `http://localhost:8010/` - - Now supports ingestion than just querying. + - Supports querying **and** ingestion of the knowledge graphs. + - Native W3C PROV-O provenance in the graph database, with triple-level delta + tracking (per-job delta graphs + query/compare endpoints). + - **Spaces**: team-owned, private/public containers of named graphs — keep data + private to members or publish it publicly (anonymous read). Per-endpoint JWT + scopes (`read`/`write`/`admin`). + - See `query_service/README.md`, `query_service/PROVENANCE_MODEL.md`, and + `query_service/SPACES_MODEL.md` for details. - **ML Service (FastAPI)**: `http://localhost:8007/` - Integrates StructSense (multi-agent NER + structured-resource extraction). - Hosts **SynthScholar** at `/api/synth-scholar/*` — PRISMA-guided literature From 62c2c3f2f1dd1596b5c64d8531f0ba07098978ff Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 14:39:04 -0400 Subject: [PATCH 10/70] Add access-filtered hybrid search (Postgres locator + Oxigraph data) 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. --- query_service/README.md | 12 ++ query_service/core/main.py | 28 ++++ query_service/core/routers/insert.py | 8 ++ query_service/core/routers/search.py | 58 ++++++++ query_service/core/search.py | 195 +++++++++++++++++++++++++++ query_service/core/spaces.py | 7 + readme.md | 3 + 7 files changed, 311 insertions(+) create mode 100644 query_service/core/routers/search.py create mode 100644 query_service/core/search.py diff --git a/query_service/README.md b/query_service/README.md index 14e123d..dddc066 100644 --- a/query_service/README.md +++ b/query_service/README.md @@ -14,6 +14,7 @@ private/public spaces. - [x] Native **PROV-O provenance** in Oxigraph (ingestion / recovery activities) - [x] **Triple-level delta tracking** — per-job delta graphs + query/compare endpoints - [x] **Spaces** — owner-controlled, private/public containers of named graphs +- [x] **Search** — hybrid Postgres-locator + Oxigraph-data, access-filtered by space ## Auth & scopes @@ -62,6 +63,17 @@ See [PROVENANCE_MODEL.md](PROVENANCE_MODEL.md). members only; **write/ingest** = space owner/editor only. See [SPACES_MODEL.md](SPACES_MODEL.md). +### Search +- `GET /search?q=…[&space={slug}][&limit&offset]` — full-text search, access-filtered. + +Hybrid design: **Postgres** holds a full-text **locator index** (`graph_search_index`: +subject + text + named graph + owning space), populated at ingest. A search runs in +Postgres (fast, filtered by space visibility/membership), then the matched subjects' +triples are fetched from **Oxigraph** (the source of truth). Anonymous → public +spaces only; authenticated → public + own/member spaces (+ legacy). Pass `space` to +scope to one workspace, omit for a full search. Private data is never returned to +non-members — the filter is enforced in the locator query. + ## Architecture notes - **Postgres** holds identity/teams/enforcement (JWT users, jobs, spaces/members/graphs). diff --git a/query_service/core/main.py b/query_service/core/main.py index e6b204d..079e5af 100644 --- a/query_service/core/main.py +++ b/query_service/core/main.py @@ -13,6 +13,7 @@ from core.routers.rapid_release import router as rapid_release from core.routers.insert import router as insert_router from core.routers.spaces import router as spaces_router +from core.routers.search import router as search_router from core.configuration import load_environment from core.database import init_db_pool from core.graph_database_connection_manager import initialize_metadata_graph @@ -52,6 +53,7 @@ app.include_router(query_router, prefix="/api") app.include_router(insert_router,prefix="/api") app.include_router(spaces_router, prefix="/api", tags=["Spaces"]) +app.include_router(search_router, prefix="/api", tags=["Search"]) # rapid-release app.include_router(rapid_release, prefix="/api/rapid-release", tags=["Rapid release"]) @@ -201,6 +203,32 @@ async def startup_event(): except Exception: pass logger.info("Spaces tables initialized") + + # Search locator index (hybrid search): Postgres full-text index that + # locates subjects/subgraphs (carrying graph + workspace/space), then the + # actual triples are fetched from Oxigraph. Access is filtered by space + # visibility/membership at query time. + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS graph_search_index ( + id BIGSERIAL PRIMARY KEY, + named_graph_iri TEXT NOT NULL, + space_id TEXT, + subject TEXT NOT NULL, + text TEXT NOT NULL, + tsv tsvector, + updated_at DOUBLE PRECISION, + UNIQUE (named_graph_iri, subject) + ) + """ + ) + try: + await conn.execute("CREATE INDEX IF NOT EXISTS idx_gsi_tsv ON graph_search_index USING GIN(tsv)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_gsi_graph ON graph_search_index(named_graph_iri)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_gsi_space ON graph_search_index(space_id)") + except Exception: + pass + logger.info("Search index table initialized") break # Success, exit retry loop finally: await pool.release(conn) diff --git a/query_service/core/routers/insert.py b/query_service/core/routers/insert.py index d7f482d..7d2341c 100644 --- a/query_service/core/routers/insert.py +++ b/query_service/core/routers/insert.py @@ -524,6 +524,14 @@ async def _write_ingestion_prov(status_label: str, results): added_triple_count=added_count, ) await write_provenance(prov_graph) + + # Update the Postgres search locator index for the target graph + # (best-effort). Only subjects touched by this job when deltas are on. + try: + from core.search import index_graph_subjects + await index_graph_subjects(named_graph, effective_delta_graph) + except Exception as _se: + logger.warning(f"[run_ingest_job] Search indexing failed for {job_id}: {_se}") except Exception as _pe: logger.warning(f"[run_ingest_job] Provenance write failed for {job_id}: {_pe}") diff --git a/query_service/core/routers/search.py b/query_service/core/routers/search.py new file mode 100644 index 0000000..8777738 --- /dev/null +++ b/query_service/core/routers/search.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# DISCLAIMER: This software is provided "as is" without any warranty, +# express or implied, including but not limited to the warranties of +# merchantability, fitness for a particular purpose, and non-infringement. +# ----------------------------------------------------------------------------- + +# @Author : Tek Raj Chhetri +# @Email : tekraj@mit.edu +# @File : search.py (router) + +"""Search endpoint — hybrid Postgres-locator + Oxigraph-data, access-filtered.""" + +import logging +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends, Query + +from core.security import get_current_user_optional +from core import search as se + +router = APIRouter() +logger = logging.getLogger(__name__) + + +def _agent(user) -> Optional[str]: + if not user: + return None + try: + return user["email"] + except (KeyError, TypeError, IndexError): + return None + + +@router.get( + "/search", + summary="Search knowledge graphs (access-filtered)", + description=( + "Full-text search over the knowledge graphs, honoring space visibility. " + "Postgres locates matching subjects (fast, filtered by workspace/visibility); " + "the matched triples are then fetched from Oxigraph.\n\n" + "- **Anonymous** (no token): searches **public** spaces only.\n" + "- **Authenticated**: public spaces + the caller's own/member (private) spaces " + "(and legacy/unmapped graphs).\n" + "- Pass `space` to scope the search to a single space you can access; omit it " + "for a full search across everything you may read.\n\n" + "Private data is never returned to non-members — the filter is enforced in the " + "locator query itself." + ), +) +async def search( + user: Annotated[Optional[object], Depends(get_current_user_optional)], + q: Annotated[str, Query(..., min_length=1, description="Search terms")], + space: Annotated[Optional[str], Query(description="Restrict to a single space slug")] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 25, + offset: Annotated[int, Query(ge=0)] = 0, +): + return await se.search(q=q, caller=_agent(user), space_slug=space, limit=limit, offset=offset) diff --git a/query_service/core/search.py b/query_service/core/search.py new file mode 100644 index 0000000..adb4eb1 --- /dev/null +++ b/query_service/core/search.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# DISCLAIMER: This software is provided "as is" without any warranty, +# express or implied, including but not limited to the warranties of +# merchantability, fitness for a particular purpose, and non-infringement. +# ----------------------------------------------------------------------------- + +# @Author : Tek Raj Chhetri +# @Email : tekraj@mit.edu +# @File : search.py + +""" +Hybrid search: Postgres is a full-text **locator** index, Oxigraph is the source +of truth for the data. + +At ingest time each subject's literal text is indexed into `graph_search_index` +along with its named graph and owning space (workspace). A search query runs in +Postgres (fast, access-filtered by space visibility/membership) to LOCATE the +matching subjects, then the actual triples for that page of hits are fetched from +Oxigraph. + +Access model (identical to spaces): + * anonymous -> public-space subjects only + * logged-in -> public + the caller's member spaces (+ legacy/unmapped, authed) +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional +from urllib.parse import quote + +import httpx + +from core.database import get_db_connection +from core.shared import get_oxigraph_auth +from core.graph_database_connection_manager import _get_endpoint +from core.provenance import query_provenance_jsonld +from core.spaces import get_space_for_graph + +logger = logging.getLogger(__name__) + + +async def _sparql_select(query: str) -> List[Dict[str, Any]]: + """Run a SPARQL SELECT and return its bindings (empty list on error).""" + try: + endpoint = _get_endpoint("get") + auth = get_oxigraph_auth() + async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=15.0)) as client: + resp = await client.post( + endpoint, + data={"query": query}, + headers={"Accept": "application/sparql-results+json"}, + auth=auth, + ) + if resp.status_code != 200: + logger.warning("[search] SELECT failed (HTTP %s): %s", resp.status_code, (resp.text or "")[:400]) + return [] + return resp.json().get("results", {}).get("bindings", []) + except Exception as e: + logger.warning(f"[search] SELECT error: {e}", exc_info=True) + return [] + + +async def index_graph_subjects(named_graph_iri: str, delta_graph: Optional[str] = None) -> int: + """ + (Re)index the subjects of a named graph into the Postgres locator index. + + Each subject's searchable text is the concatenation of its literal objects, + read from Oxigraph. When `delta_graph` is given, only subjects touched by that + job are (re)indexed (their full text is still read from the target graph). + Best-effort — returns the number of subjects indexed (0 on failure). + """ + try: + if delta_graph: + q = f""" + SELECT ?s (GROUP_CONCAT(DISTINCT STR(?o); SEPARATOR=" ") AS ?text) + WHERE {{ + GRAPH <{delta_graph}> {{ ?s ?dp ?do }} + GRAPH <{named_graph_iri}> {{ ?s ?p ?o FILTER(isLiteral(?o)) }} + }} GROUP BY ?s + """ + else: + q = f""" + SELECT ?s (GROUP_CONCAT(DISTINCT STR(?o); SEPARATOR=" ") AS ?text) + WHERE {{ GRAPH <{named_graph_iri}> {{ ?s ?p ?o FILTER(isLiteral(?o)) }} }} + GROUP BY ?s + """ + rows = await _sparql_select(q) + if not rows: + return 0 + + space = await get_space_for_graph(named_graph_iri) + space_id = space["space_id"] if space else None + + import time + now = time.time() + records = [] + for b in rows: + subj = b.get("s", {}).get("value") + text = b.get("text", {}).get("value", "") + if subj and text.strip(): + records.append((named_graph_iri, space_id, subj, text, now)) + if not records: + return 0 + + async with get_db_connection() as conn: + await conn.executemany( + """ + INSERT INTO graph_search_index (named_graph_iri, space_id, subject, text, tsv, updated_at) + VALUES ($1, $2, $3, $4, to_tsvector('english', $4), $5) + ON CONFLICT (named_graph_iri, subject) DO UPDATE SET + space_id = EXCLUDED.space_id, + text = EXCLUDED.text, + tsv = EXCLUDED.tsv, + updated_at = EXCLUDED.updated_at + """, + records, + ) + logger.info(f"[search] Indexed {len(records)} subject(s) for {named_graph_iri}") + return len(records) + except Exception as e: + logger.warning(f"[search] Failed to index {named_graph_iri}: {e}", exc_info=True) + return 0 + + +async def reindex_graph_space(named_graph_iri: str, space_id: str) -> None: + """Point a graph's existing index rows at a (new) space — e.g. when a graph is + attached to a space after it was already indexed.""" + try: + async with get_db_connection() as conn: + await conn.execute( + "UPDATE graph_search_index SET space_id = $1 WHERE named_graph_iri = $2", + space_id, named_graph_iri, + ) + except Exception as e: + logger.warning(f"[search] Failed to reassign space for {named_graph_iri}: {e}", exc_info=True) + + +async def search(q: str, caller: Optional[str], space_slug: Optional[str] = None, + limit: int = 25, offset: int = 0) -> Dict[str, Any]: + """ + Locate matching subjects in Postgres (access-filtered), then fetch their triples + from Oxigraph. `caller` is the user email, or None for anonymous. + """ + # Build the access-filtered locator query. Access is authoritative from the + # spaces tables (joined live), so visibility flips need no reindex. + params: List[Any] = [q, caller] + where = [ + "i.tsv @@ plainto_tsquery('english', $1)", + "(s.visibility = 'public' OR m.member IS NOT NULL OR (i.space_id IS NULL AND $2 IS NOT NULL))", + ] + idx = 3 + if space_slug: + where.append(f"s.slug = ${idx}") + params.append(space_slug) + idx += 1 + limit_i, offset_i = idx, idx + 1 + params.extend([limit, offset]) + + sql = f""" + SELECT i.named_graph_iri, i.subject, i.text, + s.slug AS space_slug, s.visibility AS visibility, + ts_rank(i.tsv, plainto_tsquery('english', $1)) AS rank + FROM graph_search_index i + LEFT JOIN spaces s ON s.space_id = i.space_id + LEFT JOIN space_members m ON m.space_id = i.space_id AND m.member = $2 + WHERE {' AND '.join(where)} + ORDER BY rank DESC, i.subject + LIMIT ${limit_i} OFFSET ${offset_i} + """ + async with get_db_connection() as conn: + rows = await conn.fetch(sql, *params) + + hits = [] + for r in rows: + text = r["text"] or "" + hits.append({ + "subject": r["subject"], + "named_graph_iri": r["named_graph_iri"], + "space": r["space_slug"], + "visibility": r["visibility"] or ("legacy" if r["named_graph_iri"] else None), + "snippet": (text[:240] + "…") if len(text) > 240 else text, + }) + + # Fetch the located subjects' triples from Oxigraph (the source of truth). + data = None + if hits: + construct = "CONSTRUCT { ?s ?p ?o } WHERE { " + " UNION ".join( + f'{{ BIND(<{h["subject"]}> AS ?s) GRAPH <{h["named_graph_iri"]}> {{ ?s ?p ?o }} }}' + for h in hits + ) + " }" + data = await query_provenance_jsonld(construct) + + return {"query": q, "space": space_slug, "count": len(hits), "hits": hits, "data": data} diff --git a/query_service/core/spaces.py b/query_service/core/spaces.py index 4fb7ccd..a135b84 100644 --- a/query_service/core/spaces.py +++ b/query_service/core/spaces.py @@ -241,6 +241,13 @@ async def attach_graph(space_id: str, named_graph_iri: str) -> None: """, space_id, named_graph_iri, time.time(), ) + # Point any already-indexed rows for this graph at the space so search + # access-filtering picks up the (new) workspace immediately. Done inline + # (not via core.search) to avoid an import cycle. + await conn.execute( + "UPDATE graph_search_index SET space_id = $1 WHERE named_graph_iri = $2", + space_id, named_graph_iri, + ) # --------------------------------------------------------------------------- diff --git a/readme.md b/readme.md index 3c21a81..481019a 100644 --- a/readme.md +++ b/readme.md @@ -53,6 +53,9 @@ Once started, services are accessible at: - **Spaces**: team-owned, private/public containers of named graphs — keep data private to members or publish it publicly (anonymous read). Per-endpoint JWT scopes (`read`/`write`/`admin`). + - **Search**: hybrid full-text search — Postgres locator index (aware of + workspace + visibility) finds subjects, data is fetched from Oxigraph. Results + are access-filtered (anonymous sees public only). - See `query_service/README.md`, `query_service/PROVENANCE_MODEL.md`, and `query_service/SPACES_MODEL.md` for details. - **ML Service (FastAPI)**: `http://localhost:8007/` From e3d71a9c5e230865b64c217daaa045ae1059cf06 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 14:46:31 -0400 Subject: [PATCH 11/70] Make search indexing asynchronous (background task queue) + backfill 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. --- query_service/README.md | 7 + query_service/core/indexing.py | 207 +++++++++++++++++++++++++++ query_service/core/main.py | 36 +++++ query_service/core/routers/insert.py | 10 +- query_service/core/routers/search.py | 34 ++++- 5 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 query_service/core/indexing.py diff --git a/query_service/README.md b/query_service/README.md index dddc066..06e36a4 100644 --- a/query_service/README.md +++ b/query_service/README.md @@ -65,6 +65,8 @@ members only; **write/ingest** = space owner/editor only. See ### Search - `GET /search?q=…[&space={slug}][&limit&offset]` — full-text search, access-filtered. +- `POST /search/reindex` (**admin**) — queue a background backfill/rebuild of the index. +- `GET /search/index-tasks[?task_id=…]` — background indexing task status. Hybrid design: **Postgres** holds a full-text **locator index** (`graph_search_index`: subject + text + named graph + owning space), populated at ingest. A search runs in @@ -74,6 +76,11 @@ spaces only; authenticated → public + own/member spaces (+ legacy). Pass `spac scope to one workspace, omit for a full search. Private data is never returned to non-members — the filter is enforced in the locator query. +**Indexing is asynchronous.** It never blocks ingestion: ingest enqueues an indexing +task on an in-process async queue (durable `index_tasks` table, background consumer, +atomic cross-worker claim, restart recovery) and the job completes immediately. The +`/search/reindex` backfill uses the same queue. Poll `/search/index-tasks` for status. + ## Architecture notes - **Postgres** holds identity/teams/enforcement (JWT users, jobs, spaces/members/graphs). diff --git a/query_service/core/indexing.py b/query_service/core/indexing.py new file mode 100644 index 0000000..5928921 --- /dev/null +++ b/query_service/core/indexing.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# DISCLAIMER: This software is provided "as is" without any warranty, +# express or implied, including but not limited to the warranties of +# merchantability, fitness for a particular purpose, and non-infringement. +# ----------------------------------------------------------------------------- + +# @Author : Tek Raj Chhetri +# @Email : tekraj@mit.edu +# @File : indexing.py + +""" +Async search-indexing task queue. + +Search indexing (building the Postgres locator rows from Oxigraph) can be slow for +large graphs, so it must not run inline with ingestion. This module provides a +lightweight in-process asyncio queue with a single background consumer, backed by a +durable `index_tasks` table for observability and cross-restart recovery. + +Ingest enqueues an 'ingest' task (fast) and returns immediately; a 'backfill' task +reindexes every graph. Tasks are claimed atomically in the DB so multiple gunicorn +workers never process the same task twice. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from typing import Any, Dict, List, Optional + +from core.database import get_db_connection +from core.search import index_graph_subjects, _sparql_select + +logger = logging.getLogger(__name__) + +# Graphs that are infrastructure, not user data — never indexed for search. +_SKIP_GRAPHS = { + "https://brainkb.org/metadata/named-graph", + "https://brainkb.org/metadata/spaces/", + "https://brainkb.org/provenance/", +} + +_queue: Optional[asyncio.Queue] = None +_consumer: Optional[asyncio.Task] = None + + +def _get_queue() -> asyncio.Queue: + global _queue + if _queue is None: + _queue = asyncio.Queue() + return _queue + + +async def enqueue_ingest(named_graph_iri: str, delta_graph: Optional[str] = None) -> str: + """Queue an indexing task for a single graph (called by ingest). Non-blocking.""" + task_id = uuid.uuid4().hex + async with get_db_connection() as conn: + await conn.execute( + """ + INSERT INTO index_tasks (task_id, kind, target, delta_graph, status, created_at) + VALUES ($1, 'ingest', $2, $3, 'queued', $4) + """, + task_id, named_graph_iri, delta_graph, time.time(), + ) + _get_queue().put_nowait({"task_id": task_id, "kind": "ingest", + "target": named_graph_iri, "delta_graph": delta_graph}) + return task_id + + +async def enqueue_backfill() -> str: + """Queue a full reindex of every user graph. Non-blocking.""" + task_id = uuid.uuid4().hex + async with get_db_connection() as conn: + await conn.execute( + """ + INSERT INTO index_tasks (task_id, kind, target, status, created_at) + VALUES ($1, 'backfill', 'ALL', 'queued', $2) + """, + task_id, time.time(), + ) + _get_queue().put_nowait({"task_id": task_id, "kind": "backfill", "target": "ALL"}) + return task_id + + +async def _claim(task_id: str) -> bool: + """Atomically move a task queued->running. Returns False if already claimed + (by another worker) or not claimable — prevents double processing.""" + async with get_db_connection() as conn: + row = await conn.fetchrow( + """ + UPDATE index_tasks SET status = 'running', started_at = $2 + WHERE task_id = $1 AND status = 'queued' + RETURNING task_id + """, + task_id, time.time(), + ) + return row is not None + + +async def _finish(task_id: str, status: str, subjects: int = 0, message: str = "", + graphs_total: Optional[int] = None, graphs_done: int = 0) -> None: + async with get_db_connection() as conn: + await conn.execute( + """ + UPDATE index_tasks + SET status = $2, subjects_indexed = $3, message = $4, + graphs_total = COALESCE($5, graphs_total), graphs_done = $6, ended_at = $7 + WHERE task_id = $1 + """, + task_id, status, subjects, message[:500] if message else None, + graphs_total, graphs_done, time.time(), + ) + + +async def _all_user_graphs() -> List[str]: + rows = await _sparql_select("SELECT DISTINCT ?g WHERE { GRAPH ?g { ?s ?p ?o } }") + graphs = [] + for b in rows: + g = b.get("g", {}).get("value") + if g and g not in _SKIP_GRAPHS and "/provenance/delta/" not in g: + graphs.append(g) + return graphs + + +async def _run(task: Dict[str, Any]) -> None: + task_id = task["task_id"] + if not await _claim(task_id): + return # another worker already handling it, or not queued + try: + if task["kind"] == "ingest": + n = await index_graph_subjects(task["target"], task.get("delta_graph")) + await _finish(task_id, "done", subjects=n, message=f"indexed {n} subject(s)") + elif task["kind"] == "backfill": + graphs = await _all_user_graphs() + total, done, subs = len(graphs), 0, 0 + # record the total up front (status stays 'running') + async with get_db_connection() as conn: + await conn.execute("UPDATE index_tasks SET graphs_total=$2 WHERE task_id=$1", task_id, total) + for g in graphs: + subs += await index_graph_subjects(g) + done += 1 + async with get_db_connection() as conn: + await conn.execute( + "UPDATE index_tasks SET graphs_done=$2, subjects_indexed=$3 WHERE task_id=$1", + task_id, done, subs, + ) + await _finish(task_id, "done", subjects=subs, graphs_total=total, graphs_done=done, + message=f"reindexed {done}/{total} graph(s), {subs} subject(s)") + except Exception as e: + logger.error(f"[indexing] task {task_id} failed: {e}", exc_info=True) + await _finish(task_id, "error", message=str(e)) + + +async def _consume() -> None: + q = _get_queue() + while True: + task = await q.get() + try: + await _run(task) + except Exception as e: + logger.error(f"[indexing] consumer error: {e}", exc_info=True) + finally: + q.task_done() + + +async def start_worker() -> None: + """Start the background consumer and recover tasks from a previous run. + Called once on application startup.""" + global _consumer + # Recovery: any task left 'running' by a crashed process is re-queued. + try: + async with get_db_connection() as conn: + await conn.execute("UPDATE index_tasks SET status = 'queued' WHERE status = 'running'") + pending = await conn.fetch( + "SELECT task_id, kind, target, delta_graph FROM index_tasks WHERE status = 'queued'" + ) + except Exception as e: + logger.warning(f"[indexing] recovery query failed: {e}") + pending = [] + + if _consumer is None or _consumer.done(): + _consumer = asyncio.create_task(_consume()) + logger.info("[indexing] background consumer started") + + for p in pending: + _get_queue().put_nowait({ + "task_id": p["task_id"], "kind": p["kind"], + "target": p["target"], "delta_graph": p["delta_graph"], + }) + if pending: + logger.info(f"[indexing] re-queued {len(pending)} pending task(s) from previous session") + + +async def get_task(task_id: str) -> Optional[Dict[str, Any]]: + async with get_db_connection() as conn: + row = await conn.fetchrow("SELECT * FROM index_tasks WHERE task_id = $1", task_id) + return dict(row) if row else None + + +async def list_tasks(limit: int = 50) -> List[Dict[str, Any]]: + async with get_db_connection() as conn: + rows = await conn.fetch( + "SELECT * FROM index_tasks ORDER BY created_at DESC LIMIT $1", limit + ) + return [dict(r) for r in rows] diff --git a/query_service/core/main.py b/query_service/core/main.py index 079e5af..90eaed4 100644 --- a/query_service/core/main.py +++ b/query_service/core/main.py @@ -229,6 +229,34 @@ async def startup_event(): except Exception: pass logger.info("Search index table initialized") + + # Async indexing task queue: search indexing runs in the + # background (not inline with ingest) so large graphs don't + # block jobs. Task status is durable here for observability + # and cross-restart recovery. + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS index_tasks ( + task_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + target TEXT, + delta_graph TEXT, + status TEXT NOT NULL, + subjects_indexed INTEGER DEFAULT 0, + graphs_total INTEGER, + graphs_done INTEGER DEFAULT 0, + message TEXT, + created_at DOUBLE PRECISION, + started_at DOUBLE PRECISION, + ended_at DOUBLE PRECISION + ) + """ + ) + try: + await conn.execute("CREATE INDEX IF NOT EXISTS idx_index_tasks_status ON index_tasks(status)") + except Exception: + pass + logger.info("Index task table initialized") break # Success, exit retry loop finally: await pool.release(conn) @@ -283,6 +311,14 @@ async def startup_event(): except Exception as e: logger.warning(f"Failed to recover stuck jobs: {str(e)}. Continuing anyway...") + # Start the background search-indexing consumer and recover any pending tasks + logger.info("Starting background search-indexing worker...") + try: + from core.indexing import start_worker + await start_worker() + except Exception as e: + logger.warning(f"Failed to start indexing worker: {str(e)}. Continuing anyway...") + logger.info("FastAPI startup completed successfully") diff --git a/query_service/core/routers/insert.py b/query_service/core/routers/insert.py index 7d2341c..87c89a6 100644 --- a/query_service/core/routers/insert.py +++ b/query_service/core/routers/insert.py @@ -525,13 +525,13 @@ async def _write_ingestion_prov(status_label: str, results): ) await write_provenance(prov_graph) - # Update the Postgres search locator index for the target graph - # (best-effort). Only subjects touched by this job when deltas are on. + # Queue search indexing to run in the BACKGROUND (do not block the job + # on it — indexing a large graph can be slow). Best-effort enqueue. try: - from core.search import index_graph_subjects - await index_graph_subjects(named_graph, effective_delta_graph) + from core.indexing import enqueue_ingest + await enqueue_ingest(named_graph, effective_delta_graph) except Exception as _se: - logger.warning(f"[run_ingest_job] Search indexing failed for {job_id}: {_se}") + logger.warning(f"[run_ingest_job] Failed to queue search indexing for {job_id}: {_se}") except Exception as _pe: logger.warning(f"[run_ingest_job] Provenance write failed for {job_id}: {_pe}") diff --git a/query_service/core/routers/search.py b/query_service/core/routers/search.py index 8777738..4525044 100644 --- a/query_service/core/routers/search.py +++ b/query_service/core/routers/search.py @@ -16,8 +16,10 @@ from fastapi import APIRouter, Depends, Query -from core.security import get_current_user_optional +from core.models.user import LoginUserIn +from core.security import get_current_user, get_current_user_optional, require_scopes from core import search as se +from core import indexing as ix router = APIRouter() logger = logging.getLogger(__name__) @@ -56,3 +58,33 @@ async def search( offset: Annotated[int, Query(ge=0)] = 0, ): return await se.search(q=q, caller=_agent(user), space_slug=space, limit=limit, offset=offset) + + +@router.post( + "/search/reindex", + dependencies=[Depends(require_scopes(["admin"]))], + summary="Backfill/rebuild the search index (admin) — runs in background", + description="Queues a background task that reindexes every user graph into the " + "Postgres locator index. Returns immediately with a task_id; poll " + "GET /search/index-tasks for progress.", +) +async def reindex(user: Annotated[LoginUserIn, Depends(get_current_user)]): + task_id = await ix.enqueue_backfill() + return {"status": "queued", "task_id": task_id, + "message": "Backfill reindex queued; runs in the background."} + + +@router.get( + "/search/index-tasks", + dependencies=[Depends(require_scopes(["read"]))], + summary="List background indexing tasks and their status", +) +async def index_tasks( + user: Annotated[LoginUserIn, Depends(get_current_user)], + task_id: Annotated[Optional[str], Query(description="Fetch a single task by id")] = None, + limit: Annotated[int, Query(ge=1, le=200)] = 50, +): + if task_id: + t = await ix.get_task(task_id) + return t or {"error": "task not found"} + return {"tasks": await ix.list_tasks(limit)} From f53066bceed6b39edf56b1f3afe68e000eaee5ba Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 14:59:43 -0400 Subject: [PATCH 12/70] Add per-worker ingest concurrency cap for resource safety 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. --- query_service/README.md | 9 +++++++ query_service/core/routers/insert.py | 37 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/query_service/README.md b/query_service/README.md index 06e36a4..d6224c9 100644 --- a/query_service/README.md +++ b/query_service/README.md @@ -43,6 +43,15 @@ owner-only. - `GET /insert/jobs`, `GET /insert/user/jobs/detail` — job listing / detail - `GET /insert/jobs/check-recoverable`, `POST /insert/jobs/recover` — crash recovery +Ingestion is **submit-and-forget**: the request saves data to disk, creates a +`pending` job, and returns immediately with a `job_id`; processing runs in the +background (concurrent file uploads + batched DB writes) and the client polls job +status. A per-worker **resource-safety cap** (`MAX_CONCURRENT_INGEST_JOBS`, default +3) bounds how many jobs process at once so a burst of submissions can't exhaust +memory / the DB pool / Oxigraph and crash the process — excess jobs simply wait as +`pending` until a slot frees (backpressure, no queue rework). Search indexing is +then queued separately in the background. + ### Provenance (PROV-O, JSON-LD) - `GET /provenance/job` — full bundle for one job - `GET /provenance/named-graph` — ingestion/activity history of a graph diff --git a/query_service/core/routers/insert.py b/query_service/core/routers/insert.py index 87c89a6..c23e5d0 100644 --- a/query_service/core/routers/insert.py +++ b/query_service/core/routers/insert.py @@ -107,6 +107,22 @@ def _agent_email(user): # set TRACK_TRIPLE_DELTAS=false to upload directly to the target instead. TRACK_TRIPLE_DELTAS = os.getenv("TRACK_TRIPLE_DELTAS", "true").strip().lower() in ("1", "true", "yes", "on") +# Resource-safety cap: maximum ingest jobs PROCESSING concurrently per worker +# process. Ingestion stays fire-and-forget (submit and forget) — this just bounds +# how many jobs run at once so a burst of submissions can't exhaust memory, the DB +# pool, or Oxigraph. Excess jobs return immediately and wait as 'pending' until a +# slot frees (backpressure without a queue). Effective global cap ≈ this × workers. +MAX_CONCURRENT_INGEST_JOBS = int(os.getenv("MAX_CONCURRENT_INGEST_JOBS", "3")) +_ingest_semaphore: Optional[asyncio.Semaphore] = None + + +def _get_ingest_semaphore() -> asyncio.Semaphore: + """Lazily create the per-worker ingest concurrency limiter (binds to the loop).""" + global _ingest_semaphore + if _ingest_semaphore is None: + _ingest_semaphore = asyncio.Semaphore(MAX_CONCURRENT_INGEST_JOBS) + return _ingest_semaphore + # Ensure job directory exists os.makedirs(JOB_BASE_DIR, exist_ok=True) @@ -471,6 +487,27 @@ async def run_ingest_job( max_concurrency: int, user_id: str, skip_provenance: bool = False, +): + """Background ingest runner, gated by a per-worker concurrency limiter so a + burst of concurrent submissions cannot exhaust resources and crash the process. + + While waiting for a slot the job stays 'pending' (accurate — it is queued). The + actual work runs in _run_ingest_job_body once a slot is acquired.""" + sem = _get_ingest_semaphore() + if sem.locked(): + logger.info( + f"[run_ingest_job] Job {job_id} is waiting for an ingest slot " + f"(max {MAX_CONCURRENT_INGEST_JOBS} concurrent/worker)" + ) + async with sem: + await _run_ingest_job_body(job_id, max_concurrency, user_id, skip_provenance) + + +async def _run_ingest_job_body( + job_id: str, + max_concurrency: int, + user_id: str, + skip_provenance: bool = False, ): """Background job runner for file ingestion. Processes files (attaches provenance) and uploads them to Oxigraph. From 52f3f3f2f97ad19b6627b8213a122a736b5dfdec Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 15:48:26 -0400 Subject: [PATCH 13/70] docs: add SPARQL/SQL verification queries for query_service 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. --- query_service/VERIFICATION_QUERIES.md | 208 ++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 query_service/VERIFICATION_QUERIES.md diff --git a/query_service/VERIFICATION_QUERIES.md b/query_service/VERIFICATION_QUERIES.md new file mode 100644 index 0000000..82edc7b --- /dev/null +++ b/query_service/VERIFICATION_QUERIES.md @@ -0,0 +1,208 @@ +# BrainKB — Verification Queries + +SPARQL (Oxigraph) and SQL (Postgres) queries to verify data written by the +BrainKB skill / MCP: ingested graphs, provenance, deltas, spaces, and the search +index. Replace `my-lab` / `JOB_ID` with your values. + +## Fixed graph IRIs + +| Purpose | Named graph | +|---|---| +| Graph registry (catalog) | `https://brainkb.org/metadata/named-graph` | +| Spaces manifest | `https://brainkb.org/metadata/spaces/` | +| Provenance | `https://brainkb.org/provenance/` | +| Per-job delta | `https://brainkb.org/provenance/delta/{job_id}` | +| Your data | e.g. `https://brainkb.org/graph/my-lab/` | + +## Prefixes + +```sparql +PREFIX prov: +PREFIX dcterms: +PREFIX schema: +PREFIX brainkb: +PREFIX rdfs: +``` + +--- + +## SPARQL (Oxigraph) + +### 1. All named graphs + triple counts (what exists) + +```sparql +SELECT ?g (COUNT(*) AS ?triples) WHERE { GRAPH ?g { ?s ?p ?o } } +GROUP BY ?g ORDER BY DESC(?triples) +``` + +### 2. Your ingested data + +```sparql +SELECT ?s ?p ?o WHERE { GRAPH { ?s ?p ?o } } LIMIT 200 +``` + +### 3. Registry — which graphs are registered + by whom + +```sparql +PREFIX prov: +PREFIX dcterms: +SELECT ?graph ?description ?registered_at ?registered_by WHERE { + GRAPH { + ?graph dcterms:description ?description ; prov:generatedAtTime ?registered_at . + OPTIONAL { ?graph prov:wasAttributedTo ?registered_by } + } +} +``` + +### 4. Spaces manifest — visibility, owner, contained graphs + +```sparql +PREFIX schema: +PREFIX brainkb: +SELECT ?space ?name ?visibility ?owner + (GROUP_CONCAT(DISTINCT STR(?graph); SEPARATOR=", ") AS ?graphs) WHERE { + GRAPH { + ?space a brainkb:Space ; schema:name ?name ; + brainkb:visibility ?visibility ; brainkb:owner ?owner . + OPTIONAL { ?space brainkb:containsGraph ?graph } + } +} GROUP BY ?space ?name ?visibility ?owner +``` + +Members (owner/editor/viewer): + +```sparql +PREFIX brainkb: +SELECT ?space ?role ?agent WHERE { + GRAPH { + VALUES ?role { brainkb:owner brainkb:editor brainkb:viewer } + ?space ?role ?agent . + } +} +``` + +### 5. Provenance — ingestion activities (who/when/status) + +```sparql +PREFIX prov: +PREFIX brainkb: +SELECT ?activity ?agent ?targetGraph ?status ?start ?end ?success ?fail WHERE { + GRAPH { + ?activity a brainkb:IngestionActivity ; + prov:wasAssociatedWith ?agent ; + brainkb:targetGraph ?targetGraph ; + brainkb:jobStatus ?status ; + prov:startedAtTime ?start . + OPTIONAL { ?activity prov:endedAtTime ?end } + OPTIONAL { ?activity brainkb:successCount ?success } + OPTIONAL { ?activity brainkb:failCount ?fail } + } +} ORDER BY DESC(?start) +``` + +### 6. Change history + deltas for a graph + +```sparql +PREFIX prov: +PREFIX brainkb: +SELECT ?delta ?deltaGraph ?added ?time WHERE { + GRAPH { + ?delta a brainkb:IngestionDelta ; + brainkb:targetGraph ; + brainkb:deltaGraph ?deltaGraph ; + brainkb:addedTripleCount ?added ; + prov:generatedAtTime ?time . + } +} ORDER BY DESC(?time) +``` + +The exact triples one job added: + +```sparql +SELECT ?s ?p ?o WHERE { GRAPH { ?s ?p ?o } } +``` + +### 7. Per-file results for a job + +```sparql +PREFIX prov: +PREFIX brainkb: +SELECT ?name ?status ?http ?size WHERE { + GRAPH { + ?file prov:wasGeneratedBy ; + brainkb:fileName ?name ; brainkb:uploadStatus ?status . + OPTIONAL { ?file brainkb:httpStatus ?http } + OPTIONAL { ?file brainkb:sizeBytes ?size } + } +} +``` + +### 8. Find a term across everything (what search matched) + +```sparql +PREFIX rdfs: +SELECT ?g ?s ?label WHERE { + GRAPH ?g { ?s rdfs:label ?label . FILTER(CONTAINS(LCASE(STR(?label)), "purkinje")) } +} +``` + +--- + +## How to run the SPARQL + +### Via the API (needs `admin` scope) + +```bash +Q='SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g {?s ?p ?o} } GROUP BY ?g' +curl -s "http://localhost:8010/api/query/sparql/?sparql_query=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$Q")" \ + -H "Authorization: Bearer $TOKEN" +``` + +Get `$TOKEN`: + +```bash +TOKEN=$(curl -s -X POST http://localhost:8010/api/token -H 'Content-Type: application/json' \ + -d '{"email":"you@example.com","password":"***"}' \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') +``` + +### Directly against Oxigraph (nginx proxy on :7878, HTTP Basic auth) + +```bash +curl -s "http://localhost:7878/query" \ + --data-urlencode 'query=SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g {?s ?p ?o} } GROUP BY ?g' \ + -u admin:"$OXIGRAPH_PASSWORD" -H 'Accept: application/sparql-results+json' +``` + +(`OXIGRAPH_USER` / `OXIGRAPH_PASSWORD` are in `.env`.) + +--- + +## SQL (Postgres — not in Oxigraph) + +Jobs, space ACL, the search locator index, and indexing tasks live in Postgres. + +```sql +-- spaces & membership & graph bindings +SELECT slug, name, visibility, owner FROM spaces; +SELECT space_id, member, role FROM space_members; +SELECT space_id, named_graph_iri FROM space_graphs; + +-- ingest jobs +SELECT job_id, status, total_files, success_count, fail_count, start_time, end_time +FROM jobs ORDER BY start_time DESC LIMIT 10; + +-- search locator index (subject text per graph/space) +SELECT named_graph_iri, subject, left(text, 60) AS text FROM graph_search_index; + +-- background indexing tasks +SELECT task_id, kind, target, status, subjects_indexed, graphs_done, graphs_total +FROM index_tasks ORDER BY created_at DESC LIMIT 10; +``` + +Run against the Docker Postgres: + +```bash +docker exec -e PGPASSWORD="$JWT_POSTGRES_DATABASE_PASSWORD" brainkb-postgres \ + psql -U postgres -d brainkb -c "SELECT slug, visibility, owner FROM spaces;" +``` From 587ff7e45984632bdb48a30e7a5949c4872e811e Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 18:56:31 -0400 Subject: [PATCH 14/70] =?UTF-8?q?Add=20role-based=20authorization=20(RBAC)?= =?UTF-8?q?=20=E2=80=94=20roles=20govern=20actions,=20JWT=20is=20API=20acc?= =?UTF-8?q?ess=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- query_service/RBAC_MODEL.md | 95 ++++++++++++++ query_service/core/main.py | 26 ++++ query_service/core/rbac.py | 177 +++++++++++++++++++++++++++ query_service/core/routers/insert.py | 20 +++ query_service/core/routers/query.py | 11 +- query_service/core/routers/spaces.py | 113 +++++++++++++++-- query_service/core/spaces.py | 12 +- 7 files changed, 436 insertions(+), 18 deletions(-) create mode 100644 query_service/RBAC_MODEL.md create mode 100644 query_service/core/rbac.py diff --git a/query_service/RBAC_MODEL.md b/query_service/RBAC_MODEL.md new file mode 100644 index 0000000..ea993fc --- /dev/null +++ b/query_service/RBAC_MODEL.md @@ -0,0 +1,95 @@ +# BrainKB Authorization (RBAC) + +Status: implemented on branch `improve-ingestion-query-service`. + +**JWT = authentication / API access. Roles = authorization (what you may do).** +The query_service authenticates via JWT (scopes gate *API access* only) and then +authorizes actions from the user's **roles**, mapped to **capabilities**, plus +per-resource **space membership**. + +## Where roles come from + +Roles live in the Django-owned RBAC tables and join to a JWT user **by email**: + +``` +Web_jwtuser.email == Web_user_profile.email +Web_user_profile.id -> Web_user_role (is_active, not expired) -> role name +``` + +query_service only **reads** roles (see `core/rbac.py`). It never assigns roles — +creating/removing Admins is **role assignment**, owned by the usermanagement/Django +side. This keeps the two systems consistent and means the KG API cannot be used to +escalate privileges. + +## Role hierarchy + +``` +SuperAdmin >= Admin > write roles > read roles > (no role) +``` + +- **SuperAdmin** — ultimate authority, **bootstrapped at deployment**. Can do + everything Admin can; the SuperAdmin-vs-Admin difference (managing admins) is + role assignment, handled outside query_service. +- **Admin** — all KG capabilities, incl. granting delegatable capabilities. +- **write roles** — Curator, Lab Member, Submitter, Annotator, Mapper, + Knowledge Contributor: create their own private spaces + ingest. +- **read roles** — Reviewer, Validator, Moderator, etc.: read member content. +- **no role** — a JWT user not linked to a profile/role gets **public content + only** (read), nothing else. + +## Capabilities + +| Capability | Granted by | Gates | +|---|---|---| +| `create_private_space` | write roles, admins | create an individual/private space | +| `create_team_space` | admins (or delegated) | create a team space | +| `manage_team_space` | admins (or delegated) | manage a team space's members/visibility/graphs | +| `ingest` | write roles, admins | ingest into a graph (also needs space owner/editor) | +| `recover` | write roles, admins | recover stuck/errored jobs | +| `read_private` | any role | read non-public content you're a member of | +| `sparql_admin` | admins only | run arbitrary SPARQL | +| `grant` | admins only | grant/revoke delegatable capabilities | + +**Delegated upgrades:** Admin/SuperAdmin can grant a specific user extra +capabilities via `POST /api/admin/capabilities/grant` — e.g. let a Curator or Lab +Member create/manage **team** spaces. Only **delegatable** caps may be granted +(everything except `grant` and `sparql_admin`), so the grant endpoint can't turn a +non-admin into an admin. + +## Enforcement points (layered) + +For each action: **JWT scope** (API access) → **capability** (role) → **space +membership** (resource). + +| Action | Capability required | + resource check | +|---|---|---| +| Create individual/private space | `create_private_space` | — | +| Create team space | `create_team_space` | — | +| Manage space (members/visibility/graphs) | owner, or `manage_team_space`/admin | space ownership | +| Ingest (`/insert/*`) | `ingest` | space owner/editor of the target graph | +| Recover jobs | `recover` | own jobs | +| Arbitrary SPARQL (`/query/sparql/`) | `sparql_admin` | — | +| Read space / data | `read_private` for private; public = anyone (anon) | membership for private | + +## Space types + +- **individual** — a personal/private workspace; any write-capable user can create + one (`create_private_space`). +- **team** — a shared workspace; only Admin/SuperAdmin (or a user granted + `create_team_space`) can create/manage it. + +## Admin capability endpoints + +- `GET /api/admin/capabilities?member=` — a user's roles, effective + capabilities, and grants (admin only). +- `POST /api/admin/capabilities/grant` `{member, capability}` — delegate a cap. +- `POST /api/admin/capabilities/revoke` `{member, capability}`. + +## Notes / next + +- Coarse per-space roles today are owner/editor/viewer plus the capability gates + above. **Fine-grained in-space rules** (e.g. an action inside a space limited to + Admins, or to specific Lab Members) are the next iteration — the primitives + (space membership + roles + capabilities) are in place to build on. +- JWT scopes (`read`/`write`/`admin`) remain as an API-access layer for defense in + depth; the authoritative "who can do what" is the role/capability layer above. diff --git a/query_service/core/main.py b/query_service/core/main.py index 90eaed4..ffabe5e 100644 --- a/query_service/core/main.py +++ b/query_service/core/main.py @@ -202,8 +202,34 @@ async def startup_event(): await conn.execute("CREATE INDEX IF NOT EXISTS idx_space_graphs_space ON space_graphs(space_id)") except Exception: pass + # Space type: 'individual' (personal) or 'team' (created by + # Admin/SuperAdmin or a user granted create_team_space). + try: + await conn.execute("ALTER TABLE spaces ADD COLUMN IF NOT EXISTS space_type TEXT NOT NULL DEFAULT 'individual'") + except Exception: + pass logger.info("Spaces tables initialized") + # RBAC: capabilities granted directly to a user (delegated + # upgrades by Admin/SuperAdmin), on top of role-derived caps. + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS user_capability_grants ( + id SERIAL PRIMARY KEY, + member TEXT NOT NULL, + capability TEXT NOT NULL, + granted_by TEXT, + created_at DOUBLE PRECISION, + UNIQUE (member, capability) + ) + """ + ) + try: + await conn.execute("CREATE INDEX IF NOT EXISTS idx_capability_grants_member ON user_capability_grants(member)") + except Exception: + pass + logger.info("RBAC capability-grants table initialized") + # Search locator index (hybrid search): Postgres full-text index that # locates subjects/subgraphs (carrying graph + workspace/space), then the # actual triples are fetched from Oxigraph. Access is filtered by space diff --git a/query_service/core/rbac.py b/query_service/core/rbac.py new file mode 100644 index 0000000..91480a3 --- /dev/null +++ b/query_service/core/rbac.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# DISCLAIMER: This software is provided "as is" without any warranty, +# express or implied, including but not limited to the warranties of +# merchantability, fitness for a particular purpose, and non-infringement. +# ----------------------------------------------------------------------------- + +# @Author : Tek Raj Chhetri +# @Email : tekraj@mit.edu +# @File : rbac.py + +""" +Role-based access control for BrainKB. + +Separation of concerns: + * JWT -> authentication + API access (who you are; can you call the API). + * Roles -> authorization (what you may DO): create spaces, ingest, admin, etc. + +Roles live in the Django-owned RBAC tables and are joined to a JWT user by email: + Web_jwtuser.email == Web_user_profile.email + Web_user_profile.id -> Web_user_role (active, non-expired) -> role name + +Roles map to capabilities via the policy below. Admins/SuperAdmins can also grant +extra capabilities to individual users (delegated upgrades) via +`user_capability_grants` — e.g. letting a Curator create/manage team spaces. + +A JWT user with NO active role is treated as having NO capabilities: read-only +access to PUBLIC content, nothing else. +""" + +from __future__ import annotations + +import logging +import time +from typing import Optional, Set + +from core.database import get_db_connection + +logger = logging.getLogger(__name__) + +# ---- capabilities ---------------------------------------------------------- +CREATE_PRIVATE_SPACE = "create_private_space" # make your own individual/private space +CREATE_TEAM_SPACE = "create_team_space" # create a team space (Admin/SuperAdmin or granted) +MANAGE_TEAM_SPACE = "manage_team_space" # manage members/visibility/graphs of team spaces +INGEST = "ingest" # ingest data (still needs per-space write membership) +RECOVER = "recover" # recover stuck/errored jobs +SPARQL_ADMIN = "sparql_admin" # run arbitrary SPARQL +GRANT = "grant" # grant/revoke capabilities to other users +READ_PRIVATE = "read_private" # read non-public content you're a member of + +ALL_CAPS = { + CREATE_PRIVATE_SPACE, CREATE_TEAM_SPACE, MANAGE_TEAM_SPACE, INGEST, + RECOVER, SPARQL_ADMIN, GRANT, READ_PRIVATE, +} + +# Capabilities an admin may DELEGATE to another user via the grant endpoint. +# Admin-intrinsic caps (grant, sparql_admin) are intentionally NOT delegatable: +# they come only from an Admin/SuperAdmin role, so the grant endpoint cannot be +# used to escalate a non-admin into an admin. +GRANTABLE_CAPS = { + CREATE_PRIVATE_SPACE, CREATE_TEAM_SPACE, MANAGE_TEAM_SPACE, INGEST, + RECOVER, READ_PRIVATE, +} + +# ---- role hierarchy / policy ---------------------------------------------- +# Hierarchy (highest first): SuperAdmin >= Admin > write roles > read roles > none. +# SuperAdmin is the ultimate authority and is bootstrapped at deployment. Both +# SuperAdmin and Admin get every KG capability here; the *difference* between them +# (e.g. SuperAdmin creating/removing Admins) is ROLE ASSIGNMENT, which is owned by +# the usermanagement/Django side — query_service never assigns roles, it only +# reads them and grants delegatable KG capabilities. This keeps the two systems +# consistent and prevents privilege escalation through the KG API. +SUPERADMIN_ROLE = "SuperAdmin" +ADMIN_ROLES = {"Admin", "SuperAdmin"} +# Roles that confer "write" (may create their own private space + ingest). +WRITE_ROLES = { + "Admin", "SuperAdmin", "Curator", "Lab Member", "Submitter", + "Annotator", "Mapper", "Knowledge Contributor", +} + + +def _caps_for_role(role: str) -> Set[str]: + if role in ADMIN_ROLES: + return set(ALL_CAPS) # admins can do everything + if role in WRITE_ROLES: + return {CREATE_PRIVATE_SPACE, INGEST, RECOVER, READ_PRIVATE} + # any other active role (Reviewer, Validator, Moderator, ...) = read member content + return {READ_PRIVATE} + + +# --------------------------------------------------------------------------- +# lookups +# --------------------------------------------------------------------------- + +async def active_roles(email: Optional[str]) -> Set[str]: + """Active, non-expired role names for the JWT user's email (via profile).""" + if not email: + return set() + async with get_db_connection() as conn: + rows = await conn.fetch( + """ + SELECT r.role + FROM "Web_user_role" r + JOIN "Web_user_profile" p ON p.id = r.profile_id + WHERE p.email = $1 + AND r.is_active IS TRUE + AND (r.expires_at IS NULL OR r.expires_at > now()) + """, + email, + ) + return {row["role"] for row in rows} + + +async def granted_capabilities(email: Optional[str]) -> Set[str]: + """Extra capabilities granted directly to this user (delegated upgrades).""" + if not email: + return set() + async with get_db_connection() as conn: + rows = await conn.fetch( + "SELECT capability FROM user_capability_grants WHERE member = $1", + email, + ) + return {row["capability"] for row in rows if row["capability"] in ALL_CAPS} + + +async def capabilities(email: Optional[str]) -> Set[str]: + """Effective capabilities = role-derived caps ∪ delegated grants.""" + caps: Set[str] = set() + roles = await active_roles(email) + for r in roles: + caps |= _caps_for_role(r) + if roles: # only users with at least one role can be granted extras + caps |= await granted_capabilities(email) + return caps + + +async def has_capability(email: Optional[str], cap: str) -> bool: + return cap in await capabilities(email) + + +async def is_admin(email: Optional[str]) -> bool: + return bool(await active_roles(email) & ADMIN_ROLES) + + +# --------------------------------------------------------------------------- +# delegated grants (admin only — enforced at the endpoint) +# --------------------------------------------------------------------------- + +async def grant_capability(member: str, capability: str, granted_by: str) -> None: + if capability not in GRANTABLE_CAPS: + raise ValueError(f"capability is not delegatable: {capability}") + async with get_db_connection() as conn: + await conn.execute( + """ + INSERT INTO user_capability_grants (member, capability, granted_by, created_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (member, capability) DO NOTHING + """, + member, capability, granted_by, time.time(), + ) + + +async def revoke_capability(member: str, capability: str) -> None: + async with get_db_connection() as conn: + await conn.execute( + "DELETE FROM user_capability_grants WHERE member = $1 AND capability = $2", + member, capability, + ) + + +async def list_grants(member: str) -> list: + async with get_db_connection() as conn: + rows = await conn.fetch( + "SELECT capability, granted_by, created_at FROM user_capability_grants WHERE member = $1", + member, + ) + return [dict(r) for r in rows] diff --git a/query_service/core/routers/insert.py b/query_service/core/routers/insert.py index c23e5d0..08b69fc 100644 --- a/query_service/core/routers/insert.py +++ b/query_service/core/routers/insert.py @@ -49,6 +49,7 @@ ) from core.configuration import load_environment from core.spaces import authorize as authorize_space_access +from core import rbac from core.provenance import ( build_ingestion_provenance, build_recovery_provenance, @@ -1179,6 +1180,13 @@ async def insert_knowledge_graph_triples( status_code=400, ) + # Role-based authorization: ingesting requires a write-capable role. JWT + # scope is only API access — it does not by itself grant permission to ingest. + if not await rbac.has_capability(_agent_email(user), rbac.INGEST): + return JSONResponse( + {"error": "Not authorized to ingest: a write-capable role is required."}, + status_code=403, + ) # If the graph belongs to a space, enforce space write-authorization # (owner/editor). Unmapped legacy graphs fall through (scope check applies). _graph_key = named_graph_iri if named_graph_iri.endswith("/") else named_graph_iri + "/" @@ -1301,6 +1309,13 @@ async def insert_file_knowledge_graph_triples( status_code=400, ) + # Role-based authorization: ingesting requires a write-capable role. JWT + # scope is only API access — it does not by itself grant permission to ingest. + if not await rbac.has_capability(_agent_email(user), rbac.INGEST): + return JSONResponse( + {"error": "Not authorized to ingest: a write-capable role is required."}, + status_code=403, + ) # If the graph belongs to a space, enforce space write-authorization # (owner/editor). Unmapped legacy graphs fall through (scope check applies). _graph_key = named_graph_iri if named_graph_iri.endswith("/") else named_graph_iri + "/" @@ -1715,6 +1730,11 @@ async def recover_stuck_jobs_endpoint( Returns the number of jobs recovered and details about recovered jobs. """ verify_user_access(user_id, user) + if not await rbac.has_capability(_agent_email(user), rbac.RECOVER): + return JSONResponse( + {"error": "Not authorized to recover jobs: a write-capable role is required."}, + status_code=403, + ) try: # For single job recovery, MUST check recoverability first (includes process check) if job_id: diff --git a/query_service/core/routers/query.py b/query_service/core/routers/query.py index 0cb5e96..907d0f7 100644 --- a/query_service/core/routers/query.py +++ b/query_service/core/routers/query.py @@ -16,13 +16,14 @@ # @File : query.py # @Software: PyCharm -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException from core.graph_database_connection_manager import fetch_data_gdb_async, check_named_graph_exists import logging from typing import Annotated from core.models.user import LoginUserIn from core.security import get_current_user, require_scopes from core.shared import taxonomy_postprocessing +from core import rbac from fastapi import Depends from pydantic import BaseModel, root_validator from typing import List @@ -110,6 +111,14 @@ async def get_named_graphs(user: Annotated[LoginUserIn, Depends(get_current_user async def sparql_query( user: Annotated[LoginUserIn, Depends(get_current_user)], sparql_query: str ): + # Authorization is role-based: arbitrary SPARQL requires the sparql_admin + # capability (Admin/SuperAdmin). The JWT scope only gates API access. + try: + email = user["email"] + except (KeyError, TypeError, IndexError): + email = None + if not await rbac.has_capability(email, rbac.SPARQL_ADMIN): + raise HTTPException(status_code=403, detail="Arbitrary SPARQL requires an Admin/SuperAdmin role.") response = await fetch_data_gdb_async(sparql_query) return response diff --git a/query_service/core/routers/spaces.py b/query_service/core/routers/spaces.py index e3ce29e..062fd6a 100644 --- a/query_service/core/routers/spaces.py +++ b/query_service/core/routers/spaces.py @@ -25,6 +25,7 @@ from core.provenance import agent_ref, query_provenance_jsonld from core.graph_database_connection_manager import insert_data_gdb_async, check_named_graph_exists from core import spaces as sp +from core import rbac router = APIRouter() logger = logging.getLogger(__name__) @@ -40,11 +41,24 @@ def _agent(user) -> str: return "unknown" +async def _can_manage(space: dict, email: str) -> bool: + """Who may manage a space (members/visibility/graphs): the space owner, an + Admin/SuperAdmin, or — for team spaces — a holder of manage_team_space.""" + if await sp.member_role(space["space_id"], email) == "owner": + return True + if await rbac.is_admin(email): + return True + if space.get("space_type") == "team" and await rbac.has_capability(email, rbac.MANAGE_TEAM_SPACE): + return True + return False + + class SpaceCreate(BaseModel): slug: str name: str description: Optional[str] = None visibility: str = "private" + space_type: str = "individual" # 'individual' | 'team' class MemberIn(BaseModel): @@ -72,9 +86,25 @@ async def create_space(body: SpaceCreate, user: Annotated[LoginUserIn, Depends(g raise HTTPException(400, "slug must be lowercase alphanumeric/hyphen, 3-64 chars") if body.visibility not in ("private", "public"): raise HTTPException(400, "visibility must be 'private' or 'public'") + if body.space_type not in ("individual", "team"): + raise HTTPException(400, "space_type must be 'individual' or 'team'") + + # Role-based authorization: team spaces require create_team_space (Admin/ + # SuperAdmin, or a user an admin has granted it); individual/private spaces + # require create_private_space (any write-capable role). + email = _agent(user) + if body.space_type == "team": + if not await rbac.has_capability(email, rbac.CREATE_TEAM_SPACE): + raise HTTPException(403, "not authorized to create a team space (needs Admin/SuperAdmin " + "or a granted create_team_space capability)") + else: + if not await rbac.has_capability(email, rbac.CREATE_PRIVATE_SPACE): + raise HTTPException(403, "not authorized to create a space (needs a write-capable role)") + if await sp.get_space(body.slug): raise HTTPException(409, f"space '{body.slug}' already exists") - space = await sp.create_space(body.slug, body.name, body.description, _agent(user), body.visibility) + space = await sp.create_space(body.slug, body.name, body.description, email, + body.visibility, body.space_type) await sp.mirror_space_to_rdf(space) return space @@ -99,9 +129,11 @@ async def get_space(slug: str, user: Annotated[Optional[object], Depends(get_cur return JSONResponse({"error": "space not found"}, status_code=404) member = _agent(user) if user else None if space["visibility"] != "public": + # Private: needs membership AND a role that grants read_private + # (a JWT user with no role gets public content only). role = await sp.member_role(space["space_id"], member) - if role is None: - raise HTTPException(403, "private space — membership required") + if role is None or not await rbac.has_capability(member, rbac.READ_PRIVATE): + raise HTTPException(403, "private space — membership and a role are required") return space @@ -114,8 +146,8 @@ async def set_visibility(slug: str, body: VisibilityIn, user: Annotated[LoginUse space = await sp.get_space(slug) if not space: return JSONResponse({"error": "space not found"}, status_code=404) - if await sp.member_role(space["space_id"], _agent(user)) != "owner": - raise HTTPException(403, "only the space owner can change visibility") + if not await _can_manage(space, _agent(user)): + raise HTTPException(403, "not authorized to change this space's visibility") if body.visibility not in ("private", "public"): raise HTTPException(400, "visibility must be 'private' or 'public'") await sp.set_visibility(slug, body.visibility) @@ -131,8 +163,8 @@ async def add_member(slug: str, body: MemberIn, user: Annotated[LoginUserIn, Dep space = await sp.get_space(slug) if not space: return JSONResponse({"error": "space not found"}, status_code=404) - if await sp.member_role(space["space_id"], _agent(user)) != "owner": - raise HTTPException(403, "only the space owner can manage members") + if not await _can_manage(space, _agent(user)): + raise HTTPException(403, "not authorized to manage members of this space") if body.role not in sp.ROLES: raise HTTPException(400, f"role must be one of {sp.ROLES}") await sp.add_member(space["space_id"], body.member, body.role) @@ -148,8 +180,8 @@ async def remove_member(slug: str, member: str, user: Annotated[LoginUserIn, Dep space = await sp.get_space(slug) if not space: return JSONResponse({"error": "space not found"}, status_code=404) - if await sp.member_role(space["space_id"], _agent(user)) != "owner": - raise HTTPException(403, "only the space owner can manage members") + if not await _can_manage(space, _agent(user)): + raise HTTPException(403, "not authorized to manage members of this space") await sp.remove_member(space["space_id"], member) space = await sp.get_space(slug) await sp.mirror_space_to_rdf(space) @@ -166,8 +198,9 @@ async def add_graph(slug: str, body: SpaceGraphIn, user: Annotated[LoginUserIn, space = await sp.get_space(slug) if not space: return JSONResponse({"error": "space not found"}, status_code=404) - if await sp.member_role(space["space_id"], _agent(user)) not in sp.WRITE_ROLES: - raise HTTPException(403, "only the space owner/editors can add graphs") + _role = await sp.member_role(space["space_id"], _agent(user)) + if not (await _can_manage(space, _agent(user)) or _role in sp.WRITE_ROLES): + raise HTTPException(403, "only the space owner/editors (or a space manager) can add graphs") named_graph_url = str(body.named_graph_url) if not named_graph_url.endswith("/"): @@ -197,11 +230,65 @@ async def read_space_data(slug: str, user: Annotated[Optional[object], Depends(g return JSONResponse({"error": "space not found"}, status_code=404) member = _agent(user) if user else None if space["visibility"] != "public": - if await sp.member_role(space["space_id"], member) is None: - raise HTTPException(403, "private space — membership required") + # Private: membership AND a role that grants read_private. + if await sp.member_role(space["space_id"], member) is None \ + or not await rbac.has_capability(member, rbac.READ_PRIVATE): + raise HTTPException(403, "private space — membership and a role are required") if not space["graphs"]: return Response(content='{"@graph": []}', media_type="application/ld+json") jsonld = await query_provenance_jsonld(await sp.construct_space_graphs(space)) if jsonld is None: return JSONResponse({"error": "failed to read space data"}, status_code=502) return Response(content=jsonld, media_type="application/ld+json") + + +# --------------------------------------------------------------------------- # +# Admin: delegated capability grants (Admin/SuperAdmin only) +# --------------------------------------------------------------------------- # + +class GrantIn(BaseModel): + member: str + capability: str + + +@router.get("/admin/capabilities", + dependencies=[Depends(require_scopes(["admin"]))], + summary="List a user's effective capabilities (admin only)") +async def get_capabilities( + user: Annotated[LoginUserIn, Depends(get_current_user)], + member: Annotated[str, Query(..., description="User email to inspect")], +): + if not await rbac.is_admin(_agent(user)): + raise HTTPException(403, "Admin/SuperAdmin role required") + return { + "member": member, + "roles": sorted(await rbac.active_roles(member)), + "capabilities": sorted(await rbac.capabilities(member)), + "grants": await rbac.list_grants(member), + } + + +@router.post("/admin/capabilities/grant", + dependencies=[Depends(require_scopes(["admin"]))], + summary="Grant a capability to a user (admin only)", + description="Delegated upgrade: e.g. grant 'create_team_space' or " + "'manage_team_space' to a Curator/Lab Member so they can create " + "and manage team spaces. Requires the caller to hold an " + "Admin/SuperAdmin role.") +async def grant_capability(body: GrantIn, user: Annotated[LoginUserIn, Depends(get_current_user)]): + if not await rbac.is_admin(_agent(user)): + raise HTTPException(403, "Admin/SuperAdmin role required to grant capabilities") + if body.capability not in rbac.GRANTABLE_CAPS: + raise HTTPException(400, f"capability is not delegatable; valid: {sorted(rbac.GRANTABLE_CAPS)}") + await rbac.grant_capability(body.member, body.capability, _agent(user)) + return {"status": "granted", "member": body.member, "capability": body.capability} + + +@router.post("/admin/capabilities/revoke", + dependencies=[Depends(require_scopes(["admin"]))], + summary="Revoke a granted capability (admin only)") +async def revoke_capability(body: GrantIn, user: Annotated[LoginUserIn, Depends(get_current_user)]): + if not await rbac.is_admin(_agent(user)): + raise HTTPException(403, "Admin/SuperAdmin role required to revoke capabilities") + await rbac.revoke_capability(body.member, body.capability) + return {"status": "revoked", "member": body.member, "capability": body.capability} diff --git a/query_service/core/spaces.py b/query_service/core/spaces.py index a135b84..8f94094 100644 --- a/query_service/core/spaces.py +++ b/query_service/core/spaces.py @@ -62,20 +62,22 @@ def space_iri(slug: str) -> str: # --------------------------------------------------------------------------- async def create_space(slug: str, name: str, description: Optional[str], owner: str, - visibility: str = "private") -> Dict[str, Any]: + visibility: str = "private", space_type: str = "individual") -> Dict[str, Any]: """Create a space and register the owner as a member with role 'owner'.""" if visibility not in ("private", "public"): raise ValueError("visibility must be 'private' or 'public'") + if space_type not in ("individual", "team"): + raise ValueError("space_type must be 'individual' or 'team'") space_id = uuid.uuid4().hex now = time.time() async with get_db_connection() as conn: async with conn.transaction(): await conn.execute( """ - INSERT INTO spaces (space_id, slug, name, description, owner, visibility, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + INSERT INTO spaces (space_id, slug, name, description, owner, visibility, space_type, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) """, - space_id, slug, name, description, owner, visibility, now, + space_id, slug, name, description, owner, visibility, space_type, now, ) await conn.execute( """ @@ -107,6 +109,7 @@ async def get_space(slug: str) -> Optional[Dict[str, Any]]: "description": row["description"], "owner": row["owner"], "visibility": row["visibility"], + "space_type": row.get("space_type", "individual"), "iri": space_iri(row["slug"]), "members": [{"member": m["member"], "role": m["role"]} for m in members], "graphs": [g["named_graph_iri"] for g in graphs], @@ -131,6 +134,7 @@ async def get_space_for_graph(named_graph_iri: str) -> Optional[Dict[str, Any]]: return { "space_id": row["space_id"], "slug": row["slug"], "name": row["name"], "owner": row["owner"], "visibility": row["visibility"], + "space_type": row.get("space_type", "individual"), } From b6e0605861b7407d5d24231d08ced74f07561fe6 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 19:04:45 -0400 Subject: [PATCH 15/70] Add fine-grained per-space access rules (read/write/manage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- query_service/RBAC_MODEL.md | 37 ++++++++++-- query_service/core/main.py | 23 ++++++++ query_service/core/routers/insert.py | 17 ++++++ query_service/core/routers/spaces.py | 65 ++++++++++++++++++++- query_service/core/spaces.py | 86 ++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 7 deletions(-) diff --git a/query_service/RBAC_MODEL.md b/query_service/RBAC_MODEL.md index ea993fc..d9b1be7 100644 --- a/query_service/RBAC_MODEL.md +++ b/query_service/RBAC_MODEL.md @@ -85,11 +85,36 @@ membership** (resource). - `POST /api/admin/capabilities/grant` `{member, capability}` — delegate a cap. - `POST /api/admin/capabilities/revoke` `{member, capability}`. -## Notes / next +## Fine-grained per-space access rules + +On top of capabilities + owner/editor/viewer membership, a space can carry +**access rules** that restrict a specific action to specific subjects — e.g. "in +this space, only Admins may write", "only these Lab Members may read", "let this +member manage". + +- Rule = `(action, subject_type, subject_value)`: + - `action` ∈ `read` | `write` | `manage` + - `subject_type` ∈ `global_role` (e.g. `Admin`, `Lab Member`) | `member` (an + email) | `space_role` (`viewer`|`editor`|`owner`, matched as ">=") +- Semantics per action: + - **Owner** of the space and **global Admin/SuperAdmin** always pass (no lockout). + - `read`/`write`: if **no** rules exist for the action, the normal + capability/membership/visibility gates apply; if rules **do** exist, the caller + must also **match at least one**. + - `manage`: owner/admin/`manage_team_space` by default; a `manage` rule can + additionally **grant** management to a matched subject. +- Rules layer *on top of* the global gates — e.g. ingest still needs the `ingest` + capability and space owner/editor membership; a write rule then narrows *which* + of those writers may actually ingest here. + +Endpoints (space manager only, except GET which is member/manager): + +- `GET /api/spaces/{slug}/access-rules` +- `POST /api/spaces/{slug}/access-rules` `{action, subject_type, subject_value}` +- `DELETE /api/spaces/{slug}/access-rules/{rule_id}` + +## Notes -- Coarse per-space roles today are owner/editor/viewer plus the capability gates - above. **Fine-grained in-space rules** (e.g. an action inside a space limited to - Admins, or to specific Lab Members) are the next iteration — the primitives - (space membership + roles + capabilities) are in place to build on. - JWT scopes (`read`/`write`/`admin`) remain as an API-access layer for defense in - depth; the authoritative "who can do what" is the role/capability layer above. + depth; the authoritative "who can do what" is the role/capability + per-space + rule layer above. diff --git a/query_service/core/main.py b/query_service/core/main.py index ffabe5e..1dc8896 100644 --- a/query_service/core/main.py +++ b/query_service/core/main.py @@ -230,6 +230,29 @@ async def startup_event(): pass logger.info("RBAC capability-grants table initialized") + # Fine-grained per-space access rules: restrict a space action + # (read/write/manage) to a global role, a space role, or specific + # members. Owner + global Admin/SuperAdmin always bypass. + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS space_access_rules ( + id SERIAL PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(space_id) ON DELETE CASCADE, + action TEXT NOT NULL, + subject_type TEXT NOT NULL, + subject_value TEXT NOT NULL, + created_by TEXT, + created_at DOUBLE PRECISION, + UNIQUE (space_id, action, subject_type, subject_value) + ) + """ + ) + try: + await conn.execute("CREATE INDEX IF NOT EXISTS idx_space_access_rules_space ON space_access_rules(space_id, action)") + except Exception: + pass + logger.info("Space access-rules table initialized") + # Search locator index (hybrid search): Postgres full-text index that # locates subjects/subgraphs (carrying graph + workspace/space), then the # actual triples are fetched from Oxigraph. Access is filtered by space diff --git a/query_service/core/routers/insert.py b/query_service/core/routers/insert.py index 08b69fc..de27068 100644 --- a/query_service/core/routers/insert.py +++ b/query_service/core/routers/insert.py @@ -49,6 +49,7 @@ ) from core.configuration import load_environment from core.spaces import authorize as authorize_space_access +from core import spaces as _spaces from core import rbac from core.provenance import ( build_ingestion_provenance, @@ -1196,6 +1197,14 @@ async def insert_knowledge_graph_triples( {"error": f"Not authorized to ingest into this graph: {_reason}", "named_graph_iri": named_graph_iri}, status_code=403, ) + # Fine-grained per-space write rules (if any) further restrict who can ingest. + _space_for_graph = await _spaces.get_space_for_graph(_graph_key) + if _space_for_graph and not await _spaces.space_action_permitted(_space_for_graph, "write", _agent_email(user)): + return JSONResponse( + {"error": "Not authorized to ingest into this graph: restricted by a space access rule.", + "named_graph_iri": named_graph_iri}, + status_code=403, + ) job_id = uuid.uuid4().hex @@ -1325,6 +1334,14 @@ async def insert_file_knowledge_graph_triples( {"error": f"Not authorized to ingest into this graph: {_reason}", "named_graph_iri": named_graph_iri}, status_code=403, ) + # Fine-grained per-space write rules (if any) further restrict who can ingest. + _space_for_graph = await _spaces.get_space_for_graph(_graph_key) + if _space_for_graph and not await _spaces.space_action_permitted(_space_for_graph, "write", _agent_email(user)): + return JSONResponse( + {"error": "Not authorized to ingest into this graph: restricted by a space access rule.", + "named_graph_iri": named_graph_iri}, + status_code=403, + ) job_id = uuid.uuid4().hex # generate for job tracking diff --git a/query_service/core/routers/spaces.py b/query_service/core/routers/spaces.py index 062fd6a..3c54740 100644 --- a/query_service/core/routers/spaces.py +++ b/query_service/core/routers/spaces.py @@ -43,13 +43,16 @@ def _agent(user) -> str: async def _can_manage(space: dict, email: str) -> bool: """Who may manage a space (members/visibility/graphs): the space owner, an - Admin/SuperAdmin, or — for team spaces — a holder of manage_team_space.""" + Admin/SuperAdmin, a holder of manage_team_space (team spaces), or someone + matched by a per-space 'manage' access rule.""" if await sp.member_role(space["space_id"], email) == "owner": return True if await rbac.is_admin(email): return True if space.get("space_type") == "team" and await rbac.has_capability(email, rbac.MANAGE_TEAM_SPACE): return True + if await sp.matches_access_rule(space["space_id"], "manage", email): + return True return False @@ -134,6 +137,9 @@ async def get_space(slug: str, user: Annotated[Optional[object], Depends(get_cur role = await sp.member_role(space["space_id"], member) if role is None or not await rbac.has_capability(member, rbac.READ_PRIVATE): raise HTTPException(403, "private space — membership and a role are required") + # Fine-grained per-space read rules (if any) further restrict who can read. + if not await sp.space_action_permitted(space, "read", member): + raise HTTPException(403, "restricted by a space access rule (read)") return space @@ -234,6 +240,9 @@ async def read_space_data(slug: str, user: Annotated[Optional[object], Depends(g if await sp.member_role(space["space_id"], member) is None \ or not await rbac.has_capability(member, rbac.READ_PRIVATE): raise HTTPException(403, "private space — membership and a role are required") + # Fine-grained per-space read rules (if any) further restrict who can read. + if not await sp.space_action_permitted(space, "read", member): + raise HTTPException(403, "restricted by a space access rule (read)") if not space["graphs"]: return Response(content='{"@graph": []}', media_type="application/ld+json") jsonld = await query_provenance_jsonld(await sp.construct_space_graphs(space)) @@ -251,6 +260,60 @@ class GrantIn(BaseModel): capability: str +class AccessRuleIn(BaseModel): + action: str # read | write | manage + subject_type: str # global_role | member | space_role + subject_value: str # role name / email / viewer|editor|owner + + +@router.get("/spaces/{slug}/access-rules", + summary="List a space's fine-grained access rules") +async def list_access_rules(slug: str, user: Annotated[LoginUserIn, Depends(get_current_user)]): + space = await sp.get_space(slug) + if not space: + return JSONResponse({"error": "space not found"}, status_code=404) + email = _agent(user) + # visible to managers or members of the space + if not await _can_manage(space, email) and await sp.member_role(space["space_id"], email) is None: + raise HTTPException(403, "must be a member or manager of the space") + return {"slug": slug, "rules": await sp.list_access_rules(space["space_id"])} + + +@router.post("/spaces/{slug}/access-rules", + dependencies=[Depends(require_scopes(["write"]))], + summary="Add a fine-grained access rule (space manager only)", + description="Restrict a space action to a subject. action: read|write|manage. " + "subject_type: global_role (e.g. 'Admin','Lab Member') | member " + "(an email) | space_role (viewer|editor|owner). When rules exist " + "for an action, only matching callers may perform it (owner and " + "Admin/SuperAdmin always bypass).") +async def add_access_rule(slug: str, body: AccessRuleIn, user: Annotated[LoginUserIn, Depends(get_current_user)]): + space = await sp.get_space(slug) + if not space: + return JSONResponse({"error": "space not found"}, status_code=404) + if not await _can_manage(space, _agent(user)): + raise HTTPException(403, "not authorized to manage this space's access rules") + try: + await sp.add_access_rule(space["space_id"], body.action, body.subject_type, + body.subject_value, _agent(user)) + except ValueError as e: + raise HTTPException(400, str(e)) + return {"slug": slug, "rules": await sp.list_access_rules(space["space_id"])} + + +@router.delete("/spaces/{slug}/access-rules/{rule_id}", + dependencies=[Depends(require_scopes(["write"]))], + summary="Delete a fine-grained access rule (space manager only)") +async def delete_access_rule(slug: str, rule_id: int, user: Annotated[LoginUserIn, Depends(get_current_user)]): + space = await sp.get_space(slug) + if not space: + return JSONResponse({"error": "space not found"}, status_code=404) + if not await _can_manage(space, _agent(user)): + raise HTTPException(403, "not authorized to manage this space's access rules") + await sp.remove_access_rule(space["space_id"], rule_id) + return {"slug": slug, "rules": await sp.list_access_rules(space["space_id"])} + + @router.get("/admin/capabilities", dependencies=[Depends(require_scopes(["admin"]))], summary="List a user's effective capabilities (admin only)") diff --git a/query_service/core/spaces.py b/query_service/core/spaces.py index 8f94094..1daf23c 100644 --- a/query_service/core/spaces.py +++ b/query_service/core/spaces.py @@ -258,6 +258,92 @@ async def attach_graph(space_id: str, named_graph_iri: str) -> None: # Authorization (enforcement) # --------------------------------------------------------------------------- +ACTIONS = ("read", "write", "manage") +RULE_SUBJECTS = ("global_role", "member", "space_role") +_SPACE_ROLE_RANK = {"viewer": 1, "editor": 2, "owner": 3} + + +async def add_access_rule(space_id: str, action: str, subject_type: str, + subject_value: str, created_by: str) -> None: + if action not in ACTIONS: + raise ValueError(f"action must be one of {ACTIONS}") + if subject_type not in RULE_SUBJECTS: + raise ValueError(f"subject_type must be one of {RULE_SUBJECTS}") + if subject_type == "space_role" and subject_value not in _SPACE_ROLE_RANK: + raise ValueError("space_role subject_value must be viewer/editor/owner") + async with get_db_connection() as conn: + await conn.execute( + """ + INSERT INTO space_access_rules (space_id, action, subject_type, subject_value, created_by, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (space_id, action, subject_type, subject_value) DO NOTHING + """, + space_id, action, subject_type, subject_value, created_by, time.time(), + ) + + +async def remove_access_rule(space_id: str, rule_id: int) -> None: + async with get_db_connection() as conn: + await conn.execute( + "DELETE FROM space_access_rules WHERE id = $1 AND space_id = $2", rule_id, space_id + ) + + +async def list_access_rules(space_id: str, action: Optional[str] = None) -> List[Dict[str, Any]]: + async with get_db_connection() as conn: + if action: + rows = await conn.fetch( + "SELECT id, action, subject_type, subject_value FROM space_access_rules WHERE space_id=$1 AND action=$2 ORDER BY id", + space_id, action) + else: + rows = await conn.fetch( + "SELECT id, action, subject_type, subject_value FROM space_access_rules WHERE space_id=$1 ORDER BY action, id", + space_id) + return [dict(r) for r in rows] + + +async def matches_access_rule(space_id: str, action: str, email: Optional[str]) -> bool: + """True iff the caller matches at least one access rule for (space, action). + Pure rule match — no owner/admin bypass, no 'no rules' default.""" + from core import rbac + rules = await list_access_rules(space_id, action) + if not rules: + return False + roles = await rbac.active_roles(email) + srole = await member_role(space_id, email) + srank = _SPACE_ROLE_RANK.get(srole or "", 0) + for r in rules: + st, sv = r["subject_type"], r["subject_value"] + if st == "global_role" and sv in roles: + return True + if st == "member" and email and sv == email: + return True + if st == "space_role" and srank >= _SPACE_ROLE_RANK.get(sv, 99): + return True + return False + + +async def space_action_permitted(space: Dict[str, Any], action: str, email: Optional[str]) -> bool: + """ + Fine-grained per-space check for read/write. Returns True if the caller may do + `action` in this space under the space's access rules. + + - Owner and global Admin/SuperAdmin always pass (no lockout). + - No rules for the action -> True (the endpoint's normal capability / membership + / visibility gates still apply separately). + - Rules present -> caller must match at least one. + """ + from core import rbac + space_id = space["space_id"] + if email and await member_role(space_id, email) == "owner": + return True + if await rbac.is_admin(email): + return True + if not await list_access_rules(space_id, action): + return True + return await matches_access_rule(space_id, action, email) + + async def authorize(named_graph_iri: str, member: Optional[str], need: str) -> Tuple[bool, str]: """ Decide whether ``member`` (a user email, or None if anonymous) may read/write From c57d7555f1471ec065938ca5362e59e2d28a8058 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 19:51:29 -0400 Subject: [PATCH 16/70] usermanagement: add admin activate/deactivate user endpoints 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. --- usermanagement_service/core/routers/admin.py | 32 +++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/usermanagement_service/core/routers/admin.py b/usermanagement_service/core/routers/admin.py index 9f81ddb..300d54e 100644 --- a/usermanagement_service/core/routers/admin.py +++ b/usermanagement_service/core/routers/admin.py @@ -17,10 +17,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select, func +from pydantic import BaseModel from core.database import ( user_db_manager, user_profile_repo, user_role_repo, user_activity_repo, available_role_repo, permission_repo, role_permission_repo, page_access_repo, - oauth_identity_repo, + oauth_identity_repo, jwt_user_repo, ) from core.models.database_models import ( AvailableRole as AvailableRoleModel, @@ -495,6 +496,35 @@ async def admin_delete_openrouter_key(_admin: Annotated[dict, Depends(require_ad # which re-reads `is_banned` per request. To delete a user entirely use # DELETE /api/admin/users/{profile_id}. +class _EmailIn(BaseModel): + email: str + + +@router.post("/users/activate") +async def activate_user(body: _EmailIn, _admin: Annotated[dict, Depends(require_admin)]): + """Activate a user's account (set the JWT user `is_active=True`) by email. + Needed e.g. after a password self-registration, or to re-enable an account.""" + async with user_db_manager.get_async_session() as session: + u = await jwt_user_repo.get_by_email_any_status(session, body.email) + if not u: + raise HTTPException(status_code=404, detail="user not found") + changed = await jwt_user_repo.activate_user(session, u.id) + await session.commit() + return {"email": body.email, "is_active": True, "changed": bool(changed)} + + +@router.post("/users/deactivate") +async def deactivate_user(body: _EmailIn, _admin: Annotated[dict, Depends(require_admin)]): + """Deactivate a user's account (set the JWT user `is_active=False`) by email.""" + async with user_db_manager.get_async_session() as session: + u = await jwt_user_repo.get_by_email_any_status(session, body.email) + if not u: + raise HTTPException(status_code=404, detail="user not found") + changed = await jwt_user_repo.deactivate_user(session, u.id) + await session.commit() + return {"email": body.email, "is_active": False, "changed": bool(changed)} + + @router.post("/users/{profile_id}/ban") async def ban_user( profile_id: int, From 948851f3d79e4d1e3ba1749472b655580f375f80 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 21:13:23 -0400 Subject: [PATCH 17/70] Unify identity model (auth Phase 1): one canonical user, linked credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- query_service/AUTH_UNIFICATION.md | 216 ++++++++++++++++++ query_service/core/database.py | 123 ++++++---- query_service/core/routers/jwt_auth.py | 15 +- query_service/core/security.py | 27 ++- usermanagement_service/core/bootstrap.py | 9 + usermanagement_service/core/database.py | 82 +++++++ .../core/models/database_models.py | 11 +- usermanagement_service/core/routers/oauth.py | 62 +---- 8 files changed, 440 insertions(+), 105 deletions(-) create mode 100644 query_service/AUTH_UNIFICATION.md diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md new file mode 100644 index 0000000..c462f30 --- /dev/null +++ b/query_service/AUTH_UNIFICATION.md @@ -0,0 +1,216 @@ +# BrainKB Authentication & Identity — Unification Design + +Status: **Phase 1 implemented** (branch `auth-unification`); Phase 2 proposed / for discussion. +Audience: BrainKB maintainers +Scope: `query_service`, `usermanagement_service`, `APItokenmanager` (Django), and downstream services (`ml_service`, `chat_service`, `brainkb_mcp`). + +--- + +## 1. Problem statement + +BrainKB today authenticates users through **two overlapping systems**: + +1. **JWT / scope system** — credentials in `Web_jwtuser` (email, password, `is_active`, + `read`/`write`/`admin` scopes). Tokens are issued/validated by `query_service` + (`/api/token`) and the user/scope records are administered by the Django + `APItokenmanager`. This layer answers **"can this client call this API?"** + +2. **Web / RBAC system** — identity in `Web_user_profile` + + `Web_oauth_identity` (Globus / ORCID / GitHub), with authorization in + `Web_user_role`. Tokens are issued by `usermanagement_service` + (`create_access_token_v2`, roles embedded) and OAuth auto-provisions users. + This layer answers **"who is this person and what are they allowed to do?"** + +Both live in the **same Postgres database** and are **joined by email** +(`query_service` RBAC already reads roles from the profile side by email). So this +is not two isolated silos — it is **one user split across two tables**: +`Web_jwtuser` (credentials) vs `Web_user_profile` (identity + roles). + +That split is the real source of the inconsistencies observed in practice: + +| Symptom | Cause | +|---|---| +| OAuth user cannot password-login to `query_service` | OAuth path creates a `Web_jwtuser` shell with a random password | +| Password `Web_jwtuser` has no roles → treated as public/no-role | No matching `Web_user_profile` row | +| Two token shapes | `query_service` token = scopes only; `v2` token = roles + scopes + profile | +| MCP must log in twice | One login for `query_service`, another for `usermanagement` | +| "Where do I add a user?" is ambiguous | Two admin surfaces (Django token-manager vs usermanagement) | + +--- + +## 2. Design principles (constraints we must respect) + +- **P1 — Preserve per-service token containment.** Per-service tokens were a + deliberate security decision: a token leaked from one service must not be + replayable against another. Any unification **must not** regress to a single + shared bearer token that works everywhere. +- **P2 — One identity, one authorization source.** "Who is this user and what can + they do" must be answerable exactly one way, from one source of truth. +- **P3 — OAuth is the primary onboarding path.** Globus/ORCID/GitHub provisioning + already exists and is where new users come from; password login remains a + supported credential, not a separate user universe. +- **P4 — Incremental, reversible migration.** No flag-day cutover; each phase must + be shippable and independently valuable. + +--- + +## 3. Key insight: separate the two decisions + +"Merge the auth systems" conflates two independent choices. Treat them separately: + +- **Decision A — Identity model.** One canonical user vs. the current + `jwtuser`-shell + `profile` split. +- **Decision B — Token issuance.** How many issuers, what token shape, and how + cross-service replay is prevented. + +Merging identity (A) is high-value and low-risk. Collapsing tokens (B) is where the +security tradeoff lives and must be chosen deliberately. + +--- + +## 4. Recommendation + +### 4.1 Decision A — Unify the identity model (do this) + +Converge on **one canonical user**, the profile, keyed by email: + +- `Web_user_profile` is the **single user record**. Credentials, OAuth identities, + roles, and API scopes all hang off it. +- `Web_jwtuser` is **demoted to a 1:1 credential record** for a profile (or removed + entirely, with password hash + `is_active` folded into the profile). It is no + longer a parallel "user." +- Every service resolves **identity + roles + scopes from this single source** + (`query_service` already reads roles by email — this generalizes that pattern). +- OAuth provisioning stops creating a "shell" user; it creates/links **the** profile. + A password can be set on the same profile later, so OAuth and password login + address the same identity. + +**Outcome:** the four-row table of symptoms in §1 disappears. There is one user, +one place roles/scopes live, one answer to "who is this." + +### 4.2 Decision B — Token strategy (choose one, deliberately) + +**Option B1 — Unified identity, per-service tokens (recommended first step).** +Keep each service issuing and validating its **own** token (own secret). The token +stays a thin proof-of-identity; **roles/scopes are read from the unified identity +source**, not trusted from a cross-service token. This fully preserves P1 +(containment) and requires no JWKS/audience machinery. It is the smallest change +that removes the identity fragmentation. + +**Option B2 — Single issuer + audience-scoped tokens (proper SSO; later).** +Make `usermanagement_service` the **auth authority** (it already issues role-bearing +v2 tokens and owns OAuth). Publish signing keys via **JWKS**; stamp every token with +an **`aud` (audience)** claim; each service accepts **only** tokens minted for its +own audience. This gives *one login* while still preventing cross-service replay — +containment is enforced by `aud` validation instead of separate secrets. This is the +clean long-term target but is a real project: JWKS rollout, `aud` enforcement in +every service, and token/user migration. + +**Do not** merge into a single shared-secret token that works everywhere — it +violates P1. + +### 4.3 Direction of travel + +`usermanagement_service` is the newer, richer identity service (OAuth + roles + v2 +tokens) and should become the **identity/auth authority**. The Django +`APItokenmanager`'s separate `jwtuser` notion is the **legacy piece to fold in**, +not the foundation to build on. + +--- + +## 5. Phased migration plan + +### Phase 0 — Freeze the contract (no behavior change) +- Document the canonical claims a BrainKB token carries: `sub` (email/profile id), + `roles`, `scopes`, `iss`, `exp`, and (reserved for Phase 2) `aud`. +- Confirm every service reads roles/scopes from the shared source, not only from the + token, so tokens can shrink safely later. + +### Phase 1 — Unify identity (Decision A + Option B1) +1. Make `Web_user_profile` the single user; ensure every `Web_jwtuser` has a matching + profile (backfill by email) and every profile can hold a password + `is_active`. +2. Repoint OAuth provisioning to create/link the profile (no shell user). +3. Repoint `query_service` login and RBAC to the unified record; keep its own token + issuance/validation (containment preserved). +4. Update `brainkb_mcp` + `brainkb_skills`: one login concept, one identity; the MCP + still obtains a per-service token where it calls each service, but from **one** + set of user credentials. +5. Migration safety: keep `Web_jwtuser` readable during transition; dual-read, then + deprecate. + +**Ship here.** This resolves the reported inconsistencies. Stop unless SSO is wanted. + +#### Phase 1 — what was actually implemented (branch `auth-unification`) + +Decision A (unify identity) + Decision B Option B1 (per-service tokens), verified +live against the running stack: + +- **Explicit credential→profile link.** Added `Web_jwtuser.profile_id` + (FK → `Web_user_profile.id`, `ON DELETE SET NULL`, indexed) to the ORM model + and as an idempotent inline migration in `usermanagement bootstrap`, plus a + **case-insensitive email backfill** for pre-existing rows. The credential row + is now a 1:1 record for the canonical profile, not an email-only sibling. + *(Verified: migration applied cleanly; 2/3 existing credentials backfilled.)* +- **Single provisioning path.** New `provision_identity(...)` in + `usermanagement/core/database.py` is the one place that ensures + profile + linked credential + default role (`Curator`) + bootstrap-superadmin + elevation. Idempotent; caller owns the transaction. +- **OAuth callback refactored** onto `provision_identity` — removed the ad-hoc + `_ensure_jwt_user_shell` + default-role + bootstrap duplication; OAuth now + sets `profile_id`. *(Verified: usermanagement boots clean, `/api/token` OK.)* +- **Password registration now provisions identity.** `query_service /api/register` + ensures a canonical profile, assigns the default role, and links the + credential (`_provision_profile_for_registration`, best-effort so it never + blocks account creation). This fixes the "password user has no roles" symptom. + *(Verified: fresh register → profile + `profile_id` link + `Curator` role.)* +- **Standardized token claims.** `query_service` tokens now carry + `sub`/`scopes`/`user_id`/`profile_id`/`roles`/`auth_source` — identical in + shape to usermanagement's v2 token — while still signed with query_service's + **own** secret (containment preserved). Roles remain informational; + authorization keeps re-reading roles from the DB via `core.rbac`. + *(Verified: both services' `/api/token` return the same claim shape; existing + protected endpoints still authorize.)* + +Not in Phase 1 (unchanged, deliberate): per-service secrets/token isolation; +`require_admin` still trusts the token `roles` claim in usermanagement (noted for +Phase 2); no shared issuer / JWKS / `aud`. + +### Phase 2 — Optional single-issuer SSO (Decision B → Option B2) +1. `usermanagement_service` becomes the sole issuer; expose **JWKS**. +2. Add `aud` to tokens; enforce per-service audience validation everywhere + (`query_service`, `ml_service`, `chat_service`, MCP targets). +3. Migrate services from local secret validation to JWKS + `aud`. +4. Retire per-service `/api/token` login endpoints in favor of the central one; + `APItokenmanager`'s user store is fully folded in. + +--- + +## 6. Impact on `brainkb_mcp` + +After Phase 1, the MCP no longer needs **two logins** (`query_service` + +`usermanagement`). A single `brainkb_login` authenticates one identity; admin/user +management and KG operations share it. This directly simplifies the dual-service +auth currently in `server.py` (`_um()` / separate token handling). After Phase 2, +the MCP would validate/forward a single audience-scoped token. + +--- + +## 7. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| User/token migration errors | Backfill by email; dual-read `jwtuser`↔`profile` before deprecating | +| Losing containment (P1) | Phase 1 keeps per-service tokens; Phase 2 uses `aud`, not shared secrets | +| Multi-service coordination | Phase 2 only; roll out JWKS+`aud` service by service behind a validation shim | +| OAuth vs password ambiguity | Single profile owns both; password becomes an attribute of the identity | +| Downstream breakage (`ml_service`, `chat_service`) | Phase 0 makes services read roles from source, so token shape can change safely | + +--- + +## 8. Recommendation summary + +- **Do now:** unify the **identity model** (§4.1) with **per-service tokens** + (§4.2 Option B1). High value, preserves containment, small blast radius. +- **Do later, deliberately:** single-issuer **audience-scoped SSO** (§4.2 Option B2) + when ready to operate JWKS + `aud` properly. +- **Do not:** collapse to one shared-secret token usable across all services. diff --git a/query_service/core/database.py b/query_service/core/database.py index 1621156..f021ad2 100644 --- a/query_service/core/database.py +++ b/query_service/core/database.py @@ -39,6 +39,13 @@ table_name_scope = load_environment()["JWT_POSTGRES_TABLE_SCOPE"] table_relation = load_environment()["JWT_POSTGRES_TABLE_USER_SCOPE_REL"] +# Identity unification (Phase 1): a password-registered user is a first-class +# identity, not a role-less credential orphan. On registration we ensure a +# canonical Web_user_profile, link the credential to it (Web_jwtuser.profile_id), +# and assign this default role so rbac (which reads roles via the profile) has +# something to work with. Kept in sync with usermanagement's OAuth default. +DEFAULT_REGISTRATION_ROLE = "Curator" + # Global connection pool pool = None @@ -211,6 +218,45 @@ async def debug_pool_status(): # Refactored Database Functions - Using Context Manager Pattern # ============================================================================ +async def _provision_profile_for_registration(connection, jwt_user_id: int, email: str, fullname: str) -> None: + """Ensure a canonical Web_user_profile + default role for a freshly + registered credential, and link them via Web_jwtuser.profile_id. + + Mirrors usermanagement's provision_identity for the password path so both + onboarding routes converge on the same shape (profile + linked credential + + at least one role). Best-effort: any failure is logged and swallowed so + registration itself never fails on it. rbac reads roles via the profile, so + this is what makes a password user more than public/no-role.""" + try: + async with connection.transaction(): + profile_id = await connection.fetchval( + 'SELECT id FROM "Web_user_profile" WHERE lower(email) = lower($1)', email + ) + if profile_id is None: + profile_id = await connection.fetchval( + '''INSERT INTO "Web_user_profile" (name, email, created_at, updated_at) + VALUES ($1, $2, $3, $4) RETURNING id''', + fullname or email.split("@")[0], email, datetime.utcnow(), datetime.utcnow(), + ) + await connection.execute( + f'UPDATE "{table_name_user}" SET profile_id = $1, updated_at = $2 WHERE id = $3', + profile_id, datetime.utcnow(), jwt_user_id, + ) + has_role = await connection.fetchval( + 'SELECT 1 FROM "Web_user_role" WHERE profile_id = $1 AND is_active IS TRUE LIMIT 1', + profile_id, + ) + if not has_role: + await connection.execute( + '''INSERT INTO "Web_user_role" (profile_id, role, is_active, assigned_at, updated_at) + VALUES ($1, $2, TRUE, $3, $4) + ON CONFLICT (profile_id, role) DO NOTHING''', + profile_id, DEFAULT_REGISTRATION_ROLE, datetime.utcnow(), datetime.utcnow(), + ) + except Exception as e: + logger.warning(f"Profile/role provisioning skipped for {email}: {e}") + + async def insert_data(fullname: str, email: str, password: str, conn: Optional[asyncpg.Connection] = None): """ Insert a new user with default 'read' scope. @@ -218,64 +264,43 @@ async def insert_data(fullname: str, email: str, password: str, conn: Optional[a Otherwise, manages its own connection. """ async def _insert_logic(connection): - # Use a transaction to ensure all operations succeed or fail together + # Credential + scope go in one transaction (all-or-nothing). async with connection.transaction(): - scope_exist_id = await select_scope_id(connection) - - if not scope_exist_id: - # First insert the default read access - scope_query = f""" - INSERT INTO \"{table_name_scope}\" (name, description, created_at, updated_at) - VALUES ($1, $2, $3, $4) RETURNING id""" - - new_scope_id = await connection.fetchval( - scope_query, + scope_id = await select_scope_id(connection) + if not scope_id: + # Seed the default 'read' scope the first time anyone registers. + scope_id = await connection.fetchval( + f"""INSERT INTO \"{table_name_scope}\" (name, description, created_at, updated_at) + VALUES ($1, $2, $3, $4) RETURNING id""", "read", "This allows read access", datetime.utcnow(), datetime.utcnow(), ) - user_query = f""" - INSERT INTO \"{table_name_user}\" (full_name, email, password, is_active, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6) RETURNING id - """ - jwt_user_id = await connection.fetchval( - user_query, - fullname, - email, - password, - False, - datetime.utcnow(), - datetime.utcnow(), - ) + jwt_user_id = await connection.fetchval( + f"""INSERT INTO \"{table_name_user}\" (full_name, email, password, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id""", + fullname, + email, + password, + False, + datetime.utcnow(), + datetime.utcnow(), + ) - # Connect with relationship - await connection.execute( - f"""INSERT INTO \"{table_relation}\" (jwtuser_id, scope_id) VALUES ($1, $2)""", - jwt_user_id, - new_scope_id, - ) - else: - user_query = f""" - INSERT INTO \"{table_name_user}\" (full_name, email, password, is_active, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6) RETURNING id - """ - jwt_user_id = await connection.fetchval( - user_query, - fullname, - email, - password, - False, - datetime.utcnow(), - datetime.utcnow(), - ) + await connection.execute( + f"""INSERT INTO \"{table_relation}\" (jwtuser_id, scope_id) VALUES ($1, $2)""", + jwt_user_id, + scope_id, + ) - await connection.execute( - f"""INSERT INTO \"{table_relation}\" (jwtuser_id, scope_id) VALUES ($1, $2)""", - jwt_user_id, - scope_exist_id, - ) + # Identity unification: ensure a canonical profile + default role and + # link this credential to it. Best-effort in its own transaction so a + # hiccup here (e.g. the profile_id column not migrated yet) never blocks + # account creation — the email backfill in usermanagement bootstrap can + # still repair the link later. + await _provision_profile_for_registration(connection, jwt_user_id, email, fullname) return { "detail": "Registration completed successfully! Admin will activate your account after verification." diff --git a/query_service/core/routers/jwt_auth.py b/query_service/core/routers/jwt_auth.py index 61f8c31..b874022 100644 --- a/query_service/core/routers/jwt_auth.py +++ b/query_service/core/routers/jwt_auth.py @@ -5,6 +5,7 @@ from core.database import get_db_connection, insert_data, get_scopes_by_user from core.models.user import UserIn, LoginUserIn from core.security import get_password_hash, authenticate_user, create_access_token +from core import rbac logger = logging.getLogger(__name__) @@ -39,5 +40,17 @@ async def login(user: LoginUserIn): async with get_db_connection() as conn: authenticated_user = await authenticate_user(user.email, user.password, conn) scopes = await get_scopes_by_user(user_id=authenticated_user["id"], conn=conn) - access_token = create_access_token(authenticated_user["email"], scopes) + # Enrich the token with profile_id + roles so its shape matches + # usermanagement's v2 token. Signed with query_service's own secret + # (per-service isolation preserved); roles remain informational since + # authorization re-reads them from the DB (core.rbac). + email = authenticated_user["email"] + profile_id = await conn.fetchval( + 'SELECT id FROM "Web_user_profile" WHERE lower(email) = lower($1)', email + ) + roles = sorted(await rbac.active_roles(email)) + access_token = create_access_token( + email, scopes, + user_id=authenticated_user["id"], profile_id=profile_id, roles=roles, + ) return {"access_token": access_token, "token_type": "bearer"} diff --git a/query_service/core/security.py b/query_service/core/security.py index 4e78f68..adc2628 100644 --- a/query_service/core/security.py +++ b/query_service/core/security.py @@ -48,11 +48,34 @@ def access_token_expire_minutes() -> int: return 30 -def create_access_token(email: str, scopes: List[str]) -> str: +def create_access_token( + email: str, + scopes: List[str], + *, + user_id: Optional[int] = None, + profile_id: Optional[int] = None, + roles: Optional[List[str]] = None, +) -> str: + """Mint a query_service access token. + + Claims are standardized to match usermanagement's v2 token shape + (``sub``/``scopes``/``profile_id``/``roles``/``auth_source``) so the token + is uniform across services. It is still signed with query_service's OWN + secret — per-service token isolation is preserved; a token minted here is + not accepted elsewhere. ``roles``/``profile_id`` are informational: query + authorization re-reads roles from the DB (see core.rbac), so a stale claim + cannot grant access. + """ expire = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( minutes=access_token_expire_minutes() ) - jwt_data = {"sub": email, "exp": expire, "scopes": scopes} + jwt_data = {"sub": email, "exp": expire, "scopes": scopes, "auth_source": "password"} + if user_id is not None: + jwt_data["user_id"] = user_id + if profile_id is not None: + jwt_data["profile_id"] = profile_id + if roles is not None: + jwt_data["roles"] = roles encoded_jwt = jwt.encode(jwt_data, key=SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt diff --git a/usermanagement_service/core/bootstrap.py b/usermanagement_service/core/bootstrap.py index 6f1ef89..d72ad66 100644 --- a/usermanagement_service/core/bootstrap.py +++ b/usermanagement_service/core/bootstrap.py @@ -177,6 +177,15 @@ async def apply_inline_schema_migrations() -> None: 'ALTER TABLE "Web_user_profile" ADD COLUMN IF NOT EXISTS banned_at TIMESTAMP', 'ALTER TABLE "Web_user_profile" ADD COLUMN IF NOT EXISTS banned_by INTEGER REFERENCES "Web_user_profile"(id) ON DELETE SET NULL', 'ALTER TABLE "Web_user_profile" ADD COLUMN IF NOT EXISTS ban_reason TEXT', + # Identity unification (Phase 1): make the credential row (Web_jwtuser) + # an explicit 1:1 record for a profile instead of an email-only sibling. + # Web_user_profile is the canonical user; profile_id is the link. + 'ALTER TABLE "Web_jwtuser" ADD COLUMN IF NOT EXISTS profile_id INTEGER REFERENCES "Web_user_profile"(id) ON DELETE SET NULL', + 'CREATE INDEX IF NOT EXISTS ix_jwtuser_profile_id ON "Web_jwtuser"(profile_id)', + # Backfill the link for pre-existing rows by matching email (the old + # implicit join key). Case-insensitive so mixed-case duplicates align. + 'UPDATE "Web_jwtuser" u SET profile_id = p.id FROM "Web_user_profile" p ' + 'WHERE u.profile_id IS NULL AND lower(u.email) = lower(p.email)', ] async with user_db_manager.get_async_session() as session: for stmt in statements: diff --git a/usermanagement_service/core/database.py b/usermanagement_service/core/database.py index d64cd19..cf187e3 100644 --- a/usermanagement_service/core/database.py +++ b/usermanagement_service/core/database.py @@ -1652,3 +1652,85 @@ async def delete(self, session: AsyncSession, key: str) -> bool: admin_setting_repo = AdminSettingRepository() + + +# --------------------------------------------------------------------------- +# Identity provisioning (Phase 1 unification) +# --------------------------------------------------------------------------- + +async def provision_identity( + session: AsyncSession, + *, + email: str, + full_name: Optional[str] = None, + password_hash: Optional[str] = None, + default_role: str = "Curator", + existing_profile: Optional[UserProfile] = None, +) -> tuple: + """Single source of truth for creating/linking a BrainKB identity. + + Idempotently guarantees that, for ``email``, there is: + * a canonical ``Web_user_profile`` (the user of record), + * a ``Web_jwtuser`` credential row linked to it via ``profile_id``, + * at least one role (``default_role``) on the profile, + * ``Admin`` + ``SuperAdmin`` if the email is in the bootstrap allowlist. + + The caller owns the session/transaction (this does NOT commit). OAuth's rich + profile matching (by oauth-identity / orcid) stays in the router, which + passes the already-resolved profile via ``existing_profile``; password + onboarding passes just ``email`` + ``password_hash``. A pre-existing + credential's password is never overwritten here (login stays deterministic). + + Returns ``(profile, jwt_user, role_names)``. + """ + import secrets as _secrets + from core.security import get_password_hash # lazy: avoid import cycle + from core.models.user import UserRoleEnum + + email = (email or "").strip() + if not email: + raise HTTPException(status_code=400, detail="email is required to provision an identity") + display_name = full_name or email.split("@")[0] + + # 1) Canonical profile — reuse the resolved one, else find/create by email. + profile = existing_profile or await user_profile_repo.get_by_email(session, email) + if profile is None: + profile = await user_profile_repo.create_profile(session, name=display_name, email=email) + + # 2) Credential row (get-or-create). get_by_email_any_status avoids + # re-inserting (and colliding on the unique email) for inactive shells. + jwt_user = await jwt_user_repo.get_by_email_any_status(session, email) + if jwt_user is None: + # OAuth/no-password onboarding gets an unusable random secret; a real + # hash is used only when the caller set a password (registration). + secret = password_hash or get_password_hash(_secrets.token_urlsafe(48)) + jwt_user = await jwt_user_repo.create_user( + session=session, full_name=display_name, email=email, password=secret, + ) + + # 3) Link credential -> profile (the whole point of Phase 1). Set only when + # unset/stale so we never thrash an already-correct link. + if getattr(jwt_user, "profile_id", None) != profile.id: + jwt_user.profile_id = profile.id + jwt_user.updated_at = datetime.utcnow() + await session.flush() + + # 4) Default role if the profile has none yet. + role_names = await user_role_repo.get_user_role_names(session, profile.id) + if not role_names and default_role: + await user_role_repo.assign_role( + session, profile_id=profile.id, role=default_role, is_active=True, + ) + role_names = [default_role] + + # 5) Bootstrap-superadmin allowlist: elevate on first sight. Seed both Admin + # (permissions / page access) and SuperAdmin (the protected marker). + if (profile.email or "").lower() in config.bootstrap_superadmin_emails: + for role_name in (UserRoleEnum.ADMIN.value, UserRoleEnum.SUPERADMIN.value): + if role_name not in role_names: + await user_role_repo.assign_role( + session, profile_id=profile.id, role=role_name, is_active=True, + ) + role_names.append(role_name) + + return profile, jwt_user, role_names diff --git a/usermanagement_service/core/models/database_models.py b/usermanagement_service/core/models/database_models.py index 1a89b06..fa3bfad 100644 --- a/usermanagement_service/core/models/database_models.py +++ b/usermanagement_service/core/models/database_models.py @@ -32,15 +32,22 @@ class JWTUser(Base): email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) password: Mapped[str] = mapped_column(String(255), nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=False) + # Identity unification (Phase 1): the credential row is a 1:1 record for a + # canonical Web_user_profile. Nullable + SET NULL so a profile delete never + # orphans/blocks the credential; backfilled by email in bootstrap. + profile_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("Web_user_profile.id", ondelete="SET NULL"), nullable=True + ) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - + # Relationships - JWT only, no profile relationships - + # Indexes __table_args__ = ( Index('idx_jwtuser_email', 'email'), Index('idx_jwtuser_active', 'is_active'), + Index('idx_jwtuser_profile_id', 'profile_id'), ) diff --git a/usermanagement_service/core/routers/oauth.py b/usermanagement_service/core/routers/oauth.py index 01da808..5c905bb 100644 --- a/usermanagement_service/core/routers/oauth.py +++ b/usermanagement_service/core/routers/oauth.py @@ -26,15 +26,13 @@ from core.configuration import config from core.database import ( - user_db_manager, user_profile_repo, user_role_repo, jwt_user_repo, - oauth_identity_repo, oauth_state_repo, user_activity_repo, + user_db_manager, user_profile_repo, jwt_user_repo, + oauth_identity_repo, oauth_state_repo, user_activity_repo, provision_identity, ) from core.models.user import ActivityType, OAuthLoginStart, UserRoleEnum -from core.models.database_models import UserProfile as UserProfileModel, JWTUser as JWTUserModel +from core.models.database_models import UserProfile as UserProfileModel from core.oauth import get_provider -from core.security import ( - create_access_token_v2, encrypt_token, get_password_hash, -) +from core.security import create_access_token_v2, encrypt_token logger = logging.getLogger(__name__) @@ -95,25 +93,6 @@ async def _upsert_profile_for_oauth(session, userinfo) -> UserProfileModel: return new_profile -async def _ensure_jwt_user_shell(session, email: str, full_name: str) -> JWTUserModel: - """Make sure a Web_jwtuser row exists for this email. OAuth users don't have - a usable password — we store a random high-entropy hash (can't be logged in - with, just exists so the JWT user_id claim is stable). The shell is created - with `is_active=False`, so the lookup must not filter by activation; using - `get_by_email` (active-only) here would re-INSERT on every sign-in and - collide with the unique-email constraint.""" - existing = await jwt_user_repo.get_by_email_any_status(session, email) - if existing: - return existing - random_password = secrets.token_urlsafe(48) - return await jwt_user_repo.create_user( - session=session, - full_name=full_name, - email=email, - password=get_password_hash(random_password), - ) - - # ---- routes ------------------------------------------------------------- @router.get("/auth/providers") @@ -237,23 +216,18 @@ async def oauth_callback( profile.updated_at = datetime.utcnow() await session.flush() - jwt_user = await _ensure_jwt_user_shell( + # Ensure a credential row linked to this profile, a default role, + # and bootstrap elevation — all via the single provisioning path. + # OAuth's own profile matching already ran above, so hand the + # resolved profile through as existing_profile. + profile, jwt_user, existing_roles = await provision_identity( session, email=profile.email, full_name=profile.name or userinfo.name or profile.email, + default_role=UserRoleEnum.CURATOR.value, + existing_profile=profile, ) - # Default role on first login = Curator. - existing_roles = await user_role_repo.get_user_role_names(session, profile.id) - if not existing_roles: - await user_role_repo.assign_role( - session, - profile_id=profile.id, - role=UserRoleEnum.CURATOR.value, - is_active=True, - ) - existing_roles = [UserRoleEnum.CURATOR.value] - # Upsert the oauth identity row (encrypt tokens at rest). token_expires_at = None if token_resp.expires_in: @@ -270,20 +244,6 @@ async def oauth_callback( raw_profile=userinfo.raw, ) - # Bootstrap-superadmin allowlist: if configured, elevate on first sight. - # Seed both Admin (for permissions / page-access checks) and - # SuperAdmin (the immutable marker that protects the account). - if (profile.email or "").lower() in config.bootstrap_superadmin_emails: - for role_name in (UserRoleEnum.ADMIN.value, UserRoleEnum.SUPERADMIN.value): - if role_name not in existing_roles: - await user_role_repo.assign_role( - session, - profile_id=profile.id, - role=role_name, - is_active=True, - ) - existing_roles.append(role_name) - # Log activity. await user_activity_repo.log_activity( session=session, From 5dd4f55d6da02546b10794dcde331e893e22be49 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 21:33:55 -0400 Subject: [PATCH 18/70] SSO Phase 2: single-issuer RS256 + JWKS, per-audience tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usermanagement becomes the sole token issuer; a single login mints a short-lived refresh token, exchanged for narrow per-service access tokens (aud=). 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). --- query_service/AUTH_UNIFICATION.md | 59 ++++- query_service/core/configuration.py | 7 + query_service/core/jwks.py | 93 ++++++++ query_service/core/security.py | 34 ++- usermanagement_service/core/configuration.py | 45 ++++ usermanagement_service/core/main.py | 4 + usermanagement_service/core/routers/sso.py | 127 +++++++++++ usermanagement_service/core/tokens_rs256.py | 219 +++++++++++++++++++ 8 files changed, 582 insertions(+), 6 deletions(-) create mode 100644 query_service/core/jwks.py create mode 100644 usermanagement_service/core/routers/sso.py create mode 100644 usermanagement_service/core/tokens_rs256.py diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md index c462f30..6e69e60 100644 --- a/query_service/AUTH_UNIFICATION.md +++ b/query_service/AUTH_UNIFICATION.md @@ -1,6 +1,8 @@ # BrainKB Authentication & Identity — Unification Design -Status: **Phase 1 implemented** (branch `auth-unification`); Phase 2 proposed / for discussion. +Status: **Phase 1 + Phase 2 implemented** (branch `auth-unification`). Phase 2 +(single-issuer RS256 + JWKS SSO, per-audience tokens) is code-complete and +pending a fresh deployment for live verification. Audience: BrainKB maintainers Scope: `query_service`, `usermanagement_service`, `APItokenmanager` (Django), and downstream services (`ml_service`, `chat_service`, `brainkb_mcp`). @@ -183,6 +185,61 @@ Phase 2); no shared issuer / JWKS / `aud`. 4. Retire per-service `/api/token` login endpoints in favor of the central one; `APItokenmanager`'s user store is fully folded in. +#### Phase 2 — what was actually implemented (branch `auth-unification`) + +Single-issuer, per-audience SSO with containment preserved via `aud` (Decision B +Option B2). Additive: legacy HS256 tokens keep working, so this is a safe +migration, not a flag-day cutover. + +- **usermanagement is the issuer (RS256 + JWKS).** New `core/tokens_rs256.py`: + loads an RS256 private key from `USERMANAGEMENT_JWT_PRIVATE_KEY_PEM`/`_FILE`, + or generates a **process-shared** ephemeral key persisted to a file (so all + uvicorn workers agree — a per-worker ephemeral key breaks cross-worker + verification). Publishes `GET /.well-known/jwks.json`. +- **Login → refresh → exchange.** New `core/routers/sso.py`: + `POST /api/auth/login` (`{email,password}`) returns a short-lived **refresh + token** (`aud=brainkb-auth`, not accepted by any service); + `POST /api/auth/exchange` (Bearer refresh, `{audience}`) returns a narrow + **access token** for a single service (`aud=`). Roles/scopes are + **re-read fresh from the DB at exchange time**, so a stale refresh token can't + carry stale authorization; active-credential + ban checks run here too. +- **query_service verifies via JWKS + `aud`.** New `core/jwks.py` (sync, so the + sync `require_scopes` dependency can use it): fetches + caches the JWKS, + verifies RS256, and **requires `aud == query_service`** and the configured + issuer. A new `decode_token_any()` tries RS256 (SSO) first, then falls back to + legacy HS256; it is wired into `get_current_user`, `get_current_user_optional`, + `verify_scopes`/`require_scopes`, and the websocket auth path. +- **Containment preserved.** Tokens are audience-scoped: a `query_service` token + cannot be replayed against `ml_service`. Enforcement is by `aud` validation, + not shared secrets — query_service keeps its own HS256 secret for legacy + tokens and never learns the issuer's private key. + +Deployment env (set before/at the fresh deploy): + +- usermanagement: `USERMANAGEMENT_JWT_PRIVATE_KEY_PEM` **or** `_FILE` + (**required for production** — persistent, stable `kid`; without it a shared + ephemeral key is auto-generated and logged as a warning), + `USERMANAGEMENT_JWT_ISSUER` (default `brainkb-usermanagement`), + `USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN` (15), `USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN` + (720), `USERMANAGEMENT_TOKEN_AUDIENCES` (`query_service,ml_service,chat_service`). +- query_service: `QUERY_SERVICE_SSO_JWKS_URL` (default + `http://127.0.0.1:8004/.well-known/jwks.json`; in a split deployment point at + the usermanagement service URL), `QUERY_SERVICE_SSO_ISSUER` (must match the + issuer), `QUERY_SERVICE_SSO_AUDIENCE` (`query_service`). + +Verified before deploy: JWKS endpoint serves a key; `/api/auth/login` returns a +refresh token; RS256→JWK verification roundtrip validates `aud`+`iss`; jose +accepts JWK dicts. Full login→exchange→query_service acceptance, wrong-`aud` +rejection, and legacy-HS256 coexistence to be confirmed on the fresh deployment. + +Remaining Phase 2 rollout (same pattern, not yet done): +- `ml_service` / `chat_service`: drop in a `core/jwks.py` verifier (audience + `ml_service` / `chat_service`) and dual-verify like query_service. +- `brainkb_mcp`: log in once, then request per-service access tokens via + `/api/auth/exchange` for whichever service a tool calls. +- Once clients have migrated, retire the legacy HS256 `/api/token` paths and + fold in `APItokenmanager`; tighten `require_admin` to re-read roles from the DB. + --- ## 6. Impact on `brainkb_mcp` diff --git a/query_service/core/configuration.py b/query_service/core/configuration.py index 7683d55..aae7101 100644 --- a/query_service/core/configuration.py +++ b/query_service/core/configuration.py @@ -78,6 +78,13 @@ def load_environment(env_name="production"): "JWT_POSTGRES_DATABASE_NAME": os.getenv("JWT_POSTGRES_DATABASE_NAME"), "JWT_ALGORITHM": os.getenv("JWT_ALGORITHM", "HS256"), "JWT_SECRET_KEY": os.getenv("QUERY_SERVICE_JWT_SECRET_KEY"), + # Phase 2 SSO: verify RS256 access tokens minted by usermanagement. + # Signature is checked against the issuer's published JWKS; the token's + # `aud` must equal SSO_AUDIENCE (this service) — a token minted for + # another service is rejected. Legacy HS256 tokens still validate too. + "SSO_JWKS_URL": os.getenv("QUERY_SERVICE_SSO_JWKS_URL", "http://127.0.0.1:8004/.well-known/jwks.json"), + "SSO_ISSUER": os.getenv("QUERY_SERVICE_SSO_ISSUER", "brainkb-usermanagement"), + "SSO_AUDIENCE": os.getenv("QUERY_SERVICE_SSO_AUDIENCE", "query_service"), # service specific "GRAPHDATABASE_USERNAME": os.getenv("GRAPHDATABASE_USERNAME"), "GRAPHDATABASE_PASSWORD": os.getenv("GRAPHDATABASE_PASSWORD"), diff --git a/query_service/core/jwks.py b/query_service/core/jwks.py new file mode 100644 index 0000000..ac4ab88 --- /dev/null +++ b/query_service/core/jwks.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""JWKS-based verification of Phase 2 SSO access tokens (RS256). + +Verifies tokens minted by usermanagement (the single issuer). The signature is +checked against the issuer's published JWKS (fetched + cached), and the token's +`aud` MUST equal this service's audience — so a token minted for another service +is rejected here (containment enforced by `aud`, not shared secrets). + +Deliberately SYNCHRONOUS: query_service's scope dependency (`require_scopes`) is +a sync FastAPI dependency, so verification must be callable from sync code. The +network fetch only happens on a cache miss / key rotation (default 10-min TTL); +FastAPI runs sync dependencies in a threadpool, so the occasional blocking fetch +does not stall the event loop. +""" +import logging +import threading +import time +from typing import Dict, Optional + +import httpx +from jose import jwt + +from core.configuration import load_environment + +logger = logging.getLogger(__name__) + +_env = load_environment() +JWKS_URL = _env["SSO_JWKS_URL"] +SSO_ISSUER = _env["SSO_ISSUER"] +SSO_AUDIENCE = _env["SSO_AUDIENCE"] + +_CACHE_TTL = 600 # seconds +_keys: Dict[str, dict] = {} +_fetched_at: float = 0.0 +_lock = threading.Lock() + + +def _refresh(force: bool = False) -> None: + global _keys, _fetched_at + with _lock: + if not force and _keys and (time.monotonic() - _fetched_at) < _CACHE_TTL: + return + try: + with httpx.Client(timeout=5.0) as client: + resp = client.get(JWKS_URL) + resp.raise_for_status() + data = resp.json() + _keys = {k["kid"]: k for k in data.get("keys", []) if k.get("kid")} + _fetched_at = time.monotonic() + except Exception as e: + logger.warning(f"JWKS fetch failed from {JWKS_URL}: {e}") + + +def _get_key(kid: Optional[str]) -> Optional[dict]: + if not kid: + return None + if kid in _keys and (time.monotonic() - _fetched_at) < _CACHE_TTL: + return _keys[kid] + _refresh() + if kid not in _keys: + # Unknown kid — the issuer may have rotated keys. Force one refresh. + _refresh(force=True) + return _keys.get(kid) + + +def verify_access_token(token: str) -> Optional[Dict]: + """Verify an RS256 SSO access token. Returns claims on success, or None if + the token is not RS256 / fails verification / issuer is unreachable. Never + raises — callers fall back to legacy HS256 verification.""" + try: + header = jwt.get_unverified_header(token) + except Exception: + return None + if header.get("alg") != "RS256": + return None # not an SSO token; let the caller try HS256 + key = _get_key(header.get("kid")) + if not key: + return None + try: + payload = jwt.decode( + token, + key, + algorithms=["RS256"], + audience=SSO_AUDIENCE, + issuer=SSO_ISSUER, + ) + except Exception as e: + logger.info(f"RS256 token rejected: {e}") + return None + # Only access tokens are usable at a service; refresh tokens are not. + if payload.get("typ") not in (None, "access"): + return None + return payload diff --git a/query_service/core/security.py b/query_service/core/security.py index adc2628..85c7de7 100644 --- a/query_service/core/security.py +++ b/query_service/core/security.py @@ -29,6 +29,7 @@ from core.configuration import load_environment from core.database import get_user +from core import jwks logger = logging.getLogger(__name__) @@ -108,11 +109,30 @@ def decode_jwt(token: str): raise HTTPException(status_code=403, detail="Could not validate credentials") +def decode_token_any(token: str) -> dict: + """Decode a bearer token from either scheme, newest first: + + 1. Phase 2 SSO RS256 access token — verified against the issuer's JWKS + and required to carry ``aud == this service`` (see core.jwks). + 2. Legacy HS256 query_service token — verified with this service's own + secret (per-service isolation preserved). + + Returns the validated claims. Raises jose ``JWTError`` / + ``ExpiredSignatureError`` if neither scheme validates, so existing callers' + exception handling (401/403) keeps working unchanged. + """ + payload = jwks.verify_access_token(token) + if payload is not None: + return payload + # Fall back to the legacy HS256 token signed with our own secret. + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + + async def get_current_user( token: Annotated[str, Depends(oauth2_scheme)], ): try: - payload = decode_jwt(token) + payload = decode_token_any(token) email = payload.get("sub") if email is None: raise credentials_exception @@ -145,7 +165,7 @@ async def get_current_user_optional(request: Request): if not token: return None try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + payload = decode_token_any(token) email = payload.get("sub") if not email: return None @@ -155,7 +175,10 @@ async def get_current_user_optional(request: Request): def verify_scopes(required_scopes: List[str], token: str) -> bool: - decoded_token = decode_jwt(token) + try: + decoded_token = decode_token_any(token) + except (ExpiredSignatureError, JWTError): + raise HTTPException(status_code=403, detail="Could not validate credentials") token_scopes = decoded_token.get("scopes", []) return all(scope in token_scopes for scope in required_scopes) @@ -233,9 +256,10 @@ async def authenticate_websocket(websocket: WebSocket, required_scopes: Optional logger.warning("No JWT token provided in WebSocket connection") return None - # Decode and validate JWT token (same logic as get_current_user) + # Decode and validate JWT token (same logic as get_current_user): + # RS256 SSO access token (aud-checked) first, then legacy HS256. try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + payload = decode_token_any(token) except ExpiredSignatureError: logger.warning("JWT token has expired") return None diff --git a/usermanagement_service/core/configuration.py b/usermanagement_service/core/configuration.py index 03b32ba..9bb0010 100644 --- a/usermanagement_service/core/configuration.py +++ b/usermanagement_service/core/configuration.py @@ -69,6 +69,19 @@ def load_environment(env_name="env"): "JWT_LOGIN_EMAIL": os.getenv("JWT_LOGIN_EMAIL"), "JWT_LOGIN_PASSWORD": os.getenv("JWT_LOGIN_PASSWORD"), + # Phase 2 SSO: RS256 single-issuer + JWKS. usermanagement is the sole + # issuer; a login mints a short-lived refresh token, exchanged for narrow + # per-service access tokens (aud=). See AUTH_UNIFICATION.md. + "USERMANAGEMENT_JWT_ISSUER": os.getenv("USERMANAGEMENT_JWT_ISSUER", "brainkb-usermanagement"), + # RS256 private key: PEM string, or a file path. If neither is set an + # EPHEMERAL key is generated at first use (dev only; not restart-safe). + "USERMANAGEMENT_JWT_PRIVATE_KEY_PEM": os.getenv("USERMANAGEMENT_JWT_PRIVATE_KEY_PEM"), + "USERMANAGEMENT_JWT_PRIVATE_KEY_FILE": os.getenv("USERMANAGEMENT_JWT_PRIVATE_KEY_FILE"), + "USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN": os.getenv("USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN", "15"), + "USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN": os.getenv("USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN", "720"), + # Services a refresh token may be exchanged for (valid aud values). + "USERMANAGEMENT_TOKEN_AUDIENCES": os.getenv("USERMANAGEMENT_TOKEN_AUDIENCES", "query_service,ml_service,chat_service"), + # OAuth / Admin Bootstrap "USERMANAGEMENT_PUBLIC_BASE_URL": os.getenv("USERMANAGEMENT_PUBLIC_BASE_URL", "http://localhost:8004"), "USERMANAGEMENT_FRONTEND_CALLBACK_URL": os.getenv("USERMANAGEMENT_FRONTEND_CALLBACK_URL", "http://localhost:3000/auth/callback"), @@ -153,6 +166,38 @@ def jwt_secret_key(self) -> Optional[str]: def jwt_bearer_token_url(self) -> str: """Get the JWT bearer token URL.""" return self._env_vars.get("JWT_BEARER_TOKEN_URL", "") + + # ---- Phase 2 SSO (RS256 single-issuer + JWKS) -------------------------- + @property + def jwt_issuer(self) -> str: + return self._env_vars.get("USERMANAGEMENT_JWT_ISSUER", "brainkb-usermanagement") + + @property + def jwt_private_key_pem(self) -> Optional[str]: + return self._env_vars.get("USERMANAGEMENT_JWT_PRIVATE_KEY_PEM") + + @property + def jwt_private_key_file(self) -> Optional[str]: + return self._env_vars.get("USERMANAGEMENT_JWT_PRIVATE_KEY_FILE") + + @property + def access_token_ttl_min(self) -> int: + try: + return int(self._env_vars.get("USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN", "15")) + except (TypeError, ValueError): + return 15 + + @property + def refresh_token_ttl_min(self) -> int: + try: + return int(self._env_vars.get("USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN", "720")) + except (TypeError, ValueError): + return 720 + + @property + def token_audiences(self) -> list: + raw = self._env_vars.get("USERMANAGEMENT_TOKEN_AUDIENCES", "query_service,ml_service,chat_service") or "" + return [a.strip() for a in raw.split(",") if a.strip()] @property def jwt_login_username(self) -> str: diff --git a/usermanagement_service/core/main.py b/usermanagement_service/core/main.py index 251e0cc..d46f4ce 100644 --- a/usermanagement_service/core/main.py +++ b/usermanagement_service/core/main.py @@ -16,6 +16,7 @@ from core.routers.oauth import router as oauth_router from core.routers.admin import router as admin_router from core.routers.access import router as access_router +from core.routers.sso import router as sso_router, wellknown_router from core.database import user_db_manager, user_activity_repo from core.models.user import ActivityType from core.security import verify_token @@ -147,6 +148,9 @@ async def lifespan(app: FastAPI): app.include_router(oauth_router, prefix="/api", tags=["OAuth"]) app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) app.include_router(access_router, prefix="/api", tags=["Access Control"]) +# Phase 2 SSO: JWKS at the root well-known path; auth endpoints under /api. +app.include_router(wellknown_router, tags=["SSO"]) +app.include_router(sso_router, prefix="/api", tags=["SSO"]) # log all HTTP exception when raised diff --git a/usermanagement_service/core/routers/sso.py b/usermanagement_service/core/routers/sso.py new file mode 100644 index 0000000..27a47ca --- /dev/null +++ b/usermanagement_service/core/routers/sso.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +"""Phase 2 SSO endpoints (RS256 single-issuer + JWKS). + + GET /.well-known/jwks.json public keys for token verification + POST /api/auth/login {email,password} -> refresh token (aud=brainkb-auth) + POST /api/auth/exchange Bearer , {audience} -> per-service access token + +usermanagement is the sole issuer. Clients log in once (refresh token), then +exchange for narrow, short-lived access tokens scoped to a single service via +the `aud` claim. Roles/scopes are re-read fresh from the DB at exchange time, so +a stale refresh token cannot carry stale authorization into a service. +""" +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import BaseModel + +from core import tokens_rs256 +from core.configuration import config +from core.database import ( + user_db_manager, jwt_user_repo, user_profile_repo, user_role_repo, +) +from core.models.user import LoginUserIn +from core.security import authenticate_user + +logger = logging.getLogger(__name__) + +# Auth endpoints (mounted at /api). JWKS is mounted separately at the root. +router = APIRouter() +wellknown_router = APIRouter() + +_bearer = HTTPBearer(auto_error=True) + + +class ExchangeIn(BaseModel): + audience: str + + +@wellknown_router.get("/.well-known/jwks.json", tags=["SSO"]) +async def jwks(): + """Public JWK Set. Services fetch and cache this to verify RS256 tokens.""" + return tokens_rs256.jwks() + + +@router.post("/auth/login", tags=["SSO"]) +async def sso_login(body: LoginUserIn): + """Authenticate with email/password and receive a refresh token. The refresh + token is not accepted by any service — exchange it at /api/auth/exchange.""" + async with user_db_manager.get_async_session() as session: + user_record = await authenticate_user(body.email, body.password, session) + if not user_record: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + scopes = await jwt_user_repo.get_user_scopes(session, user_record.id) or ["read"] + profile = await user_profile_repo.get_by_email(session, user_record.email) + profile_id = profile.id if profile else None + roles = await user_role_repo.get_user_role_names(session, profile.id) if profile else [] + + refresh = tokens_rs256.create_refresh_token( + email=user_record.email, + profile_id=profile_id, + roles=roles, + scopes=scopes, + auth_source="password", + ) + return { + "refresh_token": refresh, + "token_type": "refresh", + "expires_in": tokens_rs256.refresh_token_ttl_seconds(), + "audiences": config.token_audiences, + } + + +@router.post("/auth/exchange", tags=["SSO"]) +async def sso_exchange( + body: ExchangeIn, + creds: HTTPAuthorizationCredentials = Depends(_bearer), +): + """Exchange a refresh token for a short-lived access token scoped to one + service (`audience`). Roles/scopes are re-read fresh from the DB here.""" + try: + payload = tokens_rs256.verify_refresh_token(creds.credentials) + except Exception as e: + logger.info(f"Refresh token rejected: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if body.audience not in config.token_audiences: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unknown audience '{body.audience}'. Allowed: {config.token_audiences}", + ) + + email = payload.get("sub") + async with user_db_manager.get_async_session() as session: + # active-only lookup: a deactivated credential can no longer exchange. + jwt_user = await jwt_user_repo.get_by_email(session, email) + if not jwt_user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account inactive") + scopes = await jwt_user_repo.get_user_scopes(session, jwt_user.id) or ["read"] + profile = await user_profile_repo.get_by_email(session, email) + if profile and getattr(profile, "is_banned", False): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account_suspended") + profile_id = profile.id if profile else None + roles = await user_role_repo.get_user_role_names(session, profile.id) if profile else [] + + access = tokens_rs256.create_access_token( + audience=body.audience, + email=email, + profile_id=profile_id, + roles=roles, + scopes=scopes, + auth_source=payload.get("auth_source", "password"), + ) + return { + "access_token": access, + "token_type": "bearer", + "aud": body.audience, + "expires_in": tokens_rs256.access_token_ttl_seconds(), + } diff --git a/usermanagement_service/core/tokens_rs256.py b/usermanagement_service/core/tokens_rs256.py new file mode 100644 index 0000000..655f90b --- /dev/null +++ b/usermanagement_service/core/tokens_rs256.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +"""RS256 single-issuer SSO tokens (auth Phase 2). + +usermanagement is the sole issuer. A single login mints a short-lived REFRESH +token (``aud=brainkb-auth``); clients EXCHANGE it for narrow, short-lived +per-service ACCESS tokens (``aud=``). Services verify tokens against +the published JWKS and require ``aud == ``, so a token minted for +one service cannot be replayed against another — containment is enforced by the +audience claim, not by separate shared secrets. + +Keys are RS256. The private key is read from ``USERMANAGEMENT_JWT_PRIVATE_KEY_PEM`` +(a PEM string) or ``USERMANAGEMENT_JWT_PRIVATE_KEY_FILE`` (a path). If neither is +set, an EPHEMERAL key is generated on first use (dev only) and a warning is +logged — such tokens do not survive a process restart. Provision a persistent +key for any real deployment so the JWKS ``kid`` is stable. +""" +import base64 +import hashlib +import logging +import os +import tempfile +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from jose import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from core.configuration import config + +logger = logging.getLogger(__name__) + +ALGORITHM = "RS256" +REFRESH_AUDIENCE = "brainkb-auth" # audience of the login/refresh token +REFRESH_TYP = "refresh" +ACCESS_TYP = "access" + +# Lazily initialized key material (module-level cache for the process lifetime). +_private_pem: Optional[str] = None +_public_pem: Optional[str] = None +_kid: Optional[str] = None + + +def _load_or_generate() -> None: + global _private_pem, _public_pem, _kid + if _private_pem is not None: + return + + pem = config.jwt_private_key_pem + if not pem and config.jwt_private_key_file: + try: + with open(config.jwt_private_key_file, "r") as fh: + pem = fh.read() + except OSError as e: + logger.warning(f"Could not read USERMANAGEMENT_JWT_PRIVATE_KEY_FILE: {e}") + + if not pem: + # No key configured. Fall back to a process-shared ephemeral key: persist + # it to a file so ALL uvicorn workers (and restarts) use the SAME key — + # otherwise each worker signs with its own key and cross-worker + # verification fails. Production should set an explicit key instead. + cache_path = os.getenv( + "USERMANAGEMENT_JWT_EPHEMERAL_KEY_PATH", + os.path.join(tempfile.gettempdir(), "brainkb_um_sso_key.pem"), + ) + if os.path.exists(cache_path): + try: + with open(cache_path, "r") as fh: + pem = fh.read() + logger.warning( + "Using cached EPHEMERAL RS256 key at %s. Set " + "USERMANAGEMENT_JWT_PRIVATE_KEY_PEM/_FILE for production.", + cache_path, + ) + except OSError: + pem = None + if not pem: + logger.warning( + "No USERMANAGEMENT_JWT_PRIVATE_KEY_PEM/_FILE configured — generating " + "an EPHEMERAL RS256 key at %s (shared across workers). Set a " + "persistent key for production.", + cache_path, + ) + new_priv = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = new_priv.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode() + try: + # Atomic create: if another worker won the race, read theirs. + fd = os.open(cache_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fd, "w") as fh: + fh.write(pem) + except FileExistsError: + with open(cache_path, "r") as fh: + pem = fh.read() + except OSError as e: + logger.warning(f"Could not persist ephemeral key to {cache_path}: {e}") + + priv = serialization.load_pem_private_key(pem.encode(), password=None) + pub = priv.public_key() + _private_pem = priv.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode() + _public_pem = pub.public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + # Stable kid derived from the public key so it changes only when the key does. + _kid = hashlib.sha256(_public_pem.encode()).hexdigest()[:16] + + +def _b64u_uint(n: int) -> str: + raw = n.to_bytes((n.bit_length() + 7) // 8 or 1, "big") + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def jwks() -> Dict[str, Any]: + """Public JWK Set for token verification (served at /.well-known/jwks.json).""" + _load_or_generate() + pub = serialization.load_pem_public_key(_public_pem.encode()) + nums = pub.public_numbers() + return { + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": ALGORITHM, + "kid": _kid, + "n": _b64u_uint(nums.n), + "e": _b64u_uint(nums.e), + } + ] + } + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def create_refresh_token( + *, + email: str, + profile_id: Optional[int], + roles: List[str], + scopes: List[str], + auth_source: str = "password", +) -> str: + """Mint the login/refresh token (aud=brainkb-auth). Not accepted by services; + only exchangeable at /api/auth/exchange for a per-service access token.""" + _load_or_generate() + now = _now() + claims = { + "iss": config.jwt_issuer, + "aud": REFRESH_AUDIENCE, + "sub": email, + "typ": REFRESH_TYP, + "profile_id": profile_id, + "roles": roles, + "scopes": scopes, + "auth_source": auth_source, + "iat": now, + "exp": now + timedelta(minutes=config.refresh_token_ttl_min), + } + return jwt.encode(claims, _private_pem, algorithm=ALGORITHM, headers={"kid": _kid}) + + +def create_access_token( + *, + audience: str, + email: str, + profile_id: Optional[int], + roles: List[str], + scopes: List[str], + auth_source: str = "password", +) -> str: + """Mint a narrow, short-lived access token for a single service (aud=).""" + _load_or_generate() + now = _now() + claims = { + "iss": config.jwt_issuer, + "aud": audience, + "sub": email, + "typ": ACCESS_TYP, + "profile_id": profile_id, + "roles": roles, + "scopes": scopes, + "auth_source": auth_source, + "iat": now, + "exp": now + timedelta(minutes=config.access_token_ttl_min), + } + return jwt.encode(claims, _private_pem, algorithm=ALGORITHM, headers={"kid": _kid}) + + +def verify_refresh_token(token: str) -> Dict[str, Any]: + """Validate a refresh token (signature, iss, aud, exp). Raises jose errors on + failure. Verified with our own public key (no network).""" + _load_or_generate() + payload = jwt.decode( + token, + _public_pem, + algorithms=[ALGORITHM], + audience=REFRESH_AUDIENCE, + issuer=config.jwt_issuer, + ) + if payload.get("typ") != REFRESH_TYP: + raise ValueError("not a refresh token") + return payload + + +def access_token_ttl_seconds() -> int: + return config.access_token_ttl_min * 60 + + +def refresh_token_ttl_seconds() -> int: + return config.refresh_token_ttl_min * 60 From 38ea030b963d5e472d6419da4807ef3dde9c5b58 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 21:49:25 -0400 Subject: [PATCH 19/70] Auto-provision SSO signing key at container startup 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. --- .gitignore | 3 +++ Dockerfile.unified | 26 ++++++++++++++++++++++ docker-compose.unified.yml | 3 +++ env.template | 36 +++++++++++++++++++++++++++++++ query_service/AUTH_UNIFICATION.md | 11 ++++++---- 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 63289b7..37703e6 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,6 @@ usermanagement_service Network Trash Folder Temporary Items .apdisk + +# Auto-generated SSO signing key (do not commit) +secrets/ diff --git a/Dockerfile.unified b/Dockerfile.unified index e2f9fd8..7114665 100644 --- a/Dockerfile.unified +++ b/Dockerfile.unified @@ -210,6 +210,32 @@ PYTHON_SCRIPT echo "Django migrations completed" fi +# --- Auto-provision the SSO signing key (auth Phase 2, RS256 + JWKS) --- +# usermanagement signs SSO tokens with an RS256 private key and publishes the +# public half at /.well-known/jwks.json. An explicit key +# (USERMANAGEMENT_JWT_PRIVATE_KEY_PEM or _FILE) always wins. Otherwise we +# generate a persistent key here, ONCE, so the JWKS `kid` is stable across all +# gunicorn workers and restarts (a per-worker ephemeral key would break +# cross-worker verification). Persist it on the /app/secrets volume so it also +# survives redeploys. Exported env is inherited by supervisord's children. +if [ -z "${USERMANAGEMENT_JWT_PRIVATE_KEY_PEM}" ] && [ -z "${USERMANAGEMENT_JWT_PRIVATE_KEY_FILE}" ]; then + SSO_KEY_DIR="${USERMANAGEMENT_JWT_KEY_DIR:-/app/secrets}" + SSO_KEY_FILE="${SSO_KEY_DIR}/um_jwt_private.pem" + mkdir -p "${SSO_KEY_DIR}" + if [ ! -s "${SSO_KEY_FILE}" ]; then + echo "Generating SSO RS256 signing key at ${SSO_KEY_FILE}..." + if openssl genpkey -algorithm RSA -pkcs8 -pkeyopt rsa_keygen_bits:2048 -out "${SSO_KEY_FILE}" 2>/dev/null; then + chmod 600 "${SSO_KEY_FILE}" + echo "SSO signing key generated." + else + echo "WARNING: openssl key generation failed; usermanagement will fall back to a shared ephemeral key." + fi + else + echo "Using existing SSO RS256 signing key at ${SSO_KEY_FILE}." + fi + [ -s "${SSO_KEY_FILE}" ] && export USERMANAGEMENT_JWT_PRIVATE_KEY_FILE="${SSO_KEY_FILE}" +fi + # Ensure supervisor socket directory exists and is writable (in case /var/run is tmpfs) mkdir -p /var/run chmod 755 /var/run diff --git a/docker-compose.unified.yml b/docker-compose.unified.yml index feb90ea..6df0303 100644 --- a/docker-compose.unified.yml +++ b/docker-compose.unified.yml @@ -37,6 +37,9 @@ services: volumes: - ./logs:/var/log/supervisor + # Persist the auto-generated SSO RS256 signing key across redeploys so the + # JWKS `kid` stays stable (see Dockerfile.unified start.sh + AUTH_UNIFICATION.md). + - ./secrets:/app/secrets networks: - brainkb-network depends_on: diff --git a/env.template b/env.template index 1d2807d..4789692 100644 --- a/env.template +++ b/env.template @@ -86,6 +86,42 @@ USERMANAGEMENT_OAUTH_TOKEN_ENC_KEY=Z85D3iJe4XCfJ5f8DExKXW3DfznyE4HzJ7XmmaOYtUQ= # admin UI) remain fully manageable. USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS=tekraj@mit.edu +# ---------------------------------------------------------------------------- +# Single-issuer SSO (auth Phase 2 — RS256 + JWKS). See query_service/AUTH_UNIFICATION.md +# ---------------------------------------------------------------------------- +# usermanagement is the sole token issuer. A login (/api/auth/login) mints a +# short-lived refresh token; clients exchange it (/api/auth/exchange) for narrow +# per-service access tokens (aud=). Services verify via the published +# JWKS (/.well-known/jwks.json) and require their own audience, so a token minted +# for one service cannot be replayed against another. Legacy HS256 tokens above +# still work during migration. +# +# RS256 private key. You normally DON'T need to set this: the unified container's +# start.sh auto-generates a persistent key at /app/secrets/um_jwt_private.pem +# (mounted from ./secrets) on first boot, so the JWKS `kid` is stable across the +# 4 gunicorn workers and redeploys. Override only to supply your own key: +# - _PEM: the PEM inline (\n-escaped), OR +# - _FILE: a path to a mounted PEM file. +# Generate your own with: openssl genpkey -algorithm RSA -pkcs8 -out sso_key.pem +USERMANAGEMENT_JWT_PRIVATE_KEY_PEM= +USERMANAGEMENT_JWT_PRIVATE_KEY_FILE= +# Token issuer (`iss`). query_service's QUERY_SERVICE_SSO_ISSUER must match this. +USERMANAGEMENT_JWT_ISSUER=brainkb-usermanagement +# Access-token lifetime (minutes) and refresh-token lifetime (minutes). +USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN=15 +USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN=720 +# Services a refresh token may be exchanged for (valid `aud` values). +USERMANAGEMENT_TOKEN_AUDIENCES=query_service,ml_service,chat_service + +# query_service verifies RS256 access tokens against the issuer's JWKS. +# In the unified container usermanagement is on localhost:8004; in a split +# deployment point this at the usermanagement service URL. +QUERY_SERVICE_SSO_JWKS_URL=http://127.0.0.1:8004/.well-known/jwks.json +# Must match USERMANAGEMENT_JWT_ISSUER above. +QUERY_SERVICE_SSO_ISSUER=brainkb-usermanagement +# This service's audience — tokens must carry aud == this value. +QUERY_SERVICE_SSO_AUDIENCE=query_service + # GitHub OAuth App GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md index 6e69e60..af0097f 100644 --- a/query_service/AUTH_UNIFICATION.md +++ b/query_service/AUTH_UNIFICATION.md @@ -216,10 +216,13 @@ migration, not a flag-day cutover. Deployment env (set before/at the fresh deploy): -- usermanagement: `USERMANAGEMENT_JWT_PRIVATE_KEY_PEM` **or** `_FILE` - (**required for production** — persistent, stable `kid`; without it a shared - ephemeral key is auto-generated and logged as a warning), - `USERMANAGEMENT_JWT_ISSUER` (default `brainkb-usermanagement`), +- usermanagement: `USERMANAGEMENT_JWT_PRIVATE_KEY_PEM` **or** `_FILE` — normally + **not needed**: the unified container's `start.sh` auto-generates a persistent + RS256 key at `/app/secrets/um_jwt_private.pem` (mounted from `./secrets`) on + first boot, giving a stable `kid` across the 4 gunicorn workers and redeploys. + Set `_PEM`/`_FILE` only to supply your own key. (If key generation is somehow + unavailable, the service falls back to a shared ephemeral key and logs a + warning.) Plus `USERMANAGEMENT_JWT_ISSUER` (default `brainkb-usermanagement`), `USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN` (15), `USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN` (720), `USERMANAGEMENT_TOKEN_AUDIENCES` (`query_service,ml_service,chat_service`). - query_service: `QUERY_SERVICE_SSO_JWKS_URL` (default From 45f1a8ceb8ac2707b2c95a49f33a68ccd0b6f1db Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 22:35:36 -0400 Subject: [PATCH 20/70] Fix SSO key generation in start.sh (openssl genpkey has no -pkcs8) 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. --- Dockerfile.unified | 2 +- env.template | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.unified b/Dockerfile.unified index 7114665..ff7d41e 100644 --- a/Dockerfile.unified +++ b/Dockerfile.unified @@ -224,7 +224,7 @@ if [ -z "${USERMANAGEMENT_JWT_PRIVATE_KEY_PEM}" ] && [ -z "${USERMANAGEMENT_JWT_ mkdir -p "${SSO_KEY_DIR}" if [ ! -s "${SSO_KEY_FILE}" ]; then echo "Generating SSO RS256 signing key at ${SSO_KEY_FILE}..." - if openssl genpkey -algorithm RSA -pkcs8 -pkeyopt rsa_keygen_bits:2048 -out "${SSO_KEY_FILE}" 2>/dev/null; then + if openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${SSO_KEY_FILE}" 2>/dev/null; then chmod 600 "${SSO_KEY_FILE}" echo "SSO signing key generated." else diff --git a/env.template b/env.template index 4789692..08793e5 100644 --- a/env.template +++ b/env.template @@ -102,7 +102,7 @@ USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS=tekraj@mit.edu # 4 gunicorn workers and redeploys. Override only to supply your own key: # - _PEM: the PEM inline (\n-escaped), OR # - _FILE: a path to a mounted PEM file. -# Generate your own with: openssl genpkey -algorithm RSA -pkcs8 -out sso_key.pem +# Generate your own with: openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out sso_key.pem USERMANAGEMENT_JWT_PRIVATE_KEY_PEM= USERMANAGEMENT_JWT_PRIVATE_KEY_FILE= # Token issuer (`iss`). query_service's QUERY_SERVICE_SSO_ISSUER must match this. From ebab36aad0eecdf8fe8d70054cceb059ea9589a6 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 22:55:47 -0400 Subject: [PATCH 21/70] SSO Phase 2 rollout: usermanagement + ml_service verify RS256 (aud-scoped) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- env.template | 2 +- ml_service/core/configuration.py | 6 ++ ml_service/core/jwks.py | 85 ++++++++++++++++++++ ml_service/core/security.py | 27 +++++-- query_service/AUTH_UNIFICATION.md | 17 +++- usermanagement_service/core/configuration.py | 4 +- usermanagement_service/core/routers/sso.py | 1 + usermanagement_service/core/security.py | 28 ++++++- usermanagement_service/core/tokens_rs256.py | 26 ++++++ 9 files changed, 181 insertions(+), 15 deletions(-) create mode 100644 ml_service/core/jwks.py diff --git a/env.template b/env.template index 08793e5..bfb5ba0 100644 --- a/env.template +++ b/env.template @@ -111,7 +111,7 @@ USERMANAGEMENT_JWT_ISSUER=brainkb-usermanagement USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN=15 USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN=720 # Services a refresh token may be exchanged for (valid `aud` values). -USERMANAGEMENT_TOKEN_AUDIENCES=query_service,ml_service,chat_service +USERMANAGEMENT_TOKEN_AUDIENCES=usermanagement,query_service,ml_service,chat_service # query_service verifies RS256 access tokens against the issuer's JWKS. # In the unified container usermanagement is on localhost:8004; in a split diff --git a/ml_service/core/configuration.py b/ml_service/core/configuration.py index 862ecbb..24d118a 100644 --- a/ml_service/core/configuration.py +++ b/ml_service/core/configuration.py @@ -61,6 +61,12 @@ def load_environment(env_name="env"): "JWT_POSTGRES_DATABASE_NAME": os.getenv("JWT_POSTGRES_DATABASE_NAME"), "JWT_ALGORITHM": os.getenv("JWT_ALGORITHM", "HS256"), "JWT_SECRET_KEY": os.getenv("ML_SERVICE_JWT_SECRET_KEY"), + # Phase 2 SSO: verify RS256 access tokens minted by usermanagement. + # Signature checked against the issuer's JWKS; token `aud` must equal + # SSO_AUDIENCE. Legacy HS256 tokens still validate too. + "SSO_JWKS_URL": os.getenv("ML_SERVICE_SSO_JWKS_URL", "http://127.0.0.1:8004/.well-known/jwks.json"), + "SSO_ISSUER": os.getenv("ML_SERVICE_SSO_ISSUER", "brainkb-usermanagement"), + "SSO_AUDIENCE": os.getenv("ML_SERVICE_SSO_AUDIENCE", "ml_service"), # Ingestion specific environment "RABBITMQ_USERNAME": os.getenv("RABBITMQ_USERNAME"), diff --git a/ml_service/core/jwks.py b/ml_service/core/jwks.py new file mode 100644 index 0000000..aec9d06 --- /dev/null +++ b/ml_service/core/jwks.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +"""JWKS-based verification of Phase 2 SSO access tokens (RS256). + +Verifies tokens minted by usermanagement (the single issuer). The signature is +checked against the issuer's published JWKS (fetched + cached), and the token's +`aud` MUST equal this service's audience — a token minted for another service is +rejected here (containment enforced by `aud`, not shared secrets). + +Synchronous on purpose: the sync `require_scopes` dependency must be able to call +it. The network fetch only happens on a cache miss / key rotation (10-min TTL). +""" +import logging +import threading +import time +from typing import Dict, Optional + +import httpx +from jose import jwt + +from core.configuration import load_environment + +logger = logging.getLogger(__name__) + +_env = load_environment() +JWKS_URL = _env["SSO_JWKS_URL"] +SSO_ISSUER = _env["SSO_ISSUER"] +SSO_AUDIENCE = _env["SSO_AUDIENCE"] + +_CACHE_TTL = 600 # seconds +_keys: Dict[str, dict] = {} +_fetched_at: float = 0.0 +_lock = threading.Lock() + + +def _refresh(force: bool = False) -> None: + global _keys, _fetched_at + with _lock: + if not force and _keys and (time.monotonic() - _fetched_at) < _CACHE_TTL: + return + try: + with httpx.Client(timeout=5.0) as client: + resp = client.get(JWKS_URL) + resp.raise_for_status() + data = resp.json() + _keys = {k["kid"]: k for k in data.get("keys", []) if k.get("kid")} + _fetched_at = time.monotonic() + except Exception as e: + logger.warning(f"JWKS fetch failed from {JWKS_URL}: {e}") + + +def _get_key(kid: Optional[str]) -> Optional[dict]: + if not kid: + return None + if kid in _keys and (time.monotonic() - _fetched_at) < _CACHE_TTL: + return _keys[kid] + _refresh() + if kid not in _keys: + _refresh(force=True) + return _keys.get(kid) + + +def verify_access_token(token: str) -> Optional[Dict]: + """Verify an RS256 SSO access token. Returns claims on success, or None if the + token is not RS256 / fails verification / issuer unreachable. Never raises — + callers fall back to legacy HS256.""" + try: + header = jwt.get_unverified_header(token) + except Exception: + return None + if header.get("alg") != "RS256": + return None + key = _get_key(header.get("kid")) + if not key: + return None + try: + payload = jwt.decode( + token, key, algorithms=["RS256"], + audience=SSO_AUDIENCE, issuer=SSO_ISSUER, + ) + except Exception as e: + logger.info(f"RS256 token rejected: {e}") + return None + if payload.get("typ") not in (None, "access"): + return None + return payload diff --git a/ml_service/core/security.py b/ml_service/core/security.py index d8c2695..dfe6f15 100644 --- a/ml_service/core/security.py +++ b/ml_service/core/security.py @@ -29,6 +29,7 @@ from core.configuration import load_environment from core.database import get_user +from core import jwks logger = logging.getLogger(__name__) @@ -77,10 +78,20 @@ async def authenticate_user(email, password, conn): return user +def decode_token_any(token: str) -> dict: + """Decode a bearer token, newest scheme first: a Phase 2 SSO RS256 access + token (verified via the issuer's JWKS and required to carry + ``aud == this service``), else a legacy HS256 token signed with this + service's own secret. Raises jose errors if neither validates.""" + payload = jwks.verify_access_token(token) + if payload is not None: + return payload + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + + def decode_jwt(token: str): try: - decoded_token = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - return decoded_token + return decode_token_any(token) except JWTError: raise HTTPException(status_code=403, detail="Could not validate credentials") @@ -89,7 +100,7 @@ async def get_current_user( token: Annotated[str, Depends(oauth2_scheme)], ): try: - payload = decode_jwt(token) + payload = decode_token_any(token) email = payload.get("sub") if email is None: raise credentials_exception @@ -108,7 +119,10 @@ async def get_current_user( def verify_scopes(required_scopes: List[str], token: str) -> bool: - decoded_token = decode_jwt(token) + try: + decoded_token = decode_token_any(token) + except (ExpiredSignatureError, JWTError): + raise HTTPException(status_code=403, detail="Could not validate credentials") token_scopes = decoded_token.get("scopes", []) return all(scope in token_scopes for scope in required_scopes) @@ -156,9 +170,10 @@ async def authenticate_websocket(websocket: WebSocket, required_scopes: Optional logger.warning("No JWT token provided in WebSocket connection") return None - # Decode and validate JWT token (same logic as get_current_user) + # Decode and validate JWT token (same logic as get_current_user): + # RS256 SSO access token (aud-checked) first, then legacy HS256. try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + payload = decode_token_any(token) except ExpiredSignatureError: logger.warning("JWT token has expired") return None diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md index af0097f..2e92d5f 100644 --- a/query_service/AUTH_UNIFICATION.md +++ b/query_service/AUTH_UNIFICATION.md @@ -235,9 +235,20 @@ refresh token; RS256→JWK verification roundtrip validates `aud`+`iss`; jose accepts JWK dicts. Full login→exchange→query_service acceptance, wrong-`aud` rejection, and legacy-HS256 coexistence to be confirmed on the fresh deployment. -Remaining Phase 2 rollout (same pattern, not yet done): -- `ml_service` / `chat_service`: drop in a `core/jwks.py` verifier (audience - `ml_service` / `chat_service`) and dual-verify like query_service. +Phase 2 rollout — services now verifying RS256 (aud-scoped, JWKS): +- **query_service** (`aud=query_service`) — verified live. +- **usermanagement** (`aud=usermanagement`) — its own protected routes now + accept SSO tokens via `verify_token` (local public-key verify, since it is the + issuer); `usermanagement` added to the exchangeable audiences. Verified live: + usermanagement-aud → 200, query_service-aud → 401, legacy v2 → 200. +- **ml_service** (`aud=ml_service`) — `core/jwks.py` verifier + `decode_token_any` + wired into `get_current_user`, `verify_scopes`/`require_scopes`, `decode_jwt` + (covers SSE), and the websocket path. Verified live: ml-aud → 200, + query_service-aud → 401, legacy HS256 → 200. + +Remaining Phase 2 rollout (not yet done): +- **chat_service**: same `core/jwks.py` pattern (using `requests`, no httpx) — + deferred; service not currently in use. - `brainkb_mcp`: log in once, then request per-service access tokens via `/api/auth/exchange` for whichever service a tool calls. - Once clients have migrated, retire the legacy HS256 `/api/token` paths and diff --git a/usermanagement_service/core/configuration.py b/usermanagement_service/core/configuration.py index 9bb0010..0d19fb1 100644 --- a/usermanagement_service/core/configuration.py +++ b/usermanagement_service/core/configuration.py @@ -80,7 +80,7 @@ def load_environment(env_name="env"): "USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN": os.getenv("USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN", "15"), "USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN": os.getenv("USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN", "720"), # Services a refresh token may be exchanged for (valid aud values). - "USERMANAGEMENT_TOKEN_AUDIENCES": os.getenv("USERMANAGEMENT_TOKEN_AUDIENCES", "query_service,ml_service,chat_service"), + "USERMANAGEMENT_TOKEN_AUDIENCES": os.getenv("USERMANAGEMENT_TOKEN_AUDIENCES", "usermanagement,query_service,ml_service,chat_service"), # OAuth / Admin Bootstrap "USERMANAGEMENT_PUBLIC_BASE_URL": os.getenv("USERMANAGEMENT_PUBLIC_BASE_URL", "http://localhost:8004"), @@ -196,7 +196,7 @@ def refresh_token_ttl_min(self) -> int: @property def token_audiences(self) -> list: - raw = self._env_vars.get("USERMANAGEMENT_TOKEN_AUDIENCES", "query_service,ml_service,chat_service") or "" + raw = self._env_vars.get("USERMANAGEMENT_TOKEN_AUDIENCES", "usermanagement,query_service,ml_service,chat_service") or "" return [a.strip() for a in raw.split(",") if a.strip()] @property diff --git a/usermanagement_service/core/routers/sso.py b/usermanagement_service/core/routers/sso.py index 27a47ca..cda6cb7 100644 --- a/usermanagement_service/core/routers/sso.py +++ b/usermanagement_service/core/routers/sso.py @@ -118,6 +118,7 @@ async def sso_exchange( roles=roles, scopes=scopes, auth_source=payload.get("auth_source", "password"), + jwt_user_id=jwt_user.id, ) return { "access_token": access, diff --git a/usermanagement_service/core/security.py b/usermanagement_service/core/security.py index bfc4ebe..678b6d8 100644 --- a/usermanagement_service/core/security.py +++ b/usermanagement_service/core/security.py @@ -11,6 +11,7 @@ # ----------------------------------------------------------------------------- import logging +import os import base64 import hashlib from datetime import datetime, timedelta @@ -159,11 +160,32 @@ def decrypt_token(ciphertext: Optional[str]) -> Optional[str]: return None +# The audience this service accepts in RS256 SSO access tokens (Phase 2). A token +# minted for another service (aud=query_service, ...) is NOT accepted here. +SERVICE_AUDIENCE = os.getenv("USERMANAGEMENT_SERVICE_AUDIENCE", "usermanagement") + + def verify_token(token: str) -> Union[dict, None]: - """Verify and decode a JWT token""" + """Verify and decode a bearer token, newest scheme first: + + 1. Phase 2 SSO RS256 access token minted for THIS service + (aud == usermanagement), verified with our own public key — we are the + issuer, so no network fetch. + 2. Legacy HS256 v2 token signed with this service's own secret. + + Returns the claims dict, or None if neither validates. Backward-compatible: + existing HS256 tokens keep working during migration.""" + # 1) RS256 SSO access token for this service. + try: + from core import tokens_rs256 + payload = tokens_rs256.verify_access_token(token, audience=SERVICE_AUDIENCE) + if payload is not None: + return payload + except Exception as e: + logger.debug(f"RS256 verify skipped/failed, trying HS256: {e}") + # 2) Legacy HS256 token. try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - return payload + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) except JWTError as e: logger.error(f"JWT token verification failed: {str(e)}") return None diff --git a/usermanagement_service/core/tokens_rs256.py b/usermanagement_service/core/tokens_rs256.py index 655f90b..cd38379 100644 --- a/usermanagement_service/core/tokens_rs256.py +++ b/usermanagement_service/core/tokens_rs256.py @@ -176,6 +176,7 @@ def create_access_token( roles: List[str], scopes: List[str], auth_source: str = "password", + jwt_user_id: Optional[int] = None, ) -> str: """Mint a narrow, short-lived access token for a single service (aud=).""" _load_or_generate() @@ -185,6 +186,7 @@ def create_access_token( "aud": audience, "sub": email, "typ": ACCESS_TYP, + "user_id": jwt_user_id, "profile_id": profile_id, "roles": roles, "scopes": scopes, @@ -195,6 +197,30 @@ def create_access_token( return jwt.encode(claims, _private_pem, algorithm=ALGORITHM, headers={"kid": _kid}) +def verify_access_token(token: str, audience: str) -> Optional[Dict[str, Any]]: + """Verify an RS256 access token for ``audience`` using our OWN public key + (usermanagement is the issuer, so no network/JWKS fetch is needed). Returns + the claims on success, or None if it is not a valid RS256 access token for + this audience. Never raises — callers fall back to legacy HS256.""" + _load_or_generate() + try: + header = jwt.get_unverified_header(token) + except Exception: + return None + if header.get("alg") != ALGORITHM: + return None + try: + payload = jwt.decode( + token, _public_pem, algorithms=[ALGORITHM], + audience=audience, issuer=config.jwt_issuer, + ) + except Exception: + return None + if payload.get("typ") not in (None, ACCESS_TYP): + return None + return payload + + def verify_refresh_token(token: str) -> Dict[str, Any]: """Validate a refresh token (signature, iss, aud, exp). Raises jose errors on failure. Verified with our own public key (no network).""" From ef147694d6e9138b8ac5952929c85a918089ec7e Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 23:05:28 -0400 Subject: [PATCH 22/70] docs: mark MCP single sign-on migration done in AUTH_UNIFICATION.md --- query_service/AUTH_UNIFICATION.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md index 2e92d5f..07f8d95 100644 --- a/query_service/AUTH_UNIFICATION.md +++ b/query_service/AUTH_UNIFICATION.md @@ -246,11 +246,17 @@ Phase 2 rollout — services now verifying RS256 (aud-scoped, JWKS): (covers SSE), and the websocket path. Verified live: ml-aud → 200, query_service-aud → 401, legacy HS256 → 200. +- **brainkb_mcp** — migrated to single sign-on: `brainkb_login` mints a refresh + token (cached per session) that the MCP exchanges on demand for per-service + access tokens (`query_service`, `usermanagement`). One login now covers both KG + and admin tools — the old two-login logic is gone. Legacy `/api/token` remains + an automatic fallback. A header caller can pass a refresh token to unlock all + services. Verified live: login → exchange(query_service|usermanagement) → both + services accept their token; a query_service token is rejected at usermanagement. + Remaining Phase 2 rollout (not yet done): - **chat_service**: same `core/jwks.py` pattern (using `requests`, no httpx) — deferred; service not currently in use. -- `brainkb_mcp`: log in once, then request per-service access tokens via - `/api/auth/exchange` for whichever service a tool calls. - Once clients have migrated, retire the legacy HS256 `/api/token` paths and fold in `APItokenmanager`; tighten `require_admin` to re-read roles from the DB. From 78b32809dfecb27499ab0748058f2fb41897c2c6 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 23:11:36 -0400 Subject: [PATCH 23/70] docs: update READMEs for SSO + identity unification - 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. --- query_service/README.md | 17 ++++++++++++++++- readme.md | 23 +++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/query_service/README.md b/query_service/README.md index d6224c9..9f75b3b 100644 --- a/query_service/README.md +++ b/query_service/README.md @@ -18,7 +18,17 @@ private/public spaces. ## Auth & scopes -Tokens are issued by the JWT/token manager and validated here. Scope policy: +Two token schemes are accepted (see `AUTH_UNIFICATION.md`): + +- **Single sign-on (RS256)** — access tokens minted by usermanagement (the single + issuer) and verified here against its published **JWKS**; the token's `aud` must + equal `query_service`, so a token minted for another service is rejected + (containment). Configure with `QUERY_SERVICE_SSO_JWKS_URL` / + `QUERY_SERVICE_SSO_ISSUER` / `QUERY_SERVICE_SSO_AUDIENCE`. +- **Legacy HS256** — this service's own `/api/token`, signed with its own secret. + Still accepted during migration; both schemes work side by side. + +Scope policy (same for either scheme): - **GET (reads)** → `read` - **Mutations** (ingest, register/attach graph, recover, create/modify space) → `write` @@ -26,6 +36,11 @@ Tokens are issued by the JWT/token manager and validated here. Scope policy: - **Public-space reads** → no token required (anonymous), see Spaces below - `/register`, `/token` → public +`POST /register` creates the credential **and** a canonical `Web_user_profile` +with a default role (so a password user is a first-class identity, not a role-less +orphan); the account starts inactive until an admin activates it. Authorization is +role-based (see `RBAC_MODEL.md`) and read from the DB, not just the token. + Users may only act on their own `user_id` (enforced), and job-scoped endpoints are owner-only. diff --git a/readme.md b/readme.md index 481019a..4714fdf 100644 --- a/readme.md +++ b/readme.md @@ -52,7 +52,9 @@ Once started, services are accessible at: tracking (per-job delta graphs + query/compare endpoints). - **Spaces**: team-owned, private/public containers of named graphs — keep data private to members or publish it publicly (anonymous read). Per-endpoint JWT - scopes (`read`/`write`/`admin`). + scopes (`read`/`write`/`admin`); role-based authorization (see + `query_service/RBAC_MODEL.md`). Accepts both SSO (RS256/JWKS) and legacy + HS256 tokens. - **Search**: hybrid full-text search — Postgres locator index (aware of workspace + visibility) finds subjects, data is fetched from Oxigraph. Results are access-filtered (anonymous sees public only). @@ -67,7 +69,24 @@ Once started, services are accessible at: optional API keys (OpenRouter, NCBI, Semantic Scholar, CORE). - **Oxigraph SPARQL**: `http://localhost:7878/` (password protected) graph database - **pgAdmin**: `http://localhost:5051/` -- **User management service**: http://localhost:8004 +- **User management service (FastAPI)**: `http://localhost:8004` + - Canonical **identity** service: user profiles, roles/RBAC, and OAuth sign-in + (Globus / ORCID / GitHub). One canonical user; the credential row is linked to + the profile (identity unification). + - **Single sign-on** issuer (RS256 + JWKS): one login mints a refresh token, + exchanged for narrow per-service access tokens (`aud=`) that each + service verifies via `/.well-known/jwks.json`. A token for one service can't + be replayed against another. Legacy per-service HS256 tokens still work. + - Admins can activate users and assign roles/groups. See + `query_service/AUTH_UNIFICATION.md` and `usermanagement_service/README.md`. + +## Authentication + +BrainKB is moving to a single sign-on model — usermanagement is the sole token +issuer and each service verifies audience-scoped RS256 tokens against its JWKS, +while legacy per-service HS256 tokens remain accepted during migration. The full +design, phases, and deployment env are in +[query_service/AUTH_UNIFICATION.md](query_service/AUTH_UNIFICATION.md). ## Documentation From 941bd8342798a70d327b14c1db3e84ff21be5d0a Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 23 Jul 2026 23:11:54 -0400 Subject: [PATCH 24/70] =?UTF-8?q?docs:=20usermanagement=20README=20?= =?UTF-8?q?=E2=80=94=20document=20SSO=20endpoints=20(login/exchange/JWKS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- usermanagement_service/README.md | 243 +++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 usermanagement_service/README.md diff --git a/usermanagement_service/README.md b/usermanagement_service/README.md new file mode 100644 index 0000000..3ad3d71 --- /dev/null +++ b/usermanagement_service/README.md @@ -0,0 +1,243 @@ +# BrainKB User Management Service + +A modern, scalable user management service for neuroscience knowledge bases built with FastAPI, SQLAlchemy ORM, and PostgreSQL. + +## 🚀 Features + +- **🔐 JWT Authentication** - Secure token-based authentication +- **👥 User Profiles** - Comprehensive user profile management +- **📊 Activity Tracking** - Automatic logging of user activities +- **🎯 Contribution Management** - Track and manage user contributions +- **🏷️ Role-Based Access Control** - Multiple user roles and permissions +- **📈 Analytics** - User statistics and activity analytics +- **🔒 Security** - Input validation, SQL injection protection, XSS prevention + +## 🏗️ Architecture + +### Tech Stack +- **FastAPI** - Modern, fast web framework +- **SQLAlchemy ORM** - Type-safe database operations +- **PostgreSQL** - Robust relational database +- **Pydantic** - Data validation and serialization +- **JWT** - Secure authentication + +### Key Components +``` +core/ +├── models/ +│ ├── user.py # Pydantic models for API +│ └── database_models.py # SQLAlchemy ORM models +├── user_database.py # User-specific database operations +├── routers/ +│ ├── user_management.py # User management endpoints +│ └── jwt_auth.py # Authentication endpoints +├── security.py # JWT and password utilities +├── configuration.py # Environment configuration +└── main.py # FastAPI application +``` + +## 🚀 Quick Start + +### Prerequisites +- Python 3.8+ +- PostgreSQL 12+ + +### Installation + +1. **Clone and install** +```bash +git clone +cd usermanagement_service +pip install -r requirements.txt +``` + +2. **Configure environment** +```bash +cp .env.example .env +# Edit .env with your database and JWT settings +``` + +3. **Start the service** +```bash +uvicorn core.main:app --reload +``` + +### Docker Setup +```bash +# Start with Docker Compose +docker-compose -f docker-compose-postgres.yml up -d + +# Or build and run +docker build -t brainkb-user-service . +docker run -p 8000:8000 brainkb-user-service +``` + +## 📚 API Documentation + +Once running, visit: +- **Interactive API Docs**: http://localhost:8000/docs +- **ReDoc Documentation**: http://localhost:8000/redoc + +### Key Endpoints + +End-user sign-up happens automatically on first OAuth callback (see +*Configuring the Admin role* below) — there is no public `/register` endpoint. +The `/api/token` endpoint mints a JWT for service-account password login only. + +- `POST /api/token` - Legacy HS256 password login (mints a v2 JWT) +- `GET /api/auth/providers` - List OAuth providers + which are configured +- `GET /api/auth/{provider}/login` - Start OAuth flow (returns `authorize_url`) +- `GET /api/auth/{provider}/callback` - OAuth callback; auto-creates profile + linked credential + default `Curator` role on first sign-in + +**Single sign-on (RS256 + JWKS)** — usermanagement is the single token issuer. +One login mints a short-lived **refresh** token; clients **exchange** it for narrow +per-service **access** tokens (`aud=`), which each service verifies via +the published JWKS. A token minted for one service can't be replayed against +another. See `../query_service/AUTH_UNIFICATION.md`. + +- `GET /.well-known/jwks.json` - Public keys for verifying SSO tokens +- `POST /api/auth/login` - `{email, password}` → refresh token (aud `brainkb-auth`) +- `POST /api/auth/exchange` - Bearer refresh + `{audience}` → per-service access token + +The signing key is auto-provisioned at container start (persisted on the +`./secrets` volume) unless `USERMANAGEMENT_JWT_PRIVATE_KEY_PEM`/`_FILE` is set. +This service also accepts SSO access tokens minted for `aud=usermanagement` on its +own protected routes (alongside the legacy v2 token). +- `GET /api/users/profile` - Get user profile +- `POST /api/users/profile` - Create/update profile +- `GET /api/users/activities` - Get user activities +- `POST /api/users/contributions` - Create contribution +- `GET /api/users/roles` - Get user roles +- `POST /api/users/roles` - Assign role + +## 🎯 User Roles + +### Content Contribution +- **Submitter** - Upload primary content +- **Annotator** - Add metadata and tags +- **Mapper** - Align concepts to ontologies +- **Curator** - Review and edit submissions + +### Quality Control +- **Reviewer** - Evaluate content quality +- **Validator** - Check schema compliance +- **Conflict Resolver** - Handle contradictions + +### Knowledge Management +- **Knowledge Contributor** - Add domain knowledge +- **Evidence Tracer** - Link supporting evidence +- **Provenance Tracker** - Track source metadata + +### Community Management +- **Moderator** - Oversee discussions +- **Ambassador** - Community outreach + +## 👑 Configuring the Admin role + +The `Admin` role is special — only admins can assign roles to other users via +`POST /api/admin/users/{profile_id}/roles`. That creates a chicken-and-egg +problem for the *first* admin, solved by env-var bootstrap. + +### 1. First admin (bootstrap, env-var) + +In the backend's `.env` (project root, e.g. `BrainKB/.env`): + +```env +USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS=you@example.com,colleague@example.com +``` + +Comma-separated; whitespace around emails is trimmed. Restart the service — +on every startup `core/bootstrap.py::promote_bootstrap_superadmins()` runs and: + +1. **Email already has a `UserProfile`** → assigns both `Admin` and + `SuperAdmin` (idempotent, safe to re-run). +2. **Email has not signed in yet** → no profile to update, but the + `require_admin` dependency honors the env allowlist as a fallback. The + user can sign in via OAuth, which creates their profile, and the next + restart persists the roles. + +Bootstrap also seeds baseline roles and grants both `Admin` and `SuperAdmin` +every permission in the registry, so a fresh admin has full access +immediately. The `SuperAdmin` role is protected — accounts holding it cannot +be banned, deleted, or have that role stripped via the admin UI/API. + +Verify: + +```bash +# After the user has signed in, with their JWT in $TOKEN: +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8004/api/users/me | jq .roles +# → ["Admin", ...] +``` + +### 2. Subsequent admins (UI) + +Once at least one admin exists, role management happens through the BrainKB +UI's admin surface: + +1. Sign in as an existing admin and visit `/admin/users`. +2. Search by name, email, or ORCID. +3. In the *Roles* column, pick **Admin** from the *+ add role* dropdown. + +Demote: click the `Admin ✕` chip on the user's row. + +Programmatic equivalent (any admin's JWT): + +```bash +curl -X POST http://localhost:8004/api/admin/users/{profile_id}/roles \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"role": "Admin", "is_active": true}' +``` + +See `UI_INTEGRATION.md` for the full set of admin endpoints. + +## 📊 Database Models + +### Core Models +- **User** - Authentication and basic info +- **UserProfile** - Detailed profile information +- **UserActivity** - Activity tracking and logging +- **UserContribution** - Content contribution management +- **UserRole** - Role assignments and permissions + +### Automatic Setup +The service automatically creates all database tables on startup using SQLAlchemy ORM models. No manual migration required! + +## 🔧 Development + +### Adding Features +1. **Add models** in `core/models/database_models.py` +2. **Add repository methods** in `core/user_database.py` +3. **Add API endpoints** in `core/routers/user_management.py` +4. **Add Pydantic models** in `core/models/user.py` + +### Testing +```bash +pytest +pytest --cov=core +``` + +## 📈 Performance + +- **Connection Pooling** - Efficient database connections +- **Optimized Indexes** - Fast query performance +- **Async Operations** - Non-blocking operations +- **Type Safety** - Catch errors at development time + +## 🔒 Security + +- **JWT Authentication** - Secure token-based auth +- **Password Hashing** - bcrypt for password security +- **Input Validation** - Pydantic model validation +- **SQL Injection Protection** - ORM prevents injection +- **Role-Based Access** - Granular permissions + +## 📞 Support + +- **Email**: tekraj@mit.edu +- **Issues**: GitHub Issues +- **Documentation**: `/docs` endpoint when running + +--- + +**Built for scalable neuroscience knowledge base user management!** 🧠🔬 \ No newline at end of file From c751dde85a82362de2e34640dc19bdcd62dd5ab4 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 00:39:46 -0400 Subject: [PATCH 25/70] Spaces: a write access rule now GRANTS ingest (assign a team space to a group) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- query_service/README.md | 56 ++++++++++++++++++++++++++++ query_service/core/routers/insert.py | 55 ++++++++++++--------------- query_service/core/spaces.py | 26 +++++++++++++ 3 files changed, 106 insertions(+), 31 deletions(-) diff --git a/query_service/README.md b/query_service/README.md index 9f75b3b..588b202 100644 --- a/query_service/README.md +++ b/query_service/README.md @@ -44,6 +44,62 @@ role-based (see `RBAC_MODEL.md`) and read from the DB, not just the token. Users may only act on their own `user_id` (enforced), and job-scoped endpoints are owner-only. +## Capabilities & roles (RBAC) + +Two independent layers apply to every mutating call: + +1. **JWT scope** (`read`/`write`/`admin`) — API-access gate at the endpoint. +2. **Capability** — *who is allowed to do what*, derived from the user's **role(s)** + (read from the DB, not just the token) plus any admin-delegated grants. + +### Capabilities — what each one means + +| Capability | Meaning | +|---|---| +| `create_private_space` | Create your own individual/private space | +| `create_team_space` | Create a **team** (shared) space | +| `manage_team_space` | Manage a team space's members, visibility, graphs, and access rules | +| `ingest` | Ingest data into a graph — **also** needs per-space write (owner/editor membership **or** a space write access rule; see below) | +| `recover` | Recover stuck/errored ingest jobs | +| `read_private` | Read non-public content you're a member of | +| `sparql_admin` | Run arbitrary SPARQL (`/query/sparql/`) | +| `grant` | Grant/revoke capabilities to other users | + +### Which roles get which capabilities + +| Role tier | Capabilities | +|---|---| +| **SuperAdmin / Admin** | **all** of the above | +| **Write roles** — Curator, Lab Member, Submitter, Annotator, Mapper, Knowledge Contributor | `create_private_space`, `ingest`, `recover`, `read_private` | +| **Any other active role** (Reviewer, Validator, Moderator, …) | `read_private` | +| **No role** | public reads only — no create/ingest/private read | + +**Delegation (Admin only):** an admin can grant the *grantable* capabilities — +`create_private_space`, `create_team_space`, `manage_team_space`, `ingest`, +`recover`, `read_private` — to a specific user. `grant` and `sparql_admin` are +**not** delegatable (they come only from an Admin/SuperAdmin role), so the grant +endpoint can't escalate a non-admin into an admin. + +**SuperAdmin vs Admin:** identical KG capabilities here. SuperAdmin is a +bootstrap-seeded, protected marker (can't be banned/deleted/role-stripped); +role *assignment* is owned by the usermanagement service, not query_service. + +### Giving a whole group ingest access to a team space + +Ingesting into a space-mapped graph needs the `ingest` capability **and** write +authorization on the space. Write is granted by any of: global Admin, owner/editor +membership, **or a per-space write access rule**. So to let a whole group ingest +without adding each person as a member, an admin (or space manager) adds a rule: + +``` +action=write, subject_type=global_role, subject_value="" # e.g. "Lab Member" +``` + +Every user in that group can then ingest into the space's graphs (they still need +a write-capable role for the `ingest` capability). Rules can also target a single +`member` (email) or a `space_role`. Remove the rule to revoke. See +`RBAC_MODEL.md` and `SPACES_MODEL.md` for the full model. + ## Endpoints (prefix `/api`) ### Query diff --git a/query_service/core/routers/insert.py b/query_service/core/routers/insert.py index de27068..a33bdf4 100644 --- a/query_service/core/routers/insert.py +++ b/query_service/core/routers/insert.py @@ -48,7 +48,6 @@ batch_insert_job_results, ) from core.configuration import load_environment -from core.spaces import authorize as authorize_space_access from core import spaces as _spaces from core import rbac from core.provenance import ( @@ -1188,23 +1187,20 @@ async def insert_knowledge_graph_triples( {"error": "Not authorized to ingest: a write-capable role is required."}, status_code=403, ) - # If the graph belongs to a space, enforce space write-authorization - # (owner/editor). Unmapped legacy graphs fall through (scope check applies). + # If the graph belongs to a space, enforce space write authorization: owner/ + # editor membership, global admin, OR a space write access rule granting a + # group/role/member write (see spaces.can_write_space) — this is how an admin + # hands a whole group ingest access. Unmapped legacy graphs fall through to the + # endpoint scope check. _graph_key = named_graph_iri if named_graph_iri.endswith("/") else named_graph_iri + "/" - _allowed, _reason = await authorize_space_access(_graph_key, _agent_email(user), "write") - if not _allowed: - return JSONResponse( - {"error": f"Not authorized to ingest into this graph: {_reason}", "named_graph_iri": named_graph_iri}, - status_code=403, - ) - # Fine-grained per-space write rules (if any) further restrict who can ingest. _space_for_graph = await _spaces.get_space_for_graph(_graph_key) - if _space_for_graph and not await _spaces.space_action_permitted(_space_for_graph, "write", _agent_email(user)): - return JSONResponse( - {"error": "Not authorized to ingest into this graph: restricted by a space access rule.", - "named_graph_iri": named_graph_iri}, - status_code=403, - ) + if _space_for_graph is not None: + _allowed, _reason = await _spaces.can_write_space(_space_for_graph, _agent_email(user)) + if not _allowed: + return JSONResponse( + {"error": f"Not authorized to ingest into this graph: {_reason}", "named_graph_iri": named_graph_iri}, + status_code=403, + ) job_id = uuid.uuid4().hex @@ -1325,23 +1321,20 @@ async def insert_file_knowledge_graph_triples( {"error": "Not authorized to ingest: a write-capable role is required."}, status_code=403, ) - # If the graph belongs to a space, enforce space write-authorization - # (owner/editor). Unmapped legacy graphs fall through (scope check applies). + # If the graph belongs to a space, enforce space write authorization: owner/ + # editor membership, global admin, OR a space write access rule granting a + # group/role/member write (see spaces.can_write_space) — this is how an admin + # hands a whole group ingest access. Unmapped legacy graphs fall through to the + # endpoint scope check. _graph_key = named_graph_iri if named_graph_iri.endswith("/") else named_graph_iri + "/" - _allowed, _reason = await authorize_space_access(_graph_key, _agent_email(user), "write") - if not _allowed: - return JSONResponse( - {"error": f"Not authorized to ingest into this graph: {_reason}", "named_graph_iri": named_graph_iri}, - status_code=403, - ) - # Fine-grained per-space write rules (if any) further restrict who can ingest. _space_for_graph = await _spaces.get_space_for_graph(_graph_key) - if _space_for_graph and not await _spaces.space_action_permitted(_space_for_graph, "write", _agent_email(user)): - return JSONResponse( - {"error": "Not authorized to ingest into this graph: restricted by a space access rule.", - "named_graph_iri": named_graph_iri}, - status_code=403, - ) + if _space_for_graph is not None: + _allowed, _reason = await _spaces.can_write_space(_space_for_graph, _agent_email(user)) + if not _allowed: + return JSONResponse( + {"error": f"Not authorized to ingest into this graph: {_reason}", "named_graph_iri": named_graph_iri}, + status_code=403, + ) job_id = uuid.uuid4().hex # generate for job tracking diff --git a/query_service/core/spaces.py b/query_service/core/spaces.py index 1daf23c..a79a6d7 100644 --- a/query_service/core/spaces.py +++ b/query_service/core/spaces.py @@ -344,6 +344,32 @@ async def space_action_permitted(space: Dict[str, Any], action: str, email: Opti return await matches_access_rule(space_id, action, email) +async def can_write_space(space: Dict[str, Any], email: Optional[str]) -> Tuple[bool, str]: + """Whether ``email`` may WRITE (ingest) into ``space``. Write is GRANTED by any + of: + * global Admin/SuperAdmin, + * owner/editor membership of the space, + * a matching **write** access rule — by ``global_role`` (a group/role), + ``member`` (an email), or ``space_role``. + + This is what lets an admin hand a whole group ingest access to a team space + without adding every user as a member: add a write rule with + ``subject_type=global_role`` (e.g. ``Lab Member``). The caller still needs the + ``INGEST`` capability (a write-capable role) — enforced separately at the + endpoint — so a read-only group can't ingest even with a write rule.""" + from core import rbac + space_id = space["space_id"] + if await rbac.is_admin(email): + return True, "admin" + role = await member_role(space_id, email) + if role in WRITE_ROLES: + return True, f"member ({role})" + if await matches_access_rule(space_id, "write", email): + return True, "space write access rule (group/role/member)" + return False, ("requires owner/editor membership, or a space write access rule " + "granting your group/role write (ask an admin)") + + async def authorize(named_graph_iri: str, member: Optional[str], need: str) -> Tuple[bool, str]: """ Decide whether ``member`` (a user email, or None if anonymous) may read/write From 0b683b2366286fac9d3163e8d3aed7ef936d2e93 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 00:54:32 -0400 Subject: [PATCH 26/70] RBAC: grant global capabilities to a whole role/group (custom groups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- query_service/core/main.py | 22 ++++++++- query_service/core/rbac.py | 55 +++++++++++++++++++++- query_service/core/routers/spaces.py | 68 ++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/query_service/core/main.py b/query_service/core/main.py index 1dc8896..ee57a07 100644 --- a/query_service/core/main.py +++ b/query_service/core/main.py @@ -228,7 +228,27 @@ async def startup_event(): await conn.execute("CREATE INDEX IF NOT EXISTS idx_capability_grants_member ON user_capability_grants(member)") except Exception: pass - logger.info("RBAC capability-grants table initialized") + # Role/group-level capability grants: attach a delegatable KG + # capability to a whole role/group (e.g. a custom "uk_collaborator" + # group), so every member of that role gains it — without a + # per-user grant. + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS role_capability_grants ( + id SERIAL PRIMARY KEY, + role TEXT NOT NULL, + capability TEXT NOT NULL, + granted_by TEXT, + created_at DOUBLE PRECISION, + UNIQUE (role, capability) + ) + """ + ) + try: + await conn.execute("CREATE INDEX IF NOT EXISTS idx_role_capability_grants_role ON role_capability_grants(role)") + except Exception: + pass + logger.info("RBAC capability-grants tables initialized") # Fine-grained per-space access rules: restrict a space action # (read/write/manage) to a global role, a space role, or specific diff --git a/query_service/core/rbac.py b/query_service/core/rbac.py index 91480a3..1fdfd15 100644 --- a/query_service/core/rbac.py +++ b/query_service/core/rbac.py @@ -123,13 +123,28 @@ async def granted_capabilities(email: Optional[str]) -> Set[str]: return {row["capability"] for row in rows if row["capability"] in ALL_CAPS} +async def role_granted_capabilities(roles: Set[str]) -> Set[str]: + """Capabilities attached to any of ``roles`` via role/group-level grants + (role_capability_grants). Lets an admin grant a whole custom group/role a + capability (e.g. give 'uk_collaborator' the ingest capability).""" + if not roles: + return set() + async with get_db_connection() as conn: + rows = await conn.fetch( + "SELECT capability FROM role_capability_grants WHERE role = ANY($1::text[])", + list(roles), + ) + return {row["capability"] for row in rows if row["capability"] in ALL_CAPS} + + async def capabilities(email: Optional[str]) -> Set[str]: - """Effective capabilities = role-derived caps ∪ delegated grants.""" + """Effective capabilities = role-derived caps ∪ role/group grants ∪ per-user grants.""" caps: Set[str] = set() roles = await active_roles(email) for r in roles: caps |= _caps_for_role(r) if roles: # only users with at least one role can be granted extras + caps |= await role_granted_capabilities(roles) caps |= await granted_capabilities(email) return caps @@ -175,3 +190,41 @@ async def list_grants(member: str) -> list: member, ) return [dict(r) for r in rows] + + +# ---- role/group-level grants (admin only — enforced at the endpoint) -------- + +async def grant_role_capability(role: str, capability: str, granted_by: str) -> None: + if capability not in GRANTABLE_CAPS: + raise ValueError(f"capability is not delegatable: {capability}") + async with get_db_connection() as conn: + await conn.execute( + """ + INSERT INTO role_capability_grants (role, capability, granted_by, created_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (role, capability) DO NOTHING + """, + role, capability, granted_by, time.time(), + ) + + +async def revoke_role_capability(role: str, capability: str) -> None: + async with get_db_connection() as conn: + await conn.execute( + "DELETE FROM role_capability_grants WHERE role = $1 AND capability = $2", + role, capability, + ) + + +async def list_role_grants(role: Optional[str] = None) -> list: + async with get_db_connection() as conn: + if role: + rows = await conn.fetch( + "SELECT role, capability, granted_by, created_at FROM role_capability_grants WHERE role = $1 ORDER BY capability", + role, + ) + else: + rows = await conn.fetch( + "SELECT role, capability, granted_by, created_at FROM role_capability_grants ORDER BY role, capability" + ) + return [dict(r) for r in rows] diff --git a/query_service/core/routers/spaces.py b/query_service/core/routers/spaces.py index 3c54740..c7544c8 100644 --- a/query_service/core/routers/spaces.py +++ b/query_service/core/routers/spaces.py @@ -260,6 +260,11 @@ class GrantIn(BaseModel): capability: str +class RoleGrantIn(BaseModel): + role: str + capability: str + + class AccessRuleIn(BaseModel): action: str # read | write | manage subject_type: str # global_role | member | space_role @@ -355,3 +360,66 @@ async def revoke_capability(body: GrantIn, user: Annotated[LoginUserIn, Depends( raise HTTPException(403, "Admin/SuperAdmin role required to revoke capabilities") await rbac.revoke_capability(body.member, body.capability) return {"status": "revoked", "member": body.member, "capability": body.capability} + + +@router.get("/admin/capabilities/available", + dependencies=[Depends(require_scopes(["admin"]))], + summary="List all KG capabilities and which are delegatable (admin only)", + description="Catalog of query_service capabilities. 'grantable' are the ones " + "an admin may delegate to a user or role/group; 'grant' and " + "'sparql_admin' are admin-intrinsic (not delegatable).") +async def available_capabilities(user: Annotated[LoginUserIn, Depends(get_current_user)]): + if not await rbac.is_admin(_agent(user)): + raise HTTPException(403, "Admin/SuperAdmin role required") + return { + "all": sorted(rbac.ALL_CAPS), + "grantable": sorted(rbac.GRANTABLE_CAPS), + "admin_only": sorted(rbac.ALL_CAPS - rbac.GRANTABLE_CAPS), + "descriptions": { + rbac.CREATE_PRIVATE_SPACE: "Create your own individual/private space", + rbac.CREATE_TEAM_SPACE: "Create a team (shared) space", + rbac.MANAGE_TEAM_SPACE: "Manage a team space's members, visibility, graphs, access rules", + rbac.INGEST: "Ingest data (also needs per-space write: membership or a space write access rule)", + rbac.RECOVER: "Recover stuck/errored ingest jobs", + rbac.READ_PRIVATE: "Read non-public content you're a member of", + rbac.SPARQL_ADMIN: "Run arbitrary SPARQL (admin-only, not delegatable)", + rbac.GRANT: "Grant/revoke capabilities to others (admin-only, not delegatable)", + }, + } + + +@router.get("/admin/capabilities/role", + dependencies=[Depends(require_scopes(["admin"]))], + summary="List capabilities granted to a role/group (admin only)") +async def role_capabilities( + user: Annotated[LoginUserIn, Depends(get_current_user)], + role: Annotated[str, Query(..., description="Role/group name, e.g. 'uk_collaborator'")], +): + if not await rbac.is_admin(_agent(user)): + raise HTTPException(403, "Admin/SuperAdmin role required") + return {"role": role, "grants": await rbac.list_role_grants(role)} + + +@router.post("/admin/capabilities/grant-role", + dependencies=[Depends(require_scopes(["admin"]))], + summary="Grant a capability to a whole role/group (admin only)", + description="Give every member of a role/group a delegatable capability — " + "e.g. grant 'ingest' or 'create_private_space' to a custom group " + "like 'uk_collaborator'. 'grant'/'sparql_admin' are not delegatable.") +async def grant_role_capability(body: RoleGrantIn, user: Annotated[LoginUserIn, Depends(get_current_user)]): + if not await rbac.is_admin(_agent(user)): + raise HTTPException(403, "Admin/SuperAdmin role required to grant capabilities") + if body.capability not in rbac.GRANTABLE_CAPS: + raise HTTPException(400, f"capability is not delegatable; valid: {sorted(rbac.GRANTABLE_CAPS)}") + await rbac.grant_role_capability(body.role, body.capability, _agent(user)) + return {"status": "granted", "role": body.role, "capability": body.capability} + + +@router.post("/admin/capabilities/revoke-role", + dependencies=[Depends(require_scopes(["admin"]))], + summary="Revoke a capability from a role/group (admin only)") +async def revoke_role_capability(body: RoleGrantIn, user: Annotated[LoginUserIn, Depends(get_current_user)]): + if not await rbac.is_admin(_agent(user)): + raise HTTPException(403, "Admin/SuperAdmin role required to revoke capabilities") + await rbac.revoke_role_capability(body.role, body.capability) + return {"status": "revoked", "role": body.role, "capability": body.capability} From d72900457e64470b2bfc26da6561ad3e32e307e9 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 00:54:42 -0400 Subject: [PATCH 27/70] Admin hierarchy: SuperAdmin-only over Admins; disable user deletion (ban only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- usermanagement_service/core/routers/admin.py | 75 +++++++++++++------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/usermanagement_service/core/routers/admin.py b/usermanagement_service/core/routers/admin.py index 300d54e..dc5743e 100644 --- a/usermanagement_service/core/routers/admin.py +++ b/usermanagement_service/core/routers/admin.py @@ -37,6 +37,26 @@ AdminUserListItem, UserRoleInput, ActivityType, ) from core.security import require_admin +from core.configuration import config + +# Roles that make someone part of the admin tier. Managing these (assign/remove +# the role, or ban/delete such an account) is SuperAdmin-only — the hierarchy is +# SuperAdmin > Admin. The SuperAdmin role itself is additionally protected from +# removal/ban/delete everywhere below. +_ADMIN_TIER_ROLES = {"Admin", "SuperAdmin"} + + +def _is_superadmin(admin: dict) -> bool: + """True if the acting caller holds SuperAdmin (or is a bootstrap superadmin).""" + if isinstance(admin, dict) and "SuperAdmin" in (admin.get("roles") or []): + return True + email = ((admin.get("sub") or admin.get("email")) if isinstance(admin, dict) else "") or "" + return bool(email and email.lower() in config.bootstrap_superadmin_emails) + + +def _require_superadmin(admin: dict, action: str) -> None: + if not _is_superadmin(admin): + raise HTTPException(status_code=403, detail=f"Only a SuperAdmin can {action}.") logger = logging.getLogger(__name__) router = APIRouter() @@ -304,25 +324,16 @@ async def count_users(_admin: Annotated[dict, Depends(require_admin)]): return {"count": result.scalar_one()} -@router.delete("/users/{profile_id}", status_code=204) +@router.delete("/users/{profile_id}") async def delete_user(profile_id: int, _admin: Annotated[dict, Depends(require_admin)]): - async with user_db_manager.get_async_session() as session: - profile = await session.get(UserProfileModel, profile_id) - if not profile: - raise HTTPException(status_code=404, detail="User not found") - # SuperAdmin accounts are protected — they cannot be deleted via the - # admin endpoints. Drop them from the bootstrap allowlist + restart - # if this is genuinely needed. - target_roles = await user_role_repo.get_user_role_names(session, profile_id) - if "SuperAdmin" in (target_roles or []): - raise HTTPException( - status_code=403, - detail="SuperAdmin accounts cannot be deleted via the admin UI.", - ) - # Cascades delete activities, roles, contributions, countries, orgs, education, expertise, oauth_identity. - await session.delete(profile) - await session.commit() - return None + """User deletion is DISABLED by policy — ban the account instead + (`POST /users/{profile_id}/ban`). Banning is reversible and preserves the + user's provenance/audit history; hard deletion is not offered.""" + raise HTTPException( + status_code=405, + detail="User deletion is disabled. Ban the account instead " + "(POST /users/{profile_id}/ban) — reversible and preserves history.", + ) @router.post("/users/{profile_id}/roles", response_model=List[str]) @@ -335,6 +346,9 @@ async def assign_role_to_user( profile = await session.get(UserProfileModel, profile_id) if not profile: raise HTTPException(status_code=404, detail="User not found") + # Only a SuperAdmin may create/grant admin-tier roles (Admin/SuperAdmin). + if body.role in _ADMIN_TIER_ROLES: + _require_superadmin(admin, f"assign the {body.role} role") await user_role_repo.assign_role( session=session, profile_id=profile_id, @@ -358,7 +372,7 @@ async def assign_role_to_user( async def remove_role_from_user( profile_id: int, role_name: str, - _admin: Annotated[dict, Depends(require_admin)], + admin: Annotated[dict, Depends(require_admin)], ): async with user_db_manager.get_async_session() as session: profile = await session.get(UserProfileModel, profile_id) @@ -370,6 +384,9 @@ async def remove_role_from_user( status_code=403, detail="The SuperAdmin role cannot be removed via the admin UI.", ) + # Demoting an Admin (removing the Admin role) is SuperAdmin-only. + if role_name == "Admin": + _require_superadmin(admin, "remove the Admin role") await user_role_repo.remove_role(session, profile_id, role_name) roles = await user_role_repo.get_user_role_names(session, profile_id) await session.commit() @@ -534,9 +551,9 @@ async def ban_user( """Suspend a user. Body: { "reason": str }. Idempotent — re-banning an already-banned user updates the reason and timestamp. - Refuses to ban yourself or to ban a SuperAdmin. Regular Admins are - bannable directly — multiple admins can coexist, and one admin moderating - another is part of the model. + Refuses to ban yourself or a SuperAdmin. Banning an Admin is SuperAdmin-only + (hierarchy: SuperAdmin > Admin); regular Admins can ban non-admin users. + Banning is how accounts are removed — deletion is disabled (we don't delete). """ reason = (payload.get("reason") or "").strip() if isinstance(payload, dict) else "" if not reason: @@ -559,9 +576,13 @@ async def ban_user( status_code=403, detail="SuperAdmin accounts cannot be banned via the admin UI.", ) + # Banning an Admin is SuperAdmin-only (SuperAdmin > Admin). + if "Admin" in (target_roles or []): + _require_superadmin(admin, "ban an Admin account") + banned_at = datetime.utcnow() profile.is_banned = True - profile.banned_at = datetime.utcnow() + profile.banned_at = banned_at profile.banned_by = actor_id profile.ban_reason = reason await session.flush() @@ -575,12 +596,14 @@ async def ban_user( user_agent=None, ) await session.commit() + # Build the response from locals — after commit the ORM attributes are + # expired and touching them would trigger async lazy-load (MissingGreenlet). return { "profile_id": profile_id, "is_banned": True, - "banned_at": profile.banned_at.isoformat() if profile.banned_at else None, - "banned_by": profile.banned_by, - "ban_reason": profile.ban_reason, + "banned_at": banned_at.isoformat(), + "banned_by": actor_id, + "ban_reason": reason, } From 8b713b6df6700eb55699aa5aef2821527b401bcd Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 00:59:03 -0400 Subject: [PATCH 28/70] docs: READMEs for role/group capability grants, SuperAdmin-over-Admin, no-delete (ban) policy --- query_service/README.md | 18 ++++++++++++++---- usermanagement_service/README.md | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/query_service/README.md b/query_service/README.md index 588b202..b1c1854 100644 --- a/query_service/README.md +++ b/query_service/README.md @@ -74,11 +74,21 @@ Two independent layers apply to every mutating call: | **Any other active role** (Reviewer, Validator, Moderator, …) | `read_private` | | **No role** | public reads only — no create/ingest/private read | -**Delegation (Admin only):** an admin can grant the *grantable* capabilities — +**Delegation (Admin/SuperAdmin only):** the *grantable* capabilities — `create_private_space`, `create_team_space`, `manage_team_space`, `ingest`, -`recover`, `read_private` — to a specific user. `grant` and `sparql_admin` are -**not** delegatable (they come only from an Admin/SuperAdmin role), so the grant -endpoint can't escalate a non-admin into an admin. +`recover`, `read_private` — can be granted to either: + +- **an individual** — `POST /admin/capabilities/grant` `{member, capability}` + (revoke: `/admin/capabilities/revoke`); or +- **a whole role/group** — `POST /admin/capabilities/grant-role` + `{role, capability}` (revoke: `/admin/capabilities/revoke-role`; inspect: + `GET /admin/capabilities/role?role=`). This gives every member of a role/group + (including a custom group like `uk_collaborator`) the capability. + +`GET /admin/capabilities/available` lists the full catalog and which are +delegatable. `grant` and `sparql_admin` are **not** delegatable (they come only +from an Admin/SuperAdmin role), so grants can't escalate a non-admin into an admin. +Effective capabilities = role-derived ∪ role/group grants ∪ per-user grants. **SuperAdmin vs Admin:** identical KG capabilities here. SuperAdmin is a bootstrap-seeded, protected marker (can't be banned/deleted/role-stripped); diff --git a/usermanagement_service/README.md b/usermanagement_service/README.md index 3ad3d71..6843d4e 100644 --- a/usermanagement_service/README.md +++ b/usermanagement_service/README.md @@ -110,6 +110,24 @@ own protected routes (alongside the legacy v2 token). - `GET /api/users/roles` - Get user roles - `POST /api/users/roles` - Assign role +### Admin: roles, permissions & moderation (`/api/admin`, Admin/SuperAdmin) + +- `GET/POST /api/admin/roles`, `PUT /api/admin/roles/{id}` — list/create custom + roles/groups (e.g. `uk_collaborator`). +- `GET/POST /api/admin/permissions` — list all permissions and **add new ones** + (`{name, resource, action, description}`); attach to roles via + `PUT /api/admin/roles/{id}/permissions`. +- `POST /api/admin/users/{profile_id}/roles`, `DELETE .../roles/{role}` — assign/ + remove a user's role. **Assigning/removing the `Admin` role is SuperAdmin-only** + (hierarchy: SuperAdmin > Admin); the `SuperAdmin` role is protected. +- `POST /api/admin/users/{profile_id}/ban` + `DELETE …/ban` — ban/unban. **Banning + an Admin is SuperAdmin-only.** Banning is the removal mechanism. +- `POST /api/admin/users/{activate,deactivate}` — toggle login access. + +**No hard deletion.** `DELETE /api/admin/users/{id}` is disabled (returns `405`) — +accounts are **banned** (reversible, preserves provenance/audit history), never +deleted. + ## 🎯 User Roles ### Content Contribution From 6323fa895729985f2bfafb18d4aecd80fa467dad Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 01:15:32 -0400 Subject: [PATCH 29/70] OAuth login via skill/MCP: paste-code (CLI) flow for Globus/ORCID/GitHub 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. --- usermanagement_service/README.md | 5 + usermanagement_service/core/bootstrap.py | 4 + usermanagement_service/core/database.py | 39 ++++- .../core/models/database_models.py | 23 +++ usermanagement_service/core/routers/oauth.py | 137 ++++++++++++++++-- 5 files changed, 193 insertions(+), 15 deletions(-) diff --git a/usermanagement_service/README.md b/usermanagement_service/README.md index 6843d4e..9e71708 100644 --- a/usermanagement_service/README.md +++ b/usermanagement_service/README.md @@ -98,6 +98,11 @@ another. See `../query_service/AUTH_UNIFICATION.md`. - `GET /.well-known/jwks.json` - Public keys for verifying SSO tokens - `POST /api/auth/login` - `{email, password}` → refresh token (aud `brainkb-auth`) - `POST /api/auth/exchange` - Bearer refresh + `{audience}` → per-service access token +- `POST /api/auth/cli/start` - `{provider}` → authorize URL for a CLI/skill OAuth + login (paste-code). The provider callback shows a short one-time code (minimal + page, no SPA) instead of redirecting to the frontend. +- `POST /api/auth/cli/exchange` - `{code}` → SSO refresh token (single-use). Lets + the MCP/skill complete a Globus/ORCID/GitHub login without the web UI. The signing key is auto-provisioned at container start (persisted on the `./secrets` volume) unless `USERMANAGEMENT_JWT_PRIVATE_KEY_PEM`/`_FILE` is set. diff --git a/usermanagement_service/core/bootstrap.py b/usermanagement_service/core/bootstrap.py index d72ad66..01526b1 100644 --- a/usermanagement_service/core/bootstrap.py +++ b/usermanagement_service/core/bootstrap.py @@ -182,6 +182,10 @@ async def apply_inline_schema_migrations() -> None: # Web_user_profile is the canonical user; profile_id is the link. 'ALTER TABLE "Web_jwtuser" ADD COLUMN IF NOT EXISTS profile_id INTEGER REFERENCES "Web_user_profile"(id) ON DELETE SET NULL', 'CREATE INDEX IF NOT EXISTS ix_jwtuser_profile_id ON "Web_jwtuser"(profile_id)', + # OAuth CLI/skill paste-code flow: mark whether a state was started by the + # browser (web) or the MCP/skill (cli). New table Web_oauth_cli_result is + # created by create_all(). + 'ALTER TABLE "Web_oauth_state" ADD COLUMN IF NOT EXISTS mode VARCHAR(16) DEFAULT \'web\'', # Backfill the link for pre-existing rows by matching email (the old # implicit join key). Case-insensitive so mixed-case duplicates align. 'UPDATE "Web_jwtuser" u SET profile_id = p.id FROM "Web_user_profile" p ' diff --git a/usermanagement_service/core/database.py b/usermanagement_service/core/database.py index cf187e3..6f432fb 100644 --- a/usermanagement_service/core/database.py +++ b/usermanagement_service/core/database.py @@ -26,7 +26,7 @@ from core.models.database_models import ( Base, JWTUser, UserProfile, UserActivity, UserContribution, UserRole, UserCountry, UserOrganization, UserEducation, UserExpertise, AvailableRole, AvailableCountry, - OAuthIdentity, OAuthState, Permission, RolePermission, PageAccess, PageAccessRole, PageAccessUser, + OAuthIdentity, OAuthState, OAuthCliResult, Permission, RolePermission, PageAccess, PageAccessRole, PageAccessUser, AdminSetting, ) from core.models.user import ActivityType, ContributionStatus @@ -1409,6 +1409,42 @@ async def purge_expired(self, session: AsyncSession) -> None: logger.error(f"Error purging expired oauth states: {str(e)}") +class OAuthCliResultRepository(UserBaseRepository): + """CLI/skill paste-code OAuth results: code -> refresh token, single-use.""" + + def __init__(self): + super().__init__(OAuthCliResult) + + async def store(self, session: AsyncSession, *, code: str, refresh_token: str, + email: Optional[str], expires_at: datetime) -> OAuthCliResult: + row = OAuthCliResult(code=code, refresh_token=refresh_token, email=email, + expires_at=expires_at, consumed=False) + session.add(row) + await session.flush() + return row + + async def consume(self, session: AsyncSession, code: str) -> Optional[OAuthCliResult]: + """Return the result for a code if valid (exists, unexpired, unconsumed), + marking it consumed. Returns None otherwise.""" + result = await session.execute(select(OAuthCliResult).where(OAuthCliResult.code == code)) + row = result.scalar_one_or_none() + if row is None or row.consumed or row.expires_at < datetime.utcnow(): + return None + row.consumed = True + await session.flush() + return row + + async def purge_expired(self, session: AsyncSession) -> None: + try: + await session.execute( + text('DELETE FROM "Web_oauth_cli_result" WHERE expires_at < :now OR consumed = true'), + {"now": datetime.utcnow()}, + ) + await session.flush() + except SQLAlchemyError as e: + logger.error(f"Error purging oauth cli results: {str(e)}") + + class PermissionRepository(UserBaseRepository): def __init__(self): super().__init__(Permission) @@ -1590,6 +1626,7 @@ async def check_access( available_country_repo = AvailableCountryRepository() oauth_identity_repo = OAuthIdentityRepository() oauth_state_repo = OAuthStateRepository() +oauth_cli_result_repo = OAuthCliResultRepository() permission_repo = PermissionRepository() role_permission_repo = RolePermissionRepository() page_access_repo = PageAccessRepository() diff --git a/usermanagement_service/core/models/database_models.py b/usermanagement_service/core/models/database_models.py index fa3bfad..84a5656 100644 --- a/usermanagement_service/core/models/database_models.py +++ b/usermanagement_service/core/models/database_models.py @@ -394,6 +394,9 @@ class OAuthState(Base): provider: Mapped[str] = mapped_column(String(32), nullable=False) code_verifier: Mapped[Optional[str]] = mapped_column(String(256)) redirect_after_login: Mapped[Optional[str]] = mapped_column(String(500)) + # 'web' (default, browser redirect to the SPA) or 'cli' (paste-code flow for + # the MCP/skill — the callback shows a short code instead of redirecting). + mode: Mapped[str] = mapped_column(String(16), default="web") created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) @@ -403,6 +406,26 @@ class OAuthState(Base): ) +class OAuthCliResult(Base): + """Result bucket for the CLI/skill paste-code OAuth flow. After a CLI-initiated + OAuth callback provisions the user and mints an SSO refresh token, that token is + stored here keyed by a short human-typable code, shown in the browser. The + MCP/skill exchanges the code for the refresh token (single-use, short-lived).""" + __tablename__ = "Web_oauth_cli_result" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(32), unique=True, nullable=False) + refresh_token: Mapped[str] = mapped_column(Text, nullable=False) + email: Mapped[Optional[str]] = mapped_column(String(255)) + consumed: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + __table_args__ = ( + Index('idx_oauth_cli_result_code', 'code'), + ) + + class Permission(Base): """Permission registry. A permission is a (resource, action) tuple, e.g. ('user', 'delete').""" __tablename__ = "Web_permission" diff --git a/usermanagement_service/core/routers/oauth.py b/usermanagement_service/core/routers/oauth.py index 5c905bb..e4bfd8b 100644 --- a/usermanagement_service/core/routers/oauth.py +++ b/usermanagement_service/core/routers/oauth.py @@ -22,12 +22,16 @@ from urllib.parse import urlencode from fastapi import APIRouter, HTTPException, Query, Request -from fastapi.responses import RedirectResponse +from fastapi.responses import RedirectResponse, HTMLResponse from core.configuration import config +from pydantic import BaseModel + +from core import tokens_rs256 from core.database import ( user_db_manager, user_profile_repo, jwt_user_repo, - oauth_identity_repo, oauth_state_repo, user_activity_repo, provision_identity, + oauth_identity_repo, oauth_state_repo, oauth_cli_result_repo, + user_activity_repo, provision_identity, ) from core.models.user import ActivityType, OAuthLoginStart, UserRoleEnum from core.models.database_models import UserProfile as UserProfileModel @@ -52,6 +56,41 @@ def _redirect_uri_for(provider_name: str) -> str: return f"{config.public_base_url.rstrip('/')}/api/auth/{provider_name}/callback" +# Unambiguous alphabet (no I/L/O/0/1) for the paste-code shown to users. +_CLI_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + + +def _gen_cli_code() -> str: + raw = "".join(secrets.choice(_CLI_CODE_ALPHABET) for _ in range(8)) + return f"{raw[:4]}-{raw[4:]}" + + +def _cli_success_page(code: str) -> str: + """Minimal self-contained page shown after a CLI/skill OAuth login. Displays the + one-time code the user pastes back into the skill. No SPA/frontend needed.""" + safe = (code or "").replace("<", "").replace(">", "") + return ( + "" + "" + "BrainKB login" + "
" + "

✅ Signed in to BrainKB

" + "

Copy this one-time code and paste it back into your assistant " + "(brainkb_finish_login):

" + f"
{safe}
" + "

The code expires in ~10 minutes and can be used once. " + "You can close this tab afterward.

" + "
" + ) + + async def _upsert_profile_for_oauth(session, userinfo) -> UserProfileModel: """Find or create a UserProfile for an OAuth identity. Matching order: (1) existing OAuth identity → its linked profile, @@ -108,17 +147,13 @@ async def list_providers(): } -@router.get("/auth/{provider_name}/login", response_model=OAuthLoginStart) -async def oauth_login( - provider_name: str, - redirect_after_login: Optional[str] = Query(None, description="Relative path to send the user to after login completes"), -): - """Start an OAuth flow. Returns the authorize URL; the UI redirects the browser there.""" +async def _begin_oauth(provider_name: str, redirect_after_login: Optional[str], mode: str): + """Mint + persist OAuth state (+PKCE) and return (authorize_url, state). + ``mode`` is 'web' (browser → SPA) or 'cli' (MCP/skill paste-code).""" try: provider = get_provider(provider_name) except KeyError: raise HTTPException(status_code=404, detail=f"Unknown provider: {provider_name}") - if not provider.is_configured(): raise HTTPException(status_code=503, detail=f"{provider_name} OAuth is not configured on the server") @@ -130,12 +165,8 @@ async def oauth_login( redirect_uri = _redirect_uri_for(provider.name) authorize_url = provider.authorize_url( - redirect_uri=redirect_uri, - state=state, - code_challenge=code_challenge, + redirect_uri=redirect_uri, state=state, code_challenge=code_challenge, ) - - # Persist state so the callback (on a different request) can validate it. async with user_db_manager.get_async_session() as session: await oauth_state_repo.create( session, @@ -143,13 +174,67 @@ async def oauth_login( provider=provider.name, code_verifier=code_verifier, redirect_after_login=redirect_after_login, + mode=mode, expires_at=datetime.utcnow() + timedelta(minutes=10), ) await session.commit() + return authorize_url, state + +@router.get("/auth/{provider_name}/login", response_model=OAuthLoginStart) +async def oauth_login( + provider_name: str, + redirect_after_login: Optional[str] = Query(None, description="Relative path to send the user to after login completes"), +): + """Start an OAuth flow (browser/SPA). Returns the authorize URL; the UI redirects the browser there.""" + authorize_url, state = await _begin_oauth(provider_name, redirect_after_login, "web") return OAuthLoginStart(authorize_url=authorize_url, state=state) +class _CliStartIn(BaseModel): + provider: str = "globus" + + +@router.post("/auth/cli/start", tags=["SSO"]) +async def oauth_cli_start(body: _CliStartIn): + """Start an OAuth flow for the MCP/skill (paste-code). Returns an authorize URL; + open it in a browser, sign in with the provider, then paste the short code the + browser shows into `brainkb_finish_login`. No web UI required.""" + authorize_url, state = await _begin_oauth(body.provider, None, "cli") + return { + "authorize_url": authorize_url, + "state": state, + "mode": "cli", + "instructions": ("Open authorize_url in a browser and sign in. When it " + "shows a code, paste it into brainkb_finish_login(code)."), + } + + +class _CliExchangeIn(BaseModel): + code: str + + +@router.post("/auth/cli/exchange", tags=["SSO"]) +async def oauth_cli_exchange(body: _CliExchangeIn): + """Exchange the paste-code (shown after a CLI OAuth login) for an SSO refresh + token. Single-use and short-lived.""" + code = (body.code or "").strip().upper() + async with user_db_manager.get_async_session() as session: + await oauth_cli_result_repo.purge_expired(session) + row = await oauth_cli_result_repo.consume(session, code) + # Read the token INSIDE the session — after commit the ORM attribute is + # expired and touching it would trigger async lazy-load (MissingGreenlet). + refresh_token = row.refresh_token if row is not None else None + await session.commit() + if not refresh_token: + raise HTTPException(status_code=400, detail="Invalid, expired, or already-used code.") + return { + "refresh_token": refresh_token, + "token_type": "refresh", + "expires_in": tokens_rs256.refresh_token_ttl_seconds(), + } + + @router.get("/auth/{provider_name}/callback") async def oauth_callback( provider_name: str, @@ -187,6 +272,7 @@ async def oauth_callback( raise HTTPException(status_code=400, detail="OAuth state expired") code_verifier = state_row.code_verifier redirect_after_login = state_row.redirect_after_login + login_mode = getattr(state_row, "mode", "web") or "web" await session.commit() redirect_uri = _redirect_uri_for(provider.name) @@ -200,6 +286,7 @@ async def oauth_callback( if not userinfo.provider_user_id: return RedirectResponse(_frontend_error_redirect("provider returned no user id"), status_code=302) + cli_code = None async with user_db_manager.get_async_session() as session: try: profile = await _upsert_profile_for_oauth(session, userinfo) @@ -263,6 +350,24 @@ async def oauth_callback( scopes=scopes, auth_source=provider.name, ) + # CLI/skill (paste-code) flow: mint an SSO refresh token and stash it + # behind a short code the browser will display for the user to paste. + if login_mode == "cli": + refresh = tokens_rs256.create_refresh_token( + email=profile.email, + profile_id=profile.id, + roles=existing_roles, + scopes=scopes, + auth_source=provider.name, + ) + cli_code = _gen_cli_code() + await oauth_cli_result_repo.store( + session, + code=cli_code, + refresh_token=refresh, + email=profile.email, + expires_at=datetime.utcnow() + timedelta(minutes=10), + ) await session.commit() except HTTPException: await session.rollback() @@ -272,6 +377,10 @@ async def oauth_callback( logger.exception("Error finalizing OAuth login") return RedirectResponse(_frontend_error_redirect(f"finalize_failed: {e}"), status_code=302) + # CLI/skill login: show the paste-code page instead of redirecting to the SPA. + if login_mode == "cli": + return HTMLResponse(_cli_success_page(cli_code)) + qs = {"token": token} if redirect_after_login: qs["redirect"] = redirect_after_login From 752437b9399991e4df9558578c990e9cb0cfcdc2 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 01:21:33 -0400 Subject: [PATCH 30/70] env.template: document token TTLs as login/session lifetime (re-login cadence) --- env.template | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/env.template b/env.template index bfb5ba0..6a98f8a 100644 --- a/env.template +++ b/env.template @@ -107,7 +107,14 @@ USERMANAGEMENT_JWT_PRIVATE_KEY_PEM= USERMANAGEMENT_JWT_PRIVATE_KEY_FILE= # Token issuer (`iss`). query_service's QUERY_SERVICE_SSO_ISSUER must match this. USERMANAGEMENT_JWT_ISSUER=brainkb-usermanagement -# Access-token lifetime (minutes) and refresh-token lifetime (minutes). +# Token lifetimes (minutes). These control how long a login lasts before the user +# must re-authenticate — logins are NOT forever. +# - ACCESS: short-lived per-service token (exchanged on demand). Keep small. +# - REFRESH: the session lifetime from one login. When it expires, the user is +# asked to log in again. 720 = 12h; lower it (e.g. 240 = 4h, 60 = 1h) to force +# more frequent re-login, raise it for longer sessions. +# The MCP/skill caps its cached session to the refresh token's expiry (and to its +# own MCP_SESSION_TTL_MIN) — see brainkb_mcp/.env.example. USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN=15 USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN=720 # Services a refresh token may be exchanged for (valid `aud` values). From 60197f480de0849573e191f8f6dd60db26a82318 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 07:05:56 -0400 Subject: [PATCH 31/70] docs(auth): add current auth-flow diagrams (incl. MCP) + refresh MCP section --- query_service/AUTH_UNIFICATION.md | 137 +++++++++++++++++++++++++++--- 1 file changed, 127 insertions(+), 10 deletions(-) diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md index 07f8d95..f86396b 100644 --- a/query_service/AUTH_UNIFICATION.md +++ b/query_service/AUTH_UNIFICATION.md @@ -1,13 +1,111 @@ # BrainKB Authentication & Identity — Unification Design -Status: **Phase 1 + Phase 2 implemented** (branch `auth-unification`). Phase 2 -(single-issuer RS256 + JWKS SSO, per-audience tokens) is code-complete and -pending a fresh deployment for live verification. +Status: **Phase 1 + Phase 2 implemented & verified** (branch `auth-unification`). +Single-issuer RS256 + JWKS SSO with per-audience tokens is live-verified for +`query_service`, `usermanagement_service` (its own routes), and `ml_service`; the +`brainkb_mcp` is migrated to single sign-on. Legacy HS256 tokens still validate +during migration. `chat_service` deferred (not in use). The only step not +exercisable in the dev sandbox is the actual Globus browser consent. Audience: BrainKB maintainers Scope: `query_service`, `usermanagement_service`, `APItokenmanager` (Django), and downstream services (`ml_service`, `chat_service`, `brainkb_mcp`). --- +## 0. Current authentication flow (as implemented) + +usermanagement is the **single issuer**. One login mints a short-lived **refresh +token**; clients **exchange** it for narrow, per-service **access tokens** +(`aud=`). Each service verifies against the issuer's **JWKS** and requires +its own audience, so a token minted for one service can't be replayed against +another (containment via `aud`, not shared secrets). Legacy HS256 per-service +tokens still validate during migration. + +### A. Password / SSO login + per-service calls (via the MCP) + +```mermaid +sequenceDiagram + actor U as User + participant MCP as brainkb_mcp / skill + participant UM as usermanagement
(issuer + JWKS) + participant QS as query_service + participant ML as ml_service + + U->>MCP: brainkb_login(email, password) + MCP->>UM: POST /api/auth/login + UM-->>MCP: refresh token (aud=brainkb-auth) + Note over MCP: cache refresh per session
(expires with the token) + + U->>MCP: "ingest / search ..." (a KG tool) + MCP->>UM: POST /api/auth/exchange {audience: query_service} + UM-->>MCP: access token (aud=query_service, ~15m) + MCP->>QS: request + Bearer access token + QS->>UM: GET /.well-known/jwks.json (cached ~10m) + QS-->>MCP: 200 — verify RS256 + iss + aud=query_service + + U->>MCP: "list users ..." (an admin tool) + MCP->>UM: POST /api/auth/exchange {audience: usermanagement} + UM-->>MCP: access token (aud=usermanagement) + MCP->>UM: admin call + Bearer (verify aud=usermanagement) + Note over MCP,ML: same exchange for aud=ml_service, etc.
a query_service token is REJECTED elsewhere (403/401) +``` + +### B. OAuth login via the skill (Globus / ORCID / GitHub — paste-code) + +The browser consent is unavoidable (only the user can approve at the provider), +but the result is picked up out-of-band — no web UI needed. + +```mermaid +sequenceDiagram + actor U as User + participant MCP as brainkb_mcp / skill + participant BR as Browser + participant UM as usermanagement + participant P as Globus / ORCID / GitHub + + U->>MCP: brainkb_globus_login() + MCP->>UM: POST /api/auth/cli/start {provider} + UM-->>MCP: authorize_url (state.mode=cli) + MCP-->>U: open this URL + U->>BR: open URL, sign in + BR->>P: consent + P->>UM: GET /api/auth/{provider}/callback?code&state + UM->>UM: provision/link profile + default role
mint refresh token, store behind a short CODE + UM-->>BR: minimal page shows CODE + U->>MCP: brainkb_finish_login(CODE) + MCP->>UM: POST /api/auth/cli/exchange {code} + UM-->>MCP: refresh token (single-use code) + Note over MCP: now exchanges per service as in flow A +``` + +### C. Trust / containment overview + +```mermaid +flowchart LR + subgraph Clients + MCP[brainkb_mcp / skill] + WEB[Web UI] + end + UM["usermanagement
issuer • RS256 private key
/.well-known/jwks.json
login · exchange · OAuth"] + QS[query_service
aud=query_service] + ML[ml_service
aud=ml_service] + MCP -- login / exchange --> UM + WEB -- OAuth / login --> UM + MCP -- "Bearer aud=query_service" --> QS + MCP -- "Bearer aud=ml_service" --> ML + MCP -- "Bearer aud=usermanagement" --> UM + QS -- "fetch public keys (JWKS)" --> UM + ML -- "fetch public keys (JWKS)" --> UM + QS -. "rejects aud≠query_service" .-> QS + ML -. "rejects aud≠ml_service" .-> ML +``` + +Sessions are **not forever**: a cached login lasts until its refresh token expires +(`USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN`, default 12h; MCP additionally caps via +`MCP_SESSION_TTL_MIN`). On expiry the MCP forgets the credentials and prompts a new +login. + +--- + ## 1. Problem statement BrainKB today authenticates users through **two overlapping systems**: @@ -262,13 +360,32 @@ Remaining Phase 2 rollout (not yet done): --- -## 6. Impact on `brainkb_mcp` - -After Phase 1, the MCP no longer needs **two logins** (`query_service` + -`usermanagement`). A single `brainkb_login` authenticates one identity; admin/user -management and KG operations share it. This directly simplifies the dual-service -auth currently in `server.py` (`_um()` / separate token handling). After Phase 2, -the MCP would validate/forward a single audience-scoped token. +## 6. `brainkb_mcp` — how it authenticates (implemented) + +The MCP is migrated to single sign-on (see the diagrams in §0). Summary of the +implemented behavior: + +- **One login, per-service exchange.** `brainkb_login(email, password)` mints a + refresh token cached for the session; `_token_for(audience)` exchanges it on + demand for a `query_service` or `usermanagement` access token. The old + two-login model (`_um_login` / separate `_UM_TOKENS`) is gone — one login now + covers both KG and admin tools. +- **OAuth via the skill** (Globus/ORCID/GitHub): `brainkb_globus_login()` → + authorize URL → user signs in → browser shows a short code → + `brainkb_finish_login(code)` (backend `/api/auth/cli/start` + `/cli/exchange`, + flow B in §0). No web UI required. +- **Header pass-through (stateless, multi-user remote):** a caller may send + `Authorization: Bearer `. A **refresh** token unlocks all services (the + MCP exchanges it per service); a single **service access token** is used as-is. +- **Sessions expire.** The cached session lasts until its refresh token expires + (`USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN`), hard-capped by `MCP_SESSION_TTL_MIN`. + On lapse the MCP forgets the credentials and asks the user to log in again; + `brainkb_whoami` reports `session_expires_in_min`. +- **Legacy fallback.** If the backend has no SSO, the MCP falls back to the + per-service `/api/token` login automatically, so it keeps working during + migration. +- **Abuse protection.** Per-caller (source-IP) rate limiting; login/register are + the strict `auth` bucket. See `brainkb_mcp/README.md`. --- From a7265e1d3f4e37cd8d77b073b11230d859adedd7 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 09:01:23 -0400 Subject: [PATCH 32/70] Onboarding via OAuth (no self-register) + rename /api/token -> /api/login; 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. --- ml_service/core/routers/jwt_auth.py | 31 ++++----- query_service/AUTH_UNIFICATION.md | 59 ++++++++++-------- query_service/README.md | 13 ++-- query_service/core/routers/jwt_auth.py | 37 +++++------ query_service/docs/auth/flow-a.png | Bin 0 -> 94732 bytes query_service/docs/auth/flow-b.png | Bin 0 -> 88737 bytes query_service/docs/auth/flow-c.png | Bin 0 -> 76051 bytes usermanagement_service/README.md | 7 ++- .../core/routers/jwt_auth.py | 6 +- 9 files changed, 78 insertions(+), 75 deletions(-) create mode 100644 query_service/docs/auth/flow-a.png create mode 100644 query_service/docs/auth/flow-b.png create mode 100644 query_service/docs/auth/flow-c.png diff --git a/ml_service/core/routers/jwt_auth.py b/ml_service/core/routers/jwt_auth.py index 60621fd..fbda53a 100644 --- a/ml_service/core/routers/jwt_auth.py +++ b/ml_service/core/routers/jwt_auth.py @@ -11,30 +11,23 @@ router = APIRouter() -@router.post("/register", status_code=201) +@router.post("/register", include_in_schema=False) async def register(user: UserIn): - """ - Register a new user. Uses proper connection management to avoid connection leaks. - - Note: The unique constraint check is handled atomically by the database. - This prevents race conditions where two concurrent requests could both pass - a pre-check and then both attempt to insert the same email. - """ - async with get_db_connection() as conn: - hashed_password = await get_password_hash(user.password) - - # Let the database enforce uniqueness atomically - no pre-check needed - # insert_data will catch UniqueViolationError and return a user-friendly message - return await insert_data( - conn=conn, fullname=user.full_name, email=user.email, password=hashed_password - ) + """Self-registration is DISABLED — no separate register step. Users onboard by + signing in with Globus / ORCID / GitHub (profile auto-created on first login).""" + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail=("Self-registration is disabled. Sign in with Globus / ORCID / GitHub " + "— your account is created automatically on first login."), + ) -@router.post("/token") +@router.post("/login") +@router.post("/token", include_in_schema=False) # deprecated alias of /login async def login(user: LoginUserIn): """ - Authenticate user and return JWT token. Uses proper connection management to avoid connection leaks. - Reuses the same database connection for both authentication and scope retrieval to minimize connection pool usage. + Authenticate (password) and return a JWT. Primary path is `/api/login`; + `/api/token` is a deprecated alias. """ async with get_db_connection() as conn: authenticated_user = await authenticate_user(user.email, user.password, conn) diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md index f86396b..e7d5926 100644 --- a/query_service/AUTH_UNIFICATION.md +++ b/query_service/AUTH_UNIFICATION.md @@ -22,38 +22,44 @@ tokens still validate during migration. ### A. Password / SSO login + per-service calls (via the MCP) +![Auth flow A — login, exchange, per-service access](docs/auth/flow-a.png) + +
Diagram source (Mermaid) + ```mermaid sequenceDiagram actor U as User participant MCP as brainkb_mcp / skill - participant UM as usermanagement
(issuer + JWKS) + participant UM as usermanagement issuer + JWKS participant QS as query_service participant ML as ml_service - U->>MCP: brainkb_login(email, password) MCP->>UM: POST /api/auth/login - UM-->>MCP: refresh token (aud=brainkb-auth) - Note over MCP: cache refresh per session
(expires with the token) - - U->>MCP: "ingest / search ..." (a KG tool) - MCP->>UM: POST /api/auth/exchange {audience: query_service} - UM-->>MCP: access token (aud=query_service, ~15m) + UM-->>MCP: refresh token, aud=brainkb-auth + Note over MCP: cache refresh per session, expires with token + U->>MCP: a KG tool (ingest / search) + MCP->>UM: POST /api/auth/exchange audience=query_service + UM-->>MCP: access token, aud=query_service, ~15m MCP->>QS: request + Bearer access token QS->>UM: GET /.well-known/jwks.json (cached ~10m) - QS-->>MCP: 200 — verify RS256 + iss + aud=query_service - - U->>MCP: "list users ..." (an admin tool) - MCP->>UM: POST /api/auth/exchange {audience: usermanagement} - UM-->>MCP: access token (aud=usermanagement) - MCP->>UM: admin call + Bearer (verify aud=usermanagement) - Note over MCP,ML: same exchange for aud=ml_service, etc.
a query_service token is REJECTED elsewhere (403/401) + QS-->>MCP: 200 verify RS256 + iss + aud=query_service + U->>MCP: an admin tool (list users) + MCP->>UM: POST /api/auth/exchange audience=usermanagement + UM-->>MCP: access token, aud=usermanagement + MCP->>UM: admin call + Bearer, verify aud=usermanagement + Note over MCP,ML: same exchange for aud=ml_service. a query_service token is rejected elsewhere ``` +
### B. OAuth login via the skill (Globus / ORCID / GitHub — paste-code) The browser consent is unavoidable (only the user can approve at the provider), but the result is picked up out-of-band — no web UI needed. +![Auth flow B — OAuth paste-code login via the skill](docs/auth/flow-b.png) + +
Diagram source (Mermaid) + ```mermaid sequenceDiagram actor U as User @@ -61,31 +67,35 @@ sequenceDiagram participant BR as Browser participant UM as usermanagement participant P as Globus / ORCID / GitHub - U->>MCP: brainkb_globus_login() - MCP->>UM: POST /api/auth/cli/start {provider} - UM-->>MCP: authorize_url (state.mode=cli) + MCP->>UM: POST /api/auth/cli/start provider + UM-->>MCP: authorize_url, state.mode=cli MCP-->>U: open this URL U->>BR: open URL, sign in BR->>P: consent - P->>UM: GET /api/auth/{provider}/callback?code&state - UM->>UM: provision/link profile + default role
mint refresh token, store behind a short CODE + P->>UM: GET /api/auth/provider/callback with code + state + UM->>UM: provision profile + default role, mint refresh, store behind short CODE UM-->>BR: minimal page shows CODE U->>MCP: brainkb_finish_login(CODE) - MCP->>UM: POST /api/auth/cli/exchange {code} - UM-->>MCP: refresh token (single-use code) + MCP->>UM: POST /api/auth/cli/exchange code + UM-->>MCP: refresh token, single-use code Note over MCP: now exchanges per service as in flow A ``` +
### C. Trust / containment overview +![Auth flow C — single issuer, per-audience tokens, JWKS verification](docs/auth/flow-c.png) + +
Diagram source (Mermaid) + ```mermaid flowchart LR subgraph Clients MCP[brainkb_mcp / skill] WEB[Web UI] end - UM["usermanagement
issuer • RS256 private key
/.well-known/jwks.json
login · exchange · OAuth"] + UM["usermanagement
issuer - RS256 private key
/.well-known/jwks.json
login - exchange - OAuth"] QS[query_service
aud=query_service] ML[ml_service
aud=ml_service] MCP -- login / exchange --> UM @@ -95,9 +105,8 @@ flowchart LR MCP -- "Bearer aud=usermanagement" --> UM QS -- "fetch public keys (JWKS)" --> UM ML -- "fetch public keys (JWKS)" --> UM - QS -. "rejects aud≠query_service" .-> QS - ML -. "rejects aud≠ml_service" .-> ML ``` +
Sessions are **not forever**: a cached login lasts until its refresh token expires (`USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN`, default 12h; MCP additionally caps via diff --git a/query_service/README.md b/query_service/README.md index b1c1854..521d983 100644 --- a/query_service/README.md +++ b/query_service/README.md @@ -25,8 +25,9 @@ Two token schemes are accepted (see `AUTH_UNIFICATION.md`): equal `query_service`, so a token minted for another service is rejected (containment). Configure with `QUERY_SERVICE_SSO_JWKS_URL` / `QUERY_SERVICE_SSO_ISSUER` / `QUERY_SERVICE_SSO_AUDIENCE`. -- **Legacy HS256** — this service's own `/api/token`, signed with its own secret. - Still accepted during migration; both schemes work side by side. +- **Legacy HS256** — this service's own password login at **`/api/login`** + (`/api/token` is a deprecated alias), signed with its own secret. Still accepted + during migration; both schemes work side by side. Scope policy (same for either scheme): @@ -34,11 +35,11 @@ Scope policy (same for either scheme): - **Mutations** (ingest, register/attach graph, recover, create/modify space) → `write` - **Arbitrary SPARQL** (`/query/sparql/`) → `admin` - **Public-space reads** → no token required (anonymous), see Spaces below -- `/register`, `/token` → public +- `/login` (and deprecated alias `/token`) → public -`POST /register` creates the credential **and** a canonical `Web_user_profile` -with a default role (so a password user is a first-class identity, not a role-less -orphan); the account starts inactive until an admin activates it. Authorization is +**Onboarding is via Globus/ORCID/GitHub sign-in — there is no self-registration.** +`POST /register` is **disabled** (returns 405). A user's profile + default role are +auto-created/linked on first OAuth login (usermanagement). Authorization is role-based (see `RBAC_MODEL.md`) and read from the DB, not just the token. Users may only act on their own `user_id` (enforced), and job-scoped endpoints are diff --git a/query_service/core/routers/jwt_auth.py b/query_service/core/routers/jwt_auth.py index b874022..6d416af 100644 --- a/query_service/core/routers/jwt_auth.py +++ b/query_service/core/routers/jwt_auth.py @@ -12,30 +12,25 @@ router = APIRouter() -@router.post("/register", status_code=201) +@router.post("/register", include_in_schema=False) async def register(user: UserIn): - """ - Register a new user. Uses proper connection management to avoid connection leaks. - - Note: The unique constraint check is handled atomically by the database. - This prevents race conditions where two concurrent requests could both pass - a pre-check and then both attempt to insert the same email. - """ - async with get_db_connection() as conn: - hashed_password = await get_password_hash(user.password) - - # Let the database enforce uniqueness atomically - no pre-check needed - # insert_data will catch UniqueViolationError and return a user-friendly message - return await insert_data( - conn=conn, fullname=user.full_name, email=user.email, password=hashed_password - ) - - -@router.post("/token", include_in_schema=True) + """Self-registration is DISABLED — there is no separate register step. + Users are onboarded by signing in with Globus / ORCID / GitHub + (usermanagement `/api/auth/{provider}/login`, or `brainkb_globus_login` in the + skill), which auto-creates and links the profile on first login.""" + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail=("Self-registration is disabled. Sign in with Globus / ORCID / GitHub " + "— your account is created automatically on first login."), + ) + + +@router.post("/login") +@router.post("/token", include_in_schema=False) # deprecated alias of /login async def login(user: LoginUserIn): """ - Authenticate user and return JWT token. Uses proper connection management to avoid connection leaks. - Reuses the same database connection for both authentication and scope retrieval to minimize connection pool usage. + Authenticate (password) and return a JWT. Primary path is `/api/login`; + `/api/token` is kept as a deprecated alias for backward compatibility. """ async with get_db_connection() as conn: authenticated_user = await authenticate_user(user.email, user.password, conn) diff --git a/query_service/docs/auth/flow-a.png b/query_service/docs/auth/flow-a.png new file mode 100644 index 0000000000000000000000000000000000000000..ccb5d570b54cc23a02737bfa2c78567908fb6eaa GIT binary patch literal 94732 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%0DL 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 ag7?w=>udK{$w9yG0;;H?P$p*{^8Wzb|Hml+ literal 0 HcmV?d00001 diff --git a/query_service/docs/auth/flow-b.png b/query_service/docs/auth/flow-b.png new file mode 100644 index 0000000000000000000000000000000000000000..20c8411e7f586bdef46e50d005e2cbd7337c7255 GIT binary patch literal 88737 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 literal 0 HcmV?d00001 diff --git a/query_service/docs/auth/flow-c.png b/query_service/docs/auth/flow-c.png new file mode 100644 index 0000000000000000000000000000000000000000..412417edf729c50b8e38cd01681466924d6a0c5b GIT binary patch literal 76051 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 literal 0 HcmV?d00001 diff --git a/usermanagement_service/README.md b/usermanagement_service/README.md index 9e71708..8e2ff53 100644 --- a/usermanagement_service/README.md +++ b/usermanagement_service/README.md @@ -81,10 +81,11 @@ Once running, visit: ### Key Endpoints End-user sign-up happens automatically on first OAuth callback (see -*Configuring the Admin role* below) — there is no public `/register` endpoint. -The `/api/token` endpoint mints a JWT for service-account password login only. +*Configuring the Admin role* below) — there is **no self-registration**; users are +created on first OAuth login. Password login is at `/api/login` (`/api/token` is a +deprecated alias) for accounts that already exist. -- `POST /api/token` - Legacy HS256 password login (mints a v2 JWT) +- `POST /api/login` - Legacy HS256 password login (mints a v2 JWT); `/api/token` = deprecated alias - `GET /api/auth/providers` - List OAuth providers + which are configured - `GET /api/auth/{provider}/login` - Start OAuth flow (returns `authorize_url`) - `GET /api/auth/{provider}/callback` - OAuth callback; auto-creates profile + linked credential + default `Curator` role on first sign-in diff --git a/usermanagement_service/core/routers/jwt_auth.py b/usermanagement_service/core/routers/jwt_auth.py index f6ad117..b7e359e 100644 --- a/usermanagement_service/core/routers/jwt_auth.py +++ b/usermanagement_service/core/routers/jwt_auth.py @@ -11,7 +11,11 @@ router = APIRouter() -@router.post("/token") +# Legacy password login (issues a v2 HS256 token). The SSO login is +# /api/auth/login (refresh token); this stays as a deprecated compatibility path. +# `/api/login` is the preferred name; `/api/token` remains as a deprecated alias. +@router.post("/login") +@router.post("/token", include_in_schema=False) async def login(user: LoginUserIn, request: Request): async with user_db_manager.get_async_session() as session: # Authenticate user From 0da1257461aa3b48c6d9b4cfa51728f437ba59c1 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 09:10:55 -0400 Subject: [PATCH 33/70] docs: OAuth provider setup (Globus redirect URL, app-type caveat, env) in usermanagement README + env.template --- env.template | 8 +++++- usermanagement_service/README.md | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/env.template b/env.template index 6a98f8a..3d0d010 100644 --- a/env.template +++ b/env.template @@ -139,7 +139,13 @@ ORCID_CLIENT_SECRET= # Use https://sandbox.orcid.org for dev, https://orcid.org for prod ORCID_BASE_URL=https://orcid.org -# Globus OAuth (register at https://app.globus.org/settings/developers) +# Globus OAuth (register at https://developers.globus.org). +# IMPORTANT: use an app that supports REDIRECTS (not a Service Account), and add +# the redirect URL ${USERMANAGEMENT_PUBLIC_BASE_URL}/api/auth/globus/callback +# local: http://localhost:8004/api/auth/globus/callback +# deploy: https:///api/auth/globus/callback +# Same callback serves web + skill (paste-code) login. See usermanagement_service/README.md +# ("OAuth provider setup"). GitHub/ORCID use the same /api/auth//callback pattern. GLOBUS_CLIENT_ID= GLOBUS_CLIENT_SECRET= diff --git a/usermanagement_service/README.md b/usermanagement_service/README.md index 8e2ff53..420fbbe 100644 --- a/usermanagement_service/README.md +++ b/usermanagement_service/README.md @@ -134,6 +134,52 @@ own protected routes (alongside the legacy v2 token). accounts are **banned** (reversible, preserves provenance/audit history), never deleted. +## 🔑 OAuth provider setup (Globus / ORCID / GitHub) + +BrainKB uses the **Authorization Code + PKCE** flow. The backend builds its +callback (redirect URI) as: + +``` +${USERMANAGEMENT_PUBLIC_BASE_URL}/api/auth/{provider}/callback +``` + +so for **Globus** the redirect URL to register is: + +| Environment | Redirect URL to register | +|---|---| +| Local | `http://localhost:8004/api/auth/globus/callback` | +| Deployment | `https:///api/auth/globus/callback` | + +It must **exactly** match `${USERMANAGEMENT_PUBLIC_BASE_URL}/api/auth/globus/callback`. +One app can register **both** URLs. This is the **usermanagement backend** callback +(`:8004`) — not the frontend and not query_service. The web login and the +CLI/skill paste-code login (`/api/auth/cli/start`) use the **same** callback, so +only this one redirect is needed. (`USERMANAGEMENT_FRONTEND_CALLBACK_URL` is where +the browser is sent *after* a web login; it is not registered with the provider.) + +> ⚠️ **App type matters.** A Globus **Service Account** (client-credentials) app +> has no redirect and **cannot** do user sign-in. Register a Globus app that +> **supports redirect URLs** (developers.globus.org → your Project → *Add an app* → +> a portal/web-app registration, not a service account) and add the redirect +> URL(s) above. Use that app's Client UUID + secret. + +Configure in `.env`: + +``` +GLOBUS_CLIENT_ID= +GLOBUS_CLIENT_SECRET= +USERMANAGEMENT_PUBLIC_BASE_URL=http://localhost:8004 # local +# USERMANAGEMENT_PUBLIC_BASE_URL=https://api.yourhost.org # deploy (public HTTPS) +# GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET, ORCID_CLIENT_ID / ORCID_CLIENT_SECRET similarly +``` + +- Scopes requested at authorize time: `openid email profile` (standard OIDC). If a + provider rejects the authorize, add those scopes to the app. +- Globus requires **HTTPS** redirects for non-localhost (localhost may be `http`). +- Verify: `GET /api/auth/providers` shows `globus: configured=true`, and + `POST /api/auth/cli/start {"provider":"globus"}` returns an `authorize_url` + pointing at `auth.globus.org` with your `redirect_uri`. + ## 🎯 User Roles ### Content Contribution From 75d1f9c7549deb4649bc0362a029919f96bdc2f0 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 09:16:04 -0400 Subject: [PATCH 34/70] =?UTF-8?q?docs(auth):=20add=20=C2=A79=20implementat?= =?UTF-8?q?ion=20decisions=20log=20(problems=20->=20decisions)=20+=20statu?= =?UTF-8?q?s=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- query_service/AUTH_UNIFICATION.md | 95 +++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md index e7d5926..2bf6da5 100644 --- a/query_service/AUTH_UNIFICATION.md +++ b/query_service/AUTH_UNIFICATION.md @@ -6,6 +6,12 @@ Single-issuer RS256 + JWKS SSO with per-audience tokens is live-verified for `brainkb_mcp` is migrated to single sign-on. Legacy HS256 tokens still validate during migration. `chat_service` deferred (not in use). The only step not exercisable in the dev sandbox is the actual Globus browser consent. + +Several further decisions were taken **during** implementation — onboarding via +OAuth (no self-registration), `/api/token` → `/api/login`, role/group-level +capability grants, SuperAdmin-over-Admin, ban-not-delete, OAuth login via the +skill (paste-code), and expiring sessions. Each is recorded with its problem and +rationale in **§9. Implementation decisions log**. Audience: BrainKB maintainers Scope: `query_service`, `usermanagement_service`, `APItokenmanager` (Django), and downstream services (`ml_service`, `chat_service`, `brainkb_mcp`). @@ -417,3 +423,92 @@ implemented behavior: - **Do later, deliberately:** single-issuer **audience-scoped SSO** (§4.2 Option B2) when ready to operate JWKS + `aud` properly. - **Do not:** collapse to one shared-secret token usable across all services. + +--- + +## 9. Implementation decisions log (problems → decisions) + +Decisions taken while building Phases 1–2. Each: the problem, the decision, and +where it lives. All are live-verified except the Globus browser consent (dev +sandbox has no browser); the mechanics around it are verified. + +### 9.1 Onboarding: no self-registration — OAuth first-login creates the user +- **Problem.** Two onboarding paths existed: a password `/api/register` (created a + `Web_jwtuser`, initially role-less / inactive, needing admin activation) *and* + OAuth. The password path produced role-less "orphan" accounts and an extra + activation step, and duplicated identity creation. +- **Decision.** A user is created **only** on first Globus/ORCID/GitHub login, + which auto-provisions + links the profile and assigns a default role + (`provision_identity`). Self-registration is **disabled**: `/api/register` → + `405` on `query_service` and `ml_service`; the MCP `brainkb_register` tool was + removed. +- **Trade-off.** No API path to create a *password* account anymore (Globus is the + identity source). Existing/seeded password accounts still log in. + +### 9.2 Endpoint naming: `/api/token` → `/api/login` +- **Problem.** "token" was ambiguous next to the SSO refresh/exchange tokens, and + read as an issuance detail rather than "log in". +- **Decision.** Password login is **`/api/login`** on `query_service`, + `usermanagement`, `ml_service`; **`/api/token` kept as a hidden deprecated + alias** (same handler) so existing clients don't break. MCP prefers `/api/login` + and falls back to `/api/token`. + +### 9.3 Authorization: grant capabilities to a whole group/role +- **Problem.** Capabilities could be granted per-**user** (`user_capability_grants`) + or scoped to a space (access rules), but a **custom group/role** (e.g. + `uk_collaborator`) could only ever get the hardcoded `read_private` — no way to + give a whole group `ingest`/`create_private_space`, etc. +- **Decision.** New `role_capability_grants` table + `grant/revoke_role_capability` + and `/admin/capabilities/grant-role|revoke-role|role|available` endpoints. + Effective caps = role-derived ∪ **role/group grants** ∪ per-user grants. Only the + delegatable set is grantable (`grant`/`sparql_admin` stay admin-intrinsic — no + escalation). Also: a **space write access rule now GRANTS ingest** to a group + (previously rules could only restrict). + +### 9.4 Admin hierarchy: SuperAdmin-over-Admin +- **Problem.** Any Admin could assign/remove the `Admin` role on, or ban, another + Admin — no real hierarchy; a peer/rogue Admin could lock others out. +- **Decision.** Assigning/removing the `Admin` (or `SuperAdmin`) role and banning + an Admin are **SuperAdmin-only**. `SuperAdmin` stays bootstrap-seeded and + protected (never removable/bannable). Regular Admins manage non-admin users. + +### 9.5 Removal: ban, never hard-delete +- **Problem.** Hard-deleting a user destroys provenance/audit history and is + irreversible. +- **Decision.** **We don't delete.** `DELETE /api/admin/users/{id}` → `405`; + removal is a reversible **ban** (`/ban` + `DELETE /ban` to lift), which preserves + history. `deactivate` toggles login access. + +### 9.6 OAuth login through the skill (paste-code) +- **Problem.** OAuth needs a browser consent the MCP can't perform, and the normal + callback redirects to the **web UI** — so "Globus login via skill" seemed to + require the website. +- **Decision.** An out-of-band **paste-code** flow: `POST /api/auth/cli/start` + (state marked `cli`) → user signs in → the callback mints an SSO refresh token, + stores it behind a short one-time **code**, and shows a minimal page (no SPA) → + `POST /api/auth/cli/exchange {code}` returns the refresh token (single-use). MCP: + `brainkb_globus_login` → `brainkb_finish_login(code)`. + +### 9.7 Sessions expire (logins are not forever) +- **Problem.** The MCP cached credentials/refresh, so a login effectively lasted + forever (a cached password could silently re-login). +- **Decision.** A cached session lives only until its **refresh token expires** + (`USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN`, default 12h), hard-capped by + `MCP_SESSION_TTL_MIN`. On lapse the MCP forgets the credentials and prompts a new + login; `brainkb_whoami` reports `session_expires_in_min`. + +### 9.8 Operational decisions +- **SSO signing key** auto-provisioned at container start to `/app/secrets` (a + persistent volume) so the JWKS `kid` is stable across the 4 gunicorn workers and + redeploys — fixing a real multi-worker "MissingGreenlet"/per-worker-key failure + found in testing. An explicit `USERMANAGEMENT_JWT_PRIVATE_KEY_PEM/_FILE` overrides. +- **Rate limiting** (per-caller, source-IP) in the MCP; `login`/OAuth-start use a + strict bucket. Large **file** ingest is not byte-capped and uses no read/write + timeout (raw-text ingest is capped) so ~5 GB TTL/JSON-LD uploads aren't aborted. + +### Still open (deliberately deferred) +- Retire the legacy HS256 `/api/login`(`/token`) paths once all clients use SSO; + fold in `APItokenmanager`. +- Tighten usermanagement `require_admin` to re-read roles from the DB (it currently + trusts the token `roles` claim; SSO tokens are short-lived + re-read at exchange). +- `chat_service` RS256 verification (same `core/jwks.py` pattern) — not in use. From adcf016c92b46456113932f88ec0c26f58507d82 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 09:28:46 -0400 Subject: [PATCH 35/70] docs(query_service README): create-group->grant-capability flow, global vs per-space, full admin action catalog --- query_service/README.md | 42 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/query_service/README.md b/query_service/README.md index 521d983..25371f3 100644 --- a/query_service/README.md +++ b/query_service/README.md @@ -111,6 +111,48 @@ a write-capable role for the `ingest` capability). Rules can also target a singl `member` (email) or a `space_role`. Remove the rule to revoke. See `RBAC_MODEL.md` and `SPACES_MODEL.md` for the full model. +### Creating a custom group and giving it powers + +Creating a group and assigning it always works; a **brand-new custom role starts +with no powers** (only `read_private`) — you then **grant** it capabilities. Full +flow (Admin/SuperAdmin): + +``` +# 1. create the group/category (usermanagement) +POST /api/admin/roles {name: "uk_collaborator", category, description} +# 2. give the group a power (query_service) — global capability +POST /api/admin/capabilities/grant-role {role: "uk_collaborator", capability: "ingest"} +# 3. put a user in the group (usermanagement) +POST /api/admin/users/{profile_id}/roles {role: "uk_collaborator"} +# → every uk_collaborator can now ingest +``` + +**Global vs per-space** — two ways to let a group ingest: +- **Global** (§ grant-role above): the group can ingest into any space where it + has write authorization (membership/owner/admin) — a broad, workspace-wide power. +- **Per-space** (§ access rule above): the group can ingest into **one** named + space only. Narrower; preferred when scoping a group to a specific workspace. + +### Identity & admin actions catalog (usermanagement `/api/admin`) + +Beyond the KG capabilities above, these are the management actions and who may do +them (Admin unless noted): + +| Action | Endpoint / tool | +|---|---| +| Create / list custom groups (roles) | `POST/GET /api/admin/roles` · `brainkb_create_role`, `brainkb_available_roles` | +| Assign / remove a role on a user | `POST/DELETE /api/admin/users/{id}/roles` · `brainkb_assign_role` / `brainkb_remove_role` *(Admin role = **SuperAdmin-only**)* | +| Grant / revoke a capability to a **user** | `brainkb_grant_capability` / `brainkb_revoke_capability` | +| Grant / revoke a capability to a **group** | `brainkb_grant_role_capability` / `brainkb_revoke_role_capability` | +| List capability catalog / a group's caps | `brainkb_list_capabilities` · `brainkb_role_capabilities` | +| Create / list permissions (resource·action) | `POST/GET /api/admin/permissions` · `brainkb_create_permission`, `brainkb_list_permissions` | +| Per-space access rules (read/write/manage) | `brainkb_add_access_rule` / `brainkb_remove_access_rule` | +| Activate / deactivate login | `brainkb_activate_user` / `brainkb_deactivate_user` | +| Ban / unban (removal — **no delete**) | `brainkb_ban_user` / `brainkb_unban_user` *(banning an Admin = **SuperAdmin-only**)* | + +Onboarding is via Globus/ORCID/GitHub sign-in (auto-creates the profile + default +role); there is **no self-registration** (`/api/register` → 405). + ## Endpoints (prefix `/api`) ### Query From ad20ad909c4e3ded52ff042494ced7eb61eef98e Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 09:36:18 -0400 Subject: [PATCH 36/70] RBAC: scope manage_team_space to owned/assigned team spaces (not all); 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. --- query_service/core/routers/spaces.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/query_service/core/routers/spaces.py b/query_service/core/routers/spaces.py index c7544c8..de96a2d 100644 --- a/query_service/core/routers/spaces.py +++ b/query_service/core/routers/spaces.py @@ -42,17 +42,26 @@ def _agent(user) -> str: async def _can_manage(space: dict, email: str) -> bool: - """Who may manage a space (members/visibility/graphs): the space owner, an - Admin/SuperAdmin, a holder of manage_team_space (team spaces), or someone - matched by a per-space 'manage' access rule.""" - if await sp.member_role(space["space_id"], email) == "owner": - return True + """Who may manage a space (members/visibility/graphs/access-rules): + + * **Admin/SuperAdmin** — every space (platform-wide). + * **Owner** (the creator) — their own space. + * A non-admin with **manage_team_space** — ONLY team spaces they are + **assigned to** (a member of), not every team space. + * Anyone matched by a per-space **'manage'** access rule (explicit assignment). + + i.e. unless you're an Admin, you can manage only the team spaces you created or + were assigned to — never all of them.""" if await rbac.is_admin(email): return True - if space.get("space_type") == "team" and await rbac.has_capability(email, rbac.MANAGE_TEAM_SPACE): + srole = await sp.member_role(space["space_id"], email) + if srole == "owner": return True if await sp.matches_access_rule(space["space_id"], "manage", email): return True + if (space.get("space_type") == "team" and srole is not None + and await rbac.has_capability(email, rbac.MANAGE_TEAM_SPACE)): + return True return False From 401b7e1a2dcd611ecccc630d775a25b03820f809 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 09:37:53 -0400 Subject: [PATCH 37/70] docs: Admin/SuperAdmin creation + hierarchy, and manage_team_space is scoped (owned/assigned, not all) --- query_service/AUTH_UNIFICATION.md | 16 +++++++++++++++- query_service/README.md | 18 ++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md index 2bf6da5..3e5aa8f 100644 --- a/query_service/AUTH_UNIFICATION.md +++ b/query_service/AUTH_UNIFICATION.md @@ -465,12 +465,26 @@ sandbox has no browser); the mechanics around it are verified. escalation). Also: a **space write access rule now GRANTS ingest** to a group (previously rules could only restrict). -### 9.4 Admin hierarchy: SuperAdmin-over-Admin +### 9.4 Admin hierarchy: SuperAdmin-over-Admin (and who creates whom) - **Problem.** Any Admin could assign/remove the `Admin` role on, or ban, another Admin — no real hierarchy; a peer/rogue Admin could lock others out. - **Decision.** Assigning/removing the `Admin` (or `SuperAdmin`) role and banning an Admin are **SuperAdmin-only**. `SuperAdmin` stays bootstrap-seeded and protected (never removable/bannable). Regular Admins manage non-admin users. +- **Who creates whom.** **SuperAdmin** is bootstrapped at deployment via + `USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS` (seeded on first login) and can grant + `SuperAdmin`/`Admin` to others. **Admin** is created by a SuperAdmin assigning the + `Admin` role. Admin and SuperAdmin have the same KG capabilities; the difference + is that SuperAdmin is the protected, admin-managing tier. + +### 9.4a `manage_team_space` is scoped, not blanket +- **Problem.** A `manage_team_space` holder could manage **every** team space — + effectively a platform-wide admin power leaking through a delegatable capability. +- **Decision.** A non-admin manages a team space **only** if they **own** it + (created it), are matched by a per-space `manage` access rule, or hold + `manage_team_space` **and are a member of that space** — i.e. only spaces they + created or were assigned to. **Admin/SuperAdmin** still manage all. (query_service + `_can_manage`.) Verified: non-member holder → 403; owner/member-holder/admin → OK. ### 9.5 Removal: ban, never hard-delete - **Problem.** Hard-deleting a user destroys provenance/audit history and is diff --git a/query_service/README.md b/query_service/README.md index 25371f3..6fe6260 100644 --- a/query_service/README.md +++ b/query_service/README.md @@ -59,7 +59,7 @@ Two independent layers apply to every mutating call: |---|---| | `create_private_space` | Create your own individual/private space | | `create_team_space` | Create a **team** (shared) space | -| `manage_team_space` | Manage a team space's members, visibility, graphs, and access rules | +| `manage_team_space` | Manage a team space's members/visibility/graphs/rules — **only for team spaces you own or are assigned to** (a member of), *not* all team spaces (Admin/SuperAdmin manage all) | | `ingest` | Ingest data into a graph — **also** needs per-space write (owner/editor membership **or** a space write access rule; see below) | | `recover` | Recover stuck/errored ingest jobs | | `read_private` | Read non-public content you're a member of | @@ -91,9 +91,19 @@ delegatable. `grant` and `sparql_admin` are **not** delegatable (they come only from an Admin/SuperAdmin role), so grants can't escalate a non-admin into an admin. Effective capabilities = role-derived ∪ role/group grants ∪ per-user grants. -**SuperAdmin vs Admin:** identical KG capabilities here. SuperAdmin is a -bootstrap-seeded, protected marker (can't be banned/deleted/role-stripped); -role *assignment* is owned by the usermanagement service, not query_service. +**SuperAdmin vs Admin — who they are and who creates them:** +- **SuperAdmin** is the top authority, **bootstrapped at deployment** from + `USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS` (seeded on that user's first login). + It's a protected marker — can't be banned, deleted, or role-stripped. Only a + **SuperAdmin** can grant the `Admin` (or `SuperAdmin`) role to others. +- **Admin** is created by a **SuperAdmin** assigning the `Admin` role + (`brainkb_assign_role(email, "Admin")` — SuperAdmin-only). Admins have the same + KG capabilities but are themselves manageable (a SuperAdmin can demote/ban them). +- **Scope of "manage all":** only Admin/SuperAdmin manage *every* team space. + A non-admin (even with `manage_team_space`) manages **only** the team spaces they + **created (own)** or were **assigned to** (a member, or matched by a per-space + `manage` rule). Role *assignment* itself is owned by usermanagement, not + query_service. ### Giving a whole group ingest access to a team space From f9f78dbb1d186b63c678a7cc791aafe3b5f1d60d Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 09:41:45 -0400 Subject: [PATCH 38/70] Harden require_admin (+ SuperAdmin gate) to re-read roles from the DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- usermanagement_service/core/routers/admin.py | 24 ++++++++++-------- usermanagement_service/core/security.py | 26 ++++++++++++++++---- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/usermanagement_service/core/routers/admin.py b/usermanagement_service/core/routers/admin.py index dc5743e..bafc9a4 100644 --- a/usermanagement_service/core/routers/admin.py +++ b/usermanagement_service/core/routers/admin.py @@ -46,16 +46,20 @@ _ADMIN_TIER_ROLES = {"Admin", "SuperAdmin"} -def _is_superadmin(admin: dict) -> bool: - """True if the acting caller holds SuperAdmin (or is a bootstrap superadmin).""" - if isinstance(admin, dict) and "SuperAdmin" in (admin.get("roles") or []): - return True +async def _is_superadmin(admin: dict) -> bool: + """True if the acting caller currently holds SuperAdmin. Roles are re-read from + the DB (not trusted from the token) so a just-demoted SuperAdmin can't still act. + The bootstrap-superadmin allowlist is honored for first sign-in.""" email = ((admin.get("sub") or admin.get("email")) if isinstance(admin, dict) else "") or "" - return bool(email and email.lower() in config.bootstrap_superadmin_emails) + if email and email.lower() in config.bootstrap_superadmin_emails: + return True + from core.security import _current_roles_from_db + roles = await _current_roles_from_db(email.lower(), admin.get("profile_id") if isinstance(admin, dict) else None) + return "SuperAdmin" in roles -def _require_superadmin(admin: dict, action: str) -> None: - if not _is_superadmin(admin): +async def _require_superadmin(admin: dict, action: str) -> None: + if not await _is_superadmin(admin): raise HTTPException(status_code=403, detail=f"Only a SuperAdmin can {action}.") logger = logging.getLogger(__name__) @@ -348,7 +352,7 @@ async def assign_role_to_user( raise HTTPException(status_code=404, detail="User not found") # Only a SuperAdmin may create/grant admin-tier roles (Admin/SuperAdmin). if body.role in _ADMIN_TIER_ROLES: - _require_superadmin(admin, f"assign the {body.role} role") + await _require_superadmin(admin, f"assign the {body.role} role") await user_role_repo.assign_role( session=session, profile_id=profile_id, @@ -386,7 +390,7 @@ async def remove_role_from_user( ) # Demoting an Admin (removing the Admin role) is SuperAdmin-only. if role_name == "Admin": - _require_superadmin(admin, "remove the Admin role") + await _require_superadmin(admin, "remove the Admin role") await user_role_repo.remove_role(session, profile_id, role_name) roles = await user_role_repo.get_user_role_names(session, profile_id) await session.commit() @@ -578,7 +582,7 @@ async def ban_user( ) # Banning an Admin is SuperAdmin-only (SuperAdmin > Admin). if "Admin" in (target_roles or []): - _require_superadmin(admin, "ban an Admin account") + await _require_superadmin(admin, "ban an Admin account") banned_at = datetime.utcnow() profile.is_banned = True diff --git a/usermanagement_service/core/security.py b/usermanagement_service/core/security.py index 678b6d8..6c012cc 100644 --- a/usermanagement_service/core/security.py +++ b/usermanagement_service/core/security.py @@ -374,12 +374,28 @@ async def get_current_user_optional( return user_data -def require_admin(current_user: Annotated[dict, Depends(get_current_user)]) -> dict: - """Dependency: caller must have the Admin or SuperAdmin role (profile-level). - Checks JWT 'roles' claim, falling back to the bootstrap superadmin email - allowlist for the very first sign-in (before any role is assigned in the DB).""" +async def _current_roles_from_db(email: str, profile_id) -> list: + """Active role names for a user, read fresh from the DB (not the token).""" + from core.database import user_db_manager, user_role_repo, user_profile_repo + async with user_db_manager.get_async_session() as session: + pid = profile_id + if pid is None and email: + prof = await user_profile_repo.get_by_email(session, email) + pid = prof.id if prof else None + if pid is None: + return [] + return await user_role_repo.get_user_role_names(session, pid) or [] + + +async def require_admin(current_user: Annotated[dict, Depends(get_current_user)]) -> dict: + """Dependency: caller must currently hold Admin or SuperAdmin. + + Roles are re-read from the DB (not trusted from the token's `roles` claim) so a + revoked/demoted admin loses access immediately, without waiting for the token to + expire. Falls back to the bootstrap-superadmin allowlist for the very first + sign-in (before any role exists in the DB).""" email = (current_user.get("email") or "").lower() - roles = current_user.get("roles", []) or [] + roles = await _current_roles_from_db(email, current_user.get("profile_id")) if UserRoleEnum.ADMIN.value in roles or UserRoleEnum.SUPERADMIN.value in roles: return current_user From a1b71b0c7ba987fc6ef284d3866059672e0fc59a Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 09:42:12 -0400 Subject: [PATCH 39/70] =?UTF-8?q?docs(auth):=20require=5Fadmin=20DB=20re-r?= =?UTF-8?q?ead=20done=20(=C2=A79.9);=20clarify=20legacy-retirement=20is=20?= =?UTF-8?q?gated=20on=20all-clients-on-SSO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- query_service/AUTH_UNIFICATION.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md index 3e5aa8f..6f3b4c3 100644 --- a/query_service/AUTH_UNIFICATION.md +++ b/query_service/AUTH_UNIFICATION.md @@ -520,9 +520,21 @@ sandbox has no browser); the mechanics around it are verified. strict bucket. Large **file** ingest is not byte-capped and uses no read/write timeout (raw-text ingest is capped) so ~5 GB TTL/JSON-LD uploads aren't aborted. +### 9.9 `require_admin` re-reads roles from the DB (done) +- **Problem.** `require_admin` trusted the token's `roles` claim, so a revoked/ + demoted admin kept access until their token expired. +- **Decision.** `require_admin` (and the `_is_superadmin` gate on admin-tier + actions) now re-read **active roles from the DB** (`_current_roles_from_db` by + profile_id/email); the bootstrap-superadmin allowlist is still honored for first + sign-in. Verified: an old token claiming `roles=[Admin]` is accepted while the + role exists, and rejected (403) the moment the role is removed in the DB. + ### Still open (deliberately deferred) -- Retire the legacy HS256 `/api/login`(`/token`) paths once all clients use SSO; - fold in `APItokenmanager`. -- Tighten usermanagement `require_admin` to re-read roles from the DB (it currently - trusts the token `roles` claim; SSO tokens are short-lived + re-read at exchange). +- **Retire the legacy HS256 `/api/login`(`/token`) paths + fold in + `APItokenmanager`.** *Gated on "all clients on SSO", which is NOT yet true* — the + MCP still falls back to legacy, and other clients (e.g. the web UI) may still use + password login; `/api/token` is intentionally kept as a compatibility alias. + Removing it now would break password login. Retire only after confirming every + client authenticates via SSO (login → exchange / OAuth), then delete the legacy + routes and migrate `APItokenmanager`'s user/scope store into usermanagement. - `chat_service` RS256 verification (same `core/jwks.py` pattern) — not in use. From 1bb68216fd3c031cda9dea54727321fe2c6fb199 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 09:51:49 -0400 Subject: [PATCH 40/70] CORS: allow https://brainkb.org (+ www) across query_service, ml_service, usermanagement --- ml_service/core/main.py | 3 +++ query_service/core/main.py | 9 +++++---- usermanagement_service/core/main.py | 4 +++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/ml_service/core/main.py b/ml_service/core/main.py index 360f70c..b22d488 100644 --- a/ml_service/core/main.py +++ b/ml_service/core/main.py @@ -167,7 +167,10 @@ async def lifespan(app: FastAPI): # CORS Configuration origins = [ + "https://brainkb.org", + "https://www.brainkb.org", "https://beta.brainkb.org", + "https://sandbox.brainkb.org", "http://localhost", "http://localhost:3000", "http://localhost:3001", diff --git a/query_service/core/main.py b/query_service/core/main.py index ee57a07..0e7fd99 100644 --- a/query_service/core/main.py +++ b/query_service/core/main.py @@ -23,12 +23,13 @@ environment = load_environment()["ENV_STATE"] -origins = [ +origins = [ + "https://brainkb.org", + "https://www.brainkb.org", "https://beta.brainkb.org", -"https://sandbox.brainkb.org", - "http://localhost:3000/", + "https://sandbox.brainkb.org", "http://localhost:3000", - "http://127.0.0.1:3000:" + "http://127.0.0.1:3000", ] if environment == "prods": diff --git a/usermanagement_service/core/main.py b/usermanagement_service/core/main.py index d46f4ce..d7e57dc 100644 --- a/usermanagement_service/core/main.py +++ b/usermanagement_service/core/main.py @@ -123,11 +123,13 @@ async def lifespan(app: FastAPI): logger = logging.getLogger(__name__) origins = [ + "https://brainkb.org", + "https://www.brainkb.org", "https://beta.brainkb.org", "https://sandbox.brainkb.org", "localhost:3000", "http://localhost:3000", - "http://127.0.0.1:300", + "http://127.0.0.1:3000", ] app.add_middleware( From cff52784640e90f4108efefbcebe0efefbc882c6 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 10:01:49 -0400 Subject: [PATCH 41/70] SSO: add /api/auth/session-exchange (session token -> per-service access 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). --- usermanagement_service/core/routers/sso.py | 63 +++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/usermanagement_service/core/routers/sso.py b/usermanagement_service/core/routers/sso.py index cda6cb7..33ae934 100644 --- a/usermanagement_service/core/routers/sso.py +++ b/usermanagement_service/core/routers/sso.py @@ -22,7 +22,7 @@ user_db_manager, jwt_user_repo, user_profile_repo, user_role_repo, ) from core.models.user import LoginUserIn -from core.security import authenticate_user +from core.security import authenticate_user, get_current_user logger = logging.getLogger(__name__) @@ -32,6 +32,22 @@ _bearer = HTTPBearer(auto_error=True) +# Scopes are derived from roles (RBAC is the source of truth) so no separate +# scope-management (the old Django APItokenmanager) is needed. +_WRITE_ROLES = {"Admin", "SuperAdmin", "Curator", "Lab Member", "Submitter", + "Annotator", "Mapper", "Knowledge Contributor"} +_ADMIN_ROLES = {"Admin", "SuperAdmin"} + + +def _scopes_for_roles(roles) -> list: + rset = set(roles or []) + scopes = ["read"] + if rset & _WRITE_ROLES: + scopes.append("write") + if rset & _ADMIN_ROLES: + scopes.append("admin") + return scopes + class ExchangeIn(BaseModel): audience: str @@ -126,3 +142,48 @@ async def sso_exchange( "aud": body.audience, "expires_in": tokens_rs256.access_token_ttl_seconds(), } + + +@router.post("/auth/session-exchange", tags=["SSO"]) +async def sso_session_exchange( + body: ExchangeIn, + current_user: dict = Depends(get_current_user), +): + """Exchange an authenticated **session token** (the web UI's usermanagement + JWT — v2 or SSO) for a short-lived per-service access token (`aud=`). + + This lets the web UI call query_service / ml_service with an audience-scoped + token derived from the logged-in user, instead of a shared service-account + password — so no password login is needed for those calls. Roles are re-read + fresh from the DB and scopes are derived from them (RBAC is authoritative).""" + if body.audience not in config.token_audiences: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unknown audience '{body.audience}'. Allowed: {config.token_audiences}", + ) + email = current_user.get("email") or current_user.get("sub") + if not email: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no identity in token") + + async with user_db_manager.get_async_session() as session: + profile = await user_profile_repo.get_by_email(session, email) + profile_id = profile.id if profile else current_user.get("profile_id") + roles = await user_role_repo.get_user_role_names(session, profile.id) if profile else [] + jwt_user = await jwt_user_repo.get_by_email_any_status(session, email) + jwt_user_id = jwt_user.id if jwt_user else current_user.get("user_id") + + access = tokens_rs256.create_access_token( + audience=body.audience, + email=email, + profile_id=profile_id, + roles=roles, + scopes=_scopes_for_roles(roles), + auth_source=current_user.get("auth_source", "session"), + jwt_user_id=jwt_user_id, + ) + return { + "access_token": access, + "token_type": "bearer", + "aud": body.audience, + "expires_in": tokens_rs256.access_token_ttl_seconds(), + } From 97663d4436aaefec2b780e61721702758a84a0c1 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 10:11:45 -0400 Subject: [PATCH 42/70] =?UTF-8?q?docs(auth):=20=C2=A79.10=20UI=20migrated?= =?UTF-8?q?=20to=20session-exchange;=20legacy=20retirement=20now=20unblock?= =?UTF-8?q?ed=20(do=20after=20deploy=20test)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- query_service/AUTH_UNIFICATION.md | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md index 6f3b4c3..17a66f1 100644 --- a/query_service/AUTH_UNIFICATION.md +++ b/query_service/AUTH_UNIFICATION.md @@ -529,12 +529,26 @@ sandbox has no browser); the mechanics around it are verified. sign-in. Verified: an old token claiming `roles=[Admin]` is accepted while the role exists, and rejected (403) the moment the role is removed in the DB. +### 9.10 Web UI migrated off the password service account (SSO session-exchange) +- **Problem.** The `brainkb-ui` audit showed user login is already OAuth-only, but + ML/query calls used a shared **service-account password** on `/api/token` + (`NEXT_PUBLIC_JWT_USER/PASSWORD`), and two NER routes took form-entered + credentials. That was the last thing blocking password-login retirement. +- **Decision.** New `POST /api/auth/session-exchange` (usermanagement): swap an + authenticated **session token** (the UI's usermanagement JWT) for a short-lived + `aud=` access token, **scopes derived from roles** (RBAC authoritative — + removes the need for the Django scope manager). The UI now session-exchanges for + `ml_service`/`query_service` tokens; the service-account password is a deprecated + fallback only. Verified backend-side (session token → aud token → 200 at + query_service); the UI change needs deploy testing. + ### Still open (deliberately deferred) - **Retire the legacy HS256 `/api/login`(`/token`) paths + fold in - `APItokenmanager`.** *Gated on "all clients on SSO", which is NOT yet true* — the - MCP still falls back to legacy, and other clients (e.g. the web UI) may still use - password login; `/api/token` is intentionally kept as a compatibility alias. - Removing it now would break password login. Retire only after confirming every - client authenticates via SSO (login → exchange / OAuth), then delete the legacy - routes and migrate `APItokenmanager`'s user/scope store into usermanagement. + `APItokenmanager`.** Now unblocked for the web UI (migrated to session-exchange, + §9.10) and the MCP (SSO). **Do after** confirming, on a real deploy, that the UI + works via session-exchange — then: (1) restrict/remove password login (keep a + SuperAdmin break-glass if wanted), (2) remove the `api_tokenmanager` Django + program from `Dockerfile.unified` + change the container healthcheck off `:8000` + + drop its start.sh migration steps (`Web_jwtuser`/`Web_scope` tables are created + by usermanagement `create_all`). - `chat_service` RS256 verification (same `core/jwks.py` pattern) — not in use. From 39732eca9cd5e8d4bdc1642231f8fde43f6df21d Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 13:23:39 -0400 Subject: [PATCH 43/70] Add Personal Access Tokens (PAT) for browser-free CLI/MCP auth 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) --- env.template | 9 + query_service/AUTH_UNIFICATION.md | 88 ++++++- usermanagement_service/core/database.py | 63 ++++- usermanagement_service/core/main.py | 3 + .../core/models/database_models.py | 36 +++ usermanagement_service/core/routers/pat.py | 231 ++++++++++++++++++ 6 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 usermanagement_service/core/routers/pat.py diff --git a/env.template b/env.template index 3d0d010..1736921 100644 --- a/env.template +++ b/env.template @@ -120,6 +120,15 @@ USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN=720 # Services a refresh token may be exchanged for (valid `aud` values). USERMANAGEMENT_TOKEN_AUDIENCES=usermanagement,query_service,ml_service,chat_service +# Personal Access Tokens (PATs) — browser-free CLI/MCP auth. A user mints a PAT +# once (while logged in), sets it as BRAINKB_TOKEN in their MCP/skill config, and +# authenticates with it thereafter (no login/browser) until it expires or is +# revoked. PATs are opaque + hashed at rest + revocable instantly; on use they +# are exchanged for the same short-lived per-service token the login flow issues. +USERMANAGEMENT_PAT_DEFAULT_DAYS=90 # default lifetime when the user omits `days` +USERMANAGEMENT_PAT_MAX_DAYS=365 # hard cap on a PAT's lifetime +USERMANAGEMENT_PAT_MAX_PER_USER=20 # max active PATs one user may hold + # query_service verifies RS256 access tokens against the issuer's JWKS. # In the unified container usermanagement is on localhost:8004; in a split # deployment point this at the usermanagement service URL. diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md index 17a66f1..aa8d193 100644 --- a/query_service/AUTH_UNIFICATION.md +++ b/query_service/AUTH_UNIFICATION.md @@ -389,9 +389,15 @@ implemented behavior: authorize URL → user signs in → browser shows a short code → `brainkb_finish_login(code)` (backend `/api/auth/cli/start` + `/cli/exchange`, flow B in §0). No web UI required. +- **Personal Access Token (browser-free, recommended).** Set `BRAINKB_TOKEN` to a + `brainkb_pat_…` (minted once via `brainkb_create_token`) — `_token_for` recognizes + the prefix and exchanges it at `/api/auth/pat/exchange` per service, no login or + browser afterward. `brainkb_use_token(pat)` does the same for one session. + Manage with `brainkb_list_tokens` / `brainkb_revoke_token`. See §9.11. - **Header pass-through (stateless, multi-user remote):** a caller may send - `Authorization: Bearer `. A **refresh** token unlocks all services (the - MCP exchanges it per service); a single **service access token** is used as-is. + `Authorization: Bearer `. A **refresh** token or a **PAT** unlocks all + services (the MCP exchanges it per service); a single **service access token** + is used as-is. - **Sessions expire.** The cached session lasts until its refresh token expires (`USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN`), hard-capped by `MCP_SESSION_TTL_MIN`. On lapse the MCP forgets the credentials and asks the user to log in again; @@ -542,6 +548,84 @@ sandbox has no browser); the mechanics around it are verified. fallback only. Verified backend-side (session token → aud token → 200 at query_service); the UI change needs deploy testing. +### 9.11 Personal Access Tokens (PATs) — browser-free CLI/MCP auth +- **Problem.** CLI/MCP auth required either a password login or a one-time Globus + browser paste-code **every session** — and both mint an RS256 refresh token that + depends on the SSO key material (the per-worker key-file we had to persist). Users + wanted a "generate once, paste into the skill config, no browser afterward" + credential. +- **Decision.** Add an **opaque, DB-backed Personal Access Token**. A user mints one + while logged in (`POST /api/auth/tokens` → shown once as `brainkb_pat_…`), sets it + as `BRAINKB_TOKEN` in the MCP config, and every call thereafter exchanges it at + `POST /api/auth/pat/exchange` for the same short-lived `aud=` access token + the refresh flow issues. No browser/login after the one-time mint. +- **Why opaque (not a signed/long-lived JWT).** (1) **Instantly revocable** — a + signed JWT lives until it expires; an opaque token is a DB row we flip. (2) **No + key material exposed to the user** — they handle one string, never a key; this is + exactly the "no RSA/key for CLI" ask. (3) **Roles re-read live** at exchange time, + so a ban/demotion takes effect immediately. Only the SHA-256 hash is stored, so a + DB leak yields no usable tokens. +- **Why services need no change.** The PAT is validated only at usermanagement; the + token it *exchanges into* is the ordinary RS256 per-service token, so query/ml + verify it via JWKS unchanged — containment (`aud`) preserved. +- **Model.** `Web_personal_access_token` (token_hash unique, prefix, name, + profile_id, jwt_user_id, email, revoked, expires_at, last_used_at). Endpoints: + create / list / revoke (session-auth) + `pat/exchange` (PAT-auth). Env: + `USERMANAGEMENT_PAT_DEFAULT_DAYS` (90), `_MAX_DAYS` (365), `_MAX_PER_USER` (20). +- **MCP.** `BRAINKB_TOKEN` env + `brainkb_use_token` set a PAT; `_token_for` + recognizes the `brainkb_pat_` prefix and PAT-exchanges (header, session, or env). + Tools: `brainkb_create_token`, `brainkb_list_tokens`, `brainkb_revoke_token`. +- **Verified live** (unified container): create → list → exchange → **200** at + query_service → revoke → exchange **401** (instant revocation); exchange re-read + roles fresh from the DB (`auth_source=pat`). +- **Note (signing scheme).** Considered switching the internal SSO tokens to an + HS256 shared secret. **Decision: no change for now** — stay on RS256/JWKS (full + reasoning in §9.12). The PAT is independent of this (opaque, DB-validated); it + exchanges into whatever the issuer mints. + +### 9.12 Why the SSO access tokens are RS256 (asymmetric) and not a shared HS256 secret +- **Question raised.** For a four-service deployment, wouldn't a single shared + secret (HS256) be simpler than RSA — no JWKS, no public-key distribution, no + per-worker key generation? (It would; the shared secret *is* the key. The + question is what that simplicity costs.) +- **The core property RS256 buys: a verifier that cannot forge.** + usermanagement is the **sole issuer**; query/ml/chat only **verify**. + - **RS256 (what we use).** The issuer holds the **private** key (signs); every + other service holds only the **public** key (published at + `/.well-known/jwks.json`) and can *verify without holding any secret capable of + minting*. If ml_service is compromised, the attacker gets a public key — they + **still cannot mint tokens** for any service. + - **HS256 (shared secret).** Signing and verifying use the **same** secret. Every + service that verifies must hold a secret that can equally **forge**. Compromise + of *any one* service (or a leaked env/log) lets the attacker mint tokens for + **all** services with arbitrary `sub`/`roles`/`scopes`/`aud`. The other services + cannot distinguish the forgery from a genuine token. +- **Containment is the whole point of Phase 2.** Per-`aud` tokens give crypto-enforced + containment: a token minted for `query_service` is provably unusable at + `ml_service`. Under HS256 that containment degrades to *trust-based* — any + secret-holder can set `aud` to anything — which undoes a property we deliberately + built (see §4.2 / Phase 2). +- **Other HS256 costs (not removed, just relocated).** A shared secret still has to + be distributed to every service, environment, and worker, and **rotated + everywhere simultaneously**; its compromise radius is all four services at once. + So HS256 removes *asymmetric-key* management but not *secret* management. +- **Why RSA's operational pain is acceptable.** The one real downside we hit was + provisioning the private key across gunicorn workers (fixed by persisting one key + to a shared file; production sets `USERMANAGEMENT_JWT_PRIVATE_KEY_PEM/_FILE` + explicitly). That is a one-time deploy concern, not a per-request cost — RS256 + verification is local and stateless (no introspection call), same as HS256. +- **When we *would* switch.** If the four services ever collapse into a context where + asymmetry provably buys nothing (and stays that way) *and* the key-provisioning + overhead outweighs containment, HS256 is defensible — but only with: strict + `algorithms=["HS256"]` (never let the JWT header pick the alg), enforced + `iss`+`aud`, a ≥256-bit secret from a secret manager (not `.env` in Git), separate + secrets per environment, and the internet-facing MCP holding **no** secret (it + only relays, so it never becomes a forger). Until there's a concrete reason, + RS256's "verifier ≠ issuer" property is worth its modest operational cost. +- **If reversing:** keep it a **config** switch, not a rewrite — retain RS256 verify + paths so a future distributed/split deployment can re-enable asymmetric signing + without new code. + ### Still open (deliberately deferred) - **Retire the legacy HS256 `/api/login`(`/token`) paths + fold in `APItokenmanager`.** Now unblocked for the web UI (migrated to session-exchange, diff --git a/usermanagement_service/core/database.py b/usermanagement_service/core/database.py index 6f432fb..86d06ba 100644 --- a/usermanagement_service/core/database.py +++ b/usermanagement_service/core/database.py @@ -27,7 +27,7 @@ Base, JWTUser, UserProfile, UserActivity, UserContribution, UserRole, UserCountry, UserOrganization, UserEducation, UserExpertise, AvailableRole, AvailableCountry, OAuthIdentity, OAuthState, OAuthCliResult, Permission, RolePermission, PageAccess, PageAccessRole, PageAccessUser, - AdminSetting, + AdminSetting, PersonalAccessToken, ) from core.models.user import ActivityType, ContributionStatus @@ -1445,6 +1445,66 @@ async def purge_expired(self, session: AsyncSession) -> None: logger.error(f"Error purging oauth cli results: {str(e)}") +class PersonalAccessTokenRepository(UserBaseRepository): + """Personal Access Tokens (opaque, hashed at rest). Used by the CLI/MCP to + authenticate without a browser after a one-time mint. Only the SHA-256 hash + is stored; the plaintext is returned to the user once at creation.""" + + def __init__(self): + super().__init__(PersonalAccessToken) + + async def create(self, session: AsyncSession, *, token_hash: str, prefix: str, + name: str, profile_id: Optional[int], jwt_user_id: Optional[int], + email: str, expires_at: datetime) -> PersonalAccessToken: + row = PersonalAccessToken( + token_hash=token_hash, prefix=prefix, name=name or "", profile_id=profile_id, + jwt_user_id=jwt_user_id, email=email, expires_at=expires_at, revoked=False, + ) + session.add(row) + await session.flush() + return row + + async def get_valid_by_hash(self, session: AsyncSession, token_hash: str) -> Optional[PersonalAccessToken]: + """Return the PAT for a hash if it is usable (exists, not revoked, not + expired), else None. Touches last_used_at as a side effect.""" + result = await session.execute( + select(PersonalAccessToken).where(PersonalAccessToken.token_hash == token_hash) + ) + row = result.scalar_one_or_none() + if row is None or row.revoked or row.expires_at < datetime.utcnow(): + return None + row.last_used_at = datetime.utcnow() + await session.flush() + return row + + async def list_for_profile(self, session: AsyncSession, profile_id: int) -> List[PersonalAccessToken]: + result = await session.execute( + select(PersonalAccessToken) + .where(PersonalAccessToken.profile_id == profile_id) + .order_by(PersonalAccessToken.created_at.desc()) + ) + return list(result.scalars().all()) + + async def get_owned(self, session: AsyncSession, pat_id: int, + profile_id: int) -> Optional[PersonalAccessToken]: + result = await session.execute( + select(PersonalAccessToken).where( + PersonalAccessToken.id == pat_id, + PersonalAccessToken.profile_id == profile_id, + ) + ) + return result.scalar_one_or_none() + + async def revoke(self, session: AsyncSession, pat_id: int, profile_id: int) -> bool: + """Revoke a PAT the caller owns. Returns True if a row was revoked.""" + row = await self.get_owned(session, pat_id, profile_id) + if row is None or row.revoked: + return False + row.revoked = True + await session.flush() + return True + + class PermissionRepository(UserBaseRepository): def __init__(self): super().__init__(Permission) @@ -1627,6 +1687,7 @@ async def check_access( oauth_identity_repo = OAuthIdentityRepository() oauth_state_repo = OAuthStateRepository() oauth_cli_result_repo = OAuthCliResultRepository() +personal_access_token_repo = PersonalAccessTokenRepository() permission_repo = PermissionRepository() role_permission_repo = RolePermissionRepository() page_access_repo = PageAccessRepository() diff --git a/usermanagement_service/core/main.py b/usermanagement_service/core/main.py index d7e57dc..051a483 100644 --- a/usermanagement_service/core/main.py +++ b/usermanagement_service/core/main.py @@ -17,6 +17,7 @@ from core.routers.admin import router as admin_router from core.routers.access import router as access_router from core.routers.sso import router as sso_router, wellknown_router +from core.routers.pat import router as pat_router from core.database import user_db_manager, user_activity_repo from core.models.user import ActivityType from core.security import verify_token @@ -153,6 +154,8 @@ async def lifespan(app: FastAPI): # Phase 2 SSO: JWKS at the root well-known path; auth endpoints under /api. app.include_router(wellknown_router, tags=["SSO"]) app.include_router(sso_router, prefix="/api", tags=["SSO"]) +# Personal Access Tokens (browser-free CLI/MCP auth). +app.include_router(pat_router, prefix="/api", tags=["PAT"]) # log all HTTP exception when raised diff --git a/usermanagement_service/core/models/database_models.py b/usermanagement_service/core/models/database_models.py index 84a5656..60d1542 100644 --- a/usermanagement_service/core/models/database_models.py +++ b/usermanagement_service/core/models/database_models.py @@ -426,6 +426,42 @@ class OAuthCliResult(Base): ) +class PersonalAccessToken(Base): + """A long-lived, opaque Personal Access Token (PAT) for CLI/MCP use. + + The plaintext token is shown to the user exactly ONCE at creation and is + never stored — only its SHA-256 hash is persisted, so a DB leak cannot + recover usable tokens. A PAT is presented by the MCP/skill and exchanged at + ``POST /api/auth/pat/exchange`` for short-lived per-service access tokens + (the same RS256 tokens the refresh-token flow issues). Unlike a signed JWT a + PAT is revocable instantly (``revoked``) and time-bounded (``expires_at``); + roles are re-read from the DB at exchange time, so authorization is never + stale. This is the "generate once, paste into the skill config, no browser + afterward" credential.""" + __tablename__ = "Web_personal_access_token" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # SHA-256 hex of the full opaque secret — the ONLY copy we keep. + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + # Short, non-secret leading fragment shown in listings so a user can tell + # their tokens apart without exposing the secret (e.g. "brainkb_pat_9f3a"). + prefix: Mapped[str] = mapped_column(String(32), nullable=False) + name: Mapped[str] = mapped_column(String(120), nullable=False, default="") + profile_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey('Web_user_profile.id', ondelete='CASCADE')) + jwt_user_id: Mapped[Optional[int]] = mapped_column(Integer) + email: Mapped[str] = mapped_column(String(255), nullable=False) + revoked: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime) + expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + __table_args__ = ( + Index('idx_pat_token_hash', 'token_hash'), + Index('idx_pat_profile_id', 'profile_id'), + ) + + class Permission(Base): """Permission registry. A permission is a (resource, action) tuple, e.g. ('user', 'delete').""" __tablename__ = "Web_permission" diff --git a/usermanagement_service/core/routers/pat.py b/usermanagement_service/core/routers/pat.py new file mode 100644 index 0000000..33ecc2c --- /dev/null +++ b/usermanagement_service/core/routers/pat.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +"""Personal Access Tokens (PATs) — browser-free auth for the CLI / MCP skills. + +A PAT is an opaque, long-lived, revocable credential. The user mints one **once** +(needing a normal login only at that moment), pastes it into their MCP/skill +config (``BRAINKB_TOKEN``), and from then on every call authenticates with the +PAT — no browser, no password, no paste-code. + + POST /api/auth/tokens (authenticated) mint a PAT — plaintext shown ONCE + GET /api/auth/tokens (authenticated) list the caller's PATs (metadata only) + DELETE /api/auth/tokens/{id} (authenticated) revoke one of the caller's PATs + POST /api/auth/pat/exchange (PAT in body) PAT -> short-lived per-service access token + +Design notes +------------ +* The token is opaque (``brainkb_pat_``) — NOT a JWT and NOT RSA-signed — + so nothing key-related is exposed to the user. Only its SHA-256 hash is stored. +* Validation is a DB lookup (hash match, not revoked, not expired, user not + banned). That makes a PAT **instantly revocable**, unlike a signed token that + lives until it expires. +* On exchange we re-read the user's roles from the DB and derive scopes, then + mint the same RS256 per-service access token the refresh-token flow issues — + so downstream services need **no changes** and containment (per-``aud`` tokens) + is preserved. +""" +import hashlib +import logging +import os +import secrets +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field + +from core import tokens_rs256 +from core.configuration import config +from core.database import ( + user_db_manager, personal_access_token_repo, user_profile_repo, + user_role_repo, jwt_user_repo, +) +from core.routers.sso import _scopes_for_roles +from core.security import get_current_user + +logger = logging.getLogger(__name__) + +router = APIRouter() + +_PAT_PREFIX = "brainkb_pat_" +# Default lifetime and hard cap for a PAT, in days (configurable via env). +_PAT_DEFAULT_DAYS = max(1, int(os.getenv("USERMANAGEMENT_PAT_DEFAULT_DAYS", "90"))) +_PAT_MAX_DAYS = max(1, int(os.getenv("USERMANAGEMENT_PAT_MAX_DAYS", "365"))) +# Upper bound on how many active (unrevoked, unexpired) tokens a user may hold — +# a light guard against unbounded token sprawl. +_PAT_MAX_PER_USER = max(1, int(os.getenv("USERMANAGEMENT_PAT_MAX_PER_USER", "20"))) + + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def _gen_token() -> str: + """Mint a fresh opaque PAT: ``brainkb_pat_`` + 43 urlsafe chars (~256 bits).""" + return _PAT_PREFIX + secrets.token_urlsafe(32) + + +class CreateTokenIn(BaseModel): + name: str = Field("", max_length=120, + description="Human label so you can tell tokens apart, e.g. 'laptop'.") + days: int = Field(_PAT_DEFAULT_DAYS, ge=1, le=_PAT_MAX_DAYS, + description=f"Lifetime in days (1..{_PAT_MAX_DAYS}).") + + +class PatExchangeIn(BaseModel): + token: str + audience: str + + +@router.post("/auth/tokens", tags=["PAT"]) +async def create_token(body: CreateTokenIn, current_user: dict = Depends(get_current_user)): + """Mint a Personal Access Token for the logged-in user. The plaintext token is + returned **once** — it is never stored (only a hash) and cannot be retrieved + again. Paste it into your MCP/skill config as ``BRAINKB_TOKEN``.""" + email = current_user.get("email") or current_user.get("sub") + if not email: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no identity in token") + + days = min(max(1, int(body.days or _PAT_DEFAULT_DAYS)), _PAT_MAX_DAYS) + token = _gen_token() + token_hash = _hash(token) + # Non-secret display fragment: prefix + first 4 chars of the random part. + display_prefix = token[: len(_PAT_PREFIX) + 4] + expires_at = datetime.utcnow() + timedelta(days=days) + + async with user_db_manager.get_async_session() as session: + profile = await user_profile_repo.get_by_email(session, email) + profile_id = profile.id if profile else current_user.get("profile_id") + if profile and getattr(profile, "is_banned", False): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account_suspended") + jwt_user = await jwt_user_repo.get_by_email_any_status(session, email) + jwt_user_id = jwt_user.id if jwt_user else current_user.get("user_id") + + if profile_id is not None: + existing = await personal_access_token_repo.list_for_profile(session, profile_id) + active = [t for t in existing if not t.revoked and t.expires_at >= datetime.utcnow()] + if len(active) >= _PAT_MAX_PER_USER: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=(f"You already have {len(active)} active tokens " + f"(max {_PAT_MAX_PER_USER}). Revoke one first."), + ) + + row = await personal_access_token_repo.create( + session, token_hash=token_hash, prefix=display_prefix, name=body.name, + profile_id=profile_id, jwt_user_id=jwt_user_id, email=email, expires_at=expires_at, + ) + # Capture values before commit (attributes expire afterward → MissingGreenlet). + pat_id = row.id + created_prefix = row.prefix + await session.commit() + + return { + "id": pat_id, + "token": token, # shown ONCE — never returned again + "prefix": created_prefix, + "name": body.name, + "expires_at": expires_at.isoformat() + "Z", + "expires_in_days": days, + "note": ("Copy this token now — it will not be shown again. Set it as " + "BRAINKB_TOKEN in your MCP/skill config."), + } + + +@router.get("/auth/tokens", tags=["PAT"]) +async def list_tokens(current_user: dict = Depends(get_current_user)): + """List the caller's PATs (metadata only — the secret is never returned).""" + email = current_user.get("email") or current_user.get("sub") + async with user_db_manager.get_async_session() as session: + profile = await user_profile_repo.get_by_email(session, email) + profile_id = profile.id if profile else current_user.get("profile_id") + if profile_id is None: + return {"tokens": []} + rows = await personal_access_token_repo.list_for_profile(session, profile_id) + now = datetime.utcnow() + tokens = [ + { + "id": r.id, + "name": r.name, + "prefix": r.prefix, + "created_at": r.created_at.isoformat() + "Z" if r.created_at else None, + "last_used_at": r.last_used_at.isoformat() + "Z" if r.last_used_at else None, + "expires_at": r.expires_at.isoformat() + "Z" if r.expires_at else None, + "revoked": r.revoked, + "expired": bool(r.expires_at and r.expires_at < now), + "active": (not r.revoked) and bool(r.expires_at and r.expires_at >= now), + } + for r in rows + ] + return {"tokens": tokens} + + +@router.delete("/auth/tokens/{pat_id}", tags=["PAT"]) +async def revoke_token(pat_id: int, current_user: dict = Depends(get_current_user)): + """Revoke one of the caller's PATs. Takes effect immediately — the next + exchange with that token fails.""" + email = current_user.get("email") or current_user.get("sub") + async with user_db_manager.get_async_session() as session: + profile = await user_profile_repo.get_by_email(session, email) + profile_id = profile.id if profile else current_user.get("profile_id") + if profile_id is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found") + ok = await personal_access_token_repo.revoke(session, pat_id, profile_id) + await session.commit() + if not ok: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail="Token not found or already revoked") + return {"revoked": True, "id": pat_id} + + +@router.post("/auth/pat/exchange", tags=["PAT"]) +async def pat_exchange(body: PatExchangeIn): + """Exchange a Personal Access Token for a short-lived per-service access token + (``aud=``). The PAT itself is the credential — no other auth needed. + + Roles are re-read fresh from the DB and scopes derived from them, so a + demoted/banned user (or a revoked token) cannot mint a privileged token.""" + token = (body.token or "").strip() + if not token.startswith(_PAT_PREFIX): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not a BrainKB access token") + if body.audience not in config.token_audiences: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unknown audience '{body.audience}'. Allowed: {config.token_audiences}", + ) + + token_hash = _hash(token) + async with user_db_manager.get_async_session() as session: + row = await personal_access_token_repo.get_valid_by_hash(session, token_hash) + if row is None: + await session.commit() + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalid, expired, or revoked") + # Capture PAT-linked identity before touching related rows / committing. + email = row.email + profile_id = row.profile_id + jwt_user_id = row.jwt_user_id + + profile = await user_profile_repo.get_by_email(session, email) + if profile is not None: + profile_id = profile.id + if getattr(profile, "is_banned", False): + await session.commit() + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account_suspended") + roles = await user_role_repo.get_user_role_names(session, profile_id) if profile_id else [] + await session.commit() + + access = tokens_rs256.create_access_token( + audience=body.audience, + email=email, + profile_id=profile_id, + roles=roles, + scopes=_scopes_for_roles(roles), + auth_source="pat", + jwt_user_id=jwt_user_id, + ) + return { + "access_token": access, + "token_type": "bearer", + "aud": body.audience, + "expires_in": tokens_rs256.access_token_ttl_seconds(), + } From 45d6296ce01503d37a785618d4f873f51186f9c2 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 13:38:43 -0400 Subject: [PATCH 44/70] PAT: default lifetime 3 days (was 90) Shorter default PAT lifetime; users may still request up to PAT_MAX_DAYS. Updated code default, env.template, and .env (untracked). --- env.template | 4 ++-- usermanagement_service/core/routers/pat.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/env.template b/env.template index 1736921..06d6297 100644 --- a/env.template +++ b/env.template @@ -125,8 +125,8 @@ USERMANAGEMENT_TOKEN_AUDIENCES=usermanagement,query_service,ml_service,chat_serv # authenticates with it thereafter (no login/browser) until it expires or is # revoked. PATs are opaque + hashed at rest + revocable instantly; on use they # are exchanged for the same short-lived per-service token the login flow issues. -USERMANAGEMENT_PAT_DEFAULT_DAYS=90 # default lifetime when the user omits `days` -USERMANAGEMENT_PAT_MAX_DAYS=365 # hard cap on a PAT's lifetime +USERMANAGEMENT_PAT_DEFAULT_DAYS=3 # default lifetime when the user omits `days` +USERMANAGEMENT_PAT_MAX_DAYS=365 # hard cap on a PAT's lifetime (user may request up to this) USERMANAGEMENT_PAT_MAX_PER_USER=20 # max active PATs one user may hold # query_service verifies RS256 access tokens against the issuer's JWKS. diff --git a/usermanagement_service/core/routers/pat.py b/usermanagement_service/core/routers/pat.py index 33ecc2c..464683a 100644 --- a/usermanagement_service/core/routers/pat.py +++ b/usermanagement_service/core/routers/pat.py @@ -47,7 +47,7 @@ _PAT_PREFIX = "brainkb_pat_" # Default lifetime and hard cap for a PAT, in days (configurable via env). -_PAT_DEFAULT_DAYS = max(1, int(os.getenv("USERMANAGEMENT_PAT_DEFAULT_DAYS", "90"))) +_PAT_DEFAULT_DAYS = max(1, int(os.getenv("USERMANAGEMENT_PAT_DEFAULT_DAYS", "3"))) _PAT_MAX_DAYS = max(1, int(os.getenv("USERMANAGEMENT_PAT_MAX_DAYS", "365"))) # Upper bound on how many active (unrevoked, unexpired) tokens a user may hold — # a light guard against unbounded token sprawl. From 0c0910680b151d5dedf64e0869b66bad08758809 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 13:43:36 -0400 Subject: [PATCH 45/70] =?UTF-8?q?docs:=20PAT=20default=20lifetime=203=20da?= =?UTF-8?q?ys=20in=20AUTH=5FUNIFICATION=20=C2=A79.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- query_service/AUTH_UNIFICATION.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md index aa8d193..4723038 100644 --- a/query_service/AUTH_UNIFICATION.md +++ b/query_service/AUTH_UNIFICATION.md @@ -571,7 +571,8 @@ sandbox has no browser); the mechanics around it are verified. - **Model.** `Web_personal_access_token` (token_hash unique, prefix, name, profile_id, jwt_user_id, email, revoked, expires_at, last_used_at). Endpoints: create / list / revoke (session-auth) + `pat/exchange` (PAT-auth). Env: - `USERMANAGEMENT_PAT_DEFAULT_DAYS` (90), `_MAX_DAYS` (365), `_MAX_PER_USER` (20). + `USERMANAGEMENT_PAT_DEFAULT_DAYS` (**3** — short-lived by default; a user may + still request up to the cap), `_MAX_DAYS` (365), `_MAX_PER_USER` (20). - **MCP.** `BRAINKB_TOKEN` env + `brainkb_use_token` set a PAT; `_token_for` recognizes the `brainkb_pat_` prefix and PAT-exchanges (header, session, or env). Tools: `brainkb_create_token`, `brainkb_list_tokens`, `brainkb_revoke_token`. From 3c53b9c62a5e7703b63e8c1571986bd6162525db Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 15:57:05 -0400 Subject: [PATCH 46/70] updated env template --- env.template | 18 +++++++++--------- pgadmin-init/servers.json | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) create mode 100644 pgadmin-init/servers.json diff --git a/env.template b/env.template index 06d6297..e7f15e0 100644 --- a/env.template +++ b/env.template @@ -96,15 +96,15 @@ USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS=tekraj@mit.edu # for one service cannot be replayed against another. Legacy HS256 tokens above # still work during migration. # -# RS256 private key. You normally DON'T need to set this: the unified container's -# start.sh auto-generates a persistent key at /app/secrets/um_jwt_private.pem -# (mounted from ./secrets) on first boot, so the JWKS `kid` is stable across the -# 4 gunicorn workers and redeploys. Override only to supply your own key: -# - _PEM: the PEM inline (\n-escaped), OR -# - _FILE: a path to a mounted PEM file. -# Generate your own with: openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out sso_key.pem -USERMANAGEMENT_JWT_PRIVATE_KEY_PEM= -USERMANAGEMENT_JWT_PRIVATE_KEY_FILE= +# RS256 private key: AUTO-GENERATED — do NOT set it here. The unified container's +# start.sh creates a persistent key at /app/secrets/um_jwt_private.pem (mounted +# from ./secrets) on first boot, so the JWKS `kid` is stable across the 4 gunicorn +# workers and redeploys. Because it is auto-generated, it is intentionally NOT a +# variable in this template or in .env. +# To OVERRIDE with your own key (optional), add one of these lines yourself: +# USERMANAGEMENT_JWT_PRIVATE_KEY_PEM= +# USERMANAGEMENT_JWT_PRIVATE_KEY_FILE= +# Generate one with: openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out sso_key.pem # Token issuer (`iss`). query_service's QUERY_SERVICE_SSO_ISSUER must match this. USERMANAGEMENT_JWT_ISSUER=brainkb-usermanagement # Token lifetimes (minutes). These control how long a login lasts before the user diff --git a/pgadmin-init/servers.json b/pgadmin-init/servers.json new file mode 100644 index 0000000..f7014e9 --- /dev/null +++ b/pgadmin-init/servers.json @@ -0,0 +1,15 @@ +{ + "Servers": { + "1": { + "Name": "BrainKB PostgreSQL", + "Group": "Servers", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "brainkb", + "Username": "postgres", + "Password": "postgres", + "SSLMode": "prefer", + "Comment": "BrainKB PostgreSQL Database" + } + } +} From bf6bed81836691ea3150ec7d8ccc1afcb2b706ce Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 16:22:29 -0400 Subject: [PATCH 47/70] spaces: annotate list_visible_spaces with caller's per-space permission 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.) --- query_service/core/spaces.py | 90 ++++++++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 13 deletions(-) diff --git a/query_service/core/spaces.py b/query_service/core/spaces.py index a79a6d7..d5757ee 100644 --- a/query_service/core/spaces.py +++ b/query_service/core/spaces.py @@ -173,13 +173,21 @@ async def member_role(space_id: str, member: Optional[str]) -> Optional[str]: async def list_visible_spaces(member: Optional[str]) -> List[Dict[str, Any]]: - """Spaces the caller may see: all public spaces plus any they are a member of. - Anonymous callers (member=None) see only public spaces.""" + """Spaces the caller may see, each annotated with THIS caller's permission: + - your_role: their space-membership role ('owner'|'editor'|'viewer') or None + - is_owner: whether they own the space + - access: how it is available to them — 'owner' | 'member' | 'public' + - can_write: whether their space role permits writing/ingest (owner/editor). + NOTE: an actual ingest ALSO requires the caller's global role to + grant the `ingest` capability and to pass any per-space access + rules — this flag reflects only the space-membership gate. + Anonymous callers (member=None) see only public spaces (your_role None).""" async with get_db_connection() as conn: if member: rows = await conn.fetch( """ - SELECT DISTINCT s.slug, s.name, s.description, s.owner, s.visibility, s.created_at + SELECT DISTINCT s.slug, s.name, s.description, s.owner, s.visibility, + s.space_type, s.created_at, m.role AS your_role FROM spaces s LEFT JOIN space_members m ON m.space_id = s.space_id AND m.member = $1 WHERE s.visibility = 'public' OR m.member IS NOT NULL @@ -190,17 +198,28 @@ async def list_visible_spaces(member: Optional[str]) -> List[Dict[str, Any]]: else: rows = await conn.fetch( """ - SELECT s.slug, s.name, s.description, s.owner, s.visibility, s.created_at + SELECT s.slug, s.name, s.description, s.owner, s.visibility, + s.space_type, s.created_at, NULL::text AS your_role FROM spaces s WHERE s.visibility = 'public' ORDER BY s.created_at DESC """, ) - return [ - {"slug": r["slug"], "name": r["name"], "description": r["description"], - "owner": r["owner"], "visibility": r["visibility"], "iri": space_iri(r["slug"]), - "created_at": r["created_at"]} - for r in rows - ] + out: List[Dict[str, Any]] = [] + for r in rows: + your_role = r["your_role"] + is_owner = bool(member) and r["owner"] == member + access = "owner" if is_owner else ("member" if your_role else "public") + out.append({ + "slug": r["slug"], "name": r["name"], "description": r["description"], + "owner": r["owner"], "visibility": r["visibility"], + "space_type": r["space_type"], "iri": space_iri(r["slug"]), + "created_at": r["created_at"], + "your_role": your_role, + "is_owner": is_owner, + "access": access, + "can_write": your_role in ("owner", "editor"), + }) + return out async def add_member(space_id: str, member: str, role: str) -> None: @@ -235,23 +254,68 @@ async def set_visibility(slug: str, visibility: str) -> None: ) -async def attach_graph(space_id: str, named_graph_iri: str) -> None: +class GraphAlreadyBound(Exception): + """A named graph is already bound to a DIFFERENT space. + + named_graph_iri is globally UNIQUE in space_graphs, so a graph lives in exactly + one space. Attaching one that another space already holds cannot succeed, and + must not look like it did. + """ + + def __init__(self, named_graph_iri: str, slug: str): + self.named_graph_iri = named_graph_iri + self.slug = slug + super().__init__( + f"named graph '{named_graph_iri}' is already registered to space " + f"'{slug}'; a graph can belong to only one space" + ) + + +async def attach_graph(space_id: str, named_graph_iri: str) -> bool: + """Bind a named graph to a space. + + Returns True when newly attached, False when it was already attached to THIS + space (idempotent re-registration). Raises GraphAlreadyBound when another space + holds it — previously that case silently did nothing while the caller received + a success response. + """ async with get_db_connection() as conn: - await conn.execute( + row = await conn.fetchrow( """ INSERT INTO space_graphs (space_id, named_graph_iri, added_at) VALUES ($1, $2, $3) ON CONFLICT (named_graph_iri) DO NOTHING + RETURNING id """, space_id, named_graph_iri, time.time(), ) + if row is None: + # The insert was a no-op: the graph is already bound. Determine whether + # it is bound HERE (fine) or to another space (a conflict we must + # report). Crucially, do NOT touch the search index in the latter case: + # search access-filtering keys off graph_search_index.space_id, so + # repointing it would let this space's visibility/membership govern + # another space's graph while space_graphs still says otherwise. + owner = await conn.fetchrow( + """ + SELECT s.space_id, s.slug FROM space_graphs g + JOIN spaces s ON s.space_id = g.space_id + WHERE g.named_graph_iri = $1 + """, + named_graph_iri, + ) + if owner is not None and owner["space_id"] != space_id: + raise GraphAlreadyBound(named_graph_iri, owner["slug"]) + # Point any already-indexed rows for this graph at the space so search # access-filtering picks up the (new) workspace immediately. Done inline - # (not via core.search) to avoid an import cycle. + # (not via core.search) to avoid an import cycle. Only reached when the + # graph genuinely belongs to this space. await conn.execute( "UPDATE graph_search_index SET space_id = $1 WHERE named_graph_iri = $2", space_id, named_graph_iri, ) + return row is not None # --------------------------------------------------------------------------- From 842deb14d5bccce6eac51521c010dde1bb877671 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 16:31:54 -0400 Subject: [PATCH 48/70] globally unique to one space --- query_service/core/routers/spaces.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/query_service/core/routers/spaces.py b/query_service/core/routers/spaces.py index de96a2d..5b3be17 100644 --- a/query_service/core/routers/spaces.py +++ b/query_service/core/routers/spaces.py @@ -221,6 +221,14 @@ async def add_graph(slug: str, body: SpaceGraphIn, user: Annotated[LoginUserIn, if not named_graph_url.endswith("/"): named_graph_url += "/" + # Bind first: a graph is globally unique to one space, so if another space + # already holds it this must fail with a conflict rather than register registry + # metadata for a graph we cannot attach. + try: + await sp.attach_graph(space["space_id"], named_graph_url) + except sp.GraphAlreadyBound as e: + raise HTTPException(409, str(e)) + # Register in the graph registry if not already there (idempotent-ish). if not await check_named_graph_exists(named_graph_url): await insert_data_gdb_async(named_graph_metadata( @@ -228,7 +236,6 @@ async def add_graph(slug: str, body: SpaceGraphIn, user: Annotated[LoginUserIn, description=body.description, agent_uri=str(agent_ref(_agent(user))), )) - await sp.attach_graph(space["space_id"], named_graph_url) space = await sp.get_space(slug) await sp.mirror_space_to_rdf(space) return space From f21ef3681b131cdf3df3ed558e448731e7689f98 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 16:54:24 -0400 Subject: [PATCH 49/70] query_service: fix get_current_user treating get_user()==False as a valid user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- query_service/core/security.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/query_service/core/security.py b/query_service/core/security.py index 85c7de7..1f857f6 100644 --- a/query_service/core/security.py +++ b/query_service/core/security.py @@ -145,7 +145,10 @@ async def get_current_user( except JWTError as e: raise credentials_exception from e user = await get_user(email=email) - if user is None: + # get_user returns False (not None) when there is no active user row, so check + # falsiness — otherwise `False` slips through as the "user" and downstream code + # (_agent, role lookup) breaks, yielding a misleading 403 instead of a 401. + if not user: raise credentials_exception return user @@ -169,7 +172,9 @@ async def get_current_user_optional(request: Request): email = payload.get("sub") if not email: return None - return await get_user(email=email) + # get_user returns False when no active user row; normalize to None so + # callers' truthiness/None checks behave (anonymous, not a bogus `False`). + return (await get_user(email=email)) or None except (ExpiredSignatureError, JWTError, Exception): return None @@ -282,10 +287,11 @@ async def authenticate_websocket(websocket: WebSocket, required_scopes: Optional # Get user from database (same as get_current_user) user = await get_user(email=email) - if user is None: + # get_user returns False (not None) when no active user row — check falsiness. + if not user: logger.warning(f"User not found for email: {email}") return None - + return user except Exception as e: From 310dbcf44ed220a1e4546fdd7594b10a12b16801 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 16:56:33 -0400 Subject: [PATCH 50/70] =?UTF-8?q?PAT:=20sliding=20expiry=20=E2=80=94=20sta?= =?UTF-8?q?y=20logged=20in=20while=20used,=20expire=20when=20idle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- env.template | 11 +++++++++-- usermanagement_service/core/routers/pat.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/env.template b/env.template index e7f15e0..f7eaa98 100644 --- a/env.template +++ b/env.template @@ -125,9 +125,16 @@ USERMANAGEMENT_TOKEN_AUDIENCES=usermanagement,query_service,ml_service,chat_serv # authenticates with it thereafter (no login/browser) until it expires or is # revoked. PATs are opaque + hashed at rest + revocable instantly; on use they # are exchanged for the same short-lived per-service token the login flow issues. -USERMANAGEMENT_PAT_DEFAULT_DAYS=3 # default lifetime when the user omits `days` -USERMANAGEMENT_PAT_MAX_DAYS=365 # hard cap on a PAT's lifetime (user may request up to this) +# DEFAULT_DAYS also acts as the SLIDING/idle window (see PAT_SLIDING): an +# actively-used token keeps rolling forward and never re-prompts; leave it unused +# this many days and it expires. +USERMANAGEMENT_PAT_DEFAULT_DAYS=3 # default lifetime + idle window when `days` omitted +USERMANAGEMENT_PAT_MAX_DAYS=365 # absolute ceiling — a PAT can't roll past created_at + this USERMANAGEMENT_PAT_MAX_PER_USER=20 # max active PATs one user may hold +# Sliding expiry: each use pushes expiry to now + DEFAULT_DAYS (capped at +# created_at + MAX_DAYS). true = "stay logged in while you keep using it, expire +# when idle"; false = fixed lifetime from creation. +USERMANAGEMENT_PAT_SLIDING=true # query_service verifies RS256 access tokens against the issuer's JWKS. # In the unified container usermanagement is on localhost:8004; in a split diff --git a/usermanagement_service/core/routers/pat.py b/usermanagement_service/core/routers/pat.py index 464683a..de90b7a 100644 --- a/usermanagement_service/core/routers/pat.py +++ b/usermanagement_service/core/routers/pat.py @@ -49,6 +49,12 @@ # Default lifetime and hard cap for a PAT, in days (configurable via env). _PAT_DEFAULT_DAYS = max(1, int(os.getenv("USERMANAGEMENT_PAT_DEFAULT_DAYS", "3"))) _PAT_MAX_DAYS = max(1, int(os.getenv("USERMANAGEMENT_PAT_MAX_DAYS", "365"))) +# Sliding expiry: when true (default), each successful use pushes the PAT's expiry +# forward by _PAT_DEFAULT_DAYS (the idle window) — so an actively-used token keeps +# working and never re-prompts, while an unused one expires after that many idle +# days. The extension is capped at created_at + _PAT_MAX_DAYS (an absolute ceiling, +# so a token can't roll forever). Set false for fixed-lifetime tokens. +_PAT_SLIDING = os.getenv("USERMANAGEMENT_PAT_SLIDING", "true").strip().lower() not in ("0", "false", "no") # Upper bound on how many active (unrevoked, unexpired) tokens a user may hold — # a light guard against unbounded token sprawl. _PAT_MAX_PER_USER = max(1, int(os.getenv("USERMANAGEMENT_PAT_MAX_PER_USER", "20"))) @@ -205,6 +211,18 @@ async def pat_exchange(body: PatExchangeIn): profile_id = row.profile_id jwt_user_id = row.jwt_user_id + # Sliding expiry: recent use extends the window so an actively-used token + # never re-prompts; an idle one still expires after _PAT_DEFAULT_DAYS. Cap + # the roll at created_at + _PAT_MAX_DAYS so it can't live forever. Only ever + # push expiry forward, never shorten it. + if _PAT_SLIDING: + now = datetime.utcnow() + sliding = now + timedelta(days=_PAT_DEFAULT_DAYS) + cap = (row.created_at or now) + timedelta(days=_PAT_MAX_DAYS) + new_exp = min(sliding, cap) + if new_exp > row.expires_at: + row.expires_at = new_exp + profile = await user_profile_repo.get_by_email(session, email) if profile is not None: profile_id = profile.id From 1ec8df5db121e3ed5d08b2c6ad79abd0ecd89bcf Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 17:00:56 -0400 Subject: [PATCH 51/70] oauth CLI paste-code: long, high-entropy (20 chars ~98 bits) 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. --- env.template | 5 +++++ usermanagement_service/core/routers/oauth.py | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/env.template b/env.template index f7eaa98..949c3d0 100644 --- a/env.template +++ b/env.template @@ -120,6 +120,11 @@ USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN=720 # Services a refresh token may be exchanged for (valid `aud` values). USERMANAGEMENT_TOKEN_AUDIENCES=usermanagement,query_service,ml_service,chat_service +# OAuth CLI/skill paste-code length (raw chars, before dash grouping). The code +# is short-lived (~10 min) + single-use, but kept high-entropy: 20 chars over a +# 30-symbol alphabet ≈ 98 bits. Clamped to 24 to fit the storage column. +USERMANAGEMENT_CLI_CODE_LEN=20 + # Personal Access Tokens (PATs) — browser-free CLI/MCP auth. A user mints a PAT # once (while logged in), sets it as BRAINKB_TOKEN in their MCP/skill config, and # authenticates with it thereafter (no login/browser) until it expires or is diff --git a/usermanagement_service/core/routers/oauth.py b/usermanagement_service/core/routers/oauth.py index e4bfd8b..f80b8f1 100644 --- a/usermanagement_service/core/routers/oauth.py +++ b/usermanagement_service/core/routers/oauth.py @@ -16,6 +16,7 @@ import base64 import hashlib import logging +import os import secrets from datetime import datetime, timedelta from typing import Optional @@ -57,12 +58,22 @@ def _redirect_uri_for(provider_name: str) -> str: # Unambiguous alphabet (no I/L/O/0/1) for the paste-code shown to users. -_CLI_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" +_CLI_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" # 30 symbols +# Paste-code length (raw chars, before dash grouping). The code is short-LIVED +# (~10 min) and single-use, but we still make it HIGH-ENTROPY for defense in +# depth: 20 chars over a 30-symbol alphabet ≈ 98 bits (~1e29 combinations), +# infeasible to brute-force within the 10-minute window even without rate limits. +# Clamped to 24 so the dash-grouped value still fits the +# Web_oauth_cli_result.code column (String(32)); configurable via env. +_CLI_CODE_LEN = min(24, max(8, int(os.getenv("USERMANAGEMENT_CLI_CODE_LEN", "20")))) def _gen_cli_code() -> str: - raw = "".join(secrets.choice(_CLI_CODE_ALPHABET) for _ in range(8)) - return f"{raw[:4]}-{raw[4:]}" + """A long, single-use, ~10-min paste-code, grouped in 4s for readability, + e.g. ``A3KM-7QRS-9WXY-2BCD-EFGH``. High entropy so it can't be guessed in the + short window; it is only a handle exchanged once for the real refresh token.""" + raw = "".join(secrets.choice(_CLI_CODE_ALPHABET) for _ in range(_CLI_CODE_LEN)) + return "-".join(raw[i:i + 4] for i in range(0, len(raw), 4)) def _cli_success_page(code: str) -> str: From 5626aeafbe4614056ec17e9d7a1980ee747e74b3 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 20:45:47 -0400 Subject: [PATCH 52/70] env.template: document multiple bootstrap SuperAdmins (comma-separated 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. --- env.template | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/env.template b/env.template index 949c3d0..fc84b7d 100644 --- a/env.template +++ b/env.template @@ -79,12 +79,19 @@ USERMANAGEMENT_FRONTEND_CALLBACK_URL=http://localhost:3000/auth/callback # Generate once: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" USERMANAGEMENT_OAUTH_TOKEN_ENC_KEY=Z85D3iJe4XCfJ5f8DExKXW3DfznyE4HzJ7XmmaOYtUQ= -# Comma-separated emails that are bootstrapped as SuperAdmin on first startup. +# Comma-separated emails bootstrapped as SuperAdmin on first startup — supports +# MULTIPLE (each email in the list is seeded as SuperAdmin+Admin, and every one is +# honored by require_admin even before its profile exists). Each must be a +# Globus-verifiable account: it goes live when that person logs in via Globus. # SuperAdmins also receive the regular Admin role for permissions, but the # SuperAdmin marker protects them from being banned, deleted, or having that # role stripped via the admin UI/API. Regular Admins (assigned through the # admin UI) remain fully manageable. -USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS=tekraj@mit.edu +# You can also add more SuperAdmins at runtime: an existing SuperAdmin assigns the +# SuperAdmin role to another user (SuperAdmin-only action). +# Example (multiple, comma-separated — no spaces needed): +# USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS=admin@example.org,lead@example.org,pi@lab.example.edu +USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS=admin@example.org,second-admin@example.org # ---------------------------------------------------------------------------- # Single-issuer SSO (auth Phase 2 — RS256 + JWKS). See query_service/AUTH_UNIFICATION.md From 60fd9a0e37219d8e76e9079a4818ed747b9b449a Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 22:53:07 -0400 Subject: [PATCH 53/70] usermanagement: longer web-session token so the UI stops 401-ing mid-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. --- env.template | 4 ++++ usermanagement_service/core/configuration.py | 12 ++++++++++++ usermanagement_service/core/routers/oauth.py | 4 ++++ usermanagement_service/core/security.py | 9 +++++++-- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/env.template b/env.template index fc84b7d..9ffcd3d 100644 --- a/env.template +++ b/env.template @@ -124,6 +124,10 @@ USERMANAGEMENT_JWT_ISSUER=brainkb-usermanagement # own MCP_SESSION_TTL_MIN) — see brainkb_mcp/.env.example. USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN=15 USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN=720 +# TTL (minutes) of the web-session JWT the UI receives at OAuth login (?token=). +# It is stored in the NextAuth session and is NOT auto-refreshed, so if it's too +# short the web UI starts returning 401 (/api/users/me) mid-session. 720 = 12h. +USERMANAGEMENT_WEB_SESSION_TTL_MIN=720 # Services a refresh token may be exchanged for (valid `aud` values). USERMANAGEMENT_TOKEN_AUDIENCES=usermanagement,query_service,ml_service,chat_service diff --git a/usermanagement_service/core/configuration.py b/usermanagement_service/core/configuration.py index 0d19fb1..9d43ed2 100644 --- a/usermanagement_service/core/configuration.py +++ b/usermanagement_service/core/configuration.py @@ -79,6 +79,10 @@ def load_environment(env_name="env"): "USERMANAGEMENT_JWT_PRIVATE_KEY_FILE": os.getenv("USERMANAGEMENT_JWT_PRIVATE_KEY_FILE"), "USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN": os.getenv("USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN", "15"), "USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN": os.getenv("USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN", "720"), + # TTL of the web-session JWT handed to the UI at OAuth login (?token=). + # It lives in the NextAuth session and is NOT auto-refreshed, so a too-short + # value makes the UI 401 mid-session. Default 12h. + "USERMANAGEMENT_WEB_SESSION_TTL_MIN": os.getenv("USERMANAGEMENT_WEB_SESSION_TTL_MIN", "720"), # Services a refresh token may be exchanged for (valid aud values). "USERMANAGEMENT_TOKEN_AUDIENCES": os.getenv("USERMANAGEMENT_TOKEN_AUDIENCES", "usermanagement,query_service,ml_service,chat_service"), @@ -194,6 +198,14 @@ def refresh_token_ttl_min(self) -> int: except (TypeError, ValueError): return 720 + @property + def web_session_ttl_min(self) -> int: + """TTL (minutes) of the web-session JWT issued to the UI at OAuth login.""" + try: + return int(self._env_vars.get("USERMANAGEMENT_WEB_SESSION_TTL_MIN", "720")) + except (TypeError, ValueError): + return 720 + @property def token_audiences(self) -> list: raw = self._env_vars.get("USERMANAGEMENT_TOKEN_AUDIENCES", "usermanagement,query_service,ml_service,chat_service") or "" diff --git a/usermanagement_service/core/routers/oauth.py b/usermanagement_service/core/routers/oauth.py index f80b8f1..0f8e372 100644 --- a/usermanagement_service/core/routers/oauth.py +++ b/usermanagement_service/core/routers/oauth.py @@ -360,6 +360,10 @@ async def oauth_callback( roles=existing_roles, scopes=scopes, auth_source=provider.name, + # Web-session TTL (default 12h), not the 30-min default: this token + # lives in the NextAuth session and isn't auto-refreshed, so a short + # TTL made the UI 401 (/api/users/me) mid-session. + expires_minutes=config.web_session_ttl_min, ) # CLI/skill (paste-code) flow: mint an SSO refresh token and stash it # behind a short code the browser will display for the user to paste. diff --git a/usermanagement_service/core/security.py b/usermanagement_service/core/security.py index 6c012cc..b3fe765 100644 --- a/usermanagement_service/core/security.py +++ b/usermanagement_service/core/security.py @@ -100,9 +100,14 @@ def create_access_token_v2( roles: List[str], scopes: List[str], auth_source: str = "password", + expires_minutes: Optional[int] = None, ) -> str: """Create a JWT carrying both JWT-level scopes and profile-level roles. - auth_source: 'password' | 'github' | 'orcid' | 'globus'.""" + auth_source: 'password' | 'github' | 'orcid' | 'globus'. + expires_minutes overrides the default 30-min TTL — the OAuth callback passes a + longer web-session TTL so the browser session token doesn't lapse after 30 min + and break the UI (it is stored in the NextAuth session and not auto-refreshed).""" + ttl = expires_minutes if (expires_minutes and expires_minutes > 0) else ACCESS_TOKEN_EXPIRE_MINUTES to_encode = { "sub": email, "scopes": scopes, @@ -110,7 +115,7 @@ def create_access_token_v2( "profile_id": profile_id, "roles": roles, "auth_source": auth_source, - "exp": datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), + "exp": datetime.utcnow() + timedelta(minutes=ttl), } return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) From 30af82f82bbc4136b7db57610ff0b94ceefb3dfd Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 24 Jul 2026 23:05:18 -0400 Subject: [PATCH 54/70] usermanagement: issue a web refresh token at OAuth login for silent renew 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. --- env.template | 10 +++++++--- usermanagement_service/core/configuration.py | 19 +++++++++++++++---- usermanagement_service/core/routers/oauth.py | 15 +++++++++++++++ usermanagement_service/core/tokens_rs256.py | 8 ++++++-- 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/env.template b/env.template index 9ffcd3d..c16eeaa 100644 --- a/env.template +++ b/env.template @@ -124,10 +124,14 @@ USERMANAGEMENT_JWT_ISSUER=brainkb-usermanagement # own MCP_SESSION_TTL_MIN) — see brainkb_mcp/.env.example. USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN=15 USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN=720 -# TTL (minutes) of the web-session JWT the UI receives at OAuth login (?token=). -# It is stored in the NextAuth session and is NOT auto-refreshed, so if it's too -# short the web UI starts returning 401 (/api/users/me) mid-session. 720 = 12h. +# TTL (minutes) of the web-session ACCESS JWT the UI receives at OAuth login +# (?token=). The UI silently refreshes it using the web refresh token below, so +# this can stay short-ish. 720 = 12h. USERMANAGEMENT_WEB_SESSION_TTL_MIN=720 +# TTL (minutes) of the web REFRESH token (?refresh=) the UI stores to renew its +# access token without re-login (exchanged at /api/auth/exchange). Governs the +# overall web session length before the user must sign in again. 10080 = 7 days. +USERMANAGEMENT_WEB_REFRESH_TTL_MIN=10080 # Services a refresh token may be exchanged for (valid `aud` values). USERMANAGEMENT_TOKEN_AUDIENCES=usermanagement,query_service,ml_service,chat_service diff --git a/usermanagement_service/core/configuration.py b/usermanagement_service/core/configuration.py index 9d43ed2..fbb5374 100644 --- a/usermanagement_service/core/configuration.py +++ b/usermanagement_service/core/configuration.py @@ -79,10 +79,12 @@ def load_environment(env_name="env"): "USERMANAGEMENT_JWT_PRIVATE_KEY_FILE": os.getenv("USERMANAGEMENT_JWT_PRIVATE_KEY_FILE"), "USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN": os.getenv("USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN", "15"), "USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN": os.getenv("USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN", "720"), - # TTL of the web-session JWT handed to the UI at OAuth login (?token=). - # It lives in the NextAuth session and is NOT auto-refreshed, so a too-short - # value makes the UI 401 mid-session. Default 12h. + # TTL of the web-session ACCESS JWT handed to the UI at OAuth login + # (?token=). Short-lived; the UI silently refreshes it. Default 12h. "USERMANAGEMENT_WEB_SESSION_TTL_MIN": os.getenv("USERMANAGEMENT_WEB_SESSION_TTL_MIN", "720"), + # TTL of the web REFRESH token (?refresh=) the UI stores to mint new access + # tokens without re-login. Governs the overall web session length. 7d. + "USERMANAGEMENT_WEB_REFRESH_TTL_MIN": os.getenv("USERMANAGEMENT_WEB_REFRESH_TTL_MIN", "10080"), # Services a refresh token may be exchanged for (valid aud values). "USERMANAGEMENT_TOKEN_AUDIENCES": os.getenv("USERMANAGEMENT_TOKEN_AUDIENCES", "usermanagement,query_service,ml_service,chat_service"), @@ -200,12 +202,21 @@ def refresh_token_ttl_min(self) -> int: @property def web_session_ttl_min(self) -> int: - """TTL (minutes) of the web-session JWT issued to the UI at OAuth login.""" + """TTL (minutes) of the web-session ACCESS JWT issued to the UI at login.""" try: return int(self._env_vars.get("USERMANAGEMENT_WEB_SESSION_TTL_MIN", "720")) except (TypeError, ValueError): return 720 + @property + def web_refresh_ttl_min(self) -> int: + """TTL (minutes) of the web REFRESH token the UI stores to renew access + tokens without re-login. Governs overall web session length.""" + try: + return int(self._env_vars.get("USERMANAGEMENT_WEB_REFRESH_TTL_MIN", "10080")) + except (TypeError, ValueError): + return 10080 + @property def token_audiences(self) -> list: raw = self._env_vars.get("USERMANAGEMENT_TOKEN_AUDIENCES", "usermanagement,query_service,ml_service,chat_service") or "" diff --git a/usermanagement_service/core/routers/oauth.py b/usermanagement_service/core/routers/oauth.py index 0f8e372..f9d0d4b 100644 --- a/usermanagement_service/core/routers/oauth.py +++ b/usermanagement_service/core/routers/oauth.py @@ -365,6 +365,19 @@ async def oauth_callback( # TTL made the UI 401 (/api/users/me) mid-session. expires_minutes=config.web_session_ttl_min, ) + # Web flow: also mint a longer-lived REFRESH token so the UI can renew + # its access token silently (no re-login) until this expires. Exchanged + # by the UI at /api/auth/exchange (audience=usermanagement). + web_refresh = None + if login_mode != "cli": + web_refresh = tokens_rs256.create_refresh_token( + email=profile.email, + profile_id=profile.id, + roles=existing_roles, + scopes=scopes, + auth_source=provider.name, + expires_minutes=config.web_refresh_ttl_min, + ) # CLI/skill (paste-code) flow: mint an SSO refresh token and stash it # behind a short code the browser will display for the user to paste. if login_mode == "cli": @@ -397,6 +410,8 @@ async def oauth_callback( return HTMLResponse(_cli_success_page(cli_code)) qs = {"token": token} + if web_refresh: + qs["refresh"] = web_refresh if redirect_after_login: qs["redirect"] = redirect_after_login return RedirectResponse(f"{config.frontend_callback_url}?{urlencode(qs)}", status_code=302) diff --git a/usermanagement_service/core/tokens_rs256.py b/usermanagement_service/core/tokens_rs256.py index cd38379..bf768c6 100644 --- a/usermanagement_service/core/tokens_rs256.py +++ b/usermanagement_service/core/tokens_rs256.py @@ -148,11 +148,15 @@ def create_refresh_token( roles: List[str], scopes: List[str], auth_source: str = "password", + expires_minutes: Optional[int] = None, ) -> str: """Mint the login/refresh token (aud=brainkb-auth). Not accepted by services; - only exchangeable at /api/auth/exchange for a per-service access token.""" + only exchangeable at /api/auth/exchange for a per-service access token. + expires_minutes overrides the default refresh TTL (the web flow passes a longer + one so the browser session can silently refresh over several days).""" _load_or_generate() now = _now() + ttl = expires_minutes if (expires_minutes and expires_minutes > 0) else config.refresh_token_ttl_min claims = { "iss": config.jwt_issuer, "aud": REFRESH_AUDIENCE, @@ -163,7 +167,7 @@ def create_refresh_token( "scopes": scopes, "auth_source": auth_source, "iat": now, - "exp": now + timedelta(minutes=config.refresh_token_ttl_min), + "exp": now + timedelta(minutes=ttl), } return jwt.encode(claims, _private_pem, algorithm=ALGORITHM, headers={"kid": _kid}) From b37e5e5ff339cef622921b20a7e1163a2550ae8c Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Tue, 11 Aug 2026 16:54:21 +0545 Subject: [PATCH 55/70] usermanagement: let OAuth accounts exchange a refresh token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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_service/core/routers/sso.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/usermanagement_service/core/routers/sso.py b/usermanagement_service/core/routers/sso.py index 33ae934..8f116f6 100644 --- a/usermanagement_service/core/routers/sso.py +++ b/usermanagement_service/core/routers/sso.py @@ -115,10 +115,25 @@ async def sso_exchange( ) email = payload.get("sub") + auth_source = payload.get("auth_source", "password") async with user_db_manager.get_async_session() as session: - # active-only lookup: a deactivated credential can no longer exchange. - jwt_user = await jwt_user_repo.get_by_email(session, email) + # Look the credential row up regardless of is_active, because an inactive + # row means two different things here. OAuth onboarding deliberately + # creates a SHELL row with is_active=False (provision_identity / + # _ensure_jwt_user_shell) — an OAuth user has no usable password, and the + # shell exists only to supply a stable user_id claim. An active-only + # lookup therefore rejected every OAuth user: Globus/ORCID/GitHub logins + # could mint a refresh token and then never exchange it, which broke both + # the MCP/CLI flow and the UI's silent renew. + jwt_user = await jwt_user_repo.get_by_email_any_status(session, email) if not jwt_user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unknown account") + # For PASSWORD credentials is_active is the deactivation switch that + # POST /api/admin/users/deactivate flips, so it must still be enforced — + # dropping the check outright would make deactivation a no-op here. + # OAuth accounts are removed by BANNING (the documented mechanism, since + # deletion is disabled), which the is_banned check below enforces. + if not jwt_user.is_active and auth_source == "password": raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account inactive") scopes = await jwt_user_repo.get_user_scopes(session, jwt_user.id) or ["read"] profile = await user_profile_repo.get_by_email(session, email) @@ -133,7 +148,7 @@ async def sso_exchange( profile_id=profile_id, roles=roles, scopes=scopes, - auth_source=payload.get("auth_source", "password"), + auth_source=auth_source, jwt_user_id=jwt_user.id, ) return { From f43adf5ca4a16b6dddc11a07a8aaef38c936a0fa Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Tue, 11 Aug 2026 17:33:05 +0545 Subject: [PATCH 56/70] query_service: let OAuth callers past the active-user lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- query_service/core/database.py | 15 ++++++++++++--- query_service/core/security.py | 23 +++++++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/query_service/core/database.py b/query_service/core/database.py index f021ad2..a9e249a 100644 --- a/query_service/core/database.py +++ b/query_service/core/database.py @@ -405,14 +405,23 @@ async def get_scopes_by_user(user_id: int, conn: Optional[asyncpg.Connection] = raise HTTPException(status_code=400, detail=str(e)) -async def get_user(email: str, conn: Optional[asyncpg.Connection] = None): +async def get_user(email: str, conn: Optional[asyncpg.Connection] = None, + include_inactive: bool = False): """ Get an active user by email. Returns the user row if found and active, False otherwise. + + `include_inactive=True` drops the is_active filter. Needed for OAuth callers: + usermanagement provisions them a credential SHELL row with is_active=False + (they have no usable password; the row exists only to carry a stable user_id), + so an active-only lookup rejects every Globus/ORCID/GitHub user even though + their token verifies. Do NOT use it on the password path — there is_active is + the deactivation switch that POST /api/admin/users/deactivate flips. """ + active_filter = "" if include_inactive else "AND is_active = True" query = f""" - SELECT * FROM \"{table_name_user}\" - WHERE email = $1 AND is_active = True + SELECT * FROM \"{table_name_user}\" + WHERE email = $1 {active_filter} LIMIT 1 """ diff --git a/query_service/core/security.py b/query_service/core/security.py index 1f857f6..bcb8e15 100644 --- a/query_service/core/security.py +++ b/query_service/core/security.py @@ -144,7 +144,13 @@ async def get_current_user( ) from e except JWTError as e: raise credentials_exception from e - user = await get_user(email=email) + # An OAuth caller's credential row is a SHELL with is_active=False (they have + # no usable password), so an active-only lookup 401s every Globus/ORCID/GitHub + # user despite a perfectly valid token. The token records how it was issued, so + # relax the filter only for those — on the password path is_active IS the + # deactivation switch and must keep being enforced. + user = await get_user(email=email, + include_inactive=payload.get("auth_source", "password") != "password") # get_user returns False (not None) when there is no active user row, so check # falsiness — otherwise `False` slips through as the "user" and downstream code # (_agent, role lookup) breaks, yielding a misleading 403 instead of a 401. @@ -174,7 +180,14 @@ async def get_current_user_optional(request: Request): return None # get_user returns False when no active user row; normalize to None so # callers' truthiness/None checks behave (anonymous, not a bogus `False`). - return (await get_user(email=email)) or None + # include_inactive for OAuth callers, as in get_current_user — otherwise a + # signed-in Globus user silently reads these endpoints as ANONYMOUS (e.g. + # list_spaces returning an empty list instead of their own spaces), which + # looks like "you have no data" rather than an auth failure. + return (await get_user( + email=email, + include_inactive=payload.get("auth_source", "password") != "password", + )) or None except (ExpiredSignatureError, JWTError, Exception): return None @@ -285,8 +298,10 @@ async def authenticate_websocket(websocket: WebSocket, required_scopes: Optional logger.warning(f"Insufficient scopes. Required: {required_scopes}, Token has: {token_scopes}") return None - # Get user from database (same as get_current_user) - user = await get_user(email=email) + # Get user from database (same as get_current_user, including the OAuth + # shell allowance — a websocket caller is the same identity as an HTTP one). + user = await get_user(email=email, + include_inactive=payload.get("auth_source", "password") != "password") # get_user returns False (not None) when no active user row — check falsiness. if not user: logger.warning(f"User not found for email: {email}") From 007d6726b978b29310e425f5c1fd243a29348618 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Wed, 12 Aug 2026 08:10:33 +0545 Subject: [PATCH 57/70] update readme --- .gitignore | 1 + README.unified-docker.md | 2 +- query_service/AUTH_UNIFICATION.md | 4 ++++ readme.md | 6 ++++-- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 37703e6..54ce914 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,4 @@ Temporary Items # Auto-generated SSO signing key (do not commit) secrets/ +env_copy diff --git a/README.unified-docker.md b/README.unified-docker.md index 423b21d..b9bdb0a 100644 --- a/README.unified-docker.md +++ b/README.unified-docker.md @@ -551,7 +551,7 @@ All environment variables are loaded from the `.env` file in the project root. T - **JWT**: `*_SERVICE_JWT_SECRET_KEY` (service-specific keys), `JWT_POSTGRES_*` (all JWT-related variables) - **Oxigraph**: `OXIGRAPH_USER`, `OXIGRAPH_PASSWORD` - **Ports**: `API_TOKEN_PORT`, `QUERY_SERVICE_PORT`, `ML_SERVICE_PORT`, `USERMANAGEMENT_SERVICE_PORT`, `OXIGRAPH_PORT`, `PGADMIN_PORT` -- **User Management OAuth**: `USERMANAGEMENT_SERVICE_JWT_SECRET_KEY`, `USERMANAGEMENT_PUBLIC_BASE_URL`, `USERMANAGEMENT_FRONTEND_CALLBACK_URL`, `USERMANAGEMENT_OAUTH_TOKEN_ENC_KEY`, `USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS`, `GITHUB_CLIENT_ID/SECRET`, `ORCID_CLIENT_ID/SECRET`, `GLOBUS_CLIENT_ID/SECRET` +- **User Management OAuth**: `USERMANAGEMENT_SERVICE_JWT_SECRET_KEY`, `USERMANAGEMENT_PUBLIC_BASE_URL`, `USERMANAGEMENT_FRONTEND_CALLBACK_URL`, `USERMANAGEMENT_OAUTH_TOKEN_ENC_KEY`, `USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS` (comma-separated), `USERMANAGEMENT_WEB_SESSION_TTL_MIN` / `USERMANAGEMENT_WEB_REFRESH_TTL_MIN` (web access/refresh token TTLs), `GITHUB_CLIENT_ID/SECRET`, `ORCID_CLIENT_ID/SECRET`, `GLOBUS_CLIENT_ID/SECRET` - **Ollama**: `OLLAMA_MODEL`, `OLLAMA_PORT`, `OLLAMA_API_ENDPOINT` - **ML Service**: `MONGO_DB_URL`, `WEAVIATE_*`, etc. - **SynthScholar** (PRISMA reviews, lives in ml_service): `OPENROUTER_API_KEY` (operator fallback — UI normally forwards a per-user or admin-shared key), `NCBI_API_KEY` (optional, raises PubMed rate limits), `SEMANTIC_SCHOLAR_API_KEY` / `CORE_API_KEY` / `SYNTHSCHOLAR_EMAIL` (all optional). Database is shared — no separate DSN. diff --git a/query_service/AUTH_UNIFICATION.md b/query_service/AUTH_UNIFICATION.md index 4723038..a416d92 100644 --- a/query_service/AUTH_UNIFICATION.md +++ b/query_service/AUTH_UNIFICATION.md @@ -338,6 +338,10 @@ Deployment env (set before/at the fresh deploy): warning.) Plus `USERMANAGEMENT_JWT_ISSUER` (default `brainkb-usermanagement`), `USERMANAGEMENT_ACCESS_TOKEN_TTL_MIN` (15), `USERMANAGEMENT_REFRESH_TOKEN_TTL_MIN` (720), `USERMANAGEMENT_TOKEN_AUDIENCES` (`query_service,ml_service,chat_service`). + Web sign-in uses its own pair: `USERMANAGEMENT_WEB_SESSION_TTL_MIN` (720 — the + access JWT the UI gets as `?token=`) and `USERMANAGEMENT_WEB_REFRESH_TTL_MIN` + (10080 — the `?refresh=` token the UI exchanges for silent renew), so the overall + web session lasts 7 days without re-login. - query_service: `QUERY_SERVICE_SSO_JWKS_URL` (default `http://127.0.0.1:8004/.well-known/jwks.json`; in a split deployment point at the usermanagement service URL), `QUERY_SERVICE_SSO_ISSUER` (must match the diff --git a/readme.md b/readme.md index 4714fdf..a46b367 100644 --- a/readme.md +++ b/readme.md @@ -84,8 +84,10 @@ Once started, services are accessible at: BrainKB is moving to a single sign-on model — usermanagement is the sole token issuer and each service verifies audience-scoped RS256 tokens against its JWKS, -while legacy per-service HS256 tokens remain accepted during migration. The full -design, phases, and deployment env are in +while legacy per-service HS256 tokens remain accepted during migration. Web +sign-in returns both an access and a refresh token so the UI renews silently +(`USERMANAGEMENT_WEB_SESSION_TTL_MIN` / `USERMANAGEMENT_WEB_REFRESH_TTL_MIN`). +The full design, phases, and deployment env are in [query_service/AUTH_UNIFICATION.md](query_service/AUTH_UNIFICATION.md). From dae11da3f19d5feabb6a62642bffe573d6fd3646 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 13 Aug 2026 10:23:57 +0545 Subject: [PATCH 58/70] usermanagement: derive token scopes from roles, not just the legacy scope table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- usermanagement_service/core/routers/sso.py | 40 ++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/usermanagement_service/core/routers/sso.py b/usermanagement_service/core/routers/sso.py index 8f116f6..3994358 100644 --- a/usermanagement_service/core/routers/sso.py +++ b/usermanagement_service/core/routers/sso.py @@ -49,6 +49,33 @@ def _scopes_for_roles(roles) -> list: return scopes +def _merge_scopes(stored, roles) -> list: + """Scopes a token should carry: whatever is explicitly assigned, PLUS whatever + the user's roles imply. + + Roles are authoritative — that is what `/api/auth/session-exchange` and the PAT + exchange already assume ("RBAC is authoritative" in their docstrings). The + refresh-token paths did not: they read `Web_jwtuser_scopes` alone, a legacy + Django table that is only populated for accounts created through the old admin. + + An OAuth account has no rows there at all. Its `Web_jwtuser` row is the shell + created to supply a stable `user_id` claim, so a Globus SuperAdmin — including + one seeded by USERMANAGEMENT_BOOTSTRAP_SUPERADMIN_EMAILS, which assigns roles + and nothing else — got a token reading `roles: ["Admin", "SuperAdmin"]` and + `scopes: ["read"]`. query_service gates its admin routes on the scope claim + (`require_scopes(["admin"])`) and has no bootstrap allowlist of its own, so + every one of them answered 403 "Insufficient scopes" before reaching the role + check that would have passed. Meanwhile usermanagement's own admin routes + honour the env allowlist and worked — which is why the failure looked like a + missing audience rather than a scope derived from the wrong place. + + The union, rather than replacing: a legacy password account may hold an + explicitly granted scope that no role implies, and dropping it would be a + silent downgrade. + """ + return sorted(set(stored or []) | set(_scopes_for_roles(roles))) + + class ExchangeIn(BaseModel): audience: str @@ -71,10 +98,14 @@ async def sso_login(body: LoginUserIn): detail="Incorrect email or password", headers={"WWW-Authenticate": "Bearer"}, ) - scopes = await jwt_user_repo.get_user_scopes(session, user_record.id) or ["read"] profile = await user_profile_repo.get_by_email(session, user_record.email) profile_id = profile.id if profile else None roles = await user_role_repo.get_user_role_names(session, profile.id) if profile else [] + # Roles first, then scopes derived from them — see _merge_scopes. A password + # SuperAdmin whose account predates the legacy scope table (or was created by + # the bootstrap allowlist) otherwise gets a read-only token too. + scopes = _merge_scopes( + await jwt_user_repo.get_user_scopes(session, user_record.id), roles) refresh = tokens_rs256.create_refresh_token( email=user_record.email, @@ -135,12 +166,17 @@ async def sso_exchange( # deletion is disabled), which the is_banned check below enforces. if not jwt_user.is_active and auth_source == "password": raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account inactive") - scopes = await jwt_user_repo.get_user_scopes(session, jwt_user.id) or ["read"] profile = await user_profile_repo.get_by_email(session, email) if profile and getattr(profile, "is_banned", False): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account_suspended") profile_id = profile.id if profile else None roles = await user_role_repo.get_user_role_names(session, profile.id) if profile else [] + # This is the MCP/CLI path: Globus login -> refresh token -> per-service + # access token. Reading the legacy scope table alone handed a Globus + # SuperAdmin `scopes: ["read"]` with `roles: ["Admin", "SuperAdmin"]`, and + # query_service's admin routes gate on the scope claim. See _merge_scopes. + scopes = _merge_scopes( + await jwt_user_repo.get_user_scopes(session, jwt_user.id), roles) access = tokens_rs256.create_access_token( audience=body.audience, From 8fc6b5cf764066b140e3e5a0265142e8daa1efa1 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 13 Aug 2026 17:44:48 +0545 Subject: [PATCH 59/70] ml_service: add an unauthenticated read surface for published reviews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ml_service/core/synth_scholar/routes.py | 102 +++++++++++++++++++++++- ml_service/core/synth_scholar/store.py | 31 +++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/ml_service/core/synth_scholar/routes.py b/ml_service/core/synth_scholar/routes.py index 73c935f..3d6c57d 100644 --- a/ml_service/core/synth_scholar/routes.py +++ b/ml_service/core/synth_scholar/routes.py @@ -1247,18 +1247,29 @@ async def cancel_review( ) +_EXPORT_FORMAT_PATTERN = ( + r"^(markdown|json|bibtex|ttl|jsonld|rubric_markdown|rubric_json|charting_markdown" + r"|charting_json|appraisal_markdown|appraisal_json|narrative_summary_markdown" + r"|narrative_summary_json)$" +) + + @router.get("/synth-scholar/reviews/{review_id}/export", tags=["SynthScholar — export"]) async def export_review( review_id: str, user: Annotated[dict, Depends(get_current_user)], - format: str = Query( - default="markdown", - pattern=r"^(markdown|json|bibtex|ttl|jsonld|rubric_markdown|rubric_json|charting_markdown|charting_json|appraisal_markdown|appraisal_json|narrative_summary_markdown|narrative_summary_json)$", - ), + format: str = Query(default="markdown", pattern=_EXPORT_FORMAT_PATTERN), model: Optional[str] = Query(default=None, description="Compare-mode only: export a single model's result by model_name"), ): """Export a completed review in the requested format.""" session = await _session_or_404(review_id, user) + return await _export_session(session, format, model) + + +# Everything past the access check is identical for the authenticated route above +# and the public one further down, so it lives here once — otherwise the two +# drift, and a format added for signed-in users silently 400s on public reviews. +async def _export_session(session: ReviewSession, format: str, model: Optional[str]): if session.status != ReviewStatus.COMPLETED or not session.result: raise HTTPException( status_code=400, @@ -1440,6 +1451,89 @@ async def get_pipeline_log( } +# --------------------------------------------------------------------------- +# Public (unauthenticated) read surface +# +# Backs /knowledge-base/synth-scholar in the web UI, which is reachable without +# signing in. Those pages previously called the authenticated routes above, which +# cannot work for an anonymous visitor: the UI has no credential to present, so +# the token exchange fails before any request is sent ("ML service requires a +# signed-in session"). Nor could they work for a signed-in visitor, because +# GET /reviews is owner-scoped — the "public listing" showed the viewer their own +# reviews, and a public review's detail page 404'd for everyone but its author. +# +# So `is_public` had no reader. These routes are it. Three rules hold throughout: +# * no `Depends(get_current_user)` — that is the point; +# * every lookup goes through review_store.get_public / list_public, which +# require is_public AND completed, so an unpublished review is invisible; +# * a review that is not published answers 404, never 403 — a 403 would confirm +# the id exists. +# Nothing here is owner-scoped, because published means published to everyone. +# --------------------------------------------------------------------------- + + +async def _public_session_or_404(review_id: str) -> ReviewSession: + session = await review_store.get_public(review_id) + if not session: + raise HTTPException(status_code=404, detail=f"Review '{review_id}' not found") + return session + + +@router.get( + "/synth-scholar/public/reviews", + response_model=list[ReviewSummaryResponse], + tags=["SynthScholar — public"], +) +async def list_public_reviews(): + """List every completed review its author marked Public. No auth.""" + sessions = await review_store.list_public() + return [_to_summary_response(s) for s in sessions] + + +@router.get( + "/synth-scholar/public/reviews/{review_id}", + response_model=ReviewDetailResponse, + tags=["SynthScholar — public"], +) +async def get_public_review(review_id: str): + """Full detail for one published review. 404 if it is not published.""" + return _to_detail_response(await _public_session_or_404(review_id)) + + +@router.get("/synth-scholar/public/reviews/{review_id}/log", tags=["SynthScholar — public"]) +async def get_public_review_log(review_id: str): + """Pipeline log for a published review — the provenance timeline reads this. + + Same content the author sees: the log records which pipeline step ran when, + which is exactly the provenance a published review is meant to carry. + """ + session = await _public_session_or_404(review_id) + log_entries = list(session.pipeline_log) + return { + "review_id": review_id, + "status": session.status.value, + "step_count": session.progress_step, + "log": log_entries, + "log_events": [ + {"step": i + 1, "message": msg, "timestamp": ts} + for i, (ts, msg) in enumerate(_parse_log_entry(e) for e in log_entries) + ], + } + + +@router.get("/synth-scholar/public/reviews/{review_id}/export", tags=["SynthScholar — public"]) +async def export_public_review( + review_id: str, + format: str = Query(default="markdown", pattern=_EXPORT_FORMAT_PATTERN), + model: Optional[str] = Query( + default=None, description="Compare-mode only: export a single model's result by model_name" + ), +): + """Export a published review. Same formats as the authenticated route.""" + session = await _public_session_or_404(review_id) + return await _export_session(session, format, model) + + @router.patch( "/synth-scholar/reviews/{review_id}/visibility", response_model=ReviewSummaryResponse, diff --git a/ml_service/core/synth_scholar/store.py b/ml_service/core/synth_scholar/store.py index 2bb6769..201284c 100644 --- a/ml_service/core/synth_scholar/store.py +++ b/ml_service/core/synth_scholar/store.py @@ -723,6 +723,37 @@ async def list_for_owner(self, owner_email: Optional[str]) -> list[ReviewSession sessions.append(s) return sessions + async def list_public(self) -> list[ReviewSession]: + """List every review its author published — `is_public` and completed. + + Read by the unauthenticated public routes, so the filter lives in SQL + rather than in the caller: a client-side filter over a full listing would + mean shipping other people's unpublished reviews to the browser first. + Runtime (in-flight) state is deliberately not merged in — a completed + review has none, and consulting it would only leak progress for rows that + are being re-run. + """ + async with async_session() as db: + result = await db.execute( + select(ReviewRow) + .where(ReviewRow.is_public.is_(True)) + .where(ReviewRow.status == ReviewStatus.COMPLETED.value) + .order_by(ReviewRow.created_at.desc()) + ) + rows = result.scalars().all() + return [_row_to_session(row) for row in rows] + + async def get_public(self, review_id: str) -> Optional[ReviewSession]: + """Fetch a review only if its author published it. Returns None otherwise + — callers turn that into a 404 so an unpublished review's existence is not + disclosed by the status code.""" + session = await self.get(review_id) + if not session: + return None + if not session.is_public or session.status != ReviewStatus.COMPLETED: + return None + return session + async def delete(self, review_id: str) -> bool: self._runtime.pop(review_id, None) async with async_session() as db: From c9709664e7317ba60a9be4fab52ea997f661b071 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 13 Aug 2026 18:19:03 +0545 Subject: [PATCH 60/70] ml_service: survive an unimportable structsense; fail the build instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Dockerfile.unified | 21 ++++++++++++--- ml_service/core/routers/structsense.py | 5 +++- ml_service/core/shared.py | 37 +++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/Dockerfile.unified b/Dockerfile.unified index ff7d41e..8f22613 100644 --- a/Dockerfile.unified +++ b/Dockerfile.unified @@ -45,9 +45,24 @@ RUN pip install -r requirements.txt # Copy ml_service COPY ml_service/ /app/ml_service/ WORKDIR /app/ml_service -RUN pip install --use-deprecated=legacy-resolver "structsense==0.0.4" || \ - pip install --use-deprecated=legacy-resolver --no-deps "structsense==0.0.4" && \ - pip install --use-deprecated=legacy-resolver -r requirements.txt +# The bug here was NOT operator precedence — `A || B && C` already groups as +# `(A || B) && C`, which is what was intended. The parentheses below are only to +# make that grouping obvious to the next reader. +# +# The bug is that the fallback is unverified. When the resolver fails, B installs +# structsense with `--no-deps`, so crewai/litellm never arrive — and ml_service's +# own requirements.txt lists neither, so nothing downstream fills the gap. The RUN +# still exits 0, producing a GREEN BUILD shipping an ml_service whose every +# gunicorn worker dies on `from structsense import kickoff`: exit 3, nothing bound +# to 8007, supervisor giving up after 4 tries. +# +# The `--no-deps` fallback stays (the legacy resolver does genuinely fail on this +# tree) but the import check now fails the BUILD rather than deferring the failure +# to a deploy. +RUN ( pip install --use-deprecated=legacy-resolver "structsense==0.0.4" \ + || pip install --use-deprecated=legacy-resolver --no-deps "structsense==0.0.4" ) \ + && pip install --use-deprecated=legacy-resolver -r requirements.txt \ + && python -c "import structsense; from structsense import kickoff; print('structsense import OK')" # Copy usermanagement_service COPY usermanagement_service/ /app/usermanagement_service/ diff --git a/ml_service/core/routers/structsense.py b/ml_service/core/routers/structsense.py index 99f22b0..9a4919f 100644 --- a/ml_service/core/routers/structsense.py +++ b/ml_service/core/routers/structsense.py @@ -23,7 +23,10 @@ from core.security import get_current_user, require_scopes, authenticate_websocket from core.shared import parse_yaml_or_json, upsert_structured_resources from core.pydantic_models import AgentConfig, TaskConfig, EmbedderConfig, SearchKeyConfig -from structsense import kickoff +# NOTE: `kickoff` is deliberately NOT imported here. It was, and it was unused — +# this module reaches structsense only through core.shared.run_kickoff_with_config, +# which now imports it defensively. A hard import here defeated that guard and took +# every endpoint in this router down with it, including the read-only /ner surface. import os from datetime import datetime, timezone from core.shared import upsert_ner_annotations diff --git a/ml_service/core/shared.py b/ml_service/core/shared.py index a92faa8..335e969 100644 --- a/ml_service/core/shared.py +++ b/ml_service/core/shared.py @@ -36,7 +36,30 @@ from io import BytesIO import asyncio from enum import Enum -from structsense import kickoff +# Guarded like the SynthScholar import in core/main.py, and for the same reason: +# an unimportable extraction library must not take the whole service down. +# +# This was a hard import, and it is the only thing `structsense` is needed for. +# When the package or one of its heavy dependencies (crewai, litellm) is missing, +# the ImportError propagated through core.routers.structsense to core.main, so +# every gunicorn worker died before the app object existed — gunicorn exit 3, +# nothing bound to 8007, and supervisor eventually giving up. Endpoints that never +# touch kickoff (GET /api/ner and the rest of the saved-annotation surface, the +# whole /api/synth-scholar tree) were collateral damage. +# +# Now they keep working and only the extraction endpoints report the problem, via +# run_kickoff_with_config below. +try: + from structsense import kickoff + _STRUCTSENSE_IMPORT_ERROR = None +except ImportError as _exc: # pragma: no cover - depends on the deployed image + kickoff = None + _STRUCTSENSE_IMPORT_ERROR = _exc + logger.error( + "structsense is not importable (%s) — extraction endpoints will return 503. " + "Check that the structsense install in the image brought its dependencies.", + _exc, + ) from pathlib import Path from motor.motor_asyncio import AsyncIOMotorClient from datetime import datetime, timezone @@ -1198,6 +1221,18 @@ async def run_kickoff_with_config( api_key: str, chunking: bool, ): + # The one place that needs the structsense package. Fail here, per request, + # rather than at import time — see the guarded import at the top of this file. + if kickoff is None: + raise HTTPException( + status_code=503, + detail=( + "Extraction is unavailable: the structsense package failed to import " + f"on this server ({_STRUCTSENSE_IMPORT_ERROR}). Other endpoints are " + "unaffected." + ), + ) + # Load all sections from your config file all_config = await load_config(config_path, "all") From be4eac8e13275087c2d7014011c79ca922a2b3aa Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 13 Aug 2026 18:21:14 +0545 Subject: [PATCH 61/70] ml_service: unpin aiohttp 3.8.6, and widen the import guards to Exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ml_service/core/main.py | 6 +++++- ml_service/core/shared.py | 19 +++++++++++++++---- ml_service/requirements.txt | 12 +++++++++++- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/ml_service/core/main.py b/ml_service/core/main.py index b22d488..6567869 100644 --- a/ml_service/core/main.py +++ b/ml_service/core/main.py @@ -22,12 +22,16 @@ # SynthScholar (PRISMA literature review). Imports are lazy-guarded so a # missing `synthscholar` library doesn't crash the rest of ml_service — # the router simply won't mount and the /api/synth-scholar/* surface returns 404. +# `except Exception`, not `except ImportError`: a transitive dependency conflict +# surfaces as AttributeError/RuntimeError, not ImportError. The structsense chain +# took every worker down that way (aiohttp.SocketTimeoutError missing under the +# aiohttp==3.8.6 pin), and this guard would have let the same thing through. try: from core.synth_scholar.routes import router as synth_scholar_router from core.synth_scholar.database import init_db as init_synth_scholar_db, close_db as close_synth_scholar_db from core.synth_scholar.store import fix_stuck_reviews as fix_synth_scholar_stuck_reviews _SYNTH_SCHOLAR_AVAILABLE = True -except ImportError as _exc: +except Exception as _exc: # noqa: BLE001 - see comment above _SYNTH_SCHOLAR_AVAILABLE = False _SYNTH_SCHOLAR_IMPORT_ERROR = _exc diff --git a/ml_service/core/shared.py b/ml_service/core/shared.py index 335e969..e7dedfb 100644 --- a/ml_service/core/shared.py +++ b/ml_service/core/shared.py @@ -49,16 +49,27 @@ # # Now they keep working and only the extraction endpoints report the problem, via # run_kickoff_with_config below. +# `except Exception`, not `except ImportError`, on purpose. The failure this was +# written for is an AttributeError, not an ImportError: +# +# openai/_vendor/httpx_aiohttp/transport.py: aiohttp.SocketTimeoutError +# AttributeError: module aiohttp has no attribute SocketTimeoutError +# +# A four-package import chain (structsense -> crewai -> litellm -> openai) can fail +# in any number of ways, and every one of them must cost the extraction endpoints +# rather than the process. Narrowing this to ImportError would re-open the exact +# outage it exists to prevent. try: from structsense import kickoff _STRUCTSENSE_IMPORT_ERROR = None -except ImportError as _exc: # pragma: no cover - depends on the deployed image +except Exception as _exc: # noqa: BLE001 - see comment above kickoff = None _STRUCTSENSE_IMPORT_ERROR = _exc logger.error( - "structsense is not importable (%s) — extraction endpoints will return 503. " - "Check that the structsense install in the image brought its dependencies.", - _exc, + "structsense is not importable (%s: %s) — extraction endpoints will return " + "503. Usually a dependency version conflict in the image rather than a " + "missing package; check aiohttp/openai/litellm.", + type(_exc).__name__, _exc, ) from pathlib import Path from motor.motor_asyncio import AsyncIOMotorClient diff --git a/ml_service/requirements.txt b/ml_service/requirements.txt index dc518ce..935d910 100644 --- a/ml_service/requirements.txt +++ b/ml_service/requirements.txt @@ -3,7 +3,17 @@ fastapi==0.115.3 uvicorn==0.29.0 gunicorn==21.2.0 -aiohttp==3.8.6 +# Was pinned to 3.8.6, which is what killed ml_service on boot. Dockerfile.unified +# installs this file AFTER structsense, so the pin DOWNGRADED the aiohttp that +# structsense -> crewai -> litellm -> openai had just pulled in. Recent openai +# vendors httpx_aiohttp, which references aiohttp.SocketTimeoutError — added in +# aiohttp 3.10 — so importing openai raised +# AttributeError: module aiohttp has no attribute SocketTimeoutError +# and every gunicorn worker died before the app existed (exit 3, nothing on 8007). +# +# A floor rather than a pin, so pip can reconcile with whatever openai/litellm want. +# The other services pin 3.9.1; they do not import openai, so they are unaffected. +aiohttp>=3.10,<4 async-timeout==4.0.3 # Logging rich==13.9.4 From 29c1fb0241fb82f6d03994c03e7f5f7445e313e8 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 13 Aug 2026 18:25:17 +0545 Subject: [PATCH 62/70] CORS: make the four origin lists identical, add CORS_ALLOWED_ORIGINS override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- chat_service/core/main.py | 23 ++++++++++++++++++++--- env.template | 15 +++++++++++++++ ml_service/core/main.py | 17 +++++++++++++---- query_service/core/main.py | 10 +++++++++- usermanagement_service/core/main.py | 14 ++++++++++++-- 5 files changed, 69 insertions(+), 10 deletions(-) diff --git a/chat_service/core/main.py b/chat_service/core/main.py index c94a23e..72e5be2 100644 --- a/chat_service/core/main.py +++ b/chat_service/core/main.py @@ -1,4 +1,5 @@ import logging +import os from contextlib import asynccontextmanager # logging @@ -44,13 +45,29 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) logger = logging.getLogger(__name__) -origins = [ +# Browser origins allowed to call this service. +# +# This list was missing https://brainkb.org and https://www.brainkb.org entirely — +# so the production UI was blocked from the chat service, on the main domain, while +# beta and sandbox worked. It also had "http://127.0.0.1:300" (a typo for :3000) and +# a schemeless "localhost:3000", which can never match: the browser's Origin header +# always carries a scheme. +# +# The four BrainKB services each keep their own copy of this list and they had +# drifted apart. CORS_ALLOWED_ORIGINS (comma-separated) adds to the defaults so a +# new domain does not need a code change in four places. +_DEFAULT_ORIGINS = [ + "https://brainkb.org", + "https://www.brainkb.org", "https://beta.brainkb.org", "https://sandbox.brainkb.org", - "localhost:3000", "http://localhost:3000", - "http://127.0.0.1:300", + "http://127.0.0.1:3000", ] +origins = sorted({ + *_DEFAULT_ORIGINS, + *(o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip()), +}) app.add_middleware( CORSMiddleware, diff --git a/env.template b/env.template index c16eeaa..ecdc3dc 100644 --- a/env.template +++ b/env.template @@ -75,6 +75,21 @@ USERMANAGEMENT_PUBLIC_BASE_URL=http://localhost:8004 # e.g. http://localhost:3000/auth/callback USERMANAGEMENT_FRONTEND_CALLBACK_URL=http://localhost:3000/auth/callback +# Extra browser origins allowed to call the APIs, comma-separated. ADDED to each +# service's built-in list (brainkb.org, www/beta/sandbox, localhost:3000), so leave +# it unset unless you serve the UI from another domain. Read by all four services — +# ml_service, query_service, usermanagement_service, chat_service. +# +# Must be a scheme + host (+ port if non-default), exactly as the browser sends the +# Origin header: "https://ui.example.org", not "ui.example.org" and no trailing +# slash. A schemeless entry silently never matches. +# +# Note: a fetch failing with "No Access-Control-Allow-Origin" is not always a CORS +# problem — the load balancer's own 502 page carries no CORS headers, so a service +# that is down looks identical to one that is misconfigured. Check the service +# responds at all before editing this. +# CORS_ALLOWED_ORIGINS=https://ui.example.org,https://another.example.org + # Fernet key for encrypting OAuth access/refresh tokens at rest. # Generate once: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" USERMANAGEMENT_OAUTH_TOKEN_ENC_KEY=Z85D3iJe4XCfJ5f8DExKXW3DfznyE4HzJ7XmmaOYtUQ= diff --git a/ml_service/core/main.py b/ml_service/core/main.py index 6567869..82e4874 100644 --- a/ml_service/core/main.py +++ b/ml_service/core/main.py @@ -170,17 +170,26 @@ async def lifespan(app: FastAPI): env_state = env.get("ENV_STATE", "production").lower() # CORS Configuration -origins = [ +# Browser origins allowed to call this service. Kept identical across the four +# BrainKB services, which each hold their own copy and had drifted apart. +# CORS_ALLOWED_ORIGINS (comma-separated) adds to these without a code change. +# +# Worth knowing when a fetch to this service reports "No Access-Control-Allow-Origin": +# check whether the service is actually up first. The ALB's own 502 page carries no +# CORS headers, so an ml_service that failed to boot presents in the browser as a +# CORS misconfiguration — which is exactly how the aiohttp 3.8.6 outage looked. +_DEFAULT_ORIGINS = [ "https://brainkb.org", "https://www.brainkb.org", "https://beta.brainkb.org", "https://sandbox.brainkb.org", - "http://localhost", "http://localhost:3000", - "http://localhost:3001", "http://127.0.0.1:3000", - "http://127.0.0.1:3001", ] +origins = sorted({ + *_DEFAULT_ORIGINS, + *(o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip()), +}) app.add_middleware( CORSMiddleware, diff --git a/query_service/core/main.py b/query_service/core/main.py index 0e7fd99..1396ec7 100644 --- a/query_service/core/main.py +++ b/query_service/core/main.py @@ -1,4 +1,5 @@ import logging +import os # logging from asgi_correlation_id import CorrelationIdMiddleware @@ -23,7 +24,10 @@ environment = load_environment()["ENV_STATE"] -origins = [ +# Browser origins allowed to call this service. Kept identical across the four +# BrainKB services, which each hold their own copy and had drifted apart. +# CORS_ALLOWED_ORIGINS (comma-separated) adds to these without a code change. +_DEFAULT_ORIGINS = [ "https://brainkb.org", "https://www.brainkb.org", "https://beta.brainkb.org", @@ -31,6 +35,10 @@ "http://localhost:3000", "http://127.0.0.1:3000", ] +origins = sorted({ + *_DEFAULT_ORIGINS, + *(o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip()), +}) if environment == "prods": app = FastAPI(docs_url=None, redoc_url=None) diff --git a/usermanagement_service/core/main.py b/usermanagement_service/core/main.py index 051a483..d62022e 100644 --- a/usermanagement_service/core/main.py +++ b/usermanagement_service/core/main.py @@ -1,4 +1,5 @@ import logging +import os from contextlib import asynccontextmanager from datetime import datetime @@ -123,15 +124,24 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) logger = logging.getLogger(__name__) -origins = [ +# Browser origins allowed to call this service. Kept identical across the four +# BrainKB services, which each hold their own copy and had drifted apart. +# CORS_ALLOWED_ORIGINS (comma-separated) adds to these without a code change. +# +# Dropped the schemeless "localhost:3000": the browser's Origin header always +# carries a scheme, so that entry could never match. +_DEFAULT_ORIGINS = [ "https://brainkb.org", "https://www.brainkb.org", "https://beta.brainkb.org", "https://sandbox.brainkb.org", - "localhost:3000", "http://localhost:3000", "http://127.0.0.1:3000", ] +origins = sorted({ + *_DEFAULT_ORIGINS, + *(o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip()), +}) app.add_middleware( CORSMiddleware, From b622f5f10533142684d209abe8ce684241947111 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 13 Aug 2026 19:09:15 +0545 Subject: [PATCH 63/70] ml_service: stop using the legacy pip resolver for requirements.txt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Dockerfile.unified | 24 +++++- ml_service/scripts/verify_imports.py | 113 +++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 ml_service/scripts/verify_imports.py diff --git a/Dockerfile.unified b/Dockerfile.unified index 8f22613..5858553 100644 --- a/Dockerfile.unified +++ b/Dockerfile.unified @@ -59,10 +59,30 @@ WORKDIR /app/ml_service # The `--no-deps` fallback stays (the legacy resolver does genuinely fail on this # tree) but the import check now fails the BUILD rather than deferring the failure # to a deploy. +# The legacy resolver is used ONLY for structsense, which genuinely needs it. It is +# deliberately NOT used for requirements.txt — that difference is why ml_service +# worked in the standalone ml_service/Dockerfile build and died on the unified one: +# +# ml_service/Dockerfile : pip install -r requirements.txt (works) +# this file, previously : pip install --use-deprecated=legacy-resolver -r ... +# +# The legacy resolver does not backtrack and does not verify consistency, so a later +# requirement silently downgrades an earlier one. `aiohttp>=3.10` in requirements.txt +# was therefore not enough: structsense==0.0.4 drags an old crewai/litellm that +# tolerates aiohttp 3.8.x, that got installed last, and openai's vendored +# httpx_aiohttp then failed on import with +# AttributeError: module aiohttp has no attribute SocketTimeoutError +# taking out /api/synth-scholar entirely (and extraction, silently, behind its +# guard). The modern resolver either produces a consistent set or fails the build. +# +# The explicit aiohttp upgrade is a safety net for that one regression, not the fix +# — nothing in the tree wants aiohttp <3.10 (litellm requires >=3.14.2). Keep it +# after the requirements install so nothing can pull it back down. RUN ( pip install --use-deprecated=legacy-resolver "structsense==0.0.4" \ || pip install --use-deprecated=legacy-resolver --no-deps "structsense==0.0.4" ) \ - && pip install --use-deprecated=legacy-resolver -r requirements.txt \ - && python -c "import structsense; from structsense import kickoff; print('structsense import OK')" + && pip install -r requirements.txt \ + && pip install --upgrade "aiohttp>=3.10,<4" \ + && python scripts/verify_imports.py # Copy usermanagement_service COPY usermanagement_service/ /app/usermanagement_service/ diff --git a/ml_service/scripts/verify_imports.py b/ml_service/scripts/verify_imports.py new file mode 100644 index 0000000..65ac067 --- /dev/null +++ b/ml_service/scripts/verify_imports.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Build-time gate: fail the image build if ml_service's optional AI stacks are +unimportable, instead of shipping and discovering it in production. + +Run from Dockerfile.unified after the pip steps for ml_service. + +Why this exists +--------------- +Both AI stacks are imported behind guards at runtime (core/main.py for +synthscholar, core/shared.py for structsense) so one broken dependency cannot take +the whole service down. That is the right runtime behaviour, but it means a broken +install is INVISIBLE: the container starts, /api/health returns 200, and the +affected routers are simply never mounted. The symptom reaches you hours later as +404s on endpoints that used to exist, or a browser CORS error that is really a +missing route. + +The specific failure this was written for: + + openai/_vendor/httpx_aiohttp/transport.py: aiohttp.SocketTimeoutError + AttributeError: module aiohttp has no attribute SocketTimeoutError + +aiohttp gained SocketTimeoutError in 3.10. `pip install --use-deprecated= +legacy-resolver` does not backtrack, so a later requirement silently downgrades an +earlier one — pinning aiohttp in requirements.txt is not sufficient, which is why +the Dockerfile forces it explicitly right before this script runs. + +Note it is an AttributeError, not an ImportError. Guards written as +`except ImportError` do not catch it; both are `except Exception` for this reason. +""" +from __future__ import annotations + +import importlib +import sys + +# (module, what breaks if it is unimportable) +CHECKS = [ + ("structsense", "extraction endpoints + the /api/ner surface"), + ("synthscholar", "the whole /api/synth-scholar tree, incl. public reviews"), +] + +# Minimum aiohttp that provides SocketTimeoutError, which openai's vendored +# httpx_aiohttp transport references at import time. +AIOHTTP_MIN = (3, 10) + + +def _fail(*lines: str) -> None: + # Flush stdout first: it is block-buffered when the build log is a pipe, while + # stderr is not, so without this the failure block prints BEFORE the progress + # lines it refers to. + sys.stdout.flush() + print("", file=sys.stderr) + print("=" * 72, file=sys.stderr) + print("BUILD FAILED - ml_service import verification", file=sys.stderr) + print("=" * 72, file=sys.stderr) + for line in lines: + print(line, file=sys.stderr) + print("", file=sys.stderr) + sys.exit(1) + + +def check_aiohttp() -> str | None: + """Return an error string, or None if aiohttp is usable.""" + try: + import aiohttp + except Exception as exc: + return f"aiohttp itself is unimportable: {type(exc).__name__}: {exc}" + + version = getattr(aiohttp, "__version__", "unknown") + print(f" aiohttp {version}") + + # Check the attribute rather than parsing the version: the attribute is what + # openai actually touches, and it is the real contract. + if not hasattr(aiohttp, "SocketTimeoutError"): + return ( + f"aiohttp {version} has no SocketTimeoutError (needs " + f">={'.'.join(map(str, AIOHTTP_MIN))}). Something downgraded it AFTER " + "the explicit upgrade in Dockerfile.unified - check whether a " + "requirement added since then pins an older aiohttp." + ) + return None + + +def main() -> None: + print("Verifying ml_service AI stack imports...") + + problems = [] + + err = check_aiohttp() + if err: + problems.append(err) + + for module, consequence in CHECKS: + try: + mod = importlib.import_module(module) + except Exception as exc: + # Deliberately broad: this chain (structsense -> crewai -> litellm -> + # openai, synthscholar -> pydantic-ai -> openai) raises AttributeError + # and RuntimeError as readily as ImportError. + problems.append( + f"{module} is unimportable - would disable {consequence}\n" + f" {type(exc).__name__}: {exc}" + ) + else: + print(f" {module} {getattr(mod, '__version__', '?')} OK") + + if problems: + _fail(*(f" * {p}" for p in problems)) + + print("All ml_service imports verified.") + + +if __name__ == "__main__": + main() From 21d543d7fe547af02d3f83ed2d922290a014f048 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 13 Aug 2026 19:40:26 +0545 Subject: [PATCH 64/70] ml_service: disable structsense and its extraction endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Dockerfile.unified | 23 ++++++++++-- ml_service/core/routers/structsense.py | 51 ++++++++++++++++++++++++-- ml_service/core/shared.py | 9 ++++- ml_service/scripts/verify_imports.py | 7 +++- 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/Dockerfile.unified b/Dockerfile.unified index 5858553..a1dc4d5 100644 --- a/Dockerfile.unified +++ b/Dockerfile.unified @@ -78,9 +78,26 @@ WORKDIR /app/ml_service # The explicit aiohttp upgrade is a safety net for that one regression, not the fix # — nothing in the tree wants aiohttp <3.10 (litellm requires >=3.14.2). Keep it # after the requirements install so nothing can pull it back down. -RUN ( pip install --use-deprecated=legacy-resolver "structsense==0.0.4" \ - || pip install --use-deprecated=legacy-resolver --no-deps "structsense==0.0.4" ) \ - && pip install -r requirements.txt \ +# structsense is NOT installed. It is pinned at 0.0.4, which drags an old +# crewai/litellm that holds aiohttp below 3.10 — and openai's vendored httpx_aiohttp +# references aiohttp.SocketTimeoutError at import time, so the whole LLM chain fails +# with an AttributeError. That took out synthscholar (and therefore every +# /api/synth-scholar route, including the public reviews the UI reads) as collateral +# damage, because both stacks import openai. +# +# Dropping it is safe: core/shared.py guards the import and +# core/routers/structsense.py registers the three extraction WebSocket routes only +# when the package is present. Everything else in that router — GET /ner, +# GET /structured-resource, the save endpoints, GET /job/{task_id} — reads stored +# data and keeps working, which is what /knowledge-base/ner needs. +# +# To re-enable, restore the install below and add "structsense" back to CHECKS in +# scripts/verify_imports.py. It will need a structsense release whose crewai/litellm +# accept aiohttp>=3.10, or the two stacks will keep fighting over it. +# RUN ( pip install --use-deprecated=legacy-resolver "structsense==0.0.4" \ +# || pip install --use-deprecated=legacy-resolver --no-deps "structsense==0.0.4" ) \ +# && ... +RUN pip install -r requirements.txt \ && pip install --upgrade "aiohttp>=3.10,<4" \ && python scripts/verify_imports.py diff --git a/ml_service/core/routers/structsense.py b/ml_service/core/routers/structsense.py index 9a4919f..e860579 100644 --- a/ml_service/core/routers/structsense.py +++ b/ml_service/core/routers/structsense.py @@ -31,7 +31,8 @@ from datetime import datetime, timezone from core.shared import upsert_ner_annotations from core.shared import (_is_safe_path, run_kickoff_with_config, JobStatus, _job_storage, - _handle_websocket_connection, _get_job) + _handle_websocket_connection, _get_job, + STRUCTSENSE_AVAILABLE, _STRUCTSENSE_IMPORT_ERROR) from core.configuration import load_environment from motor.motor_asyncio import AsyncIOMotorClient from typing import Optional @@ -74,10 +75,52 @@ def serialize_mongo_document(doc): router = APIRouter(tags=["Multi-agent Systems"]) +# The three extraction WebSocket endpoints below are the only routes in this module +# that need the structsense package (via core.shared.run_kickoff_with_config). +# Everything else — GET /ner, GET /structured-resource, the save endpoints, +# GET /job/{task_id} — only reads and writes stored data, and must keep working: +# /knowledge-base/ner in the web UI depends on GET /ner. +# +# So registration is conditional. When structsense is unimportable (or deliberately +# not installed — see Dockerfile.unified) those paths do not exist at all, which is +# a better contract than accepting a WebSocket upgrade and then failing mid-session +# once the client has already uploaded a PDF. +def _extraction_ws(path: str): + """Register a WebSocket route only if the extraction stack is usable.""" + def _decorator(fn): + if STRUCTSENSE_AVAILABLE: + return router.websocket(path)(fn) + logger.warning( + "Extraction endpoint %s NOT registered — structsense unavailable (%s)", + path, _STRUCTSENSE_IMPORT_ERROR, + ) + return fn + return _decorator + + @router.get("/ws-info") async def ws_info(): + # Report availability first: the extraction WebSocket routes are not registered + # when structsense is unavailable, so a client that trusts this document would + # otherwise be told to connect to paths that 404. + if not STRUCTSENSE_AVAILABLE: + return JSONResponse({ + "extraction_available": False, + "reason": ( + "The structsense extraction stack is not installed on this server, " + "so /ws/ner, /ws/extract-resources and /ws/pdf2reproschema are not " + "registered. Stored annotations remain readable via GET /api/ner and " + "GET /api/structured-resource." + ), + "disabled_endpoints": [ + "/ws/ner/{client_id}", + "/ws/extract-resources/{client_id}", + "/ws/pdf2reproschema/{client_id}", + ], + }) return JSONResponse({ + "extraction_available": True, "connect_to": "/ws/{client_id}/ner or /ws/{client_id}/resource", "protocol": [ {"type": "message", "text": "string (required, non-empty)"}, @@ -114,7 +157,7 @@ async def ws_info(): }) -@router.websocket("/ws/ner/{client_id}") +@_extraction_ws("/ws/ner/{client_id}") async def websocket_endpoint_ner(websocket: WebSocket, client_id: str): """WebSocket endpoint for NER processing with JWT authentication.""" try: @@ -152,7 +195,7 @@ async def websocket_endpoint_ner(websocket: WebSocket, client_id: str): except Exception: pass -@router.websocket("/ws/extract-resources/{client_id}") +@_extraction_ws("/ws/extract-resources/{client_id}") async def websocket_endpoint_ner(websocket: WebSocket, client_id: str): """WebSocket endpoint for NER processing with JWT authentication.""" try: @@ -190,7 +233,7 @@ async def websocket_endpoint_ner(websocket: WebSocket, client_id: str): except Exception: pass -@router.websocket("/ws/pdf2reproschema/{client_id}") +@_extraction_ws("/ws/pdf2reproschema/{client_id}") async def websocket_endpoint_ner(websocket: WebSocket, client_id: str): """WebSocket endpoint for NER processing with JWT authentication.""" try: diff --git a/ml_service/core/shared.py b/ml_service/core/shared.py index e7dedfb..117ea4f 100644 --- a/ml_service/core/shared.py +++ b/ml_service/core/shared.py @@ -66,11 +66,16 @@ kickoff = None _STRUCTSENSE_IMPORT_ERROR = _exc logger.error( - "structsense is not importable (%s: %s) — extraction endpoints will return " - "503. Usually a dependency version conflict in the image rather than a " + "structsense is not importable (%s: %s) — extraction endpoints are " + "disabled. Usually a dependency version conflict in the image rather than a " "missing package; check aiohttp/openai/litellm.", type(_exc).__name__, _exc, ) + +# Read by core/routers/structsense.py to decide whether to register the extraction +# WebSocket routes at all. Exported from here rather than recomputed there, so +# there is one source of truth for "is the extraction stack usable". +STRUCTSENSE_AVAILABLE = kickoff is not None from pathlib import Path from motor.motor_asyncio import AsyncIOMotorClient from datetime import datetime, timezone diff --git a/ml_service/scripts/verify_imports.py b/ml_service/scripts/verify_imports.py index 65ac067..0c8415a 100644 --- a/ml_service/scripts/verify_imports.py +++ b/ml_service/scripts/verify_imports.py @@ -33,8 +33,13 @@ import sys # (module, what breaks if it is unimportable) +# +# structsense is deliberately absent: it is NOT installed by Dockerfile.unified, +# because structsense==0.0.4 holds aiohttp below 3.10 via an old crewai/litellm and +# that breaks openai's import for synthscholar too. Adding it back here without +# restoring the install would fail every build. See the comment on the ml_service +# pip step in Dockerfile.unified. CHECKS = [ - ("structsense", "extraction endpoints + the /api/ner surface"), ("synthscholar", "the whole /api/synth-scholar tree, incl. public reviews"), ] From 1354bd2b3b11f79cd66e72db7e60cc5bfdd63134 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 13 Aug 2026 19:46:51 +0545 Subject: [PATCH 65/70] ml_service: public read routes for saved NER annotations and resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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. --- ml_service/core/routers/structsense.py | 73 +++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/ml_service/core/routers/structsense.py b/ml_service/core/routers/structsense.py index e860579..c9f021c 100644 --- a/ml_service/core/routers/structsense.py +++ b/ml_service/core/routers/structsense.py @@ -15,7 +15,7 @@ # @File : structsense.py # @Software: PyCharm from fastapi import Request -from fastapi import APIRouter, File, UploadFile, HTTPException, Form, Depends, WebSocket +from fastapi import APIRouter, File, UploadFile, HTTPException, Form, Depends, WebSocket, Query from fastapi.responses import JSONResponse import logging from typing import Annotated @@ -601,4 +601,73 @@ async def get_structured_resources( raise except Exception as e: logger.error(f"Error retrieving structured resources: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to retrieve structured resources: {str(e)}") \ No newline at end of file + raise HTTPException(status_code=500, detail=f"Failed to retrieve structured resources: {str(e)}") + + +# --------------------------------------------------------------------------- +# Public (unauthenticated) read surface +# +# Backs https://brainkb.org/knowledge-base/ner and .../knowledge-base/resources, +# which are public pages. They were reading the authenticated endpoints above, so an +# anonymous visitor got 403 {"detail":"Not authenticated"} — get_current_user rejects +# for having no credential at all, before require_scopes(["read"]) is ever consulted. +# +# These serve the same documents as the authenticated routes. That is deliberate and +# safe here: saved annotations carry documentName, processedAt, sourceType, +# sourceContent and the extracted entities, with no submitter identity in the write +# path (see upsert_ner_annotations / upsert_structured_resources) — so there is +# nothing per-user to leak and no visibility flag to honour. If per-record visibility +# is ever added, filter it HERE first. +# +# `limit` is capped lower than the authenticated routes: these are open to the +# internet and the payload includes sourceContent, which can be a whole paper. +# --------------------------------------------------------------------------- + +PUBLIC_MAX_LIMIT = 200 + + +@router.get("/public/ner", + summary="Get saved NER annotations (public, no auth)", + description="Unauthenticated read of saved NER annotations. Backs the " + "public /knowledge-base/ner page.") +async def get_public_ner_annotations( + client: AsyncIOMotorClient = Depends(get_mongo_client), + document_name: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + limit: int = Query(default=100, ge=1, le=PUBLIC_MAX_LIMIT), + skip: int = Query(default=0, ge=0), +): + env = load_environment() + db_name = env.get("NER_DATABASE") + collection_name = env.get("NER_COLLECTION") + if not db_name or not collection_name: + raise HTTPException(status_code=500, detail="MongoDB configuration not found") + return await _get_documents_from_collection( + client=client, db_name=db_name, collection_name=collection_name, + document_name=document_name, start_date=start_date, end_date=end_date, + limit=limit, skip=skip, + ) + + +@router.get("/public/structured-resource", + summary="Get saved structured resources (public, no auth)", + description="Unauthenticated read of saved structured resources. Backs " + "the public /knowledge-base/resources page.") +async def get_public_structured_resources( + client: AsyncIOMotorClient = Depends(get_mongo_client), + document_name: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + limit: int = Query(default=100, ge=1, le=PUBLIC_MAX_LIMIT), + skip: int = Query(default=0, ge=0), +): + env = load_environment() + db_name = env.get("NER_DATABASE") + if not db_name: + raise HTTPException(status_code=500, detail="MongoDB configuration not found") + return await _get_documents_from_collection( + client=client, db_name=db_name, collection_name="structured_resource", + document_name=document_name, start_date=start_date, end_date=end_date, + limit=limit, skip=skip, + ) \ No newline at end of file From 755265881b72357bced8ba13805f9fb05e189ebc Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 13 Aug 2026 20:02:28 +0545 Subject: [PATCH 66/70] ml_service: split the synthscholar pip solve; cache dependency layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /` 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. --- Dockerfile.unified | 50 +++++++++++++++++++++++++++++++------ ml_service/requirements.txt | 25 +++++++++++++++---- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/Dockerfile.unified b/Dockerfile.unified index a1dc4d5..e792f20 100644 --- a/Dockerfile.unified +++ b/Dockerfile.unified @@ -30,20 +30,36 @@ RUN apt-get update && apt-get install -y \ # Create directories for all services WORKDIR /app +# DEPENDENCY LAYERING +# +# Each service copies ONLY requirements.txt, installs, and copies its source +# afterwards. Previously every service did `COPY /` before `pip install`, +# so editing any .py file invalidated that service's pip layer and reinstalled +# everything — for ml_service that means torch (via +# synthscholar[semantic] -> sentence-transformers), which is 1-2.5 GB and the reason +# a rebuild took ~8 minutes on that step alone. +# +# With this ordering the pip layers are keyed on requirements.txt alone: a code-only +# change reuses them, and a dependency change invalidates them automatically. That +# also means `--no-cache` is no longer needed to pick up a pin change. + # Copy APItokenmanager -COPY APItokenmanager/ /app/APItokenmanager/ +COPY APItokenmanager/requirements.txt /app/APItokenmanager/requirements.txt WORKDIR /app/APItokenmanager RUN pip install --upgrade pip && \ pip install -r requirements.txt && \ pip install python-decouple gunicorn +COPY APItokenmanager/ /app/APItokenmanager/ # Copy query_service -COPY query_service/ /app/query_service/ +COPY query_service/requirements.txt /app/query_service/requirements.txt WORKDIR /app/query_service RUN pip install -r requirements.txt +COPY query_service/ /app/query_service/ -# Copy ml_service -COPY ml_service/ /app/ml_service/ +# Copy ml_service. requirements.txt first — see DEPENDENCY LAYERING above. This is +# the layer that matters most: it installs torch. +COPY ml_service/requirements.txt /app/ml_service/requirements.txt WORKDIR /app/ml_service # The bug here was NOT operator precedence — `A || B && C` already groups as # `(A || B) && C`, which is what was intended. The parentheses below are only to @@ -97,14 +113,34 @@ WORKDIR /app/ml_service # RUN ( pip install --use-deprecated=legacy-resolver "structsense==0.0.4" \ # || pip install --use-deprecated=legacy-resolver --no-deps "structsense==0.0.4" ) \ # && ... +# Two pip solves, not one. Together on this empty base image, pip gives up: +# +# error: resolution-too-deep +# × Dependency resolution exceeded maximum depth +# +# synthscholar drags pydantic-ai -> openai and sentence-transformers -> torch; added +# to the exact `==` pins in requirements.txt, the graph exceeds what pip will search. +# (The standalone ml_service/Dockerfile does it in one solve only because its tiangolo +# base preinstalls fastapi/uvicorn/pydantic, which constrains the search. python:3.11- +# slim starts empty, so pip explores everything — which is why this worked locally.) +# +# Order matters: requirements.txt first so its pins are established, then synthscholar +# resolves against an already-populated environment. RUN pip install -r requirements.txt \ - && pip install --upgrade "aiohttp>=3.10,<4" \ - && python scripts/verify_imports.py + && pip install "synthscholar[fulltext,semantic]==0.0.11" \ + && pip install --upgrade "aiohttp>=3.10,<4" +COPY ml_service/ /app/ml_service/ +# Separate layer, deliberately: this needs the source (scripts/verify_imports.py) so +# it cannot join the pip step, and it is seconds rather than minutes. It re-runs on +# every code change, which is what you want — it is the gate that stops a build with +# an unimportable synthscholar from shipping. +RUN python scripts/verify_imports.py # Copy usermanagement_service -COPY usermanagement_service/ /app/usermanagement_service/ +COPY usermanagement_service/requirements.txt /app/usermanagement_service/requirements.txt WORKDIR /app/usermanagement_service RUN pip install -r requirements.txt +COPY usermanagement_service/ /app/usermanagement_service/ # Create supervisor configuration diff --git a/ml_service/requirements.txt b/ml_service/requirements.txt index 935d910..a503e1c 100644 --- a/ml_service/requirements.txt +++ b/ml_service/requirements.txt @@ -57,9 +57,24 @@ grpcio-health-checking==1.60.2 PyMuPDF==1.26.5 ollama==0.6.0 -# SynthScholar (PRISMA literature review). The `synthscholar` package owns -# the AI pipeline; the local module under core/synth_scholar/ is just the -# orchestration layer (sessions, SSE, exports). SQLAlchemy 2.0 async backs -# the review tables, separate from ml_service's raw-asyncpg pool. -synthscholar[fulltext,semantic]==0.0.11 +# SynthScholar (PRISMA literature review). The `synthscholar` package owns the AI +# pipeline; the local module under core/synth_scholar/ is just the orchestration layer +# (sessions, SSE, exports). SQLAlchemy 2.0 async backs the review tables, separate +# from ml_service's raw-asyncpg pool. +# +# synthscholar itself is NOT listed here — it is installed as a separate pip step in +# Dockerfile.unified. Solved together with this file on an empty base image, pip gives +# up: +# +# error: resolution-too-deep +# × Dependency resolution exceeded maximum depth +# +# It drags pydantic-ai -> openai plus sentence-transformers -> torch, and combined +# with the exact `==` pins here the graph is too large for one solve. Two smaller +# solves succeed where one large one does not. (The standalone ml_service/Dockerfile +# gets away with one solve only because its tiangolo base image preinstalls +# fastapi/uvicorn/pydantic, which constrains the search.) +# +# Keep sqlalchemy here: core/synth_scholar/ imports it directly, independently of the +# synthscholar package. sqlalchemy[asyncio]>=2.0.30 \ No newline at end of file From df2a27b7faf918deb2521ceb652042e8e18bb533 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 13 Aug 2026 20:13:19 +0545 Subject: [PATCH 67/70] ml_service: declare the deps structsense was silently providing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ml_service/requirements.txt | 17 +++++++++++ ml_service/scripts/verify_imports.py | 44 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/ml_service/requirements.txt b/ml_service/requirements.txt index a503e1c..1d4d8f7 100644 --- a/ml_service/requirements.txt +++ b/ml_service/requirements.txt @@ -57,6 +57,23 @@ grpcio-health-checking==1.60.2 PyMuPDF==1.26.5 ollama==0.6.0 +# Imported DIRECTLY by core/shared.py and core/configuration.py, but never declared — +# they arrived as transitive dependencies of structsense. Dropping structsense took +# them with it, and ml_service died on boot with +# ModuleNotFoundError: No module named 'bs4' +# (gunicorn exit 3, nothing bound to 8007). Declared explicitly so what this service +# imports no longer depends on what another package happens to pull in. +# +# Found by parsing every import in core/ and diffing against this file, not one crash +# at a time. Deliberately NOT added: pydantic and starlette (guaranteed by the fastapi +# pin, and adding unbounded entries risks the resolution-too-deep problem again), +# pytest (test-only), structsense (guarded, intentionally absent), synthscholar +# (installed in its own pip step — see the note below). +beautifulsoup4>=4.12 +python-dotenv>=1.0 +rdflib>=6.0 +PyYAML>=6.0 + # SynthScholar (PRISMA literature review). The `synthscholar` package owns the AI # pipeline; the local module under core/synth_scholar/ is just the orchestration layer # (sessions, SSE, exports). SQLAlchemy 2.0 async backs the review tables, separate diff --git a/ml_service/scripts/verify_imports.py b/ml_service/scripts/verify_imports.py index 0c8415a..fa9e799 100644 --- a/ml_service/scripts/verify_imports.py +++ b/ml_service/scripts/verify_imports.py @@ -31,6 +31,14 @@ import importlib import sys +from pathlib import Path + +# Running this as `python scripts/verify_imports.py` puts scripts/ on sys.path, not the +# service root, so `import core.main` would fail with a misleading ModuleNotFoundError +# regardless of what is installed. Add the service root explicitly. +_SERVICE_ROOT = Path(__file__).resolve().parent.parent +if str(_SERVICE_ROOT) not in sys.path: + sys.path.insert(0, str(_SERVICE_ROOT)) # (module, what breaks if it is unimportable) # @@ -85,6 +93,38 @@ def check_aiohttp() -> str | None: return None +def check_app() -> str | None: + """Import the real ASGI app, the way gunicorn does. + + This is the check that matters most, and it was missing. Verifying only the two + optional AI stacks let a build ship with a dependency the service's OWN code + imports directly: bs4 arrived transitively via structsense, dropping structsense + took it away, and ml_service died on boot with + `ModuleNotFoundError: No module named 'bs4'` — through core/shared.py, a module + this script never touched. + + Only ModuleNotFoundError is fatal. Anything else here (missing env vars, no + database) is expected at build time and says nothing about the image: importing + core.main builds the app and mounts routers but opens no connections — that + happens in the lifespan, at runtime. + """ + try: + importlib.import_module("core.main") + except ModuleNotFoundError as exc: + return ( + f"core.main cannot be imported: {exc}\n" + " A package the service imports directly is not installed. Add it to " + "requirements.txt rather than relying on another package to pull it in." + ) + except Exception as exc: + # Not a dependency problem — report and continue. + print(f" core.main imported with a non-import error ({type(exc).__name__}: " + f"{exc}) - expected at build time if it needs env/database") + return None + print(" core.main OK (the ASGI app gunicorn loads)") + return None + + def main() -> None: print("Verifying ml_service AI stack imports...") @@ -108,6 +148,10 @@ def main() -> None: else: print(f" {module} {getattr(mod, '__version__', '?')} OK") + err = check_app() + if err: + problems.append(err) + if problems: _fail(*(f" * {p}" for p in problems)) From 81354bcfe56654fd9327cfa1b0a29ee57be8e348 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 13 Aug 2026 20:57:38 +0545 Subject: [PATCH 68/70] ml_service: make the build check the synth_scholar router, with a traceback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ml_service/scripts/verify_imports.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ml_service/scripts/verify_imports.py b/ml_service/scripts/verify_imports.py index fa9e799..6fc077c 100644 --- a/ml_service/scripts/verify_imports.py +++ b/ml_service/scripts/verify_imports.py @@ -31,6 +31,7 @@ import importlib import sys +import traceback from pathlib import Path # Running this as `python scripts/verify_imports.py` puts scripts/ on sys.path, not the @@ -49,6 +50,13 @@ # pip step in Dockerfile.unified. CHECKS = [ ("synthscholar", "the whole /api/synth-scholar tree, incl. public reviews"), + # The package importing is not enough. core/main.py mounts the router inside a + # try/except, so a failure in THIS module disables every /api/synth-scholar route + # while the service still starts and /api/health still returns 200 — which is + # exactly how the outage stayed invisible. Checked separately, and fatally, + # because synthscholar is deliberately installed: if it is present but the router + # cannot import, that is a defect, not a configuration choice. + ("core.synth_scholar.routes", "every /api/synth-scholar route (the router itself)"), ] # Minimum aiohttp that provides SocketTimeoutError, which openai's vendored @@ -141,10 +149,17 @@ def main() -> None: # Deliberately broad: this chain (structsense -> crewai -> litellm -> # openai, synthscholar -> pydantic-ai -> openai) raises AttributeError # and RuntimeError as readily as ImportError. + # + # Print the FULL traceback, not just the message. When core/main.py + # swallowed these, diagnosis meant a docker exec into a running container + # to reproduce the import by hand; the frame that actually fails is the + # only thing that identifies the bad dependency. problems.append( f"{module} is unimportable - would disable {consequence}\n" f" {type(exc).__name__}: {exc}" ) + print(f" {module} FAILED - traceback follows:", flush=True) + traceback.print_exc() else: print(f" {module} {getattr(mod, '__version__', '?')} OK") From d38e053e9a8cff1732069961b21d8df03e9a48ff Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Thu, 13 Aug 2026 21:01:36 +0545 Subject: [PATCH 69/70] Fix cross-service pin conflicts in the shared site-packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Dockerfile.unified | 27 ++++++++++++++++++------- chat_service/requirements.txt | 4 +++- usermanagement_service/requirements.txt | 10 +++++++-- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/Dockerfile.unified b/Dockerfile.unified index e792f20..de17e5e 100644 --- a/Dockerfile.unified +++ b/Dockerfile.unified @@ -127,14 +127,8 @@ WORKDIR /app/ml_service # Order matters: requirements.txt first so its pins are established, then synthscholar # resolves against an already-populated environment. RUN pip install -r requirements.txt \ - && pip install "synthscholar[fulltext,semantic]==0.0.11" \ - && pip install --upgrade "aiohttp>=3.10,<4" + && pip install "synthscholar[fulltext,semantic]==0.0.11" COPY ml_service/ /app/ml_service/ -# Separate layer, deliberately: this needs the source (scripts/verify_imports.py) so -# it cannot join the pip step, and it is seconds rather than minutes. It re-runs on -# every code change, which is what you want — it is the gate that stops a build with -# an unimportable synthscholar from shipping. -RUN python scripts/verify_imports.py # Copy usermanagement_service COPY usermanagement_service/requirements.txt /app/usermanagement_service/requirements.txt @@ -142,6 +136,25 @@ WORKDIR /app/usermanagement_service RUN pip install -r requirements.txt COPY usermanagement_service/ /app/usermanagement_service/ +# --------------------------------------------------------------------------- +# FINAL dependency reconciliation + verification. Must stay LAST, after every +# service's pip step. +# +# All four services share ONE site-packages in this image (supervisor runs them as +# programs in a single container), so the last `pip install` wins. usermanagement's +# `aiohttp==3.9.1` was silently downgrading the `aiohttp>=3.10` that ml_service needs +# for aiohttp.SocketTimeoutError — which openai's vendored httpx_aiohttp imports — +# and that disabled every /api/synth-scholar route at runtime. +# +# The pins are now compatible (see the notes in usermanagement/chat requirements), so +# this upgrade should be a no-op. It stays as a backstop, and the verification is the +# real point: it previously ran inside the ml_service block, i.e. BEFORE usermanagement +# downgraded aiohttp, so the build passed while the runtime was broken. Verifying here +# checks the environment the container actually runs. +WORKDIR /app/ml_service +RUN pip install --upgrade "aiohttp>=3.10,<4" \ + && python scripts/verify_imports.py + # Create supervisor configuration RUN mkdir -p /etc/supervisor/conf.d diff --git a/chat_service/requirements.txt b/chat_service/requirements.txt index 843f9ab..266b63a 100644 --- a/chat_service/requirements.txt +++ b/chat_service/requirements.txt @@ -4,7 +4,9 @@ uvicorn==0.29.0 gunicorn==21.2.0 # HTTP requests -aiohttp==3.9.1 +# Shared site-packages with the other services — see the note in +# usermanagement_service/requirements.txt. Must not pin below ml_service's >=3.10. +aiohttp>=3.10,<4 # Logging rich==13.9.4 diff --git a/usermanagement_service/requirements.txt b/usermanagement_service/requirements.txt index 787c7dd..2cb73d0 100644 --- a/usermanagement_service/requirements.txt +++ b/usermanagement_service/requirements.txt @@ -4,7 +4,12 @@ uvicorn==0.29.0 gunicorn==21.2.0 # HTTP requests -aiohttp==3.9.1 +# Shared site-packages: all four services install into ONE environment in +# Dockerfile.unified, so an exact pin here overrides what another service needs. +# ==3.9.1 downgraded the aiohttp ml_service requires (>=3.10, for +# aiohttp.SocketTimeoutError, which openai's vendored httpx_aiohttp imports) and +# silently disabled every /api/synth-scholar route. +aiohttp>=3.10,<4 # Logging rich==13.9.4 @@ -34,7 +39,8 @@ python-dotenv==1.0.0 asyncpg==0.29.0 # ORM Framework -sqlalchemy==2.0.23 +# Was ==2.0.23, which violated ml_service's >=2.0.30 in the shared environment. +sqlalchemy>=2.0.30 alembic==1.13.1 greenlet==3.0.3 From f9960cb27d241c7d9099cdcf3acce196da41ed07 Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Fri, 14 Aug 2026 10:21:04 +0545 Subject: [PATCH 70/70] synth_scholar: default the direct Oxigraph push off, ingest via query_service instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ...// — 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. --- .../synth_scholar/backfill_oxigraph_push.py | 29 +++++++++++++++++-- .../core/synth_scholar/oxigraph_push.py | 25 ++++++++++++++-- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/ml_service/core/synth_scholar/backfill_oxigraph_push.py b/ml_service/core/synth_scholar/backfill_oxigraph_push.py index 8ef062f..9a0442e 100644 --- a/ml_service/core/synth_scholar/backfill_oxigraph_push.py +++ b/ml_service/core/synth_scholar/backfill_oxigraph_push.py @@ -1,6 +1,13 @@ """One-shot backfill — push every completed review's RDF to Oxigraph. -Use cases: +LEGACY. This is the direct-to-Oxigraph path, which writes triples without an ingest +job, without PROV-O provenance and without a search-index row — a named graph that +belongs to no space and that only an Admin SPARQL query can see. Reviews now reach +BrainKB by ingesting their TTL export through query_service (see the `brainkb` skill, +"Ingest a SynthScholar review"), which attributes the write to a real user. This +script refuses to run unless SYNTH_SCHOLAR_PUSH_TO_GRAPHDB is explicitly enabled. + +Use cases (all predate the ingest path): * You ran reviews **before** the auto-push (mark_completed → oxigraph_push) was wired in. Their result_json is sitting in Postgres but no triples @@ -35,6 +42,7 @@ import argparse import asyncio import logging +import os import sys from typing import Any, Optional @@ -42,7 +50,7 @@ from .database import async_session from .db_models import ReviewRow -from .oxigraph_push import push_review_to_oxigraph +from .oxigraph_push import _truthy, push_review_to_oxigraph logger = logging.getLogger("backfill_oxigraph_push") @@ -137,6 +145,23 @@ async def main_async(argv: list[str] | None = None) -> int: format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", ) + # The direct push is off by default now (reviews go through the query_service + # ingest pipeline instead, so they get provenance). Without this guard the + # backfill would report "pushed N reviews" while _make_config returned None for + # every one of them and nothing left the process — a silent no-op is the worst + # possible outcome for a one-shot repair script. + if not _truthy(os.getenv("SYNTH_SCHOLAR_PUSH_TO_GRAPHDB"), default=False): + logger.error( + "SYNTH_SCHOLAR_PUSH_TO_GRAPHDB is not enabled, so every push would be " + "skipped. Reviews now reach BrainKB by ingesting their TTL export " + "through query_service, which is what records provenance — see the " + "`brainkb` skill, 'Ingest a SynthScholar review'. To run this legacy " + "direct push anyway, set SYNTH_SCHOLAR_PUSH_TO_GRAPHDB=true, and make " + "sure nothing else writes the same named graph (ingest is append-only " + "and the export's blank nodes do not de-duplicate)." + ) + return 2 + rows = await _select_reviews(args.review_id or None) if args.limit is not None: rows = rows[: args.limit] diff --git a/ml_service/core/synth_scholar/oxigraph_push.py b/ml_service/core/synth_scholar/oxigraph_push.py index e8acf2c..91586d4 100644 --- a/ml_service/core/synth_scholar/oxigraph_push.py +++ b/ml_service/core/synth_scholar/oxigraph_push.py @@ -22,8 +22,11 @@ ``GRAPHDATABASE_TYPE`` Informational; only ``OXIGRAPH`` triggers the GSP path. Default ``OXIGRAPH``. ``SYNTH_SCHOLAR_PUSH_TO_GRAPHDB`` Feature flag (``true``/``false``). - Default ``true`` — set to ``false`` to - disable the push without unsetting creds. + **Default ``false``** — reviews now + reach BrainKB through the query_service + ingest pipeline instead, which is what + gives them provenance. See the note in + ``_make_config`` before enabling. ``SYNTH_SCHOLAR_GRAPHDB_PATH`` Endpoint path (default ``/store`` for GSP, use ``/update`` for SPARQL Update). ``SYNTH_SCHOLAR_GRAPHDB_NAMED_GRAPH_PREFIX`` IRI prefix for review-specific named graphs. @@ -93,7 +96,23 @@ def _make_config(): optional ``synthscholar`` import fails, or when no usable endpoint can be composed. """ - if not _truthy(os.getenv("SYNTH_SCHOLAR_PUSH_TO_GRAPHDB"), default=True): + # Default OFF. This path writes to Oxigraph directly, which means it produces no + # ingest job, no PROV-O provenance and no search-index row — the triples land in + # a named graph that belongs to no space, so nothing except an Admin SPARQL query + # can see them. Reviews now reach BrainKB the same way all other RDF does: the + # TTL export is ingested through query_service (see the `brainkb` skill, + # "Ingest a SynthScholar review"), which attributes the write to a real user. + # + # Leaving this on alongside that ingest gives every review TWO graphs, not one. + # Review ids are unique, so reviews never collide with each other — the problem + # is that the two writers disagree about the IRI by one character. + # _named_graph_for below returns prefix + review_id with no trailing slash, while + # the ingest path registers and writes ...// (query_service normalises + # the registry lookup but writes the IRI verbatim). The result is a governed graph + # plus an unregistered shadow copy that only an Admin SPARQL query can see, which + # is worse than either outcome alone: search and provenance describe one of them + # and the store holds both. + if not _truthy(os.getenv("SYNTH_SCHOLAR_PUSH_TO_GRAPHDB"), default=False): return None try: