From 1793fdb6851e39234eec65e9a71af85f479b48a0 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 8 Jun 2026 10:31:29 +0530 Subject: [PATCH] feat(sso): OIDC/SAML federation + SCIM 2.0 provisioning (Step 6.8) New rag-sso package delivering enterprise identity in two surfaces: - Federation: FederatedAuth is an Auth SPI backend over per-tenant OidcProvider / SamlProvider, so a presented IdP token / assertion is verified into a Principal at the existing authenticate(token, tenant_id) boundary with no middleware change. IdP group claims map to acl_labels, so Step 6.3 ACL push-down + 6.5 PII egress govern federated users. Dependency-free defaults (stdlib HS256 JWT with full exp/nbf/iss/aud + constant-time compare; defusedxml SAML validating Issuer/Conditions/ Audience); asymmetric OIDC (PyJWT) + SAML XML-DSig (signxml) behind [oidc] / [saml] extras; an algorithm allowlist designs out alg:none / RS<->HS confusion. - Provisioning: SCIM 2.0 ScimService over a new tenant-scoped ScimStore SPI (+ NoopScimStore) drives /scim/v2/{Users,Groups} CRUD with the IdP deactivation PATCH, authenticated by a per-tenant SCIM bearer token (independent of the JWT Auth backend); SCIM-shaped errors; disabled -> 404. Per-tenant IdP config on tenants[].sso; cfg.sso / cfg.scim; GET /v1/status/sso; PII-free sso.* / scim.* events (hashed subject); ragctl sso / scim demos. Core types FederatedIdentity / ScimUser / ScimGroup + SsoError/ScimError/ScimNotFoundError/ScimConflictError; dist schemas / openapi / rag.schema regenerated. All gates green: ruff, mypy --strict (320 files), RAG001, full pytest (excl integration), schema/openapi/config-drift, policy-coverage, log-schema/event-registry/PII. Docs: docs/reference/sso.md, docs/architecture/sso-scim.md, docs/adr/ADR-0040-sso-scim.md, docs/README.md. TRACKER: 6.8 done (72/84). Co-Authored-By: Claude Opus 4.7 --- TRACKER.md | 25 +- apps/gateway/pyproject.toml | 2 + apps/gateway/src/rag_gateway/app.py | 26 + apps/gateway/src/rag_gateway/query.py | 12 + apps/gateway/src/rag_gateway/scim.py | 285 +++ apps/gateway/src/rag_gateway/status.py | 32 + apps/gateway/src/rag_gateway/wiring.py | 79 + apps/gateway/tests/test_scim_routes.py | 137 ++ apps/gateway/tests/test_sso_gateway.py | 100 + dist/openapi.json | 1757 +++++++++++++---- dist/openapi.yaml | 705 +++++++ dist/rag.schema.json | 220 +++ dist/rag.schema.yaml | 239 +++ dist/schemas/FederatedIdentity.json | 70 + dist/schemas/ScimErrorBody.json | 39 + dist/schemas/ScimGroup.json | 134 ++ dist/schemas/ScimListResponse.json | 40 + dist/schemas/ScimPatchOp.json | 25 + dist/schemas/ScimUser.json | 243 +++ dist/schemas/SsoStatusResponse.json | 56 + docs/README.md | 3 + docs/adr/ADR-0040-sso-scim.md | 99 + docs/architecture/sso-scim.md | 137 ++ docs/reference/sso.md | 141 ++ packages/config/src/rag_config/schema.py | 126 ++ packages/core/src/rag_core/errors.py | 52 + packages/core/src/rag_core/events.py | 18 + packages/core/src/rag_core/gateway_types.py | 81 + packages/core/src/rag_core/gen_schemas.py | 16 + packages/core/src/rag_core/spi/__init__.py | 2 + .../core/src/rag_core/spi/noop/__init__.py | 2 + .../core/src/rag_core/spi/noop/scim_store.py | 89 + packages/core/src/rag_core/spi/scim_store.py | 91 + packages/core/src/rag_core/types.py | 134 ++ .../src/rag_observability/events.py | 52 + packages/ragctl/pyproject.toml | 2 + packages/ragctl/src/ragctl/main.py | 182 ++ packages/ragctl/tests/test_scim.py | 23 + packages/ragctl/tests/test_sso.py | 60 + packages/sso/README.md | 21 + packages/sso/pyproject.toml | 48 + packages/sso/src/rag_sso/__init__.py | 42 + packages/sso/src/rag_sso/federated_auth.py | 124 ++ packages/sso/src/rag_sso/identity.py | 60 + packages/sso/src/rag_sso/jwt.py | 197 ++ packages/sso/src/rag_sso/oidc.py | 89 + packages/sso/src/rag_sso/py.typed | 0 packages/sso/src/rag_sso/saml.py | 202 ++ packages/sso/src/rag_sso/scim.py | 275 +++ packages/sso/tests/test_jwt.py | 107 + packages/sso/tests/test_oidc_federated.py | 109 + packages/sso/tests/test_saml.py | 135 ++ packages/sso/tests/test_scim_service.py | 142 ++ pyproject.toml | 9 +- tests/contract/conftest.py | 6 + tests/contract/test_scim_store.py | 129 ++ tests/logs/test_event_schema.py | 72 + uv.lock | 55 + 58 files changed, 6994 insertions(+), 364 deletions(-) create mode 100644 apps/gateway/src/rag_gateway/scim.py create mode 100644 apps/gateway/tests/test_scim_routes.py create mode 100644 apps/gateway/tests/test_sso_gateway.py create mode 100644 dist/schemas/FederatedIdentity.json create mode 100644 dist/schemas/ScimErrorBody.json create mode 100644 dist/schemas/ScimGroup.json create mode 100644 dist/schemas/ScimListResponse.json create mode 100644 dist/schemas/ScimPatchOp.json create mode 100644 dist/schemas/ScimUser.json create mode 100644 dist/schemas/SsoStatusResponse.json create mode 100644 docs/adr/ADR-0040-sso-scim.md create mode 100644 docs/architecture/sso-scim.md create mode 100644 docs/reference/sso.md create mode 100644 packages/core/src/rag_core/spi/noop/scim_store.py create mode 100644 packages/core/src/rag_core/spi/scim_store.py create mode 100644 packages/ragctl/tests/test_scim.py create mode 100644 packages/ragctl/tests/test_sso.py create mode 100644 packages/sso/README.md create mode 100644 packages/sso/pyproject.toml create mode 100644 packages/sso/src/rag_sso/__init__.py create mode 100644 packages/sso/src/rag_sso/federated_auth.py create mode 100644 packages/sso/src/rag_sso/identity.py create mode 100644 packages/sso/src/rag_sso/jwt.py create mode 100644 packages/sso/src/rag_sso/oidc.py create mode 100644 packages/sso/src/rag_sso/py.typed create mode 100644 packages/sso/src/rag_sso/saml.py create mode 100644 packages/sso/src/rag_sso/scim.py create mode 100644 packages/sso/tests/test_jwt.py create mode 100644 packages/sso/tests/test_oidc_federated.py create mode 100644 packages/sso/tests/test_saml.py create mode 100644 packages/sso/tests/test_scim_service.py create mode 100644 tests/contract/test_scim_store.py diff --git a/TRACKER.md b/TRACKER.md index 19b37c4..6014829 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -14,12 +14,13 @@ | | | |---|---| | **Last updated** | 2026-06-08 | -| **Current phase** | Phase 6 — Governance & Tenancy (**7 / 10 steps**) | -| **Overall** | **71 / 84 steps** — Phases 0–5 complete | -| **Next action** | **Step 6.8 — SSO / SCIM**: OIDC + SAML IdP federation; SCIM 2.0 user provisioning; per-tenant IdP config. New ground (the gateway's `Auth` SPI + `NoopAuth` header-identity is the seam this builds on). | +| **Current phase** | Phase 6 — Governance & Tenancy (**8 / 10 steps**) | +| **Overall** | **72 / 84 steps** — Phases 0–5 complete | +| **Next action** | **Step 6.9 — Air-gapped install bundle**: signed tarball with all images + Helm chart; offline bootstrap; cosign verification. | **Recently shipped** +- **6.8** ✅ SSO / SCIM — `rag-sso`: `FederatedAuth` (an `Auth` SPI backend over per-tenant `OidcProvider` / `SamlProvider`) federates a bearer token / SAML assertion → `Principal` at the existing `authenticate` seam (group claims → `acl_labels`); dependency-free defaults (stdlib HS256 JWT + `defusedxml` SAML), asymmetric OIDC / XML-DSig behind `[oidc]` / `[saml]` extras; algorithm-allowlist downgrade defense; SCIM 2.0 `ScimService` over the new tenant-scoped `ScimStore` SPI driving `/scim/v2/{Users,Groups}` (per-tenant bearer token); per-tenant IdP on `tenants[].sso`; `cfg.sso` / `cfg.scim`; `GET /v1/status/sso`; PII-free `sso.*` / `scim.*` events; `ragctl sso` / `scim` — [#159](https://github.com/officialCodeWork/AgentContextOS/pull/159) - **6.7** ✅ BYOK envelope encryption — `KeyManager` SPI + `EncryptingStorage` + `EnvelopeKeyManager` (AES-256-GCM DEK + tenant AAD); `LocalKeyManager` + four cloud KMS providers (`Aws`/`Gcp`/`AzureKeyVault`/`Vault`, behind `[kms-*]` extras); `cfg.kms` + per-tenant key + factory; per-tenant isolation + sealing + tamper-evidence; zero-downtime rotation (`RotatingKeyManager` + `rewrap`); `ragctl kms` — [#155](https://github.com/officialCodeWork/AgentContextOS/pull/155)–[#158](https://github.com/officialCodeWork/AgentContextOS/pull/158) - **6.6** ✅ Immutable audit log — **6.6a** shared `AuditWriter`/store on `app.state` + `GET /v1/audit` (tenant-scoped, `chain_verified`) + `GET /v1/audit/verify` (whole-log) + `cfg.audit.enabled` ([#153](https://github.com/officialCodeWork/AgentContextOS/pull/153)); **6.6b** `AuditExporter` self-verifying WORM bundle (SHA-256 `content_hash` + HMAC, offline `verify()`), `POST /v1/audit/export` (tenant-scoped) + `ragctl audit`, `cfg.audit.export_secret` ([#154](https://github.com/officialCodeWork/AgentContextOS/pull/154)) - **6.5** ✅ PII egress policies — `PiiPolicyEngine` (`rag-pii`) answers `egress_text` over the context (`list[Chunk]`) + agent answer (`str`) the gateway already passes, applying the per-tenant `pii_policy` (allow / redact / mask / block); reuses the Step 1.7 detector + rewriters; opt-in `cfg.pii.enabled`; emits `pii.egress_blocked` — [#152](https://github.com/officialCodeWork/AgentContextOS/pull/152) @@ -61,9 +62,9 @@ | 3 | Gateway & Agent Runtime | 11 | **11** | 0 | | 4 | Reliability | 6 | **6** | 0 | | 5 | Eval & Observability | 7 | **7** | 0 | -| 6 | Governance & Tenancy | 10 | **7** | 3 | +| 6 | Governance & Tenancy | 10 | **8** | 2 | | 7 | Pilot, Harden, GA | 10 | 0 | 10 | -| **Total** | | **84** | **71** | **13** | +| **Total** | | **84** | **72** | **12** | --- @@ -646,7 +647,7 @@ - **Phase-5 close-out:** Step 5.7 ✅ → **Phase 5 complete (7 / 7)**; deferred items remain documented (per-tenant drift / per-dimension embedding PSI; feedback/breaker/quota Grafana export + Loki-events dashboard; gRPC proto mirror of `corpus_decision` + `experiment`; sequential / multi-metric experiments) - [reference/experiments.md](docs/reference/experiments.md), [reference/admin-ui.md](docs/reference/admin-ui.md) -## Phase 6 — Governance & Tenancy (Weeks 28–34) 🚧 (3 / 10) +## Phase 6 — Governance & Tenancy (Weeks 28–34) 🚧 (2 / 10) | Step | Title | Status | Planned deliverables | |------|-------|:------:|----------------------| @@ -657,7 +658,7 @@ | 6.5 | PII policies | ✅ | [#152](https://github.com/officialCodeWork/AgentContextOS/pull/152) — `PiiPolicyEngine` egress_text decorator (allow / redact / mask / block per tenant) over answer + context; reuses Step 1.7 detector; `pii.egress_blocked` | | 6.6 | Immutable audit log | ✅ | **6.6a** [#153](https://github.com/officialCodeWork/AgentContextOS/pull/153) — read API `GET /v1/audit` + `GET /v1/audit/verify` + shared store + `cfg.audit`. **6.6b** [#154](https://github.com/officialCodeWork/AgentContextOS/pull/154) — `AuditExporter` signed WORM bundle + `POST /v1/audit/export` + `ragctl audit` | | 6.7 | BYOK (Bring Your Own Key) | ✅ | **6.7a** ✅ [#155](https://github.com/officialCodeWork/AgentContextOS/pull/155) — `KeyManager` SPI + envelope encryption (`LocalKeyManager`) + `EncryptingStorage` + sealing/isolation/tamper. **6.7b** ✅ [#156](https://github.com/officialCodeWork/AgentContextOS/pull/156) — `cfg.kms` + per-tenant key + `build_key_manager_from_config` factory + `AwsKmsKeyManager`. **6.7c** ✅ [#157](https://github.com/officialCodeWork/AgentContextOS/pull/157) — `GcpKmsKeyManager` / `AzureKeyVaultKeyManager` / `VaultKeyManager` behind `[kms-*]` extras. **6.7d** ✅ [#158](https://github.com/officialCodeWork/AgentContextOS/pull/158) — `RotatingKeyManager` zero-downtime rotation + `rewrap` | -| 6.8 | SSO / SCIM | ⏳ | OIDC + SAML IdP federation; SCIM 2.0 user provisioning; per-tenant IdP config | +| 6.8 | SSO / SCIM | ✅ | [#159](https://github.com/officialCodeWork/AgentContextOS/pull/159) — `rag-sso`: `FederatedAuth` (`Auth` SPI backend over per-tenant `OidcProvider` / `SamlProvider`) federates a token / assertion → `Principal` at the existing `authenticate` seam (groups → `acl_labels`); stdlib HS256 + `defusedxml` defaults, asymmetric OIDC / XML-DSig behind `[oidc]` / `[saml]` extras; SCIM 2.0 `ScimService` + `ScimStore` SPI → `/scim/v2/*` (per-tenant bearer); `tenants[].sso` + `cfg.sso` / `cfg.scim`; `GET /v1/status/sso`; `ragctl sso` / `scim` | | 6.9 | Air-gapped install bundle | ⏳ | Signed tarball with all images + Helm chart; offline bootstrap; cosign verification | | 6.10 | Compliance posture | ⏳ | SOC 2 Type II control mapping; GDPR data-residency config; data-retention policies | @@ -759,6 +760,16 @@ New ground — the only prior crypto was HMAC signing. The V1 plan calls for en - Composes with every provider (local + all four cloud KMS) since it orchestrates only the `KeyManager` SPI. `ragctl kms --rotate` demos the full flow (old + new decrypt, rewrap, expiry seals old data) - **Scope:** the rotation *mechanism* (satisfies the planning rotation acceptance — rotate → old + new both decrypt; expired key seals old data). **Deferred:** config-driven per-tenant multi-generation rotation + the storage-side background re-encryption job land with the `EncryptingStorage` ingest wiring (tiered storage). ~11 new tests (rotate → old+new decrypt; rewrap migration; expired-key sealing; tamper; no-key sealing; multi-generation; clock-controlled expiry; health) + `ragctl kms --rotate`. No dist drift; all gates green (ruff, mypy --strict 310 files, RAG001, policy-coverage, log-schema). [ADR-0039](docs/adr/ADR-0039-byok-envelope-encryption.md), [reference/encryption.md](docs/reference/encryption.md), [architecture/byok.md](docs/architecture/byok.md) +### 6.8 — SSO / SCIM ✅ [#159](https://github.com/officialCodeWork/AgentContextOS/pull/159) + +- **The seam is the `Auth` SPI.** The gateway middleware already calls `auth.authenticate(bearer_token, tenant_id) → Principal` at the boundary (the one SPI method that runs *before* a `RequestContext` exists). New **`FederatedAuth`** (`rag-sso`) implements it, dispatching to a per-tenant **`OidcProvider`** / **`SamlProvider`**, so wiring it as the gateway's `auth` backend is the **entire integration — no middleware change**. The returned principal's **`acl_labels` come from the IdP's group claims**, so Step 6.3 ACL push-down + 6.5 PII egress govern federated users unchanged (`authorize` stays a coarse allow — federation establishes *who*, the PolicyEngine decides *what*) +- **Dependency-free defaults, heavy crypto behind extras** (mirrors BYOK / NLI). OIDC verification is a real **stdlib HS256** JWT verifier (`verify_jwt`: split → header `alg` **allowlist** check → constant-time `hmac.compare_digest` → `exp` / `nbf` / `iss` / `aud` with leeway); asymmetric **RS256 / ES256** delegates to PyJWT behind the **`[oidc]`** extra against a configured public key. SAML parses through **`defusedxml`** (a core dep — XXE / billion-laughs safe), validates Issuer / Conditions / AudienceRestriction, and **injects** XML-DSig verification (`signxml_verifier`, **`[saml]`** extra) — `require_signature` on with no verifier **fails closed** +- **Algorithm-confusion designed out:** the `alg` allowlist rejects `alg:none` and an RS256 token replayed as HS256; symmetric vs asymmetric take *different* key material (`hmac_secret` vs `public_key`) +- **Per-tenant IdP config on `tenants[].sso`** (reuses the Step 6.1 mechanism; `${ENV}`-interpolated secrets). A tenant with no `sso` block has no provider → its bearer tokens are rejected (fail-closed) while header-identity dev flows still work. `build_federated_auth_from_config` builds one provider per tenant; **`NoopAuth`** when `cfg.sso.enabled` is off (pre-6.8 behaviour) +- **SCIM 2.0 is a separate surface with its own auth.** New tenant-scoped **`ScimStore`** SPI + **`NoopScimStore`** (CRUD for `ScimUser` / `ScimGroup`, isolation = the store key) + **`ScimService`** (uniqueness, server id + `meta`, the IdP **deactivation** PATCH, `attr eq "value"` filter, PII-free `scim.*` events). `/scim/v2/{Users,Groups}` + discovery endpoints authenticate a **per-tenant SCIM bearer token** (`cfg.scim.tokens`, constant-time compare — *not* a user JWT), return SCIM-shaped errors (RFC 7644), and 404 when disabled (checked before auth, so the surface is hidden). **No new governed SPI call** → the PolicyEngine coverage linter passes with no allowlist entry +- New core types **`FederatedIdentity` / `SsoProtocol` / `ScimUser` / `ScimGroup`** (+ nested) + **`SsoError`** (401) / **`ScimError`** (400) / **`ScimNotFoundError`** (404) / **`ScimConflictError`** (409); wire types `ScimListResponse` / `ScimPatchOp` / `ScimErrorBody` / `SsoStatusResponse` (`dist/schemas` + `dist/openapi` regenerated); `cfg.sso` / `cfg.scim` / `tenants[].sso` (`dist/rag.schema`); PII-free `sso.*` / `scim.*` events (subject **hashed**, never email / userName); **`ragctl sso`** (list + in-process OIDC demo) + **`ragctl scim`** (in-process provisioning demo) +- **Scope:** verification + provisioning at the boundary. **Deferred:** remote JWKS discovery + rotation (configured static keys only), SAML SP-initiated redirect + metadata, SCIM bulk / `/Me` / ETag, directory-backed deprovisioning at authenticate-time, the admin-console SSO/SCIM card. ~80 new tests (jwt / oidc / saml / federated-auth / scim-service unit; `ScimStore` contract suite; gateway SCIM CRUD + token guard + isolation + disabled-404; SSO status + JWT-through-middleware; `ragctl`). All gates green (ruff, mypy --strict, RAG001, schema/openapi/config-drift, policy-coverage, log-schema/event-registry/PII). [ADR-0040](docs/adr/ADR-0040-sso-scim.md), [reference/sso.md](docs/reference/sso.md), [architecture/sso-scim.md](docs/architecture/sso-scim.md) + --- ## Phase 7 — Pilot, Harden, GA (Weeks 34–40) ⏳ diff --git a/apps/gateway/pyproject.toml b/apps/gateway/pyproject.toml index b340357..5822aae 100644 --- a/apps/gateway/pyproject.toml +++ b/apps/gateway/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "rag-drift", "rag-agent", "rag-webhooks", + "rag-sso", "fastapi>=0.115", "uvicorn[standard]>=0.30", "python-multipart>=0.0.9", @@ -78,6 +79,7 @@ rag-feedback = { workspace = true } rag-drift = { workspace = true } rag-agent = { workspace = true } rag-webhooks = { workspace = true } +rag-sso = { workspace = true } [tool.hatch.build.targets.wheel] packages = ["src/rag_gateway"] diff --git a/apps/gateway/src/rag_gateway/app.py b/apps/gateway/src/rag_gateway/app.py index 9d0a419..977ac25 100644 --- a/apps/gateway/src/rag_gateway/app.py +++ b/apps/gateway/src/rag_gateway/app.py @@ -125,6 +125,7 @@ make_query_router, ) from rag_gateway.quota_guard import enforce_storage_quota +from rag_gateway.scim import make_scim_router from rag_gateway.status import ( InMemoryConnectorStatusStore, install_cors, @@ -377,6 +378,10 @@ def build_app( audit_enabled: bool = True, audit_export_secret: str = "", key_manager: Any | None = None, + scim_store: Any | None = None, + scim_enabled: bool = False, + scim_tokens: dict[str, str] | None = None, + sso_enabled: bool = False, enable_cors: bool = True, default_tenant_id: TenantId | None = None, ) -> FastAPI: @@ -429,6 +434,13 @@ def build_app( "with an SSE log stream and a WebSocket health/metrics push." ), }, + { + "name": "scim", + "description": ( + "SCIM 2.0 directory provisioning — IdP-driven user / group " + "create / update / deactivate (per-tenant bearer token)." + ), + }, ], ) @@ -528,6 +540,19 @@ def build_app( ) auth_backend = auth or NoopAuth() app.state.auth = auth_backend + # SSO / SCIM (Step 6.8). ``sso_enabled`` is a diagnostic flag surfaced on + # ``GET /v1/status/sso``; the actual federation is whatever ``auth`` backend is + # wired (a ``FederatedAuth`` when build_app_from_config builds one from + # cfg.sso). SCIM 2.0 provisioning (``/scim/v2``) runs over an in-memory + # NoopScimStore unless a durable store is injected; it is disabled (404s) until + # ``scim_enabled`` + per-tenant bearer ``scim_tokens`` are supplied from cfg.scim. + from rag_core.spi.noop import NoopScimStore + from rag_sso import ScimService + + app.state.sso_enabled = sso_enabled + app.state.scim_enabled = scim_enabled + app.state.scim_tokens = dict(scim_tokens or {}) + app.state.scim_service = ScimService(scim_store or NoopScimStore()) # PolicyEngine is optional in the gateway — when None, the # egress check in answer generation skips. Production wiring # always installs one (NoopPolicyEngine in dev; real PDP in 6.x). @@ -821,6 +846,7 @@ async def ingest_document( app.include_router(make_feedback_router()) app.include_router(make_status_router()) app.include_router(make_audit_router()) + app.include_router(make_scim_router()) return app diff --git a/apps/gateway/src/rag_gateway/query.py b/apps/gateway/src/rag_gateway/query.py index c026bca..017827f 100644 --- a/apps/gateway/src/rag_gateway/query.py +++ b/apps/gateway/src/rag_gateway/query.py @@ -40,6 +40,9 @@ RagError, RateLimitError, RetrievalError, + ScimConflictError, + ScimError, + ScimNotFoundError, ) from rag_core.gateway_types import ( Answer, @@ -107,6 +110,15 @@ def _http_status_for(exc: BaseException) -> int: if isinstance(exc, AuditNotFoundError): # Audit read API disabled / no store wired (Step 6.6). return 404 + if isinstance(exc, ScimNotFoundError): + # SCIM resource / surface not found (Step 6.8). + return 404 + if isinstance(exc, ScimConflictError): + # SCIM uniqueness violation (duplicate userName / displayName) (Step 6.8). + return 409 + if isinstance(exc, ScimError): + # Other SCIM provisioning failure (e.g. unsupported filter) (Step 6.8). + return 400 if isinstance(exc, RetrievalError): # Retrieval errors are bad-gateway because they indicate a # downstream backend failure, not bad input. diff --git a/apps/gateway/src/rag_gateway/scim.py b/apps/gateway/src/rag_gateway/scim.py new file mode 100644 index 0000000..d82eff5 --- /dev/null +++ b/apps/gateway/src/rag_gateway/scim.py @@ -0,0 +1,285 @@ +"""SCIM 2.0 provisioning surface — ``/scim/v2/{Users,Groups}`` (Step 6.8). + +Exposes the tenant-scoped SCIM directory over HTTP so an IdP (Okta / Azure AD / +OneLogin) can provision and deprovision users. SCIM clients authenticate with a +long-lived **bearer token** (the standard SCIM pattern, *not* a user JWT): a +request carries ``Authorization: Bearer `` + ``X-Tenant-Id: `` and +the token must match that tenant's configured ``cfg.scim.tokens`` entry. This is +deliberately independent of the gateway's ``Auth`` (JWT) backend. + +The router holds no business logic — it authenticates, builds a tenant-scoped +``RequestContext``, and delegates to the ``ScimService`` on ``app.state``. Errors +are returned in the SCIM error shape (:class:`ScimErrorBody`), not the platform +:class:`GatewayError`, so SCIM clients parse failures per RFC 7644. When SCIM is +disabled the surface 404s. +""" + +from __future__ import annotations + +import hmac +from typing import Any + +from fastapi import APIRouter, Query, Request, Response +from fastapi.responses import JSONResponse +from rag_core.errors import ( + AuthError, + ScimConflictError, + ScimError, + ScimNotFoundError, +) +from rag_core.gateway_types import ScimErrorBody, ScimListResponse, ScimPatchOp +from rag_core.types import ( + Principal, + PrincipalId, + PrincipalKind, + RequestContext, + ScimGroup, + ScimUser, + TenantId, +) + +__all__ = ["make_scim_router"] + + +def _require_service(request: Request) -> Any: + """Return the wired ScimService, or 404 (via ScimNotFoundError) when disabled.""" + state = request.app.state + service = getattr(state, "scim_service", None) + if not getattr(state, "scim_enabled", False) or service is None: + raise ScimNotFoundError("SCIM provisioning is not enabled on this gateway") + return service + + +def _require_scim_ctx(request: Request) -> RequestContext: + """Authenticate the SCIM bearer token and build a tenant-scoped context.""" + tokens: dict[str, str] = getattr(request.app.state, "scim_tokens", {}) or {} + headers = request.headers + tenant = headers.get("x-tenant-id") + auth_header = headers.get("authorization", "") + token = ( + auth_header.removeprefix("Bearer ").strip() if auth_header.startswith("Bearer ") else None + ) + if not tenant or not token: + raise AuthError("SCIM requires Authorization: Bearer and X-Tenant-Id") + expected = tokens.get(tenant) + if not expected or not hmac.compare_digest(token, expected): + raise AuthError("invalid SCIM bearer token for this tenant") + tid = TenantId(tenant) + return RequestContext( + tenant_id=tid, + principal=Principal( + id=PrincipalId("scim-provisioner"), + kind=PrincipalKind.service, + display_name="scim-provisioner", + tenant_id=tid, + ), + ) + + +def _scim_error(exc: Exception) -> JSONResponse: + """Translate a raised error into a SCIM-shaped error response.""" + if isinstance(exc, AuthError): + status, scim_type = 401, None + elif isinstance(exc, ScimConflictError): + status, scim_type = 409, "uniqueness" + elif isinstance(exc, ScimNotFoundError): + status, scim_type = 404, None + elif isinstance(exc, ScimError): + status, scim_type = 400, "invalidFilter" + else: # pragma: no cover - defensive + status, scim_type = 500, None + message = getattr(exc, "message", str(exc)) + body = ScimErrorBody(detail=message, status=str(status), scim_type=scim_type) + return JSONResponse( + status_code=status, + content=body.model_dump(by_alias=True, exclude_none=True), + ) + + +def _dump(resource: ScimUser | ScimGroup) -> dict[str, Any]: + return resource.model_dump(by_alias=True, exclude_none=True) + + +def make_scim_router() -> APIRouter: # noqa: C901 - flat CRUD surface, each route is trivial + """Build the SCIM 2.0 router (Step 6.8).""" + router = APIRouter(tags=["scim"]) + + # -- Users --------------------------------------------------------------- + @router.get("/scim/v2/Users", response_model=ScimListResponse) + async def list_users( + request: Request, + start_index: int = Query(1, alias="startIndex", ge=1), + count: int = Query(100, ge=0), + scim_filter: str | None = Query(None, alias="filter"), + ) -> Any: + try: + svc = _require_service(request) + ctx = _require_scim_ctx(request) + page, total = await svc.list_users( + ctx, start_index=start_index, count=count, scim_filter=scim_filter + ) + return ScimListResponse( + total_results=total, + start_index=start_index, + items_per_page=len(page), + resources=[_dump(u) for u in page], + ) + except (AuthError, ScimError) as exc: + return _scim_error(exc) + + @router.post("/scim/v2/Users", status_code=201, response_model=ScimUser) + async def create_user(request: Request, body: ScimUser) -> Any: + try: + svc = _require_service(request) + ctx = _require_scim_ctx(request) + return await svc.create_user(ctx, body) + except (AuthError, ScimError) as exc: + return _scim_error(exc) + + @router.get("/scim/v2/Users/{user_id}", response_model=ScimUser) + async def get_user(request: Request, user_id: str) -> Any: + try: + svc = _require_service(request) + ctx = _require_scim_ctx(request) + return await svc.get_user(ctx, user_id) + except (AuthError, ScimError) as exc: + return _scim_error(exc) + + @router.put("/scim/v2/Users/{user_id}", response_model=ScimUser) + async def replace_user(request: Request, user_id: str, body: ScimUser) -> Any: + try: + svc = _require_service(request) + ctx = _require_scim_ctx(request) + return await svc.replace_user(ctx, user_id, body) + except (AuthError, ScimError) as exc: + return _scim_error(exc) + + @router.patch("/scim/v2/Users/{user_id}", response_model=ScimUser) + async def patch_user(request: Request, user_id: str, body: ScimPatchOp) -> Any: + try: + svc = _require_service(request) + ctx = _require_scim_ctx(request) + return await svc.patch_user(ctx, user_id, body.operations) + except (AuthError, ScimError) as exc: + return _scim_error(exc) + + @router.delete("/scim/v2/Users/{user_id}", status_code=204, response_class=Response) + async def delete_user(request: Request, user_id: str) -> Response: + try: + svc = _require_service(request) + ctx = _require_scim_ctx(request) + await svc.delete_user(ctx, user_id) + return Response(status_code=204) + except (AuthError, ScimError) as exc: + return _scim_error(exc) + + # -- Groups -------------------------------------------------------------- + @router.get("/scim/v2/Groups", response_model=ScimListResponse) + async def list_groups( + request: Request, + start_index: int = Query(1, alias="startIndex", ge=1), + count: int = Query(100, ge=0), + scim_filter: str | None = Query(None, alias="filter"), + ) -> Any: + try: + svc = _require_service(request) + ctx = _require_scim_ctx(request) + page, total = await svc.list_groups( + ctx, start_index=start_index, count=count, scim_filter=scim_filter + ) + return ScimListResponse( + total_results=total, + start_index=start_index, + items_per_page=len(page), + resources=[_dump(g) for g in page], + ) + except (AuthError, ScimError) as exc: + return _scim_error(exc) + + @router.post("/scim/v2/Groups", status_code=201, response_model=ScimGroup) + async def create_group(request: Request, body: ScimGroup) -> Any: + try: + svc = _require_service(request) + ctx = _require_scim_ctx(request) + return await svc.create_group(ctx, body) + except (AuthError, ScimError) as exc: + return _scim_error(exc) + + @router.get("/scim/v2/Groups/{group_id}", response_model=ScimGroup) + async def get_group(request: Request, group_id: str) -> Any: + try: + svc = _require_service(request) + ctx = _require_scim_ctx(request) + return await svc.get_group(ctx, group_id) + except (AuthError, ScimError) as exc: + return _scim_error(exc) + + @router.put("/scim/v2/Groups/{group_id}", response_model=ScimGroup) + async def replace_group(request: Request, group_id: str, body: ScimGroup) -> Any: + try: + svc = _require_service(request) + ctx = _require_scim_ctx(request) + return await svc.replace_group(ctx, group_id, body) + except (AuthError, ScimError) as exc: + return _scim_error(exc) + + @router.delete("/scim/v2/Groups/{group_id}", status_code=204, response_class=Response) + async def delete_group(request: Request, group_id: str) -> Response: + try: + svc = _require_service(request) + ctx = _require_scim_ctx(request) + await svc.delete_group(ctx, group_id) + return Response(status_code=204) + except (AuthError, ScimError) as exc: + return _scim_error(exc) + + # -- Discovery (RFC 7644 §4) --------------------------------------------- + @router.get("/scim/v2/ServiceProviderConfig") + async def service_provider_config(request: Request) -> Any: + try: + _require_service(request) + _require_scim_ctx(request) + except (AuthError, ScimError) as exc: + return _scim_error(exc) + return { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], + "patch": {"supported": True}, + "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, + "filter": {"supported": True, "maxResults": 1000}, + "changePassword": {"supported": False}, + "sort": {"supported": False}, + "etag": {"supported": False}, + "authenticationSchemes": [ + { + "type": "oauthbearertoken", + "name": "OAuth Bearer Token", + "description": "Long-lived per-tenant SCIM provisioning token.", + } + ], + } + + @router.get("/scim/v2/ResourceTypes") + async def resource_types(request: Request) -> Any: + try: + _require_service(request) + _require_scim_ctx(request) + except (AuthError, ScimError) as exc: + return _scim_error(exc) + return [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + "id": "User", + "name": "User", + "endpoint": "/Users", + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + }, + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + "id": "Group", + "name": "Group", + "endpoint": "/Groups", + "schema": "urn:ietf:params:scim:schemas:core:2.0:Group", + }, + ] + + return router diff --git a/apps/gateway/src/rag_gateway/status.py b/apps/gateway/src/rag_gateway/status.py index b2aefa5..544b8d7 100644 --- a/apps/gateway/src/rag_gateway/status.py +++ b/apps/gateway/src/rag_gateway/status.py @@ -60,6 +60,7 @@ from rag_config.eval import analyze_ab_experiment from rag_config.tenancy import TenantResolver from rag_core.eval import ABAnalysisResult +from rag_core.gateway_types import SsoStatusResponse, SsoTenantStatus from rag_core.types import ( BreakerSnapshot, CircuitState, @@ -906,6 +907,37 @@ async def status_tenant(request: Request, tenant_id: str | None = None) -> Tenan physical_index=settings.physical_index, ) + @router.get("/v1/status/sso", response_model=SsoStatusResponse) + async def status_sso(request: Request, tenant_id: str | None = None) -> SsoStatusResponse: + """SSO / SCIM posture for the calling tenant (Step 6.8). + + Reports whether identity federation (``cfg.sso``) and SCIM provisioning + (``cfg.scim``) are enabled on the gateway, plus whether *this* tenant has + an IdP configured and which protocol it speaks. Tenant-scoped — it never + lists other tenants' config. Tenant resolves from the ``tenant_id`` query + param, else the ``X-Tenant-Id`` header, else the gateway default. + """ + state = request.app.state + tid = str( + tenant_id + or request.headers.get("X-Tenant-Id") + or getattr(state, "default_tenant_id", None) + or "default" + ) + auth = getattr(state, "auth", None) + provider_for = getattr(auth, "provider_for", None) + provider = provider_for(tid) if callable(provider_for) else None + protocol = str(provider.protocol) if provider is not None else None + return SsoStatusResponse( + sso_enabled=bool(getattr(state, "sso_enabled", False)), + scim_enabled=bool(getattr(state, "scim_enabled", False)), + tenant=SsoTenantStatus( + tenant_id=TenantId(tid), + configured=provider is not None, + protocol=protocol, + ), + ) + @router.websocket("/v1/status/ws") async def status_ws(websocket: WebSocket) -> None: """Push health + metrics snapshots until the client disconnects. diff --git a/apps/gateway/src/rag_gateway/wiring.py b/apps/gateway/src/rag_gateway/wiring.py index da4b029..18b7684 100644 --- a/apps/gateway/src/rag_gateway/wiring.py +++ b/apps/gateway/src/rag_gateway/wiring.py @@ -208,6 +208,72 @@ def build_key_manager_from_config(cfg: RagConfig) -> Any: return NoopKeyManager() # pragma: no cover - providers above are exhaustive +def build_federated_auth_from_config(cfg: RagConfig) -> Any: + """Build the ``Auth`` backend from ``cfg.sso`` + ``cfg.tenants[].sso`` (Step 6.8). + + Returns a ``NoopAuth`` when SSO is disabled, preserving the dev / header-identity + behaviour. When enabled, constructs a per-tenant ``OidcProvider`` / + ``SamlProvider`` from each tenant's ``sso`` block and wires them into a + ``FederatedAuth`` (group claims → ACL labels via ``cfg.sso.group_label_map``). + A tenant without an ``sso`` block has no provider, so its bearer tokens are + rejected (fail-closed) while header-identity dev flows still work. + """ + from rag_config.schema import IdpProtocol + from rag_core.spi.noop import NoopAuth + + if not cfg.sso.enabled: + return NoopAuth() + + from rag_sso import ( + FederatedAuth, + OidcProvider, + OidcSettings, + SamlProvider, + SamlSettings, + signxml_verifier, + ) + + providers: dict[str, Any] = {} + for tenant in cfg.tenants: + sso = tenant.sso + if sso is None: + continue + if sso.protocol is IdpProtocol.OIDC and sso.oidc is not None: + o = sso.oidc + providers[tenant.id] = OidcProvider( + OidcSettings( + issuer=o.issuer, + audience=o.audience, + algorithms=tuple(o.algorithms), + hmac_secret=o.hmac_secret or None, + public_key=o.public_key or None, + subject_claim=o.subject_claim, + email_claim=o.email_claim, + name_claim=o.name_claim, + group_claim=o.group_claim, + leeway_seconds=o.leeway_seconds, + require_expiry=o.require_expiry, + ) + ) + elif sso.protocol is IdpProtocol.SAML and sso.saml is not None: + s = sso.saml + verifier = signxml_verifier(s.certificate) if s.certificate else None + providers[tenant.id] = SamlProvider( + SamlSettings( + idp_entity_id=s.idp_entity_id, + audience=s.audience, + email_attribute=s.email_attribute, + group_attribute=s.group_attribute, + name_attribute=s.name_attribute or None, + require_signature=s.require_signature, + leeway_seconds=s.leeway_seconds, + ), + signature_verifier=verifier, + ) + + return FederatedAuth(providers, group_label_map=dict(cfg.sso.group_label_map) or None) + + async def seed_corpus_store(store: CorpusStore, cfg: RagConfig) -> int: """Initialise + seed a corpus store from ``cfg.corpora``. @@ -776,7 +842,18 @@ def build_app_from_config(cfg: RagConfig, **overrides: Any) -> FastAPI: # disabled). Operators inject their own via the ``key_manager`` override. key_manager = overrides.pop("key_manager", None) or build_key_manager_from_config(cfg) + # SSO identity federation (Step 6.8) — build a ``FederatedAuth`` from cfg.sso + + # per-tenant IdP blocks (Noop when disabled). SCIM 2.0 provisioning is a + # separate surface gated by ``cfg.scim.enabled`` with per-tenant bearer tokens. + auth_backend = overrides.pop("auth", None) or build_federated_auth_from_config(cfg) + scim_enabled = overrides.pop("scim_enabled", cfg.scim.enabled) + scim_tokens = overrides.pop("scim_tokens", dict(cfg.scim.tokens)) + return build_app( + auth=auth_backend, + sso_enabled=cfg.sso.enabled, + scim_enabled=scim_enabled, + scim_tokens=scim_tokens, corpus_store=corpus_store, retrieval_router=retrieval_router, corpus_router=corpus_router, @@ -802,7 +879,9 @@ def build_app_from_config(cfg: RagConfig, **overrides: Any) -> FastAPI: "build_cost_tracker_from_config", "build_drift_registry_from_config", "build_experiment_tracker_from_config", + "build_federated_auth_from_config", "build_feedback_from_config", + "build_key_manager_from_config", "build_provenance_from_config", "build_quota_enforcer_from_config", "seed_corpus_store", diff --git a/apps/gateway/tests/test_scim_routes.py b/apps/gateway/tests/test_scim_routes.py new file mode 100644 index 0000000..7156332 --- /dev/null +++ b/apps/gateway/tests/test_scim_routes.py @@ -0,0 +1,137 @@ +"""Tests for the SCIM 2.0 provisioning routes — /scim/v2/* (Step 6.8). + +Drives the gateway's SCIM surface end to end: per-tenant bearer-token auth, the +User CRUD lifecycle (including PATCH deactivation), the SCIM ListResponse shape, +filters, group CRUD, cross-tenant isolation, the disabled (404) surface, and the +discovery endpoints. +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient +from rag_gateway import build_app + +_TOKENS = {"acme": "acme-scim-token", "globex": "globex-scim-token"} +_ACME = {"Authorization": "Bearer acme-scim-token", "X-Tenant-Id": "acme"} +_GLOBEX = {"Authorization": "Bearer globex-scim-token", "X-Tenant-Id": "globex"} + + +def _client(*, enabled: bool = True) -> TestClient: + return TestClient(build_app(scim_enabled=enabled, scim_tokens=_TOKENS)) + + +def _create(client: TestClient, headers: dict[str, str], user_name: str) -> dict: + r = client.post("/scim/v2/Users", json={"userName": user_name, "active": True}, headers=headers) + assert r.status_code == 201, r.text + return r.json() + + +def test_user_lifecycle() -> None: + client = _client() + created = _create(client, _ACME, "alice@acme.test") + assert created["userName"] == "alice@acme.test" + assert created["schemas"] == ["urn:ietf:params:scim:schemas:core:2.0:User"] + assert created["meta"]["resourceType"] == "User" + uid = created["id"] + + got = client.get(f"/scim/v2/Users/{uid}", headers=_ACME) + assert got.status_code == 200 and got.json()["id"] == uid + + # PATCH deactivate (the IdP deprovisioning path). + patch = client.patch( + f"/scim/v2/Users/{uid}", + json={"Operations": [{"op": "replace", "value": {"active": False}}]}, + headers=_ACME, + ) + assert patch.status_code == 200 and patch.json()["active"] is False + + assert client.delete(f"/scim/v2/Users/{uid}", headers=_ACME).status_code == 204 + assert client.get(f"/scim/v2/Users/{uid}", headers=_ACME).status_code == 404 + + +def test_duplicate_username_conflicts() -> None: + client = _client() + _create(client, _ACME, "dup@acme.test") + r = client.post("/scim/v2/Users", json={"userName": "dup@acme.test"}, headers=_ACME) + assert r.status_code == 409 + assert r.json()["scimType"] == "uniqueness" + assert r.json()["schemas"] == ["urn:ietf:params:scim:api:messages:2.0:Error"] + + +def test_list_shape_and_filter() -> None: + client = _client() + for i in range(3): + _create(client, _ACME, f"u{i}@acme.test") + r = client.get("/scim/v2/Users", headers=_ACME) + body = r.json() + assert body["schemas"] == ["urn:ietf:params:scim:api:messages:2.0:ListResponse"] + assert body["totalResults"] == 3 + assert len(body["Resources"]) == 3 + + filtered = client.get('/scim/v2/Users?filter=userName eq "u1@acme.test"', headers=_ACME) + assert filtered.json()["totalResults"] == 1 + + +def test_unsupported_filter_400() -> None: + client = _client() + r = client.get('/scim/v2/Users?filter=userName sw "u"', headers=_ACME) + assert r.status_code == 400 + assert r.json()["scimType"] == "invalidFilter" + + +def test_auth_required_and_token_checked() -> None: + client = _client() + assert client.get("/scim/v2/Users").status_code == 401 # no creds + assert ( + client.get( + "/scim/v2/Users", + headers={"Authorization": "Bearer wrong", "X-Tenant-Id": "acme"}, + ).status_code + == 401 + ) + # Right token, wrong tenant header pairing. + assert ( + client.get( + "/scim/v2/Users", + headers={"Authorization": "Bearer acme-scim-token", "X-Tenant-Id": "globex"}, + ).status_code + == 401 + ) + + +def test_disabled_surface_404() -> None: + client = _client(enabled=False) + assert client.get("/scim/v2/Users", headers=_ACME).status_code == 404 + + +def test_tenant_isolation() -> None: + client = _client() + _create(client, _ACME, "alice@acme.test") + _create(client, _GLOBEX, "mallory@globex.test") + # Each tenant only sees its own directory. + assert client.get("/scim/v2/Users", headers=_ACME).json()["totalResults"] == 1 + assert client.get("/scim/v2/Users", headers=_GLOBEX).json()["totalResults"] == 1 + acme_users = client.get("/scim/v2/Users", headers=_ACME).json()["Resources"] + assert acme_users[0]["userName"] == "alice@acme.test" + + +def test_group_lifecycle() -> None: + client = _client() + r = client.post( + "/scim/v2/Groups", + json={"displayName": "engineering", "members": [{"value": "u-1"}]}, + headers=_ACME, + ) + assert r.status_code == 201 + gid = r.json()["id"] + assert client.get(f"/scim/v2/Groups/{gid}", headers=_ACME).status_code == 200 + assert client.get("/scim/v2/Groups", headers=_ACME).json()["totalResults"] == 1 + assert client.delete(f"/scim/v2/Groups/{gid}", headers=_ACME).status_code == 204 + + +def test_discovery_endpoints() -> None: + client = _client() + spc = client.get("/scim/v2/ServiceProviderConfig", headers=_ACME) + assert spc.status_code == 200 and spc.json()["patch"]["supported"] is True + rt = client.get("/scim/v2/ResourceTypes", headers=_ACME) + assert rt.status_code == 200 and {r["id"] for r in rt.json()} == {"User", "Group"} diff --git a/apps/gateway/tests/test_sso_gateway.py b/apps/gateway/tests/test_sso_gateway.py new file mode 100644 index 0000000..6a086b8 --- /dev/null +++ b/apps/gateway/tests/test_sso_gateway.py @@ -0,0 +1,100 @@ +"""Tests for SSO federation wiring at the gateway boundary (Step 6.8). + +Builds the gateway from a config with per-tenant OIDC and verifies that a real +ID token authenticates through the existing request-context middleware (so the +``FederatedAuth`` Auth backend slots in with no middleware change), plus the +``GET /v1/status/sso`` posture endpoint. +""" + +from __future__ import annotations + +import time + +from fastapi.testclient import TestClient +from rag_config.schema import RagConfig +from rag_gateway.wiring import build_app_from_config +from rag_sso import encode_jwt_hs256 + +_SECRET = "gateway-hs256-secret" + + +def _cfg() -> RagConfig: + return RagConfig.model_validate( + { + "version": "1", + "sso": {"enabled": True, "group_label_map": {"eng": "corpus-eng"}}, + "scim": {"enabled": False}, + "tenants": [ + { + "id": "acme", + "name": "Acme", + "sso": { + "protocol": "oidc", + "oidc": { + "issuer": "https://idp.acme.test", + "audience": "acos", + "algorithms": ["HS256"], + "hmac_secret": _SECRET, + "group_claim": "groups", + }, + }, + } + ], + } + ) + + +def _token(**claims: object) -> str: + base = { + "sub": "u-9", + "email": "u9@acme.test", + "groups": ["eng"], + "iss": "https://idp.acme.test", + "aud": "acos", + "exp": int(time.time()) + 3600, + } + base.update(claims) + return encode_jwt_hs256(base, _SECRET) + + +def test_status_sso_reports_configured_tenant() -> None: + client = TestClient(build_app_from_config(_cfg())) + body = client.get("/v1/status/sso?tenant_id=acme").json() + assert body["sso_enabled"] is True + assert body["tenant"]["configured"] is True + assert body["tenant"]["protocol"] == "oidc" + + +def test_status_sso_unconfigured_tenant() -> None: + client = TestClient(build_app_from_config(_cfg())) + body = client.get("/v1/status/sso?tenant_id=ghost").json() + assert body["tenant"]["configured"] is False + assert body["tenant"]["protocol"] is None + + +def test_valid_jwt_authenticates_through_middleware() -> None: + client = TestClient(build_app_from_config(_cfg())) + # /v1/audit requires a resolved principal — a valid OIDC token authenticates. + r = client.get( + "/v1/audit", + headers={"Authorization": f"Bearer {_token()}", "X-Tenant-Id": "acme"}, + ) + assert r.status_code == 200, r.text + + +def test_bad_jwt_rejected() -> None: + client = TestClient(build_app_from_config(_cfg())) + r = client.get( + "/v1/audit", + headers={"Authorization": "Bearer not-a-jwt", "X-Tenant-Id": "acme"}, + ) + assert r.status_code == 401 + + +def test_unknown_tenant_has_no_provider() -> None: + client = TestClient(build_app_from_config(_cfg())) + r = client.get( + "/v1/audit", + headers={"Authorization": f"Bearer {_token()}", "X-Tenant-Id": "globex"}, + ) + assert r.status_code == 401 diff --git a/dist/openapi.json b/dist/openapi.json index 5cedfe9..da8e898 100644 --- a/dist/openapi.json +++ b/dist/openapi.json @@ -3709,52 +3709,44 @@ "title": "RoutingDecision", "type": "object" }, - "SignedProvenanceRecord": { - "description": "A provenance record bundled with its signature (Step 5.1).\n\n``signature`` is ``None`` when no signing secret is configured — the record\nis still captured and retrievable, just not tamper-evident. Production sets\n``provenance.signing_secret`` to enable signing.", + "ScimEmail": { + "description": "One entry of the SCIM 2.0 multi-valued ``emails`` attribute.", "properties": { - "record": { - "$ref": "#/components/schemas/ProvenanceRecord" + "primary": { + "default": false, + "title": "Primary", + "type": "boolean" }, - "signature": { + "type": { "anyOf": [ { - "$ref": "#/components/schemas/ProvenanceSignature" + "type": "string" }, { "type": "null" } - ] + ], + "title": "Type" + }, + "value": { + "title": "Value", + "type": "string" } }, "required": [ - "record" + "value" ], - "title": "SignedProvenanceRecord", + "title": "ScimEmail", "type": "object" }, - "SpanRecord": { - "description": "A finished OTel span captured for per-query trace replay (Step 5.1).\n\nProduced by the ``TraceCollector`` span processor (rag-observability) and\nreturned by ``GET /v1/query/{id}/trace``. A flattened, JSON-safe view of one\nspan: its name, ids, timing, status, and ``rag.*`` attributes. It carries no\nraw query or answer text — those are never span attributes.", + "ScimGroup": { + "description": "A provisioned SCIM 2.0 Group resource (RFC 7643 §4.2, Step 6.8).\n\nTenant-scoped like :class:`ScimUser`; ``display_name`` is unique within a\ntenant. ``members`` references provisioned users by SCIM id. Group\nmembership is what an OIDC/SAML ``groups`` claim maps onto for ACL labels.", "properties": { - "attributes": { - "additionalProperties": true, - "title": "Attributes", - "type": "object" - }, - "duration_ms": { - "default": 0.0, - "title": "Duration Ms", - "type": "number" - }, - "end_unix_nano": { - "default": 0, - "title": "End Unix Nano", - "type": "integer" - }, - "name": { - "title": "Name", + "displayName": { + "title": "Displayname", "type": "string" }, - "parent_span_id": { + "externalId": { "anyOf": [ { "type": "string" @@ -3763,147 +3755,215 @@ "type": "null" } ], - "title": "Parent Span Id" + "title": "Externalid" }, - "span_id": { - "title": "Span Id", + "id": { + "title": "Id", "type": "string" }, - "start_unix_nano": { - "default": 0, - "title": "Start Unix Nano", - "type": "integer" + "members": { + "items": { + "$ref": "#/components/schemas/ScimMember" + }, + "title": "Members", + "type": "array" }, - "status": { - "default": "unset", - "title": "Status", - "type": "string" + "meta": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScimResourceMeta" + }, + { + "type": "null" + } + ] }, - "trace_id": { - "default": "", - "title": "Trace Id", - "type": "string" + "schemas": { + "default": [ + "urn:ietf:params:scim:schemas:core:2.0:Group" + ], + "items": { + "type": "string" + }, + "title": "Schemas", + "type": "array" } }, "required": [ - "name", - "span_id" + "displayName" ], - "title": "SpanRecord", + "title": "ScimGroup", "type": "object" }, - "StageTimings": { - "description": "Wall-time attribution across the per-stage pipeline.\n\nStep 3.1. Each field is the wall-time spent inside the named\nstage, in milliseconds. Stages that didn't run (e.g.\n``rerank=False``) report ``None`` so consumers can distinguish\n\"skipped\" from \"ran-in-zero-ms\".", + "ScimGroupRef": { + "description": "A user's group membership, as surfaced on ``User.groups`` (read-only).", "properties": { - "generate_ms": { + "display": { "anyOf": [ { - "type": "number" + "type": "string" }, { "type": "null" } ], - "title": "Generate Ms" + "title": "Display" }, - "pack_ms": { + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "value" + ], + "title": "ScimGroupRef", + "type": "object" + }, + "ScimListResponse": { + "description": "SCIM 2.0 ListResponse envelope (RFC 7644 §3.4.2, Step 6.8).\n\nWraps a page of provisioned resources. ``resources`` holds each resource\nalready serialised in its SCIM wire shape (camelCase), so a Users page and a\nGroups page share one envelope. ``total_results`` is the unpaged count;\n``start_index`` / ``items_per_page`` echo the request paging.", + "properties": { + "Resources": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Resources", + "type": "array" + }, + "itemsPerPage": { + "default": 0, + "title": "Itemsperpage", + "type": "integer" + }, + "schemas": { + "default": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse" + ], + "items": { + "type": "string" + }, + "title": "Schemas", + "type": "array" + }, + "startIndex": { + "default": 1, + "title": "Startindex", + "type": "integer" + }, + "totalResults": { + "default": 0, + "title": "Totalresults", + "type": "integer" + } + }, + "title": "ScimListResponse", + "type": "object" + }, + "ScimMember": { + "description": "One entry of a group's ``members`` attribute (a reference to a user).", + "properties": { + "display": { "anyOf": [ { - "type": "number" + "type": "string" }, { "type": "null" } ], - "title": "Pack Ms" + "title": "Display" }, - "rerank_ms": { + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "value" + ], + "title": "ScimMember", + "type": "object" + }, + "ScimName": { + "description": "SCIM 2.0 ``name`` complex attribute (RFC 7643 §4.1.1).", + "properties": { + "familyName": { "anyOf": [ { - "type": "number" + "type": "string" }, { "type": "null" } ], - "title": "Rerank Ms" + "title": "Familyname" }, - "route_ms": { + "formatted": { "anyOf": [ { - "type": "number" + "type": "string" }, { "type": "null" } ], - "title": "Route Ms" - }, - "total_ms": { - "default": 0.0, - "title": "Total Ms", - "type": "number" + "title": "Formatted" }, - "understand_ms": { + "givenName": { "anyOf": [ { - "type": "number" + "type": "string" }, { "type": "null" } ], - "title": "Understand Ms" + "title": "Givenname" } }, - "title": "StageTimings", + "title": "ScimName", "type": "object" }, - "SubscriptionList": { - "description": "``GET /v1/webhooks/subscriptions`` response.", + "ScimPatchOp": { + "description": "SCIM 2.0 PatchOp request body (RFC 7644 §3.5.2, Step 6.8).\n\n``operations`` is the ordered list of ``{op, path?, value}`` mutations an IdP\nsends — most importantly the deactivation patch (``op: replace`` of\n``active`` to ``false``) that revokes a user without deleting the record.", "properties": { - "subscriptions": { + "Operations": { "items": { - "$ref": "#/components/schemas/WebhookSubscription" + "additionalProperties": true, + "type": "object" }, - "title": "Subscriptions", + "title": "Operations", "type": "array" }, - "total": { - "default": 0, - "title": "Total", - "type": "integer" + "schemas": { + "default": [ + "urn:ietf:params:scim:api:messages:2.0:PatchOp" + ], + "items": { + "type": "string" + }, + "title": "Schemas", + "type": "array" } }, - "title": "SubscriptionList", + "title": "ScimPatchOp", "type": "object" }, - "TenantStatusResponse": { - "description": "One tenant's resolved logical-tenancy settings (Step 6.1).\n\nThe effective per-tenant view the gateway applies to each request, resolved by\nthe :class:`~rag_config.tenancy.TenantResolver` from ``cfg.tenants``. ``known``\nis whether the tenant is declared in ``rag.yaml`` — an unknown tenant resolves\nto safe defaults (namespace = the tenant id, default PII action, no labels), so\nit is isolated rather than privileged. Per-tenant like quotas / cost: the\ntenant comes from the ``tenant_id`` query param or the ``X-Tenant-Id`` header.", + "ScimResourceMeta": { + "description": "SCIM 2.0 ``meta`` complex attribute (RFC 7643 §3.1).", "properties": { - "acl_labels": { - "items": { - "type": "string" - }, - "title": "Acl Labels", - "type": "array" - }, - "dedicated_index": { - "default": false, - "title": "Dedicated Index", - "type": "boolean" - }, - "known": { - "default": false, - "title": "Known", - "type": "boolean" + "created": { + "format": "date-time", + "title": "Created", + "type": "string" }, - "namespace": { - "default": "", - "title": "Namespace", + "lastModified": { + "format": "date-time", + "title": "Lastmodified", "type": "string" }, - "physical_index": { + "location": { "anyOf": [ { "type": "string" @@ -3912,28 +3972,39 @@ "type": "null" } ], - "title": "Physical Index" + "title": "Location" }, - "pii_action": { - "default": "redact", - "title": "Pii Action", + "resourceType": { + "title": "Resourcetype", "type": "string" }, - "tenant_id": { - "title": "Tenant Id", - "type": "string" + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" } }, "required": [ - "tenant_id" + "resourceType" ], - "title": "TenantStatusResponse", + "title": "ScimResourceMeta", "type": "object" }, - "TraceContext": { - "description": "OTel-compatible trace context propagated through every pipeline step.", + "ScimUser": { + "description": "A provisioned SCIM 2.0 User resource (RFC 7643 §4.1, Step 6.8).\n\nPersisted through the tenant-scoped :class:`~rag_core.spi.scim_store.ScimStore`\n— tenant isolation comes from the store key (``ctx.tenant_id``), so the\nresource itself carries no tenant id and serialises as the exact SCIM wire\nshape (camelCase via field aliases). ``user_name`` is unique within a tenant;\n``active`` is the deprovisioning switch an IdP flips to revoke access.", "properties": { - "parent_span_id": { + "active": { + "default": true, + "title": "Active", + "type": "boolean" + }, + "displayName": { "anyOf": [ { "type": "string" @@ -3942,132 +4013,124 @@ "type": "null" } ], - "title": "Parent Span Id" + "title": "Displayname" }, - "sampled": { - "default": true, - "title": "Sampled", - "type": "boolean" + "emails": { + "items": { + "$ref": "#/components/schemas/ScimEmail" + }, + "title": "Emails", + "type": "array" }, - "span_id": { - "title": "Span Id", - "type": "string" + "externalId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Externalid" }, - "trace_id": { - "title": "Trace Id", + "groups": { + "items": { + "$ref": "#/components/schemas/ScimGroupRef" + }, + "title": "Groups", + "type": "array" + }, + "id": { + "title": "Id", "type": "string" - } - }, - "title": "TraceContext", - "type": "object" - }, - "TrustLevel": { - "description": "Provenance of a chunk's text — used by the prompt-injection defense.\n\n``trusted`` — first-party content authored under tenant control.\n``ingested`` — content fetched from a known external source (vetted feed,\n enterprise SharePoint, etc.).\n``user_supplied`` — content directly contributed by an end-user channel\n (web upload, chat-attached file, …) which may contain\n adversarial instructions.", - "enum": [ - "trusted", - "ingested", - "user_supplied" - ], - "title": "TrustLevel", - "type": "string" - }, - "ValidationError": { - "properties": { - "ctx": { - "title": "Context", - "type": "object" }, - "input": { - "title": "Input" + "meta": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScimResourceMeta" + }, + { + "type": "null" + } + ] }, - "loc": { + "name": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScimName" + }, + { + "type": "null" + } + ] + }, + "schemas": { + "default": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] + "type": "string" }, - "title": "Location", + "title": "Schemas", "type": "array" }, - "msg": { - "title": "Message", - "type": "string" - }, - "type": { - "title": "Error Type", + "userName": { + "title": "Username", "type": "string" } }, "required": [ - "loc", - "msg", - "type" + "userName" ], - "title": "ValidationError", + "title": "ScimUser", "type": "object" }, - "WebhookDelivery": { - "description": "The outcome of delivering one event to one subscription (all attempts).", + "SignedProvenanceRecord": { + "description": "A provenance record bundled with its signature (Step 5.1).\n\n``signature`` is ``None`` when no signing secret is configured — the record\nis still captured and retrievable, just not tamper-evident. Production sets\n``provenance.signing_secret`` to enable signing.", "properties": { - "attempts": { - "items": { - "$ref": "#/components/schemas/WebhookDeliveryAttempt" - }, - "title": "Attempts", - "type": "array" - }, - "event_id": { - "title": "Event Id", - "type": "string" - }, - "event_type": { - "$ref": "#/components/schemas/WebhookEventType" - }, - "status": { - "$ref": "#/components/schemas/DeliveryStatus" - }, - "subscription_id": { - "title": "Subscription Id", - "type": "string" + "record": { + "$ref": "#/components/schemas/ProvenanceRecord" }, - "url": { - "title": "Url", - "type": "string" + "signature": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProvenanceSignature" + }, + { + "type": "null" + } + ] } }, "required": [ - "event_id", - "event_type", - "subscription_id", - "url", - "status" + "record" ], - "title": "WebhookDelivery", + "title": "SignedProvenanceRecord", "type": "object" }, - "WebhookDeliveryAttempt": { - "description": "One HTTP POST attempt to a subscription.", + "SpanRecord": { + "description": "A finished OTel span captured for per-query trace replay (Step 5.1).\n\nProduced by the ``TraceCollector`` span processor (rag-observability) and\nreturned by ``GET /v1/query/{id}/trace``. A flattened, JSON-safe view of one\nspan: its name, ids, timing, status, and ``rag.*`` attributes. It carries no\nraw query or answer text — those are never span attributes.", "properties": { - "at": { - "format": "date-time", - "title": "At", - "type": "string" - }, - "attempt": { - "title": "Attempt", - "type": "integer" + "attributes": { + "additionalProperties": true, + "title": "Attributes", + "type": "object" }, "duration_ms": { "default": 0.0, "title": "Duration Ms", "type": "number" }, - "error": { + "end_unix_nano": { + "default": 0, + "title": "End Unix Nano", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "parent_span_id": { "anyOf": [ { "type": "string" @@ -4076,214 +4139,1142 @@ "type": "null" } ], - "title": "Error" + "title": "Parent Span Id" }, - "ok": { - "title": "Ok", + "span_id": { + "title": "Span Id", + "type": "string" + }, + "start_unix_nano": { + "default": 0, + "title": "Start Unix Nano", + "type": "integer" + }, + "status": { + "default": "unset", + "title": "Status", + "type": "string" + }, + "trace_id": { + "default": "", + "title": "Trace Id", + "type": "string" + } + }, + "required": [ + "name", + "span_id" + ], + "title": "SpanRecord", + "type": "object" + }, + "SsoStatusResponse": { + "description": "``GET /v1/status/sso`` response — federation + provisioning posture (Step 6.8).\n\nReports the global ``sso`` / ``scim`` enablement plus the **calling tenant's**\nown federation status (whether an IdP is configured for it, and which\nprotocol). Tenant-scoped by design — it never lists other tenants' config.", + "properties": { + "scim_enabled": { + "default": false, + "title": "Scim Enabled", "type": "boolean" }, - "status_code": { + "sso_enabled": { + "default": false, + "title": "Sso Enabled", + "type": "boolean" + }, + "tenant": { + "$ref": "#/components/schemas/SsoTenantStatus" + } + }, + "required": [ + "tenant" + ], + "title": "SsoStatusResponse", + "type": "object" + }, + "SsoTenantStatus": { + "description": "The calling tenant's SSO posture, embedded in :class:`SsoStatusResponse`.", + "properties": { + "configured": { + "default": false, + "title": "Configured", + "type": "boolean" + }, + "protocol": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Status Code" + "title": "Protocol" + }, + "tenant_id": { + "title": "Tenant Id", + "type": "string" } }, "required": [ - "attempt", - "ok" + "tenant_id" ], - "title": "WebhookDeliveryAttempt", + "title": "SsoTenantStatus", "type": "object" }, - "WebhookEvent": { - "description": "The signed JSON body POSTed to a subscriber.\n\n``id`` is stable across retries, so subscribers dedupe on it (delivery is\nat-least-once). ``data`` is the event-specific payload — its shape depends\non ``type`` (e.g. an ``IngestResult`` dump for ``ingest.completed``).\n``request_id`` / ``trace_id`` carry the originating request's correlation\nids so a subscriber can tie the event back to a gateway trace.", + "StageTimings": { + "description": "Wall-time attribution across the per-stage pipeline.\n\nStep 3.1. Each field is the wall-time spent inside the named\nstage, in milliseconds. Stages that didn't run (e.g.\n``rerank=False``) report ``None`` so consumers can distinguish\n\"skipped\" from \"ran-in-zero-ms\".", "properties": { - "created_at": { - "format": "date-time", - "title": "Created At", - "type": "string" + "generate_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Generate Ms" }, - "data": { - "additionalProperties": true, - "title": "Data", - "type": "object" + "pack_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Pack Ms" }, - "id": { - "title": "Id", - "type": "string" + "rerank_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Rerank Ms" }, - "principal_id": { + "route_ms": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } ], - "title": "Principal Id" + "title": "Route Ms" }, - "request_id": { + "total_ms": { + "default": 0.0, + "title": "Total Ms", + "type": "number" + }, + "understand_ms": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } - ], - "title": "Request Id" - }, - "tenant_id": { - "title": "Tenant Id", - "type": "string" + ], + "title": "Understand Ms" + } + }, + "title": "StageTimings", + "type": "object" + }, + "SubscriptionList": { + "description": "``GET /v1/webhooks/subscriptions`` response.", + "properties": { + "subscriptions": { + "items": { + "$ref": "#/components/schemas/WebhookSubscription" + }, + "title": "Subscriptions", + "type": "array" + }, + "total": { + "default": 0, + "title": "Total", + "type": "integer" + } + }, + "title": "SubscriptionList", + "type": "object" + }, + "TenantStatusResponse": { + "description": "One tenant's resolved logical-tenancy settings (Step 6.1).\n\nThe effective per-tenant view the gateway applies to each request, resolved by\nthe :class:`~rag_config.tenancy.TenantResolver` from ``cfg.tenants``. ``known``\nis whether the tenant is declared in ``rag.yaml`` — an unknown tenant resolves\nto safe defaults (namespace = the tenant id, default PII action, no labels), so\nit is isolated rather than privileged. Per-tenant like quotas / cost: the\ntenant comes from the ``tenant_id`` query param or the ``X-Tenant-Id`` header.", + "properties": { + "acl_labels": { + "items": { + "type": "string" + }, + "title": "Acl Labels", + "type": "array" + }, + "dedicated_index": { + "default": false, + "title": "Dedicated Index", + "type": "boolean" + }, + "known": { + "default": false, + "title": "Known", + "type": "boolean" + }, + "namespace": { + "default": "", + "title": "Namespace", + "type": "string" + }, + "physical_index": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Physical Index" + }, + "pii_action": { + "default": "redact", + "title": "Pii Action", + "type": "string" + }, + "tenant_id": { + "title": "Tenant Id", + "type": "string" + } + }, + "required": [ + "tenant_id" + ], + "title": "TenantStatusResponse", + "type": "object" + }, + "TraceContext": { + "description": "OTel-compatible trace context propagated through every pipeline step.", + "properties": { + "parent_span_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Span Id" + }, + "sampled": { + "default": true, + "title": "Sampled", + "type": "boolean" + }, + "span_id": { + "title": "Span Id", + "type": "string" + }, + "trace_id": { + "title": "Trace Id", + "type": "string" + } + }, + "title": "TraceContext", + "type": "object" + }, + "TrustLevel": { + "description": "Provenance of a chunk's text — used by the prompt-injection defense.\n\n``trusted`` — first-party content authored under tenant control.\n``ingested`` — content fetched from a known external source (vetted feed,\n enterprise SharePoint, etc.).\n``user_supplied`` — content directly contributed by an end-user channel\n (web upload, chat-attached file, …) which may contain\n adversarial instructions.", + "enum": [ + "trusted", + "ingested", + "user_supplied" + ], + "title": "TrustLevel", + "type": "string" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + }, + "WebhookDelivery": { + "description": "The outcome of delivering one event to one subscription (all attempts).", + "properties": { + "attempts": { + "items": { + "$ref": "#/components/schemas/WebhookDeliveryAttempt" + }, + "title": "Attempts", + "type": "array" + }, + "event_id": { + "title": "Event Id", + "type": "string" + }, + "event_type": { + "$ref": "#/components/schemas/WebhookEventType" + }, + "status": { + "$ref": "#/components/schemas/DeliveryStatus" + }, + "subscription_id": { + "title": "Subscription Id", + "type": "string" + }, + "url": { + "title": "Url", + "type": "string" + } + }, + "required": [ + "event_id", + "event_type", + "subscription_id", + "url", + "status" + ], + "title": "WebhookDelivery", + "type": "object" + }, + "WebhookDeliveryAttempt": { + "description": "One HTTP POST attempt to a subscription.", + "properties": { + "at": { + "format": "date-time", + "title": "At", + "type": "string" + }, + "attempt": { + "title": "Attempt", + "type": "integer" + }, + "duration_ms": { + "default": 0.0, + "title": "Duration Ms", + "type": "number" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "ok": { + "title": "Ok", + "type": "boolean" + }, + "status_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Status Code" + } + }, + "required": [ + "attempt", + "ok" + ], + "title": "WebhookDeliveryAttempt", + "type": "object" + }, + "WebhookEvent": { + "description": "The signed JSON body POSTed to a subscriber.\n\n``id`` is stable across retries, so subscribers dedupe on it (delivery is\nat-least-once). ``data`` is the event-specific payload — its shape depends\non ``type`` (e.g. an ``IngestResult`` dump for ``ingest.completed``).\n``request_id`` / ``trace_id`` carry the originating request's correlation\nids so a subscriber can tie the event back to a gateway trace.", + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "data": { + "additionalProperties": true, + "title": "Data", + "type": "object" + }, + "id": { + "title": "Id", + "type": "string" + }, + "principal_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Principal Id" + }, + "request_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Request Id" + }, + "tenant_id": { + "title": "Tenant Id", + "type": "string" + }, + "trace_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Trace Id" + }, + "type": { + "$ref": "#/components/schemas/WebhookEventType" + } + }, + "required": [ + "type", + "tenant_id" + ], + "title": "WebhookEvent", + "type": "object" + }, + "WebhookEventType": { + "description": "The catalogue of domain events the platform can emit.\n\n``ingest.completed`` and ``audit.policy_violation`` have live emitters as\nof Step 3.9 (the ingest pipeline + the policy engine). ``drift.detected``\nand ``eval.regression`` are defined here so subscribers can register for\nthem now; their emitters land with the Phase 5 eval / observability work.", + "enum": [ + "ingest.completed", + "drift.detected", + "eval.regression", + "audit.policy_violation" + ], + "title": "WebhookEventType", + "type": "string" + }, + "WebhookSubscription": { + "description": "A tenant-scoped delivery target.\n\n``event_types`` is the allow-list of events to deliver; an **empty** list\nmeans \"all event types\". ``secret`` is the HMAC-SHA256 signing key — it is\nmasked by :meth:`redacted` everywhere except the one-time create response,\nso it never leaks on subsequent reads.", + "properties": { + "active": { + "default": true, + "title": "Active", + "type": "boolean" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "event_types": { + "items": { + "$ref": "#/components/schemas/WebhookEventType" + }, + "title": "Event Types", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "secret": { + "title": "Secret", + "type": "string" + }, + "tenant_id": { + "title": "Tenant Id", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "url": { + "title": "Url", + "type": "string" + } + }, + "required": [ + "tenant_id", + "url" + ], + "title": "WebhookSubscription", + "type": "object" + }, + "WebhookTestResult": { + "description": "``POST /v1/webhooks/subscriptions/{id}/test`` response.", + "properties": { + "delivery": { + "$ref": "#/components/schemas/WebhookDelivery" + }, + "event": { + "$ref": "#/components/schemas/WebhookEvent" + } + }, + "required": [ + "event", + "delivery" + ], + "title": "WebhookTestResult", + "type": "object" + } + } + }, + "info": { + "description": "RAG gateway. Native routes for ingest, query, retrieve, and corpus discovery, plus OpenAI-compatible /v1/embeddings and /v1/chat/completions (with retrieval pre-fetch). Operator status surface at /v1/status/* (health, metrics, logs tail + SSE/WS). OpenAPI 3.1 at /openapi.json; Swagger UI at /docs.", + "title": "AgentContextOS Gateway", + "version": "0.10.0" + }, + "openapi": "3.1.0", + "paths": { + "/healthz": { + "get": { + "operationId": "healthz_healthz_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Healthz Healthz Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Healthz", + "tags": [ + "health" + ] + } + }, + "/scim/v2/Groups": { + "get": { + "operationId": "list_groups_scim_v2_Groups_get", + "parameters": [ + { + "in": "query", + "name": "startIndex", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "title": "Startindex", + "type": "integer" + } + }, + { + "in": "query", + "name": "count", + "required": false, + "schema": { + "default": 100, + "minimum": 0, + "title": "Count", + "type": "integer" + } + }, + { + "in": "query", + "name": "filter", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimListResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Groups", + "tags": [ + "scim" + ] + }, + "post": { + "operationId": "create_group_scim_v2_Groups_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimGroup" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimGroup" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Group", + "tags": [ + "scim" + ] + } + }, + "/scim/v2/Groups/{group_id}": { + "delete": { + "operationId": "delete_group_scim_v2_Groups__group_id__delete", + "parameters": [ + { + "in": "path", + "name": "group_id", + "required": true, + "schema": { + "title": "Group Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Group", + "tags": [ + "scim" + ] + }, + "get": { + "operationId": "get_group_scim_v2_Groups__group_id__get", + "parameters": [ + { + "in": "path", + "name": "group_id", + "required": true, + "schema": { + "title": "Group Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimGroup" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Group", + "tags": [ + "scim" + ] + }, + "put": { + "operationId": "replace_group_scim_v2_Groups__group_id__put", + "parameters": [ + { + "in": "path", + "name": "group_id", + "required": true, + "schema": { + "title": "Group Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimGroup" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimGroup" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Replace Group", + "tags": [ + "scim" + ] + } + }, + "/scim/v2/ResourceTypes": { + "get": { + "operationId": "resource_types_scim_v2_ResourceTypes_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Resource Types Scim V2 Resourcetypes Get" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Resource Types", + "tags": [ + "scim" + ] + } + }, + "/scim/v2/ServiceProviderConfig": { + "get": { + "operationId": "service_provider_config_scim_v2_ServiceProviderConfig_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Service Provider Config Scim V2 Serviceproviderconfig Get" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Service Provider Config", + "tags": [ + "scim" + ] + } + }, + "/scim/v2/Users": { + "get": { + "operationId": "list_users_scim_v2_Users_get", + "parameters": [ + { + "in": "query", + "name": "startIndex", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "title": "Startindex", + "type": "integer" + } + }, + { + "in": "query", + "name": "count", + "required": false, + "schema": { + "default": 100, + "minimum": 0, + "title": "Count", + "type": "integer" + } + }, + { + "in": "query", + "name": "filter", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimListResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Users", + "tags": [ + "scim" + ] + }, + "post": { + "operationId": "create_user_scim_v2_Users_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimUser" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimUser" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create User", + "tags": [ + "scim" + ] + } + }, + "/scim/v2/Users/{user_id}": { + "delete": { + "operationId": "delete_user_scim_v2_Users__user_id__delete", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete User", + "tags": [ + "scim" + ] + }, + "get": { + "operationId": "get_user_scim_v2_Users__user_id__get", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimUser" + } + } + }, + "description": "Successful Response" }, - "trace_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } - ], - "title": "Trace Id" - }, - "type": { - "$ref": "#/components/schemas/WebhookEventType" + }, + "description": "Validation Error" } }, - "required": [ - "type", - "tenant_id" - ], - "title": "WebhookEvent", - "type": "object" + "summary": "Get User", + "tags": [ + "scim" + ] }, - "WebhookEventType": { - "description": "The catalogue of domain events the platform can emit.\n\n``ingest.completed`` and ``audit.policy_violation`` have live emitters as\nof Step 3.9 (the ingest pipeline + the policy engine). ``drift.detected``\nand ``eval.regression`` are defined here so subscribers can register for\nthem now; their emitters land with the Phase 5 eval / observability work.", - "enum": [ - "ingest.completed", - "drift.detected", - "eval.regression", - "audit.policy_violation" + "patch": { + "operationId": "patch_user_scim_v2_Users__user_id__patch", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "string" + } + } ], - "title": "WebhookEventType", - "type": "string" - }, - "WebhookSubscription": { - "description": "A tenant-scoped delivery target.\n\n``event_types`` is the allow-list of events to deliver; an **empty** list\nmeans \"all event types\". ``secret`` is the HMAC-SHA256 signing key — it is\nmasked by :meth:`redacted` everywhere except the one-time create response,\nso it never leaks on subsequent reads.", - "properties": { - "active": { - "default": true, - "title": "Active", - "type": "boolean" - }, - "created_at": { - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "description": { - "default": "", - "title": "Description", - "type": "string" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimPatchOp" + } + } }, - "event_types": { - "items": { - "$ref": "#/components/schemas/WebhookEventType" + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimUser" + } + } }, - "title": "Event Types", - "type": "array" - }, - "id": { - "title": "Id", - "type": "string" - }, - "metadata": { - "additionalProperties": true, - "title": "Metadata", - "type": "object" - }, - "secret": { - "title": "Secret", - "type": "string" - }, - "tenant_id": { - "title": "Tenant Id", - "type": "string" - }, - "updated_at": { - "format": "date-time", - "title": "Updated At", - "type": "string" + "description": "Successful Response" }, - "url": { - "title": "Url", - "type": "string" + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" } }, - "required": [ - "tenant_id", - "url" - ], - "title": "WebhookSubscription", - "type": "object" + "summary": "Patch User", + "tags": [ + "scim" + ] }, - "WebhookTestResult": { - "description": "``POST /v1/webhooks/subscriptions/{id}/test`` response.", - "properties": { - "delivery": { - "$ref": "#/components/schemas/WebhookDelivery" - }, - "event": { - "$ref": "#/components/schemas/WebhookEvent" + "put": { + "operationId": "replace_user_scim_v2_Users__user_id__put", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "string" + } } - }, - "required": [ - "event", - "delivery" ], - "title": "WebhookTestResult", - "type": "object" - } - } - }, - "info": { - "description": "RAG gateway. Native routes for ingest, query, retrieve, and corpus discovery, plus OpenAI-compatible /v1/embeddings and /v1/chat/completions (with retrieval pre-fetch). Operator status surface at /v1/status/* (health, metrics, logs tail + SSE/WS). OpenAPI 3.1 at /openapi.json; Swagger UI at /docs.", - "title": "AgentContextOS Gateway", - "version": "0.10.0" - }, - "openapi": "3.1.0", - "paths": { - "/healthz": { - "get": { - "operationId": "healthz_healthz_get", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScimUser" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "additionalProperties": { - "type": "string" - }, - "title": "Response Healthz Healthz Get", - "type": "object" + "$ref": "#/components/schemas/ScimUser" } } }, "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" } }, - "summary": "Healthz", + "summary": "Replace User", "tags": [ - "health" + "scim" ] } }, @@ -5753,6 +6744,56 @@ ] } }, + "/v1/status/sso": { + "get": { + "description": "SSO / SCIM posture for the calling tenant (Step 6.8).\n\nReports whether identity federation (``cfg.sso``) and SCIM provisioning\n(``cfg.scim``) are enabled on the gateway, plus whether *this* tenant has\nan IdP configured and which protocol it speaks. Tenant-scoped — it never\nlists other tenants' config. Tenant resolves from the ``tenant_id`` query\nparam, else the ``X-Tenant-Id`` header, else the gateway default.", + "operationId": "status_sso_v1_status_sso_get", + "parameters": [ + { + "in": "query", + "name": "tenant_id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tenant Id" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SsoStatusResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Status Sso", + "tags": [ + "status" + ] + } + }, "/v1/status/tenant": { "get": { "description": "Resolved per-tenant logical-tenancy settings (Step 6.1).\n\nSurfaces what the ``TenantResolver`` resolves for a tenant — its\nnamespace, PII action, and ACL labels — so an operator can see the\neffective config a tenant's requests run under. Tenant resolves from the\n``tenant_id`` query param, else the ``X-Tenant-Id`` header, else the\ngateway default. Reports safe defaults (``known=false``) when the tenant\nis absent from ``rag.yaml`` or no resolver is wired.", @@ -6093,6 +7134,10 @@ { "description": "Operator status surface — health, metrics, and a logs tail, with an SSE log stream and a WebSocket health/metrics push.", "name": "status" + }, + { + "description": "SCIM 2.0 directory provisioning — IdP-driven user / group create / update / deactivate (per-tenant bearer token).", + "name": "scim" } ] } diff --git a/dist/openapi.yaml b/dist/openapi.yaml index d19abdf..981ce8c 100644 --- a/dist/openapi.yaml +++ b/dist/openapi.yaml @@ -3308,6 +3308,274 @@ components: - use_graph title: RoutingDecision type: object + ScimEmail: + description: One entry of the SCIM 2.0 multi-valued ``emails`` attribute. + properties: + primary: + default: false + title: Primary + type: boolean + type: + anyOf: + - type: string + - type: 'null' + title: Type + value: + title: Value + type: string + required: + - value + title: ScimEmail + type: object + ScimGroup: + description: 'A provisioned SCIM 2.0 Group resource (RFC 7643 §4.2, Step 6.8). + + + Tenant-scoped like :class:`ScimUser`; ``display_name`` is unique within a + + tenant. ``members`` references provisioned users by SCIM id. Group + + membership is what an OIDC/SAML ``groups`` claim maps onto for ACL labels.' + properties: + displayName: + title: Displayname + type: string + externalId: + anyOf: + - type: string + - type: 'null' + title: Externalid + id: + title: Id + type: string + members: + items: + $ref: '#/components/schemas/ScimMember' + title: Members + type: array + meta: + anyOf: + - $ref: '#/components/schemas/ScimResourceMeta' + - type: 'null' + schemas: + default: + - urn:ietf:params:scim:schemas:core:2.0:Group + items: + type: string + title: Schemas + type: array + required: + - displayName + title: ScimGroup + type: object + ScimGroupRef: + description: A user's group membership, as surfaced on ``User.groups`` (read-only). + properties: + display: + anyOf: + - type: string + - type: 'null' + title: Display + value: + title: Value + type: string + required: + - value + title: ScimGroupRef + type: object + ScimListResponse: + description: 'SCIM 2.0 ListResponse envelope (RFC 7644 §3.4.2, Step 6.8). + + + Wraps a page of provisioned resources. ``resources`` holds each resource + + already serialised in its SCIM wire shape (camelCase), so a Users page and + a + + Groups page share one envelope. ``total_results`` is the unpaged count; + + ``start_index`` / ``items_per_page`` echo the request paging.' + properties: + Resources: + items: + additionalProperties: true + type: object + title: Resources + type: array + itemsPerPage: + default: 0 + title: Itemsperpage + type: integer + schemas: + default: + - urn:ietf:params:scim:api:messages:2.0:ListResponse + items: + type: string + title: Schemas + type: array + startIndex: + default: 1 + title: Startindex + type: integer + totalResults: + default: 0 + title: Totalresults + type: integer + title: ScimListResponse + type: object + ScimMember: + description: One entry of a group's ``members`` attribute (a reference to a + user). + properties: + display: + anyOf: + - type: string + - type: 'null' + title: Display + value: + title: Value + type: string + required: + - value + title: ScimMember + type: object + ScimName: + description: SCIM 2.0 ``name`` complex attribute (RFC 7643 §4.1.1). + properties: + familyName: + anyOf: + - type: string + - type: 'null' + title: Familyname + formatted: + anyOf: + - type: string + - type: 'null' + title: Formatted + givenName: + anyOf: + - type: string + - type: 'null' + title: Givenname + title: ScimName + type: object + ScimPatchOp: + description: 'SCIM 2.0 PatchOp request body (RFC 7644 §3.5.2, Step 6.8). + + + ``operations`` is the ordered list of ``{op, path?, value}`` mutations an + IdP + + sends — most importantly the deactivation patch (``op: replace`` of + + ``active`` to ``false``) that revokes a user without deleting the record.' + properties: + Operations: + items: + additionalProperties: true + type: object + title: Operations + type: array + schemas: + default: + - urn:ietf:params:scim:api:messages:2.0:PatchOp + items: + type: string + title: Schemas + type: array + title: ScimPatchOp + type: object + ScimResourceMeta: + description: SCIM 2.0 ``meta`` complex attribute (RFC 7643 §3.1). + properties: + created: + format: date-time + title: Created + type: string + lastModified: + format: date-time + title: Lastmodified + type: string + location: + anyOf: + - type: string + - type: 'null' + title: Location + resourceType: + title: Resourcetype + type: string + version: + anyOf: + - type: string + - type: 'null' + title: Version + required: + - resourceType + title: ScimResourceMeta + type: object + ScimUser: + description: 'A provisioned SCIM 2.0 User resource (RFC 7643 §4.1, Step 6.8). + + + Persisted through the tenant-scoped :class:`~rag_core.spi.scim_store.ScimStore` + + — tenant isolation comes from the store key (``ctx.tenant_id``), so the + + resource itself carries no tenant id and serialises as the exact SCIM wire + + shape (camelCase via field aliases). ``user_name`` is unique within a tenant; + + ``active`` is the deprovisioning switch an IdP flips to revoke access.' + properties: + active: + default: true + title: Active + type: boolean + displayName: + anyOf: + - type: string + - type: 'null' + title: Displayname + emails: + items: + $ref: '#/components/schemas/ScimEmail' + title: Emails + type: array + externalId: + anyOf: + - type: string + - type: 'null' + title: Externalid + groups: + items: + $ref: '#/components/schemas/ScimGroupRef' + title: Groups + type: array + id: + title: Id + type: string + meta: + anyOf: + - $ref: '#/components/schemas/ScimResourceMeta' + - type: 'null' + name: + anyOf: + - $ref: '#/components/schemas/ScimName' + - type: 'null' + schemas: + default: + - urn:ietf:params:scim:schemas:core:2.0:User + items: + type: string + title: Schemas + type: array + userName: + title: Username + type: string + required: + - userName + title: ScimUser + type: object SignedProvenanceRecord: description: 'A provenance record bundled with its signature (Step 5.1). @@ -3383,6 +3651,50 @@ components: - span_id title: SpanRecord type: object + SsoStatusResponse: + description: '``GET /v1/status/sso`` response — federation + provisioning posture + (Step 6.8). + + + Reports the global ``sso`` / ``scim`` enablement plus the **calling tenant''s** + + own federation status (whether an IdP is configured for it, and which + + protocol). Tenant-scoped by design — it never lists other tenants'' config.' + properties: + scim_enabled: + default: false + title: Scim Enabled + type: boolean + sso_enabled: + default: false + title: Sso Enabled + type: boolean + tenant: + $ref: '#/components/schemas/SsoTenantStatus' + required: + - tenant + title: SsoStatusResponse + type: object + SsoTenantStatus: + description: The calling tenant's SSO posture, embedded in :class:`SsoStatusResponse`. + properties: + configured: + default: false + title: Configured + type: boolean + protocol: + anyOf: + - type: string + - type: 'null' + title: Protocol + tenant_id: + title: Tenant Id + type: string + required: + - tenant_id + title: SsoTenantStatus + type: object StageTimings: description: 'Wall-time attribution across the per-stage pipeline. @@ -3772,6 +4084,356 @@ paths: summary: Healthz tags: - health + /scim/v2/Groups: + get: + operationId: list_groups_scim_v2_Groups_get + parameters: + - in: query + name: startIndex + required: false + schema: + default: 1 + minimum: 1 + title: Startindex + type: integer + - in: query + name: count + required: false + schema: + default: 100 + minimum: 0 + title: Count + type: integer + - in: query + name: filter + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Filter + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ScimListResponse' + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: List Groups + tags: + - scim + post: + operationId: create_group_scim_v2_Groups_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ScimGroup' + required: true + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/ScimGroup' + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Create Group + tags: + - scim + /scim/v2/Groups/{group_id}: + delete: + operationId: delete_group_scim_v2_Groups__group_id__delete + parameters: + - in: path + name: group_id + required: true + schema: + title: Group Id + type: string + responses: + '204': + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Delete Group + tags: + - scim + get: + operationId: get_group_scim_v2_Groups__group_id__get + parameters: + - in: path + name: group_id + required: true + schema: + title: Group Id + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ScimGroup' + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Get Group + tags: + - scim + put: + operationId: replace_group_scim_v2_Groups__group_id__put + parameters: + - in: path + name: group_id + required: true + schema: + title: Group Id + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ScimGroup' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ScimGroup' + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Replace Group + tags: + - scim + /scim/v2/ResourceTypes: + get: + operationId: resource_types_scim_v2_ResourceTypes_get + responses: + '200': + content: + application/json: + schema: + title: Response Resource Types Scim V2 Resourcetypes Get + description: Successful Response + summary: Resource Types + tags: + - scim + /scim/v2/ServiceProviderConfig: + get: + operationId: service_provider_config_scim_v2_ServiceProviderConfig_get + responses: + '200': + content: + application/json: + schema: + title: Response Service Provider Config Scim V2 Serviceproviderconfig + Get + description: Successful Response + summary: Service Provider Config + tags: + - scim + /scim/v2/Users: + get: + operationId: list_users_scim_v2_Users_get + parameters: + - in: query + name: startIndex + required: false + schema: + default: 1 + minimum: 1 + title: Startindex + type: integer + - in: query + name: count + required: false + schema: + default: 100 + minimum: 0 + title: Count + type: integer + - in: query + name: filter + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Filter + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ScimListResponse' + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: List Users + tags: + - scim + post: + operationId: create_user_scim_v2_Users_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ScimUser' + required: true + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/ScimUser' + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Create User + tags: + - scim + /scim/v2/Users/{user_id}: + delete: + operationId: delete_user_scim_v2_Users__user_id__delete + parameters: + - in: path + name: user_id + required: true + schema: + title: User Id + type: string + responses: + '204': + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Delete User + tags: + - scim + get: + operationId: get_user_scim_v2_Users__user_id__get + parameters: + - in: path + name: user_id + required: true + schema: + title: User Id + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ScimUser' + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Get User + tags: + - scim + patch: + operationId: patch_user_scim_v2_Users__user_id__patch + parameters: + - in: path + name: user_id + required: true + schema: + title: User Id + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ScimPatchOp' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ScimUser' + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Patch User + tags: + - scim + put: + operationId: replace_user_scim_v2_Users__user_id__put + parameters: + - in: path + name: user_id + required: true + schema: + title: User Id + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ScimUser' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ScimUser' + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Replace User + tags: + - scim /v1/agent: post: description: 'Drive the agent loop for ``body.goal`` and stream its events. @@ -4788,6 +5450,46 @@ paths: summary: Reset Quota tags: - status + /v1/status/sso: + get: + description: 'SSO / SCIM posture for the calling tenant (Step 6.8). + + + Reports whether identity federation (``cfg.sso``) and SCIM provisioning + + (``cfg.scim``) are enabled on the gateway, plus whether *this* tenant has + + an IdP configured and which protocol it speaks. Tenant-scoped — it never + + lists other tenants'' config. Tenant resolves from the ``tenant_id`` query + + param, else the ``X-Tenant-Id`` header, else the gateway default.' + operationId: status_sso_v1_status_sso_get + parameters: + - in: query + name: tenant_id + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Tenant Id + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SsoStatusResponse' + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Status Sso + tags: + - status /v1/status/tenant: get: description: 'Resolved per-tenant logical-tenancy settings (Step 6.1). @@ -5007,3 +5709,6 @@ tags: - description: Operator status surface — health, metrics, and a logs tail, with an SSE log stream and a WebSocket health/metrics push. name: status +- description: SCIM 2.0 directory provisioning — IdP-driven user / group create / + update / deactivate (per-tenant bearer token). + name: scim diff --git a/dist/rag.schema.json b/dist/rag.schema.json index 3b328cb..3e3b070 100644 --- a/dist/rag.schema.json +++ b/dist/rag.schema.json @@ -858,6 +858,15 @@ "title": "HybridWeights", "type": "object" }, + "IdpProtocol": { + "description": "Identity-federation protocol for a tenant's IdP (Step 6.8).", + "enum": [ + "oidc", + "saml" + ], + "title": "IdpProtocol", + "type": "string" + }, "KeywordStoreConfig": { "additionalProperties": false, "properties": { @@ -1024,6 +1033,72 @@ "title": "LoggingSamplingConfig", "type": "object" }, + "OidcSettingsConfig": { + "additionalProperties": false, + "description": "Per-tenant OpenID Connect verification settings (Step 6.8).\n\nThe dependency-free default verifies an **HS256** token against ``hmac_secret``.\nFor production asymmetric tokens set ``algorithms: [\"RS256\"]`` and supply the\nIdP's ``public_key`` (PEM) \u2014 that path needs the rag-sso ``[oidc]`` extra.\n``issuer`` + ``audience`` are validated when set (strongly recommended).\n``hmac_secret`` / ``public_key`` support ``${ENV_VAR}`` interpolation so the\nsigning material stays out of the file.", + "properties": { + "issuer": { + "default": "", + "title": "Issuer", + "type": "string" + }, + "audience": { + "default": "", + "title": "Audience", + "type": "string" + }, + "algorithms": { + "items": { + "type": "string" + }, + "title": "Algorithms", + "type": "array" + }, + "hmac_secret": { + "default": "", + "title": "Hmac Secret", + "type": "string" + }, + "public_key": { + "default": "", + "title": "Public Key", + "type": "string" + }, + "subject_claim": { + "default": "sub", + "title": "Subject Claim", + "type": "string" + }, + "email_claim": { + "default": "email", + "title": "Email Claim", + "type": "string" + }, + "name_claim": { + "default": "name", + "title": "Name Claim", + "type": "string" + }, + "group_claim": { + "default": "groups", + "title": "Group Claim", + "type": "string" + }, + "leeway_seconds": { + "default": 60, + "minimum": 0, + "title": "Leeway Seconds", + "type": "integer" + }, + "require_expiry": { + "default": true, + "title": "Require Expiry", + "type": "boolean" + } + }, + "title": "OidcSettingsConfig", + "type": "object" + }, "PIIPolicy": { "enum": [ "block", @@ -1250,6 +1325,80 @@ "title": "RetrievalConfig", "type": "object" }, + "SamlSettingsConfig": { + "additionalProperties": false, + "description": "Per-tenant SAML 2.0 verification settings (Step 6.8).\n\n``idp_entity_id`` is the expected assertion ``Issuer``; ``audience`` is this\nservice's SP entity id (checked against ``AudienceRestriction``). The\nattribute names locate the IdP's email / group / display-name claims.\n``require_signature`` (default on) demands a verified XML-DSig \u2014 supply the\nIdP signing ``certificate`` (PEM, ``${ENV_VAR}``-interpolated); verification\nneeds the rag-sso ``[saml]`` extra.", + "properties": { + "idp_entity_id": { + "default": "", + "title": "Idp Entity Id", + "type": "string" + }, + "audience": { + "default": "", + "title": "Audience", + "type": "string" + }, + "certificate": { + "default": "", + "title": "Certificate", + "type": "string" + }, + "email_attribute": { + "default": "email", + "title": "Email Attribute", + "type": "string" + }, + "group_attribute": { + "default": "groups", + "title": "Group Attribute", + "type": "string" + }, + "name_attribute": { + "default": "displayName", + "title": "Name Attribute", + "type": "string" + }, + "require_signature": { + "default": true, + "title": "Require Signature", + "type": "boolean" + }, + "leeway_seconds": { + "default": 60, + "minimum": 0, + "title": "Leeway Seconds", + "type": "integer" + } + }, + "title": "SamlSettingsConfig", + "type": "object" + }, + "ScimConfig": { + "additionalProperties": false, + "description": "SCIM 2.0 directory provisioning (Step 6.8).\n\nWhen ``enabled`` the gateway exposes the SCIM 2.0 surface (``/scim/v2/Users`` +\n``/Groups`` and the discovery endpoints) so an IdP can provision / deprovision\nusers into the tenant-scoped directory. **Disabled by default** \u2014 it is a\nwrite surface; expose it only once tokens are set.\n\nSCIM clients authenticate with a long-lived bearer token (the standard SCIM\npattern) carried per tenant in ``tokens`` (``{tenant_id: token}``,\n``${ENV_VAR}``-interpolated). A request presents ``Authorization: Bearer\n`` + ``X-Tenant-Id: ``; the token must match that tenant's\nentry. This is independent of the JWT ``Auth`` backend \u2014 SCIM provisioning\ncredentials are not user tokens.", + "properties": { + "enabled": { + "default": false, + "title": "Enabled", + "type": "boolean" + }, + "base_path": { + "default": "/scim/v2", + "title": "Base Path", + "type": "string" + }, + "tokens": { + "additionalProperties": { + "type": "string" + }, + "title": "Tokens", + "type": "object" + } + }, + "title": "ScimConfig", + "type": "object" + }, "SecretsConfig": { "additionalProperties": false, "properties": { @@ -1307,6 +1456,26 @@ "title": "SemanticCacheConfig", "type": "object" }, + "SsoConfig": { + "additionalProperties": false, + "description": "SSO (OIDC / SAML) identity federation (Step 6.8).\n\nWhen ``enabled`` the gateway builds a ``FederatedAuth`` from each tenant's\n``tenants[].sso`` block and wires it as the ``Auth`` backend, so a presented\nIdP token / assertion is verified into a :class:`~rag_core.types.Principal`\n(group claims \u2192 ACL labels) at the existing ``authenticate`` boundary.\n\n**Disabled by default** \u2014 turning it on changes how principals are established\n(a tenant with no ``sso`` block then rejects bearer tokens it cannot verify).\nDev / header-identity flows are unaffected when off. ``group_label_map``\noptionally renames IdP groups to ACL labels platform-wide (groups absent from\nthe map pass through 1:1).", + "properties": { + "enabled": { + "default": false, + "title": "Enabled", + "type": "boolean" + }, + "group_label_map": { + "additionalProperties": { + "type": "string" + }, + "title": "Group Label Map", + "type": "object" + } + }, + "title": "SsoConfig", + "type": "object" + }, "StorageConfig": { "additionalProperties": false, "properties": { @@ -1454,6 +1623,17 @@ ], "default": null, "title": "Kms Key Id" + }, + "sso": { + "anyOf": [ + { + "$ref": "#/$defs/TenantSsoConfig" + }, + { + "type": "null" + } + ], + "default": null } }, "required": [ @@ -1549,6 +1729,40 @@ "title": "TenantQuota", "type": "object" }, + "TenantSsoConfig": { + "additionalProperties": false, + "description": "A tenant's identity-federation config (Step 6.8).\n\n``protocol`` selects ``oidc`` or ``saml``; the matching settings block must be\npresent. Attached to ``tenants[].sso`` and built into a ``rag_sso`` provider\nat the gateway boundary, so a presented token / assertion for this tenant is\nverified against *its own* IdP \u2014 the per-tenant IdP config the step delivers.", + "properties": { + "protocol": { + "$ref": "#/$defs/IdpProtocol", + "default": "oidc" + }, + "oidc": { + "anyOf": [ + { + "$ref": "#/$defs/OidcSettingsConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "saml": { + "anyOf": [ + { + "$ref": "#/$defs/SamlSettingsConfig" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "TenantSsoConfig", + "type": "object" + }, "VectorStoreConfig": { "additionalProperties": false, "properties": { @@ -1723,6 +1937,12 @@ "kms": { "$ref": "#/$defs/KmsConfig" }, + "sso": { + "$ref": "#/$defs/SsoConfig" + }, + "scim": { + "$ref": "#/$defs/ScimConfig" + }, "webhooks": { "$ref": "#/$defs/WebhooksConfig" }, diff --git a/dist/rag.schema.yaml b/dist/rag.schema.yaml index 15dcf73..58d2996 100644 --- a/dist/rag.schema.yaml +++ b/dist/rag.schema.yaml @@ -896,6 +896,13 @@ $defs: type: number title: HybridWeights type: object + IdpProtocol: + description: Identity-federation protocol for a tenant's IdP (Step 6.8). + enum: + - oidc + - saml + title: IdpProtocol + type: string KeywordStoreConfig: additionalProperties: false properties: @@ -1043,6 +1050,71 @@ $defs: type: object title: LoggingSamplingConfig type: object + OidcSettingsConfig: + additionalProperties: false + description: 'Per-tenant OpenID Connect verification settings (Step 6.8). + + + The dependency-free default verifies an **HS256** token against ``hmac_secret``. + + For production asymmetric tokens set ``algorithms: ["RS256"]`` and supply the + + IdP''s ``public_key`` (PEM) — that path needs the rag-sso ``[oidc]`` extra. + + ``issuer`` + ``audience`` are validated when set (strongly recommended). + + ``hmac_secret`` / ``public_key`` support ``${ENV_VAR}`` interpolation so the + + signing material stays out of the file.' + properties: + issuer: + default: '' + title: Issuer + type: string + audience: + default: '' + title: Audience + type: string + algorithms: + items: + type: string + title: Algorithms + type: array + hmac_secret: + default: '' + title: Hmac Secret + type: string + public_key: + default: '' + title: Public Key + type: string + subject_claim: + default: sub + title: Subject Claim + type: string + email_claim: + default: email + title: Email Claim + type: string + name_claim: + default: name + title: Name Claim + type: string + group_claim: + default: groups + title: Group Claim + type: string + leeway_seconds: + default: 60 + minimum: 0 + title: Leeway Seconds + type: integer + require_expiry: + default: true + title: Require Expiry + type: boolean + title: OidcSettingsConfig + type: object PIIPolicy: enum: - block @@ -1279,6 +1351,100 @@ $defs: $ref: '#/$defs/FallbackConfig' title: RetrievalConfig type: object + SamlSettingsConfig: + additionalProperties: false + description: 'Per-tenant SAML 2.0 verification settings (Step 6.8). + + + ``idp_entity_id`` is the expected assertion ``Issuer``; ``audience`` is this + + service''s SP entity id (checked against ``AudienceRestriction``). The + + attribute names locate the IdP''s email / group / display-name claims. + + ``require_signature`` (default on) demands a verified XML-DSig — supply the + + IdP signing ``certificate`` (PEM, ``${ENV_VAR}``-interpolated); verification + + needs the rag-sso ``[saml]`` extra.' + properties: + idp_entity_id: + default: '' + title: Idp Entity Id + type: string + audience: + default: '' + title: Audience + type: string + certificate: + default: '' + title: Certificate + type: string + email_attribute: + default: email + title: Email Attribute + type: string + group_attribute: + default: groups + title: Group Attribute + type: string + name_attribute: + default: displayName + title: Name Attribute + type: string + require_signature: + default: true + title: Require Signature + type: boolean + leeway_seconds: + default: 60 + minimum: 0 + title: Leeway Seconds + type: integer + title: SamlSettingsConfig + type: object + ScimConfig: + additionalProperties: false + description: 'SCIM 2.0 directory provisioning (Step 6.8). + + + When ``enabled`` the gateway exposes the SCIM 2.0 surface (``/scim/v2/Users`` + + + + ``/Groups`` and the discovery endpoints) so an IdP can provision / deprovision + + users into the tenant-scoped directory. **Disabled by default** — it is a + + write surface; expose it only once tokens are set. + + + SCIM clients authenticate with a long-lived bearer token (the standard SCIM + + pattern) carried per tenant in ``tokens`` (``{tenant_id: token}``, + + ``${ENV_VAR}``-interpolated). A request presents ``Authorization: Bearer + + `` + ``X-Tenant-Id: ``; the token must match that tenant''s + + entry. This is independent of the JWT ``Auth`` backend — SCIM provisioning + + credentials are not user tokens.' + properties: + enabled: + default: false + title: Enabled + type: boolean + base_path: + default: /scim/v2 + title: Base Path + type: string + tokens: + additionalProperties: + type: string + title: Tokens + type: object + title: ScimConfig + type: object SecretsConfig: additionalProperties: false properties: @@ -1324,6 +1490,41 @@ $defs: type: integer title: SemanticCacheConfig type: object + SsoConfig: + additionalProperties: false + description: 'SSO (OIDC / SAML) identity federation (Step 6.8). + + + When ``enabled`` the gateway builds a ``FederatedAuth`` from each tenant''s + + ``tenants[].sso`` block and wires it as the ``Auth`` backend, so a presented + + IdP token / assertion is verified into a :class:`~rag_core.types.Principal` + + (group claims → ACL labels) at the existing ``authenticate`` boundary. + + + **Disabled by default** — turning it on changes how principals are established + + (a tenant with no ``sso`` block then rejects bearer tokens it cannot verify). + + Dev / header-identity flows are unaffected when off. ``group_label_map`` + + optionally renames IdP groups to ACL labels platform-wide (groups absent from + + the map pass through 1:1).' + properties: + enabled: + default: false + title: Enabled + type: boolean + group_label_map: + additionalProperties: + type: string + title: Group Label Map + type: object + title: SsoConfig + type: object StorageConfig: additionalProperties: false properties: @@ -1441,6 +1642,11 @@ $defs: - type: 'null' default: null title: Kms Key Id + sso: + anyOf: + - $ref: '#/$defs/TenantSsoConfig' + - type: 'null' + default: null required: - id - name @@ -1505,6 +1711,35 @@ $defs: title: Storage Bytes title: TenantQuota type: object + TenantSsoConfig: + additionalProperties: false + description: 'A tenant''s identity-federation config (Step 6.8). + + + ``protocol`` selects ``oidc`` or ``saml``; the matching settings block must + be + + present. Attached to ``tenants[].sso`` and built into a ``rag_sso`` provider + + at the gateway boundary, so a presented token / assertion for this tenant is + + verified against *its own* IdP — the per-tenant IdP config the step delivers.' + properties: + protocol: + $ref: '#/$defs/IdpProtocol' + default: oidc + oidc: + anyOf: + - $ref: '#/$defs/OidcSettingsConfig' + - type: 'null' + default: null + saml: + anyOf: + - $ref: '#/$defs/SamlSettingsConfig' + - type: 'null' + default: null + title: TenantSsoConfig + type: object VectorStoreConfig: additionalProperties: false properties: @@ -1651,6 +1886,10 @@ properties: $ref: '#/$defs/AuditConfig' kms: $ref: '#/$defs/KmsConfig' + sso: + $ref: '#/$defs/SsoConfig' + scim: + $ref: '#/$defs/ScimConfig' webhooks: $ref: '#/$defs/WebhooksConfig' provenance: diff --git a/dist/schemas/FederatedIdentity.json b/dist/schemas/FederatedIdentity.json new file mode 100644 index 0000000..928a9ec --- /dev/null +++ b/dist/schemas/FederatedIdentity.json @@ -0,0 +1,70 @@ +{ + "$defs": { + "SsoProtocol": { + "description": "Identity-federation protocol a tenant's IdP speaks (Step 6.8).", + "enum": [ + "oidc", + "saml" + ], + "title": "SsoProtocol", + "type": "string" + } + }, + "description": "Normalised identity extracted from a verified OIDC token / SAML assertion.\n\nThe protocol-neutral output of an :class:`rag_sso` identity provider, mapped\nonto a :class:`Principal` at the gateway boundary. ``subject`` is the IdP's\nstable user id (OIDC ``sub`` / SAML ``NameID``); ``groups`` are the group /\nrole claims used to derive the principal's ACL labels (Step 6.3).\n``attributes`` carries any extra claims for diagnostics \u2014 it is **never**\nwritten to a structured-log event (which carries only a hash of the subject).", + "properties": { + "subject": { + "title": "Subject", + "type": "string" + }, + "issuer": { + "default": "", + "title": "Issuer", + "type": "string" + }, + "protocol": { + "$ref": "#/$defs/SsoProtocol", + "default": "oidc" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Email" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Name" + }, + "groups": { + "items": { + "type": "string" + }, + "title": "Groups", + "type": "array" + }, + "attributes": { + "additionalProperties": true, + "title": "Attributes", + "type": "object" + } + }, + "required": [ + "subject" + ], + "title": "FederatedIdentity", + "type": "object" +} diff --git a/dist/schemas/ScimErrorBody.json b/dist/schemas/ScimErrorBody.json new file mode 100644 index 0000000..a18057f --- /dev/null +++ b/dist/schemas/ScimErrorBody.json @@ -0,0 +1,39 @@ +{ + "description": "SCIM 2.0 error response body (RFC 7644 \u00a73.12, Step 6.8).\n\nThe SCIM surface returns this shape (not the platform :class:`GatewayError`)\nso SCIM clients parse failures per spec. ``scim_type`` carries the RFC error\nkeyword (e.g. ``uniqueness`` / ``invalidFilter``) when applicable.", + "properties": { + "schemas": { + "default": [ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + "items": { + "type": "string" + }, + "title": "Schemas", + "type": "array" + }, + "detail": { + "default": "", + "title": "Detail", + "type": "string" + }, + "status": { + "default": "400", + "title": "Status", + "type": "string" + }, + "scimType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scimtype" + } + }, + "title": "ScimErrorBody", + "type": "object" +} diff --git a/dist/schemas/ScimGroup.json b/dist/schemas/ScimGroup.json new file mode 100644 index 0000000..b688423 --- /dev/null +++ b/dist/schemas/ScimGroup.json @@ -0,0 +1,134 @@ +{ + "$defs": { + "ScimMember": { + "description": "One entry of a group's ``members`` attribute (a reference to a user).", + "properties": { + "value": { + "title": "Value", + "type": "string" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + } + }, + "required": [ + "value" + ], + "title": "ScimMember", + "type": "object" + }, + "ScimResourceMeta": { + "description": "SCIM 2.0 ``meta`` complex attribute (RFC 7643 \u00a73.1).", + "properties": { + "resourceType": { + "title": "Resourcetype", + "type": "string" + }, + "created": { + "format": "date-time", + "title": "Created", + "type": "string" + }, + "lastModified": { + "format": "date-time", + "title": "Lastmodified", + "type": "string" + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Location" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Version" + } + }, + "required": [ + "resourceType" + ], + "title": "ScimResourceMeta", + "type": "object" + } + }, + "description": "A provisioned SCIM 2.0 Group resource (RFC 7643 \u00a74.2, Step 6.8).\n\nTenant-scoped like :class:`ScimUser`; ``display_name`` is unique within a\ntenant. ``members`` references provisioned users by SCIM id. Group\nmembership is what an OIDC/SAML ``groups`` claim maps onto for ACL labels.", + "properties": { + "schemas": { + "default": [ + "urn:ietf:params:scim:schemas:core:2.0:Group" + ], + "items": { + "type": "string" + }, + "title": "Schemas", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "externalId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Externalid" + }, + "displayName": { + "title": "Displayname", + "type": "string" + }, + "members": { + "items": { + "$ref": "#/$defs/ScimMember" + }, + "title": "Members", + "type": "array" + }, + "meta": { + "anyOf": [ + { + "$ref": "#/$defs/ScimResourceMeta" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "displayName" + ], + "title": "ScimGroup", + "type": "object" +} diff --git a/dist/schemas/ScimListResponse.json b/dist/schemas/ScimListResponse.json new file mode 100644 index 0000000..9f97b27 --- /dev/null +++ b/dist/schemas/ScimListResponse.json @@ -0,0 +1,40 @@ +{ + "description": "SCIM 2.0 ListResponse envelope (RFC 7644 \u00a73.4.2, Step 6.8).\n\nWraps a page of provisioned resources. ``resources`` holds each resource\nalready serialised in its SCIM wire shape (camelCase), so a Users page and a\nGroups page share one envelope. ``total_results`` is the unpaged count;\n``start_index`` / ``items_per_page`` echo the request paging.", + "properties": { + "schemas": { + "default": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse" + ], + "items": { + "type": "string" + }, + "title": "Schemas", + "type": "array" + }, + "totalResults": { + "default": 0, + "title": "Totalresults", + "type": "integer" + }, + "startIndex": { + "default": 1, + "title": "Startindex", + "type": "integer" + }, + "itemsPerPage": { + "default": 0, + "title": "Itemsperpage", + "type": "integer" + }, + "Resources": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Resources", + "type": "array" + } + }, + "title": "ScimListResponse", + "type": "object" +} diff --git a/dist/schemas/ScimPatchOp.json b/dist/schemas/ScimPatchOp.json new file mode 100644 index 0000000..75449d5 --- /dev/null +++ b/dist/schemas/ScimPatchOp.json @@ -0,0 +1,25 @@ +{ + "description": "SCIM 2.0 PatchOp request body (RFC 7644 \u00a73.5.2, Step 6.8).\n\n``operations`` is the ordered list of ``{op, path?, value}`` mutations an IdP\nsends \u2014 most importantly the deactivation patch (``op: replace`` of\n``active`` to ``false``) that revokes a user without deleting the record.", + "properties": { + "schemas": { + "default": [ + "urn:ietf:params:scim:api:messages:2.0:PatchOp" + ], + "items": { + "type": "string" + }, + "title": "Schemas", + "type": "array" + }, + "Operations": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Operations", + "type": "array" + } + }, + "title": "ScimPatchOp", + "type": "object" +} diff --git a/dist/schemas/ScimUser.json b/dist/schemas/ScimUser.json new file mode 100644 index 0000000..5095037 --- /dev/null +++ b/dist/schemas/ScimUser.json @@ -0,0 +1,243 @@ +{ + "$defs": { + "ScimEmail": { + "description": "One entry of the SCIM 2.0 multi-valued ``emails`` attribute.", + "properties": { + "value": { + "title": "Value", + "type": "string" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Type" + }, + "primary": { + "default": false, + "title": "Primary", + "type": "boolean" + } + }, + "required": [ + "value" + ], + "title": "ScimEmail", + "type": "object" + }, + "ScimGroupRef": { + "description": "A user's group membership, as surfaced on ``User.groups`` (read-only).", + "properties": { + "value": { + "title": "Value", + "type": "string" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + } + }, + "required": [ + "value" + ], + "title": "ScimGroupRef", + "type": "object" + }, + "ScimName": { + "description": "SCIM 2.0 ``name`` complex attribute (RFC 7643 \u00a74.1.1).", + "properties": { + "formatted": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Formatted" + }, + "familyName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Familyname" + }, + "givenName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Givenname" + } + }, + "title": "ScimName", + "type": "object" + }, + "ScimResourceMeta": { + "description": "SCIM 2.0 ``meta`` complex attribute (RFC 7643 \u00a73.1).", + "properties": { + "resourceType": { + "title": "Resourcetype", + "type": "string" + }, + "created": { + "format": "date-time", + "title": "Created", + "type": "string" + }, + "lastModified": { + "format": "date-time", + "title": "Lastmodified", + "type": "string" + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Location" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Version" + } + }, + "required": [ + "resourceType" + ], + "title": "ScimResourceMeta", + "type": "object" + } + }, + "description": "A provisioned SCIM 2.0 User resource (RFC 7643 \u00a74.1, Step 6.8).\n\nPersisted through the tenant-scoped :class:`~rag_core.spi.scim_store.ScimStore`\n\u2014 tenant isolation comes from the store key (``ctx.tenant_id``), so the\nresource itself carries no tenant id and serialises as the exact SCIM wire\nshape (camelCase via field aliases). ``user_name`` is unique within a tenant;\n``active`` is the deprovisioning switch an IdP flips to revoke access.", + "properties": { + "schemas": { + "default": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "items": { + "type": "string" + }, + "title": "Schemas", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "externalId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Externalid" + }, + "userName": { + "title": "Username", + "type": "string" + }, + "name": { + "anyOf": [ + { + "$ref": "#/$defs/ScimName" + }, + { + "type": "null" + } + ], + "default": null + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Displayname" + }, + "emails": { + "items": { + "$ref": "#/$defs/ScimEmail" + }, + "title": "Emails", + "type": "array" + }, + "active": { + "default": true, + "title": "Active", + "type": "boolean" + }, + "groups": { + "items": { + "$ref": "#/$defs/ScimGroupRef" + }, + "title": "Groups", + "type": "array" + }, + "meta": { + "anyOf": [ + { + "$ref": "#/$defs/ScimResourceMeta" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "userName" + ], + "title": "ScimUser", + "type": "object" +} diff --git a/dist/schemas/SsoStatusResponse.json b/dist/schemas/SsoStatusResponse.json new file mode 100644 index 0000000..7cdbb80 --- /dev/null +++ b/dist/schemas/SsoStatusResponse.json @@ -0,0 +1,56 @@ +{ + "$defs": { + "SsoTenantStatus": { + "description": "The calling tenant's SSO posture, embedded in :class:`SsoStatusResponse`.", + "properties": { + "tenant_id": { + "title": "Tenant Id", + "type": "string" + }, + "configured": { + "default": false, + "title": "Configured", + "type": "boolean" + }, + "protocol": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Protocol" + } + }, + "required": [ + "tenant_id" + ], + "title": "SsoTenantStatus", + "type": "object" + } + }, + "description": "``GET /v1/status/sso`` response \u2014 federation + provisioning posture (Step 6.8).\n\nReports the global ``sso`` / ``scim`` enablement plus the **calling tenant's**\nown federation status (whether an IdP is configured for it, and which\nprotocol). Tenant-scoped by design \u2014 it never lists other tenants' config.", + "properties": { + "sso_enabled": { + "default": false, + "title": "Sso Enabled", + "type": "boolean" + }, + "scim_enabled": { + "default": false, + "title": "Scim Enabled", + "type": "boolean" + }, + "tenant": { + "$ref": "#/$defs/SsoTenantStatus" + } + }, + "required": [ + "tenant" + ], + "title": "SsoStatusResponse", + "type": "object" +} diff --git a/docs/README.md b/docs/README.md index 25bcc28..36e469e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ | [policy-engine.md](architecture/policy-engine.md) | `PolicyEngine` (PDP) — single decision point for ACL, PII, quotas, redaction; replaces scattered governance checks | | [audit-log.md](architecture/audit-log.md) | Immutable audit log (Step 6.6): tamper-evidence (SHA-256 hash chain) vs immutability-at-rest (WORM export, 6.6b); one shared `AuditWriter`/store on `app.state`; read-path tenant scoping (tenant-scoped list vs whole-log verify); why the read API defaults on | | [byok.md](architecture/byok.md) | BYOK / envelope encryption (Step 6.7): what's encrypted (chunk content at rest) vs not (embedding vectors — search needs plaintext); DEK+KEK envelope (client-side AES-GCM DEK, provider wraps the DEK); per-tenant isolation via KEK + `tenant_id` AAD; sealing as a typed error; rag-core/rag-backends split; slicing (6.7a library, 6.7b config + factory + AWS KMS, 6.7c GCP/Azure/Vault, 6.7d rotation) | +| [sso-scim.md](architecture/sso-scim.md) | SSO / SCIM (Step 6.8): OIDC + SAML federation + SCIM 2.0 provisioning + per-tenant IdP config. `FederatedAuth` *is* an `Auth` SPI backend (the `authenticate(token, tenant_id) → Principal` seam — no middleware change); group claims → `acl_labels` so Step 6.3/6.5 govern federated users; dependency-free defaults (stdlib HS256 JWT, `defusedxml` SAML) with asymmetric OIDC / XML-DSig behind `[oidc]` / `[saml]` extras; algorithm-allowlist (`alg:none`/downgrade defense); per-tenant IdP on `tenants[].sso`; SCIM is a separate surface with its own per-tenant bearer token + tenant-scoped `ScimStore`; PII-free `sso.*`/`scim.*` events (hashed subject); deferred (JWKS rotation, SP-initiated SAML, directory-backed deprovisioning) | | [caching.md](architecture/caching.md) | Three-cache split: `EmbeddingCache`, `RetrievalCache`, `AnswerCache` — distinct invalidation rules | | [performance.md](architecture/performance.md) | Hot-path discipline, per-SPI p99 budgets, async telemetry, reviewer checklist | | [pipeline-batcher.md](architecture/pipeline-batcher.md) | `Pipeline` (async DAG, bounded queues, per-stage workers) + `Batcher` (DataLoader-pattern coalescing) primitives — Step 1.1d | @@ -108,6 +109,7 @@ | [tenancy.md](reference/tenancy.md) | Logical multi-tenancy (Step 6.1) — per-tenant `rag.yaml` config (`namespace` / `acl_labels` / `pii_policy` / `quota`); `TenantResolver.resolve(id) → TenantSettings`; `RequestContext.namespace`; `GET /v1/status/tenant`; `ragctl tenant list` / `resolve`; config table + scope/boundaries (6.2/6.3/6.5) + extension points; physical tenancy (6.2), ACL push-down (6.3) + egress verifier (6.4 — `cfg.acl.verify_egress`) sections | | [audit.md](reference/audit.md) | Audit log (Step 6.6) — `AuditEvent` / `AuditStore` (append / events / verify_chain) / `NoopAuditStore` SHA-256 hash chain / `AuditWriter` (+ `.store`); read API `GET /v1/audit` (tenant-scoped, `chain_verified`) + `GET /v1/audit/verify` (whole-log); WORM signed export (6.6b) — `AuditExporter` (content_hash + HMAC), `POST /v1/audit/export`, offline `verify()`, `ragctl audit`; `cfg.audit.enabled` / `export_secret`; durable-store extension points | | [encryption.md](reference/encryption.md) | BYOK envelope encryption (Step 6.7) — `KeyManager` SPI + `NoopKeyManager`; `EnvelopeKeyManager` (AES-256-GCM DEK + `tenant_id` AAD) + `LocalKeyManager` + cloud providers **`AwsKmsKeyManager`** / **`GcpKmsKeyManager`** / **`AzureKeyVaultKeyManager`** / **`VaultKeyManager`** (behind `[kms-*]` extras); `EncryptingStorage` decorator; `EncryptionError` / `KeyUnavailableError` (sealing); provider table; `cfg.kms` + `tenants[].kms_key_id` + `build_key_manager_from_config` factory; **`RotatingKeyManager`** + `RetiredKey` + `rewrap` (6.7d zero-downtime rotation); `ragctl kms [--rotate]` | +| [sso.md](reference/sso.md) | SSO / SCIM (Step 6.8, `rag_sso`) — `FederatedAuth` (`Auth` backend) + `OidcProvider`/`OidcSettings` + `SamlProvider`/`SamlSettings`/`signxml_verifier` + `identity_to_principal`; `ScimService`/`parse_eq_filter`; low-level `verify_jwt`/`encode_jwt_hs256` (stdlib HS256, `[oidc]` RS256); `ScimStore` SPI + `NoopScimStore`; `FederatedIdentity`/`SsoProtocol`/`ScimUser`/`ScimGroup` core types + `ScimListResponse`/`ScimPatchOp`/`ScimErrorBody`/`SsoStatusResponse` wire types; `SsoError`/`ScimError`/`ScimNotFoundError`/`ScimConflictError`; `/scim/v2/*` + `GET /v1/status/sso`; `cfg.sso`/`cfg.scim`/`tenants[].sso`; `sso.*`/`scim.*` events; `ragctl sso`/`ragctl scim`; extension points | | [webhooks.md](reference/webhooks.md) | Outbound webhooks (Step 3.9) — event catalogue (`ingest.completed` / `audit.policy_violation` / `drift.detected` / `eval.regression`), event envelope, HMAC signing + `verify()`, at-least-once delivery, `/v1/webhooks/subscriptions` CRUD + test, `rag.yaml` block, `ragctl webhooks demo`, internals + extension points | | [integrations.md](reference/integrations.md) | Framework adapters (Step 3.8) — `agentcontextos.integrations.*` for LangChain / LlamaIndex / Haystack / DSPy / LangGraph / CrewAI / AutoGen / Semantic Kernel; per-framework extras, shared config + chunk metadata, usage per framework, internals + extension points | | [status-api.md](reference/status-api.md) | Status & Metrics API (Step 3.11) — `/v1/status/health` / `metrics` / `logs` (+ SSE `logs/stream`), `WS /v1/status/ws`, `/v1/connectors/status`; metric catalogue + request-timing middleware, the `MetricsCollector` / `LogTail` read-side, CORS + query-param identity for browser streams, extension points | @@ -179,6 +181,7 @@ broken, and what to fix before committing to the next phase. | [ADR-0034-physical-multi-tenancy.md](adr/ADR-0034-physical-multi-tenancy.md) | Decision (Step 6.2): a *dedicated* vector index/collection per tenant. `TenantConfig.dedicated_index` resolves to a `physical_index` key on `TenantSettings`, threaded onto `RequestContext.physical_index`; backends read only `ctx` (graph is backends→core, never rag-config) and namespace their base under it (`-`), lazily creating it; one instance + per-tenant derivation (no per-tenant instances, no SPI change); Noop is the CI conformance oracle (keyed by `physical_index`) for a cross-tenant probe gate that proves isolation independent of the tenant filter; Noop + Pinecone + Qdrant this step, others later | | [ADR-0035-acl-pushdown.md](adr/ADR-0035-acl-pushdown.md) | Decision (Step 6.3): label-based ACL push-down at retrieval. `AclPolicyEngine` (a decorator like `QuotaPolicyEngine`) And-merges `any_in("acl_labels", principal.acl_labels)` into every `read_chunk` push-down at the canonical `HybridRetriever` PDP site — overlap semantics via the existing `AnyIn` predicate (zero backend/translator changes), **fail-closed** (label-less principal matches nothing; "public" = a shared label), **opt-in** via `cfg.acl.enabled`; emits `acl.egress_denied` on a request-level denial; graph edge ACLs + post-retrieval re-verification (6.4) deferred | | [ADR-0036-acl-egress-verifier.md](adr/ADR-0036-acl-egress-verifier.md) | Decision (Step 6.4): a post-retrieval ACL re-check as a **defense-in-depth second layer** behind the 6.3 push-down. `AclEgressVerifier.verify(ctx, refs)` drops any returned `ChunkRef` whose labels don't overlap the principal's — same overlap semantics (no-op on correct results), reading `ChunkRef.acl_labels` (no re-hydration), **independent of the PDP** (consults only `ctx.principal.acl_labels`) so a push-down bug/bypass can't disable both; wired at the gateway as a `SupportsRoute` wrapper around `app.state.retrieval_router` (covers query/retrieve/corpus/OpenAI/agent); `cfg.acl.verify_egress` default on but gated by `enabled`; emits `acl.egress_violation` on a caught leak; a red-team gate proves a zero escaped-violation rate when the push-down is bypassed; backend-mislabel re-hydration + per-tenant violation metrics deferred | +| [ADR-0040-sso-scim.md](adr/ADR-0040-sso-scim.md) | Decision (Step 6.8): enterprise identity in two surfaces. **Federation** — `FederatedAuth` *is* an `Auth` SPI backend (the `authenticate(token, tenant_id) → Principal` seam already runs at the boundary, so wiring it is the whole integration — no middleware change); group claims → `acl_labels` so Step 6.3 push-down + 6.5 PII egress govern federated users unchanged (`authorize` stays a coarse allow — federation establishes *who*, the PDP decides *what*). Dependency-free defaults (stdlib HS256 JWT with full `exp`/`nbf`/`iss`/`aud` + constant-time compare; `defusedxml` SAML validating Issuer/Conditions/Audience) with asymmetric OIDC (PyJWT, `[oidc]`) + SAML XML-DSig (signxml, `[saml]`, injected verifier → fail-closed) behind extras; **algorithm-allowlist** designs out `alg:none`/RS↔HS confusion. Per-tenant IdP on `tenants[].sso` (reuses Step 6.1 config; no provider → bearer rejected, header-identity still works). **Provisioning** — SCIM 2.0 is a separate surface with its own per-tenant bearer token (`cfg.scim.tokens`, not a user JWT), a tenant-scoped `ScimStore` SPI (`NoopScimStore`) + `ScimService`, SCIM-shaped errors, disabled→404; no new governed SPI call (linter passes). PII-free `sso.*`/`scim.*` events (hashed subject, never email/userName). Deferred: JWKS rotation, SP-initiated SAML + metadata, SCIM bulk/`/Me`/ETag, directory-backed deprovisioning, admin-console card; rejected Authlib/python3-saml (heavy lxml/xmlsec on the default install), a dedicated SSO middleware, SCIM token on `TenantConfig` | | [ADR-0037-pii-egress-policies.md](adr/ADR-0037-pii-egress-policies.md) | Decision (Step 6.5): per-tenant PII enforcement at egress via a `PiiPolicyEngine` `egress_text` decorator (mirrors `QuotaPolicyEngine` / `AclPolicyEngine`), living in `rag-pii` (gains a `rag-policy` dep, like `rag-quota`). Handles both subject shapes the gateway already passes — `list[Chunk]` context + `str` answer — so it plugs into the existing `egress_text` call sites with no route change; maps `ctx.pii_policy.action` allow→delegate / redact·mask→`transform` / block→`deny`, reusing the Step 1.7 detector + rewriters and the same `min_score`+`entities` filter (no-op on clean text); opt-in `cfg.pii.enabled` (injects `RegexPIIDetector`); PII-free `pii.egress_blocked` (block) / `pii.detected` (redact·mask); post-gen answer re-check for query/OpenAI/gRPC + citation egress deferred (stored chunks are ingest-sanitised) | | [ADR-0038-immutable-audit-log.md](adr/ADR-0038-immutable-audit-log.md) | Decision (Step 6.6): make the 0.7c hash-chain audit log usable + provably intact, in two slices. 6.6a — the SHA-256 chain is the tamper-*evidence* mechanism (no second scheme); one shared `AuditWriter`/store on `app.state` (corpus router + read API write/read the same chain); `GET /v1/audit` tenant-scoped (a tenant sees only its own events, newest-first, `chain_verified` inline) + `GET /v1/audit/verify` whole-log `{ok,event_count}` (content-free, so global verification leaks nothing cross-tenant); read API on by default (`cfg.audit.enabled=true` — passive compliance record, unlike behaviour-changing ACL/PII). 6.6b — WORM signed export: `AuditExporter` builds a self-verifying `AuditExport` (SHA-256 `content_hash` over the events + HMAC signature, mirroring the ProvenanceSigner scheme), `POST /v1/audit/export` (tenant-scoped) + `ragctl audit` (whole-log), verifiable offline (`{content_ok, verified, reason}`); the artifact for immutable storage (S3 Object Lock) → immutability at rest; unsigned when no `export_secret`. Audit-coverage expansion + a durable live-store backend deferred | | [ADR-0031-cost-anomaly.md](adr/ADR-0031-cost-anomaly.md) | Decision (Step 5.6c): detect per-tenant spend spikes with a rolling `CostTracker` (not the cumulative quota counter); detect scale-free on the token series (cost = tokens × a constant price) so detection is decoupled from quota pricing and works with quotas off; two gates (ratio + z-score, z relaxed on a flat baseline) → tri-state verdict; put it in `rag-observability` as a `dataclass` (gateway wraps it in a Pydantic `CostStatusResponse`) so there's **no `rag-core` type / `dist/schemas` churn**; feed O(1) from `record_request_usage` before the quota block; pull-based `GET /v1/status/cost` (no per-request span/event); rejected folding into the infra-scoped drift registry, a new package, a `cost.anomaly_detected` push event (deferred), per-model pricing, a time-series DB | diff --git a/docs/adr/ADR-0040-sso-scim.md b/docs/adr/ADR-0040-sso-scim.md new file mode 100644 index 0000000..e45bf50 --- /dev/null +++ b/docs/adr/ADR-0040-sso-scim.md @@ -0,0 +1,99 @@ +# ADR-0040 — SSO (OIDC / SAML) federation + SCIM 2.0 provisioning + +**Status:** Accepted +**Date:** 2026-06-08 +**Step:** 6.8 — SSO / SCIM (Phase 6 — Governance & Tenancy) +**Related:** [ADR-0005](ADR-0005-policy-engine.md) (PolicyEngine PDP), [architecture/multi-tenancy.md](../architecture/multi-tenancy.md) (Step 6.1 per-tenant config), [architecture/sso-scim.md](../architecture/sso-scim.md), [reference/sso.md](../reference/sso.md) + +## Context + +The V1 plan (Step 6.8) calls for enterprise identity: **OIDC + SAML IdP +federation**, **SCIM 2.0 user provisioning**, and **per-tenant IdP config**. The +seam already exists — the gateway's `Auth` SPI (`authenticate(token, tenant_id) -> +Principal`, the one SPI method that runs *before* a `RequestContext` exists) with a +dev-only `NoopAuth` that trusts any token. This is new ground: the only prior +identity handling was the header-identity dev path. + +## Decision + +**1. `FederatedAuth` *is* an `Auth` backend — no middleware change.** The gateway +middleware already calls `auth.authenticate(bearer_token, tenant_id)` at the +boundary. `FederatedAuth` (rag-sso) implements that SPI, dispatching to a +per-tenant `OidcProvider` / `SamlProvider`, so wiring it as the gateway's `auth` +backend is the entire integration. A verified token becomes a `Principal` whose +`acl_labels` come from the IdP's group claims — so Step 6.3 ACL push-down and Step +6.5 PII egress govern federated users unchanged. **Federation establishes *who*; +the PolicyEngine still decides *what*** (`authorize` stays a coarse allow, mirroring +`NoopAuth`). + +**2. Dependency-free defaults; heavy crypto behind extras.** Mirrors the BYOK +(6.7) and NLI (4.3) pattern. OIDC verification is a real, stdlib-only **HS256** JWT +verifier (`hmac` / `hashlib` / `base64`) — full claims validation (`exp` / `nbf` / +`iss` / `aud` with leeway) and a constant-time signature compare. Asymmetric +RS256 / ES256 is delegated to PyJWT behind the `[oidc]` extra against a configured +public key. SAML parses through **`defusedxml`** (a core dep — safe XML parsing is +mandatory) and validates Issuer / Conditions / AudienceRestriction; XML-DSig +signature verification is **injected** (`signxml` behind the `[saml]` extra), +because C14N canonicalisation is heavy and security-critical. With +`require_signature` on and no verifier wired, SAML **fails closed**. + +**3. Algorithm-confusion is designed out.** The verifier takes an explicit +algorithm **allowlist**; a token whose header `alg` is not a member is rejected +(`alg: none`, or an RS256 token replayed as HS256 against the public key). +Symmetric and asymmetric paths take *different* key material (`hmac_secret` vs +`public_key`), so the two can never be confused. + +**4. Per-tenant IdP config lives on `tenants[].sso`.** Each tenant declares its own +`protocol` + OIDC/SAML settings (secrets `${ENV}`-interpolated). The wiring builds +one provider per tenant; a tenant without an `sso` block has no provider, so its +bearer tokens are rejected (fail-closed) while header-identity dev flows still work. +This reuses the Step 6.1 per-tenant config mechanism rather than inventing a new one. + +**5. SCIM is a separate surface with its own auth.** SCIM clients (the IdP) +authenticate with a long-lived **per-tenant bearer token** (`cfg.scim.tokens`), the +standard SCIM pattern — *not* a user JWT, and independent of `FederatedAuth`. The +`/scim/v2` router authenticates the token (constant-time compare), builds a +tenant-scoped `RequestContext`, and delegates to a `ScimService` over the new +tenant-scoped `ScimStore` SPI (`NoopScimStore` in-memory default). Errors return the +SCIM error shape (RFC 7644), not the platform `GatewayError`. Disabled → 404 (the +surface is hidden before auth is checked). + +**6. SCIM and SSO are decoupled in this slice.** Group claims in the *token* +drive authz; the SCIM *directory* is the provisioning system of record. +Directory-backed deprovisioning checks at authenticate-time (reject a token whose +SCIM user is `active=false`) are deferred — short token TTLs + SCIM +`active=false` already cover revocation, and the `Auth.authenticate` seam has no +`ctx` to scope a directory read cleanly. + +**7. No new governed SPI call.** `ScimStore` methods (`put_user` / `list_users` / +…) are not in the PolicyEngine coverage linter's governed set (`read_chunk` / +`bulk_index` / `complete` / …), so the linter passes with no new allowlist entry — +SCIM provisioning is an admin surface gated by its own bearer token, upstream of +the retrieval PDP. + +## Consequences + +- A real OIDC token authenticates through the existing gateway with zero route + changes; `GET /v1/status/sso` reports per-tenant posture (tenant-scoped — never + lists other tenants' config). +- New `rag-sso` package (deps: rag-core + rag-observability + defusedxml; `[oidc]` + / `[saml]` extras); new `ScimStore` SPI + `NoopScimStore` + `ScimUser` / `ScimGroup` + / `FederatedIdentity` core types; `cfg.sso` / `cfg.scim` / `tenants[].sso`; + `sso.*` / `scim.*` PII-free events (subject **hashed**, never the raw subject / + email / userName); `ragctl sso` + `ragctl scim`. +- **Deferred:** remote JWKS discovery + key rotation (configured static keys only), + SAML SP-initiated redirect flow + metadata endpoint, SCIM bulk + `/Me` + + ETag/versioning, directory-backed deprovisioning at authenticate-time, an + admin-console SSO/SCIM card. + +## Alternatives considered + +- **A full OIDC client library (Authlib) + python3-saml.** Heavy transitive deps + (lxml, xmlsec) on the default install for what is, at the verification boundary, + claims + signature checking. Rejected in favour of the stdlib HS256 default + + optional extras, consistent with the rest of the platform. +- **A dedicated SSO middleware.** Unnecessary — the `Auth` SPI already runs at the + boundary and returns a `Principal`; a second middleware would duplicate it. +- **Per-tenant SCIM token on `TenantConfig`.** Kept SCIM tokens in `cfg.scim.tokens` + so all SCIM config sits in one block and `TenantConfig` stays focused on data + governance; revisit if per-tenant SCIM settings grow. diff --git a/docs/architecture/sso-scim.md b/docs/architecture/sso-scim.md new file mode 100644 index 0000000..0e142bf --- /dev/null +++ b/docs/architecture/sso-scim.md @@ -0,0 +1,137 @@ +# SSO / SCIM — identity federation + directory provisioning (Step 6.8) + +## Overview + +Step 6.8 adds enterprise identity to AgentContextOS in two cooperating surfaces: + +- **Federation** — verify an IdP-issued **OIDC** ID token or **SAML 2.0** + assertion and turn it into a trusted `Principal`. +- **Provisioning** — let an IdP push users / groups into a tenant-scoped + directory over **SCIM 2.0** (RFC 7643 / 7644). + +Both are **per-tenant**: each tenant configures its own IdP. The whole feature +lives in the new `rag-sso` package, with the SPI + types in `rag-core`, config in +`rag-config`, and the HTTP surfaces in the gateway. + +``` + ┌──────────────────────── gateway ────────────────────────┐ + user → Bearer ─→│ request-context middleware │ + │ auth.authenticate(token, tenant_id) ── FederatedAuth ─┼─→ OidcProvider / SamlProvider + │ └→ Principal (groups → acl_labels) → RequestContext│ (per tenant) + │ │ + IdP → Bearer ─→│ /scim/v2/* router → ScimService → ScimStore (per tenant)│ + └──────────────────────────────────────────────────────────┘ +``` + +## Usage + +Enable federation + provisioning in `rag.yaml`: + +```yaml +sso: + enabled: true + group_label_map: { engineering: corpus-eng } # IdP group → ACL label (optional) +scim: + enabled: true + tokens: { acme: "${ACME_SCIM_TOKEN}" } # per-tenant SCIM bearer token + +tenants: + - id: acme + name: Acme + sso: + protocol: oidc + oidc: + issuer: https://acme.okta.com + audience: agentcontextos + algorithms: ["RS256"] # default ["HS256"] needs only hmac_secret + public_key: "${ACME_OIDC_PUBLIC_KEY}" + group_claim: groups +``` + +- A user calls the gateway with `Authorization: Bearer ` + + `X-Tenant-Id: acme`. `FederatedAuth` verifies it against Acme's IdP and the + request runs as the resolved principal. +- The IdP provisions users at `POST /scim/v2/Users` with + `Authorization: Bearer ` + `X-Tenant-Id: acme`. +- `GET /v1/status/sso` reports the calling tenant's posture. +- `ragctl sso demo` / `ragctl scim` run both flows in-process, creds-free. + +## Internals + +### The `Auth` seam + +`Auth.authenticate(token, tenant_id) -> Principal` is the one SPI method that runs +*before* a `RequestContext` exists — the gateway middleware already calls it. +`FederatedAuth` implements it, so federation is wired by passing it as the gateway's +`auth` backend (`build_app_from_config` does this from `cfg.sso`). No middleware +change. The returned `Principal` carries `acl_labels` derived from the IdP's group +claims, so **Step 6.3 ACL push-down and Step 6.5 PII egress apply to federated +users with no extra work** — federation only changes how the principal is +*established*. `authorize` stays a coarse allow; the PolicyEngine is the in-flight +PDP. + +### OIDC verification (`jwt.py` / `oidc.py`) + +`verify_jwt` is dependency-free for **HS256/384/512** (stdlib `hmac`): it splits the +compact JWS, checks the header `alg` against an **allowlist** (the +algorithm-confusion / `alg:none` defense), verifies the signature with +`hmac.compare_digest`, then validates `exp` / `nbf` / `iss` / `aud` with a clock-skew +`leeway`. Asymmetric **RS256 / ES256 / PS256** is delegated to PyJWT (`[oidc]` +extra) against a configured public key. `OidcProvider` maps verified claims → +`FederatedIdentity` (subject / email / display name / groups; the group claim name +is configurable). + +### SAML verification (`saml.py`) + +The credential is a base64 SAML `Response`. Parsing goes through **`defusedxml`** +(XXE / billion-laughs safe). `SamlProvider` validates the assertion's `Issuer`, +`Conditions` window (NotBefore / NotOnOrAfter, with leeway), and +`AudienceRestriction`, and extracts NameID + attributes → `FederatedIdentity`. +**XML-DSig verification is injected** (`signxml_verifier`, `[saml]` extra) because +C14N canonicalisation is heavy; with `require_signature` on and no verifier, it +**fails closed**. + +### Identity → Principal (`identity.py`) + +`identity_to_principal` maps `subject → PrincipalId`, the IdP `groups → roles` +(verbatim) **and `→ acl_labels`** (via the optional `group_label_map`, else 1:1). +This is the bridge that lets IdP group membership drive label-based ACLs. + +### SCIM provisioning (`scim.py` + `ScimStore`) + +`ScimService` enforces `userName` / `displayName` uniqueness, assigns server ids + +`meta`, applies the common PATCH operations (notably the IdP deactivation patch +`active = false`), parses the `attr eq "value"` list filter, and emits PII-free +`scim.*` events — all over the tenant-scoped `ScimStore` SPI (`NoopScimStore` +in-memory default; tenant isolation is the store key). The `/scim/v2` router +authenticates a **per-tenant SCIM bearer token** (constant-time compare, +independent of the JWT `Auth` backend), builds a tenant-scoped `RequestContext`, +and returns SCIM-shaped errors. Disabled → 404 (checked before auth, so the surface +is hidden). + +### Privacy + +`sso.*` / `scim.*` events are PII-free by construction: the SSO event carries a +**SHA-256 hash** of the subject (never the raw subject / email), and the SCIM event +carries only the server-assigned resource id (a UUID) — never `userName` / email / +`displayName`. Verified by the log PII gate. + +## Extension points + +- **A real Auth backend** — implement `Auth` (or wrap `FederatedAuth`) for API-key + stores, OPA/Casbin, or remote JWKS discovery; pass it as `build_app(auth=…)`. +- **A durable directory** — implement `ScimStore` (Postgres / LDAP-mirror) and + inject it as `build_app(scim_store=…)`; the contract suite in + `tests/contract/test_scim_store.py` is the conformance oracle. +- **A SAML signature verifier** — pass any `Callable[[bytes], bool]` as + `SamlProvider(signature_verifier=…)`; `signxml_verifier(cert_pem)` is the + `[saml]`-extra default. +- **Custom claim mapping** — `group_label_map` (config) renames groups to labels; + for richer mapping, build the `Principal` in a custom provider. + +## Boundaries / deferred + +Remote JWKS discovery + rotation (configured static keys only), SAML SP-initiated +redirect + metadata endpoint, SCIM bulk / `/Me` / ETag, directory-backed +deprovisioning at authenticate-time, and the admin-console SSO/SCIM card are +deferred. See [ADR-0040](../adr/ADR-0040-sso-scim.md). diff --git a/docs/reference/sso.md b/docs/reference/sso.md new file mode 100644 index 0000000..197debc --- /dev/null +++ b/docs/reference/sso.md @@ -0,0 +1,141 @@ +# SSO / SCIM reference (`rag-sso`, Step 6.8) + +Public API for OIDC / SAML identity federation and SCIM 2.0 directory +provisioning. Import root: `rag_sso`. + +## Overview + +| Concern | Entry point | +|---------|-------------| +| Federate a request (Auth SPI) | `FederatedAuth` | +| Verify an OIDC ID token | `OidcProvider` / `OidcSettings` | +| Verify a SAML assertion | `SamlProvider` / `SamlSettings` / `signxml_verifier` | +| Map identity → `Principal` | `identity_to_principal` / `IdentityProvider` | +| SCIM 2.0 provisioning | `ScimService` / `parse_eq_filter` | +| Low-level JWT | `verify_jwt` / `encode_jwt_hs256` / `decode_jwt_unverified` | + +Core types (`rag_core.types`): `FederatedIdentity`, `SsoProtocol`, `ScimUser`, +`ScimGroup`, `ScimName`, `ScimEmail`, `ScimMember`, `ScimResourceMeta`. SPI: +`rag_core.spi.ScimStore` + `rag_core.spi.noop.NoopScimStore`. Errors: +`SsoError` (401), `ScimError` (400), `ScimNotFoundError` (404), `ScimConflictError` +(409). Wire types (`rag_core.gateway_types`): `ScimListResponse`, `ScimPatchOp`, +`ScimErrorBody`, `SsoStatusResponse`. + +## Usage + +### Federate a request + +```python +from rag_sso import FederatedAuth, OidcProvider, OidcSettings + +auth = FederatedAuth( + {"acme": OidcProvider(OidcSettings( + issuer="https://acme.okta.com", audience="agentcontextos", + algorithms=("RS256",), public_key=PEM, group_claim="groups", + ))}, + group_label_map={"engineering": "corpus-eng"}, +) +principal = await auth.authenticate(id_token, TenantId("acme")) +# principal.acl_labels == {"corpus-eng", ...} — drives Step 6.3 push-down +``` + +`FederatedAuth` is an `Auth` SPI backend — pass it as `build_app(auth=…)`, or let +`build_app_from_config` build it from `cfg.sso`. A tenant with no provider rejects +bearer tokens (fail-closed); `default_provider=` sets a fallback. + +The dependency-free default verifies **HS256** (`hmac_secret`); RS256 / ES256 needs +the `[oidc]` extra (PyJWT) + a `public_key`. `verify_jwt` validates `exp` / `nbf` / +`iss` / `aud` and rejects any `alg` outside the allowlist. + +### Verify a SAML assertion + +```python +from rag_sso import SamlProvider, SamlSettings, signxml_verifier + +provider = SamlProvider( + SamlSettings(idp_entity_id="https://idp", audience="sp-entity"), + signature_verifier=signxml_verifier(idp_cert_pem), # [saml] extra +) +identity = provider.verify(base64_saml_response) +``` + +With `require_signature=True` (default) and no verifier, SAML fails closed. +`defusedxml` (a core dep) makes parsing XXE/billion-laughs safe. + +### SCIM provisioning + +```python +from rag_sso import ScimService +from rag_core.spi.noop import NoopScimStore +from rag_core.types import ScimUser + +svc = ScimService(NoopScimStore()) +user = await svc.create_user(ctx, ScimUser(user_name="alice@acme.test")) +await svc.patch_user(ctx, user.id, [{"op": "replace", "value": {"active": False}}]) # deprovision +page, total = await svc.list_users(ctx, scim_filter='userName eq "alice@acme.test"') +``` + +All methods are `ctx`-first and tenant-scoped. Uniqueness violations raise +`ScimConflictError`; an unknown resource raises `ScimNotFoundError`; an unsupported +filter raises `ScimError`. + +## HTTP surfaces + +| Method / path | Purpose | +|---------------|---------| +| `GET/POST /scim/v2/Users`, `GET/PUT/PATCH/DELETE /scim/v2/Users/{id}` | SCIM 2.0 User CRUD | +| `GET/POST /scim/v2/Groups`, `GET/PUT/PATCH/DELETE /scim/v2/Groups/{id}` | SCIM 2.0 Group CRUD | +| `GET /scim/v2/ServiceProviderConfig`, `/ResourceTypes` | SCIM discovery | +| `GET /v1/status/sso` | Calling tenant's SSO / SCIM posture | + +SCIM auth is a **per-tenant bearer token** (`Authorization: Bearer ` + +`X-Tenant-Id`) configured in `cfg.scim.tokens` — independent of the JWT `Auth` +backend. Disabled SCIM → 404. + +## Configuration + +```yaml +sso: + enabled: false # off by default (changes how principals are established) + group_label_map: {} # IdP group → ACL label (1:1 when absent) +scim: + enabled: false # off by default (a write surface) + base_path: /scim/v2 + tokens: {} # {tenant_id: bearer-token}; ${ENV} supported +tenants: + - id: acme + sso: # per-tenant IdP + protocol: oidc | saml + oidc: { issuer, audience, algorithms, hmac_secret, public_key, group_claim, ... } + saml: { idp_entity_id, audience, certificate, group_attribute, require_signature, ... } +``` + +`hmac_secret` / `public_key` / `certificate` / SCIM `tokens` all support +`${ENV_VAR}` interpolation so secrets stay out of the file. + +## `ragctl` + +```bash +ragctl sso list -f rag.yaml # inspect per-tenant IdP config +ragctl sso demo --tenant acme # in-process OIDC federation demo +ragctl scim --tenant acme # in-process SCIM provisioning demo +``` + +## Events + +| Event | When | +|-------|------| +| `sso.authenticated` / `sso.auth_failed` | A federation attempt succeeds / fails | +| `scim.user_provisioned` / `scim.user_deprovisioned` | A user is created/replaced / deactivated or deleted | +| `scim.group_changed` | A group is created / replaced / deleted | + +All are PII-free: the SSO event carries a **hash** of the subject; the SCIM event +carries only the server-assigned resource id. + +## Extension points + +- Implement `Auth` for non-IdP backends; implement `ScimStore` for a durable + directory (conformance suite: `tests/contract/test_scim_store.py`). +- Pass any `Callable[[bytes], bool]` as a SAML `signature_verifier`. +- See [architecture/sso-scim.md](../architecture/sso-scim.md) and + [ADR-0040](../adr/ADR-0040-sso-scim.md). diff --git a/packages/config/src/rag_config/schema.py b/packages/config/src/rag_config/schema.py index ee9a780..2847f4b 100644 --- a/packages/config/src/rag_config/schema.py +++ b/packages/config/src/rag_config/schema.py @@ -345,6 +345,11 @@ class TenantConfig(_StrictBase): # (encrypt/decrypt raise ``KeyUnavailableError``) unless ``kms.default_key_id`` # is set. Supports ``${ENV_VAR}`` interpolation. kms_key_id: str | None = None + # SSO (Step 6.8): this tenant's identity-federation config (OIDC / SAML). When + # ``sso.enabled`` is set the gateway builds a per-tenant IdP provider from this + # block, so a bearer token / SAML assertion for the tenant is verified against + # its own IdP. ``None`` → the tenant has no federated IdP. + sso: TenantSsoConfig | None = None # --------------------------------------------------------------------------- @@ -694,6 +699,125 @@ class QuotaConfig(_StrictBase): default: TenantQuota = Field(default_factory=TenantQuota) +# --------------------------------------------------------------------------- +# SSO / SCIM section (Step 6.8) +# --------------------------------------------------------------------------- + + +class IdpProtocol(StrEnum): + """Identity-federation protocol for a tenant's IdP (Step 6.8).""" + + OIDC = "oidc" + SAML = "saml" + + +class OidcSettingsConfig(_StrictBase): + """Per-tenant OpenID Connect verification settings (Step 6.8). + + The dependency-free default verifies an **HS256** token against ``hmac_secret``. + For production asymmetric tokens set ``algorithms: ["RS256"]`` and supply the + IdP's ``public_key`` (PEM) — that path needs the rag-sso ``[oidc]`` extra. + ``issuer`` + ``audience`` are validated when set (strongly recommended). + ``hmac_secret`` / ``public_key`` support ``${ENV_VAR}`` interpolation so the + signing material stays out of the file. + """ + + issuer: str = "" + audience: str = "" + algorithms: list[str] = Field(default_factory=lambda: ["HS256"]) + hmac_secret: str = "" + public_key: str = "" + subject_claim: str = "sub" + email_claim: str = "email" + name_claim: str = "name" + group_claim: str = "groups" + leeway_seconds: Annotated[int, Field(ge=0)] = 60 + require_expiry: bool = True + + +class SamlSettingsConfig(_StrictBase): + """Per-tenant SAML 2.0 verification settings (Step 6.8). + + ``idp_entity_id`` is the expected assertion ``Issuer``; ``audience`` is this + service's SP entity id (checked against ``AudienceRestriction``). The + attribute names locate the IdP's email / group / display-name claims. + ``require_signature`` (default on) demands a verified XML-DSig — supply the + IdP signing ``certificate`` (PEM, ``${ENV_VAR}``-interpolated); verification + needs the rag-sso ``[saml]`` extra. + """ + + idp_entity_id: str = "" + audience: str = "" + certificate: str = "" + email_attribute: str = "email" + group_attribute: str = "groups" + name_attribute: str = "displayName" + require_signature: bool = True + leeway_seconds: Annotated[int, Field(ge=0)] = 60 + + +class TenantSsoConfig(_StrictBase): + """A tenant's identity-federation config (Step 6.8). + + ``protocol`` selects ``oidc`` or ``saml``; the matching settings block must be + present. Attached to ``tenants[].sso`` and built into a ``rag_sso`` provider + at the gateway boundary, so a presented token / assertion for this tenant is + verified against *its own* IdP — the per-tenant IdP config the step delivers. + """ + + protocol: IdpProtocol = IdpProtocol.OIDC + oidc: OidcSettingsConfig | None = None + saml: SamlSettingsConfig | None = None + + @model_validator(mode="after") + def _matching_block_present(self) -> TenantSsoConfig: + if self.protocol is IdpProtocol.OIDC and self.oidc is None: + raise ValueError("sso.protocol is 'oidc' but no sso.oidc block was provided") + if self.protocol is IdpProtocol.SAML and self.saml is None: + raise ValueError("sso.protocol is 'saml' but no sso.saml block was provided") + return self + + +class SsoConfig(_StrictBase): + """SSO (OIDC / SAML) identity federation (Step 6.8). + + When ``enabled`` the gateway builds a ``FederatedAuth`` from each tenant's + ``tenants[].sso`` block and wires it as the ``Auth`` backend, so a presented + IdP token / assertion is verified into a :class:`~rag_core.types.Principal` + (group claims → ACL labels) at the existing ``authenticate`` boundary. + + **Disabled by default** — turning it on changes how principals are established + (a tenant with no ``sso`` block then rejects bearer tokens it cannot verify). + Dev / header-identity flows are unaffected when off. ``group_label_map`` + optionally renames IdP groups to ACL labels platform-wide (groups absent from + the map pass through 1:1). + """ + + enabled: bool = False + group_label_map: dict[str, str] = Field(default_factory=dict) + + +class ScimConfig(_StrictBase): + """SCIM 2.0 directory provisioning (Step 6.8). + + When ``enabled`` the gateway exposes the SCIM 2.0 surface (``/scim/v2/Users`` + + ``/Groups`` and the discovery endpoints) so an IdP can provision / deprovision + users into the tenant-scoped directory. **Disabled by default** — it is a + write surface; expose it only once tokens are set. + + SCIM clients authenticate with a long-lived bearer token (the standard SCIM + pattern) carried per tenant in ``tokens`` (``{tenant_id: token}``, + ``${ENV_VAR}``-interpolated). A request presents ``Authorization: Bearer + `` + ``X-Tenant-Id: ``; the token must match that tenant's + entry. This is independent of the JWT ``Auth`` backend — SCIM provisioning + credentials are not user tokens. + """ + + enabled: bool = False + base_path: str = "/scim/v2" + tokens: dict[str, str] = Field(default_factory=dict) + + # --------------------------------------------------------------------------- # Corpus registry section (Step 3.5) # --------------------------------------------------------------------------- @@ -973,6 +1097,8 @@ class RagConfig(_StrictBase): pii: PiiConfig = Field(default_factory=PiiConfig) audit: AuditConfig = Field(default_factory=AuditConfig) kms: KmsConfig = Field(default_factory=KmsConfig) + sso: SsoConfig = Field(default_factory=SsoConfig) + scim: ScimConfig = Field(default_factory=ScimConfig) webhooks: WebhooksConfig = Field(default_factory=WebhooksConfig) provenance: ProvenanceConfig = Field(default_factory=ProvenanceConfig) feedback: FeedbackConfig = Field(default_factory=FeedbackConfig) diff --git a/packages/core/src/rag_core/errors.py b/packages/core/src/rag_core/errors.py index 7cf5b50..b98b4ef 100644 --- a/packages/core/src/rag_core/errors.py +++ b/packages/core/src/rag_core/errors.py @@ -317,3 +317,55 @@ class KeyUnavailableError(EncryptionError): """ code = "key_unavailable" + + +# --------------------------------------------------------------------------- +# SSO / SCIM (Step 6.8) +# --------------------------------------------------------------------------- +class SsoError(AuthError): + """Identity-federation (OIDC / SAML) authentication failed (Step 6.8). + + Raised by :mod:`rag_sso` when a presented token / assertion cannot be turned + into a trusted :class:`~rag_core.types.Principal` — a bad signature, an + expired or not-yet-valid token, the wrong issuer / audience, a SAML condition + breach, or no IdP configured for the tenant. A subtype of + :class:`AuthError`, so it inherits the gateway's HTTP 401 mapping while + carrying a distinct ``code`` for audit. ``context`` never carries the raw + token or any PII. + """ + + code = "sso_error" + + +class ScimError(RagError): + """A SCIM 2.0 provisioning request could not be satisfied (Step 6.8). + + Base for the SCIM directory surface. Maps to HTTP 400 at the gateway unless a + more specific subtype applies; the SCIM error body carries this ``code`` (as + the RFC 7644 ``scimType`` hint) and ``message`` (``detail``). + """ + + code = "scim_error" + + +class ScimNotFoundError(ScimError): + """The requested SCIM resource does not exist for this tenant (Step 6.8). + + Raised by ``GET/PUT/PATCH/DELETE /scim/v2/{Users,Groups}/{id}`` when the id is + unknown to the asking tenant (never provisioned, already deleted, or owned by + a different tenant — cross-tenant reads are impossible by construction). Maps + to HTTP 404. + """ + + code = "scim_not_found" + + +class ScimConflictError(ScimError): + """A SCIM resource uniqueness constraint was violated (Step 6.8). + + Raised when creating a User whose ``userName`` (or a Group whose + ``displayName``) already exists for the tenant — RFC 7644 ``scimType`` + ``uniqueness``. Maps to HTTP 409. + """ + + code = "scim_conflict" diff --git a/packages/core/src/rag_core/events.py b/packages/core/src/rag_core/events.py index 505c772..af4c7f2 100644 --- a/packages/core/src/rag_core/events.py +++ b/packages/core/src/rag_core/events.py @@ -31,7 +31,16 @@ from rag_observability.events import EVT_RETRIEVAL_COMPLETED as EVT_RETRIEVAL_COMPLETED from rag_observability.events import EVT_RETRIEVAL_FALLBACK as EVT_RETRIEVAL_FALLBACK from rag_observability.events import EVT_RETRIEVAL_STARTED as EVT_RETRIEVAL_STARTED +from rag_observability.events import EVT_SCIM_GROUP_CHANGED as EVT_SCIM_GROUP_CHANGED +from rag_observability.events import ( + EVT_SCIM_USER_DEPROVISIONED as EVT_SCIM_USER_DEPROVISIONED, +) +from rag_observability.events import ( + EVT_SCIM_USER_PROVISIONED as EVT_SCIM_USER_PROVISIONED, +) from rag_observability.events import EVT_SPI_CALL as EVT_SPI_CALL +from rag_observability.events import EVT_SSO_AUTH_FAILED as EVT_SSO_AUTH_FAILED +from rag_observability.events import EVT_SSO_AUTHENTICATED as EVT_SSO_AUTHENTICATED from rag_observability.events import BreakerEvent as BreakerEvent from rag_observability.events import CacheEvent as CacheEvent from rag_observability.events import DriftEvent as DriftEvent @@ -43,7 +52,9 @@ from rag_observability.events import QuotaEvent as QuotaEvent from rag_observability.events import RagEvent as RagEvent from rag_observability.events import RetrievalEvent as RetrievalEvent +from rag_observability.events import ScimEvent as ScimEvent from rag_observability.events import SpiCallEvent as SpiCallEvent +from rag_observability.events import SsoEvent as SsoEvent from rag_observability.events import check_pii as check_pii __all__ = [ @@ -59,6 +70,8 @@ "ProvenanceEvent", "FeedbackEvent", "DriftEvent", + "SsoEvent", + "ScimEvent", "EVT_INGEST_STARTED", "EVT_INGEST_COMPLETED", "EVT_INGEST_FAILED", @@ -82,5 +95,10 @@ "EVT_FEEDBACK_RECORDED", "EVT_FEEDBACK_RECORD_DEGRADED", "EVT_DRIFT_DETECTED", + "EVT_SSO_AUTHENTICATED", + "EVT_SSO_AUTH_FAILED", + "EVT_SCIM_USER_PROVISIONED", + "EVT_SCIM_USER_DEPROVISIONED", + "EVT_SCIM_GROUP_CHANGED", "check_pii", ] diff --git a/packages/core/src/rag_core/gateway_types.py b/packages/core/src/rag_core/gateway_types.py index 9048a8e..09986ce 100644 --- a/packages/core/src/rag_core/gateway_types.py +++ b/packages/core/src/rag_core/gateway_types.py @@ -34,6 +34,9 @@ from pydantic import BaseModel, Field from rag_core.types import ( + SCIM_ERROR_SCHEMA, + SCIM_LIST_RESPONSE_SCHEMA, + SCIM_PATCH_OP_SCHEMA, AuditEvent, Chunk, ChunkRef, @@ -475,6 +478,79 @@ class CorpusList(BaseModel): total: int = 0 +class ScimListResponse(BaseModel): + """SCIM 2.0 ListResponse envelope (RFC 7644 §3.4.2, Step 6.8). + + Wraps a page of provisioned resources. ``resources`` holds each resource + already serialised in its SCIM wire shape (camelCase), so a Users page and a + Groups page share one envelope. ``total_results`` is the unpaged count; + ``start_index`` / ``items_per_page`` echo the request paging. + """ + + model_config = {"frozen": True, "populate_by_name": True} + + schemas: tuple[str, ...] = (SCIM_LIST_RESPONSE_SCHEMA,) + total_results: int = Field(default=0, alias="totalResults") + start_index: int = Field(default=1, alias="startIndex") + items_per_page: int = Field(default=0, alias="itemsPerPage") + resources: list[dict[str, Any]] = Field(default_factory=list, alias="Resources") + + +class ScimPatchOp(BaseModel): + """SCIM 2.0 PatchOp request body (RFC 7644 §3.5.2, Step 6.8). + + ``operations`` is the ordered list of ``{op, path?, value}`` mutations an IdP + sends — most importantly the deactivation patch (``op: replace`` of + ``active`` to ``false``) that revokes a user without deleting the record. + """ + + model_config = {"frozen": True, "populate_by_name": True} + + schemas: tuple[str, ...] = (SCIM_PATCH_OP_SCHEMA,) + operations: list[dict[str, Any]] = Field(default_factory=list, alias="Operations") + + +class ScimErrorBody(BaseModel): + """SCIM 2.0 error response body (RFC 7644 §3.12, Step 6.8). + + The SCIM surface returns this shape (not the platform :class:`GatewayError`) + so SCIM clients parse failures per spec. ``scim_type`` carries the RFC error + keyword (e.g. ``uniqueness`` / ``invalidFilter``) when applicable. + """ + + model_config = {"frozen": True, "populate_by_name": True} + + schemas: tuple[str, ...] = (SCIM_ERROR_SCHEMA,) + detail: str = "" + status: str = "400" + scim_type: str | None = Field(default=None, alias="scimType") + + +class SsoTenantStatus(BaseModel): + """The calling tenant's SSO posture, embedded in :class:`SsoStatusResponse`.""" + + model_config = {"frozen": True} + + tenant_id: TenantId + configured: bool = False + protocol: str | None = None # SsoProtocol value when configured + + +class SsoStatusResponse(BaseModel): + """``GET /v1/status/sso`` response — federation + provisioning posture (Step 6.8). + + Reports the global ``sso`` / ``scim`` enablement plus the **calling tenant's** + own federation status (whether an IdP is configured for it, and which + protocol). Tenant-scoped by design — it never lists other tenants' config. + """ + + model_config = {"frozen": True} + + sso_enabled: bool = False + scim_enabled: bool = False + tenant: SsoTenantStatus + + class GatewayError(BaseModel): """JSON error body returned for 4xx / 5xx responses. @@ -512,5 +588,10 @@ class GatewayError(BaseModel): "QueryTraceResponse", "RetrieveRequest", "RetrieveResponse", + "ScimErrorBody", + "ScimListResponse", + "ScimPatchOp", + "SsoStatusResponse", + "SsoTenantStatus", "StageTimings", ] diff --git a/packages/core/src/rag_core/gen_schemas.py b/packages/core/src/rag_core/gen_schemas.py index 7d02d06..c49c00e 100644 --- a/packages/core/src/rag_core/gen_schemas.py +++ b/packages/core/src/rag_core/gen_schemas.py @@ -37,6 +37,10 @@ QueryTraceResponse, RetrieveRequest, RetrieveResponse, + ScimErrorBody, + ScimListResponse, + ScimPatchOp, + SsoStatusResponse, StageTimings, ) from rag_core.openai_types import ( @@ -74,6 +78,7 @@ DriftSnapshot, Embedding, FallbackResult, + FederatedIdentity, FeedbackRecord, FeedbackStats, GuardResult, @@ -101,6 +106,8 @@ RequestContext, RetrievalResult, RoutingDecision, + ScimGroup, + ScimUser, SignedProvenanceRecord, SpanRecord, StageEvent, @@ -179,6 +186,10 @@ # Drift monitors (Step 5.5). DriftSnapshot, DriftReport, + # SSO / SCIM — identity federation + directory provisioning (Step 6.8). + FederatedIdentity, + ScimUser, + ScimGroup, CorpusScore, CorpusRoutingDecision, AuditEvent, @@ -202,6 +213,11 @@ GatewayError, # A/B routing assignment tag (Step 5.7c). ExperimentAssignment, + # SSO / SCIM wire types (Step 6.8). + ScimListResponse, + ScimPatchOp, + ScimErrorBody, + SsoStatusResponse, # Online feedback wire types (Step 5.4). FeedbackRequest, FeedbackAck, diff --git a/packages/core/src/rag_core/spi/__init__.py b/packages/core/src/rag_core/spi/__init__.py index 77f328d..c588990 100644 --- a/packages/core/src/rag_core/spi/__init__.py +++ b/packages/core/src/rag_core/spi/__init__.py @@ -42,6 +42,7 @@ from rag_core.spi.quota_store import QuotaStore from rag_core.spi.reranker import Reranker from rag_core.spi.retrieval_cache import RetrievalCache +from rag_core.spi.scim_store import ScimStore from rag_core.spi.secrets import Secrets from rag_core.spi.storage import Storage from rag_core.spi.subscription_store import SubscriptionStore @@ -88,6 +89,7 @@ "QueueMessage", "Reranker", "RetrievalCache", + "ScimStore", "Secrets", "Storage", "SubscriptionStore", diff --git a/packages/core/src/rag_core/spi/noop/__init__.py b/packages/core/src/rag_core/spi/noop/__init__.py index ff3faad..29dd669 100644 --- a/packages/core/src/rag_core/spi/noop/__init__.py +++ b/packages/core/src/rag_core/spi/noop/__init__.py @@ -24,6 +24,7 @@ from rag_core.spi.noop.quota_store import NoopQuotaStore from rag_core.spi.noop.reranker import NoopReranker from rag_core.spi.noop.retrieval_cache import NoopRetrievalCache +from rag_core.spi.noop.scim_store import NoopScimStore from rag_core.spi.noop.secrets import NoopSecrets from rag_core.spi.noop.storage import NoopStorage from rag_core.spi.noop.subscription_store import NoopSubscriptionStore @@ -56,6 +57,7 @@ "NoopQueue", "NoopReranker", "NoopRetrievalCache", + "NoopScimStore", "NoopSecrets", "NoopStorage", "NoopSubscriptionStore", diff --git a/packages/core/src/rag_core/spi/noop/scim_store.py b/packages/core/src/rag_core/spi/noop/scim_store.py new file mode 100644 index 0000000..73db6ff --- /dev/null +++ b/packages/core/src/rag_core/spi/noop/scim_store.py @@ -0,0 +1,89 @@ +"""In-memory ScimStore — tenant-scoped SCIM directory for tests + single-process dev.""" + +from __future__ import annotations + +from rag_core.spi.scim_store import ScimStore +from rag_core.types import RequestContext, ScimGroup, ScimUser + + +class NoopScimStore(ScimStore): + """In-memory SCIM directory keyed by tenant id (Step 6.8). + + Users / Groups live in per-tenant insertion-ordered dicts, so a list is stable + and cross-tenant reads are impossible by construction (the tenant id selects + the sub-map before any lookup). Unbounded by design — a provisioned directory + is authoritative and naturally small; production injects a durable backend. + """ + + def __init__(self) -> None: + self._users: dict[str, dict[str, ScimUser]] = {} + self._groups: dict[str, dict[str, ScimGroup]] = {} + + # -- Users --------------------------------------------------------------- + async def put_user(self, ctx: RequestContext, user: ScimUser) -> None: + self._users.setdefault(str(ctx.tenant_id), {})[user.id] = user + + async def get_user(self, ctx: RequestContext, user_id: str) -> ScimUser | None: + return self._users.get(str(ctx.tenant_id), {}).get(user_id) + + async def get_user_by_username(self, ctx: RequestContext, user_name: str) -> ScimUser | None: + for user in self._users.get(str(ctx.tenant_id), {}).values(): + if user.user_name == user_name: + return user + return None + + async def list_users( + self, + ctx: RequestContext, + *, + start_index: int = 1, + count: int = 100, + user_name: str | None = None, + ) -> tuple[list[ScimUser], int]: + rows = list(self._users.get(str(ctx.tenant_id), {}).values()) + if user_name is not None: + rows = [u for u in rows if u.user_name == user_name] + total = len(rows) + offset = max(start_index - 1, 0) + page = rows[offset : offset + count] if count > 0 else [] + return page, total + + async def delete_user(self, ctx: RequestContext, user_id: str) -> bool: + return self._users.get(str(ctx.tenant_id), {}).pop(user_id, None) is not None + + # -- Groups -------------------------------------------------------------- + async def put_group(self, ctx: RequestContext, group: ScimGroup) -> None: + self._groups.setdefault(str(ctx.tenant_id), {})[group.id] = group + + async def get_group(self, ctx: RequestContext, group_id: str) -> ScimGroup | None: + return self._groups.get(str(ctx.tenant_id), {}).get(group_id) + + async def get_group_by_display_name( + self, ctx: RequestContext, display_name: str + ) -> ScimGroup | None: + for group in self._groups.get(str(ctx.tenant_id), {}).values(): + if group.display_name == display_name: + return group + return None + + async def list_groups( + self, + ctx: RequestContext, + *, + start_index: int = 1, + count: int = 100, + display_name: str | None = None, + ) -> tuple[list[ScimGroup], int]: + rows = list(self._groups.get(str(ctx.tenant_id), {}).values()) + if display_name is not None: + rows = [g for g in rows if g.display_name == display_name] + total = len(rows) + offset = max(start_index - 1, 0) + page = rows[offset : offset + count] if count > 0 else [] + return page, total + + async def delete_group(self, ctx: RequestContext, group_id: str) -> bool: + return self._groups.get(str(ctx.tenant_id), {}).pop(group_id, None) is not None + + async def health(self) -> bool: + return True diff --git a/packages/core/src/rag_core/spi/scim_store.py b/packages/core/src/rag_core/spi/scim_store.py new file mode 100644 index 0000000..6f83c78 --- /dev/null +++ b/packages/core/src/rag_core/spi/scim_store.py @@ -0,0 +1,91 @@ +"""ScimStore SPI — tenant-scoped persistence for the SCIM 2.0 directory (Step 6.8). + +The store is the system of record an IdP provisions into over SCIM 2.0 (RFC 7644): +Users and Groups, strictly scoped to ``ctx.tenant_id`` — a tenant can only ever +see the resources it provisioned. Uniqueness (``userName`` / ``displayName``) +and SCIM filter parsing are enforced one layer up in +:class:`rag_sso.scim.ScimService`; the store is a straight tenant-scoped CRUD +surface so a backend (Postgres, LDAP-mirror, …) only implements persistence. +""" + +from __future__ import annotations + +import abc + +from rag_core.spi._base import HealthCheckMixin +from rag_core.types import RequestContext, ScimGroup, ScimUser + + +class ScimStore(HealthCheckMixin, abc.ABC): + """Tenant-scoped CRUD store for provisioned SCIM Users and Groups (Step 6.8). + + Every method takes ``ctx`` first and scopes by ``ctx.tenant_id`` — cross-tenant + access is impossible by construction. ``put_*`` is an idempotent upsert keyed + by resource id; ``list_*`` returns a ``(page, total)`` pair for SCIM + ``totalResults`` + ``startIndex`` / ``count`` pagination, optionally filtered to + an exact ``user_name`` / ``display_name`` (the ``attr eq "value"`` filter SCIM + clients use during reconciliation). ``delete_*`` returns whether the resource + existed. + """ + + # -- Users --------------------------------------------------------------- + @abc.abstractmethod + async def put_user(self, ctx: RequestContext, user: ScimUser) -> None: + """Upsert *user* under ``(ctx.tenant_id, user.id)``.""" + + @abc.abstractmethod + async def get_user(self, ctx: RequestContext, user_id: str) -> ScimUser | None: + """Return the tenant's User *user_id*, or ``None`` if absent.""" + + @abc.abstractmethod + async def get_user_by_username(self, ctx: RequestContext, user_name: str) -> ScimUser | None: + """Return the tenant's User whose ``user_name`` matches, or ``None``.""" + + @abc.abstractmethod + async def list_users( + self, + ctx: RequestContext, + *, + start_index: int = 1, + count: int = 100, + user_name: str | None = None, + ) -> tuple[list[ScimUser], int]: + """Return ``(page, total)`` of the tenant's Users (stable order). + + *start_index* is 1-based (SCIM convention); *count* caps the page; when + *user_name* is given only the exact match is considered. + """ + + @abc.abstractmethod + async def delete_user(self, ctx: RequestContext, user_id: str) -> bool: + """Delete the tenant's User *user_id*; return whether it existed.""" + + # -- Groups -------------------------------------------------------------- + @abc.abstractmethod + async def put_group(self, ctx: RequestContext, group: ScimGroup) -> None: + """Upsert *group* under ``(ctx.tenant_id, group.id)``.""" + + @abc.abstractmethod + async def get_group(self, ctx: RequestContext, group_id: str) -> ScimGroup | None: + """Return the tenant's Group *group_id*, or ``None`` if absent.""" + + @abc.abstractmethod + async def get_group_by_display_name( + self, ctx: RequestContext, display_name: str + ) -> ScimGroup | None: + """Return the tenant's Group whose ``display_name`` matches, or ``None``.""" + + @abc.abstractmethod + async def list_groups( + self, + ctx: RequestContext, + *, + start_index: int = 1, + count: int = 100, + display_name: str | None = None, + ) -> tuple[list[ScimGroup], int]: + """Return ``(page, total)`` of the tenant's Groups (stable order).""" + + @abc.abstractmethod + async def delete_group(self, ctx: RequestContext, group_id: str) -> bool: + """Delete the tenant's Group *group_id*; return whether it existed.""" diff --git a/packages/core/src/rag_core/types.py b/packages/core/src/rag_core/types.py index 1de37b5..9b41368 100644 --- a/packages/core/src/rag_core/types.py +++ b/packages/core/src/rag_core/types.py @@ -2054,3 +2054,137 @@ class DriftReport(BaseModel): monitors: list[DriftSnapshot] = Field(default_factory=list) drifted_n: int = 0 generated_at: datetime = Field(default_factory=_utcnow) + + +# --------------------------------------------------------------------------- +# SSO / SCIM — identity federation + directory provisioning (Step 6.8) +# --------------------------------------------------------------------------- +#: Canonical SCIM 2.0 schema URNs (RFC 7643 / 7644). Emitted in the ``schemas`` +#: attribute of every resource + protocol message so SCIM clients (Okta, Azure +#: AD, OneLogin, …) recognise the payloads. +SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" +SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group" +SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse" +SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error" +SCIM_PATCH_OP_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp" + + +class SsoProtocol(StrEnum): + """Identity-federation protocol a tenant's IdP speaks (Step 6.8).""" + + oidc = "oidc" # OpenID Connect — verify a signed JWT ID token + saml = "saml" # SAML 2.0 — verify a signed assertion (POST binding) + + +class FederatedIdentity(BaseModel): + """Normalised identity extracted from a verified OIDC token / SAML assertion. + + The protocol-neutral output of an :class:`rag_sso` identity provider, mapped + onto a :class:`Principal` at the gateway boundary. ``subject`` is the IdP's + stable user id (OIDC ``sub`` / SAML ``NameID``); ``groups`` are the group / + role claims used to derive the principal's ACL labels (Step 6.3). + ``attributes`` carries any extra claims for diagnostics — it is **never** + written to a structured-log event (which carries only a hash of the subject). + """ + + model_config = {"frozen": True} + + subject: str + issuer: str = "" + protocol: SsoProtocol = SsoProtocol.oidc + email: str | None = None + display_name: str | None = None + groups: tuple[str, ...] = Field(default_factory=tuple) + attributes: dict[str, Any] = Field(default_factory=dict) + + +class ScimName(BaseModel): + """SCIM 2.0 ``name`` complex attribute (RFC 7643 §4.1.1).""" + + model_config = {"frozen": True, "populate_by_name": True} + + formatted: str | None = None + family_name: str | None = Field(default=None, alias="familyName") + given_name: str | None = Field(default=None, alias="givenName") + + +class ScimEmail(BaseModel): + """One entry of the SCIM 2.0 multi-valued ``emails`` attribute.""" + + model_config = {"frozen": True, "populate_by_name": True} + + value: str + type: str | None = None + primary: bool = False + + +class ScimGroupRef(BaseModel): + """A user's group membership, as surfaced on ``User.groups`` (read-only).""" + + model_config = {"frozen": True, "populate_by_name": True} + + value: str # the group's SCIM id + display: str | None = None + + +class ScimMember(BaseModel): + """One entry of a group's ``members`` attribute (a reference to a user).""" + + model_config = {"frozen": True, "populate_by_name": True} + + value: str # the member's SCIM id + display: str | None = None + + +class ScimResourceMeta(BaseModel): + """SCIM 2.0 ``meta`` complex attribute (RFC 7643 §3.1).""" + + model_config = {"frozen": True, "populate_by_name": True} + + resource_type: str = Field(alias="resourceType") + created: datetime = Field(default_factory=_utcnow) + last_modified: datetime = Field(default_factory=_utcnow, alias="lastModified") + location: str | None = None + version: str | None = None + + +class ScimUser(BaseModel): + """A provisioned SCIM 2.0 User resource (RFC 7643 §4.1, Step 6.8). + + Persisted through the tenant-scoped :class:`~rag_core.spi.scim_store.ScimStore` + — tenant isolation comes from the store key (``ctx.tenant_id``), so the + resource itself carries no tenant id and serialises as the exact SCIM wire + shape (camelCase via field aliases). ``user_name`` is unique within a tenant; + ``active`` is the deprovisioning switch an IdP flips to revoke access. + """ + + model_config = {"frozen": True, "populate_by_name": True} + + schemas: tuple[str, ...] = (SCIM_USER_SCHEMA,) + id: str = Field(default_factory=_new_id) + external_id: str | None = Field(default=None, alias="externalId") + user_name: str = Field(alias="userName") + name: ScimName | None = None + display_name: str | None = Field(default=None, alias="displayName") + emails: tuple[ScimEmail, ...] = Field(default_factory=tuple) + active: bool = True + groups: tuple[ScimGroupRef, ...] = Field(default_factory=tuple) + meta: ScimResourceMeta | None = None + + +class ScimGroup(BaseModel): + """A provisioned SCIM 2.0 Group resource (RFC 7643 §4.2, Step 6.8). + + Tenant-scoped like :class:`ScimUser`; ``display_name`` is unique within a + tenant. ``members`` references provisioned users by SCIM id. Group + membership is what an OIDC/SAML ``groups`` claim maps onto for ACL labels. + """ + + model_config = {"frozen": True, "populate_by_name": True} + + schemas: tuple[str, ...] = (SCIM_GROUP_SCHEMA,) + id: str = Field(default_factory=_new_id) + external_id: str | None = Field(default=None, alias="externalId") + display_name: str = Field(alias="displayName") + members: tuple[ScimMember, ...] = Field(default_factory=tuple) + meta: ScimResourceMeta | None = None diff --git a/packages/observability/src/rag_observability/events.py b/packages/observability/src/rag_observability/events.py index c2ad31e..33a0447 100644 --- a/packages/observability/src/rag_observability/events.py +++ b/packages/observability/src/rag_observability/events.py @@ -41,6 +41,8 @@ "ProvenanceEvent", "FeedbackEvent", "DriftEvent", + "SsoEvent", + "ScimEvent", # event name constants — ingest "EVT_INGEST_STARTED", "EVT_INGEST_COMPLETED", @@ -72,6 +74,12 @@ "EVT_FEEDBACK_RECORD_DEGRADED", # event name constants — drift (Step 5.5) "EVT_DRIFT_DETECTED", + # event name constants — SSO / SCIM (Step 6.8) + "EVT_SSO_AUTHENTICATED", + "EVT_SSO_AUTH_FAILED", + "EVT_SCIM_USER_PROVISIONED", + "EVT_SCIM_USER_DEPROVISIONED", + "EVT_SCIM_GROUP_CHANGED", # utilities "check_pii", ] @@ -176,6 +184,13 @@ def _register(cls, name: str) -> str: # Drift (Step 5.5) — a monitor transitioned into the drifted state EVT_DRIFT_DETECTED: str = RagEvent._register("drift.detected") +# SSO / SCIM (Step 6.8) — identity federation + directory provisioning +EVT_SSO_AUTHENTICATED: str = RagEvent._register("sso.authenticated") +EVT_SSO_AUTH_FAILED: str = RagEvent._register("sso.auth_failed") +EVT_SCIM_USER_PROVISIONED: str = RagEvent._register("scim.user_provisioned") +EVT_SCIM_USER_DEPROVISIONED: str = RagEvent._register("scim.user_deprovisioned") +EVT_SCIM_GROUP_CHANGED: str = RagEvent._register("scim.group_changed") + # --------------------------------------------------------------------------- # Typed event subtypes @@ -346,3 +361,40 @@ class DriftEvent(RagEvent): current_value: float | None = None reference_n: int = 0 current_n: int = 0 + + +class SsoEvent(RagEvent): + """Emitted on an SSO (OIDC / SAML) authentication attempt (Step 6.8). + + Carries only the protocol, the IdP ``issuer``, a **hash** of the subject, the + group count, and the outcome — never the raw token / assertion, the subject + id, the email, or any attribute value — so federation telemetry is naturally + PII-free. One ``sso.authenticated`` per success; one ``sso.auth_failed`` per + rejection (``reason`` is a coarse machine code: ``expired`` / ``bad_signature`` + / ``wrong_audience`` / ``wrong_issuer`` / ``no_provider`` / ``invalid``). The + tenant is tagged via :attr:`RagEvent.tenant_id`. + """ + + protocol: str = "" # SsoProtocol value: "oidc" | "saml" + issuer: str = "" + subject_hash: str = "" # sha256 hex prefix of the IdP subject + outcome: str = "" # "authenticated" | "failed" + groups_n: int = 0 + reason: str = "" # set on sso.auth_failed + + +class ScimEvent(RagEvent): + """Emitted on a SCIM 2.0 directory mutation (Step 6.8). + + Carries only the operation, the resource type, and the server-assigned + resource id (an opaque UUID) — never ``userName`` / ``displayName`` / emails / + other attribute values — so provisioning telemetry is naturally PII-free. One + event per create / replace / patch / delete; ``scim.user_deprovisioned`` is the + distinguished ``active=false`` (or delete) transition that revokes access. The + tenant is tagged via :attr:`RagEvent.tenant_id`. + """ + + operation: str = "" # "create" | "replace" | "patch" | "delete" + resource_type: str = "" # "User" | "Group" + resource_id: str = "" # server-assigned SCIM id (opaque UUID) + active: bool | None = None # the resulting active state, for User mutations diff --git a/packages/ragctl/pyproject.toml b/packages/ragctl/pyproject.toml index 65724ae..b013c36 100644 --- a/packages/ragctl/pyproject.toml +++ b/packages/ragctl/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "rag-feedback>=0.1.0", "rag-drift>=0.1.0", "rag-webhooks>=0.1.0", + "rag-sso>=0.1.0", "rag-gateway>=0.4.0", "httpx>=0.27", ] @@ -63,6 +64,7 @@ rag-provenance = { workspace = true } rag-feedback = { workspace = true } rag-drift = { workspace = true } rag-webhooks = { workspace = true } +rag-sso = { workspace = true } rag-gateway = { workspace = true } [project.scripts] diff --git a/packages/ragctl/src/ragctl/main.py b/packages/ragctl/src/ragctl/main.py index 500e098..50c7e3a 100644 --- a/packages/ragctl/src/ragctl/main.py +++ b/packages/ragctl/src/ragctl/main.py @@ -4681,6 +4681,188 @@ def tenant_resolve( typer.echo("index: shared (base index)") +# --------------------------------------------------------------------------- +# SSO / SCIM (Step 6.8) +# --------------------------------------------------------------------------- +sso_app = typer.Typer( + help="Inspect per-tenant SSO config + demo OIDC federation (Step 6.8).", + no_args_is_help=True, +) +app.add_typer(sso_app, name="sso") + + +@sso_app.command("list") +def sso_list( + config: Path = typer.Option( + ..., + "--config", + "-f", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="Path to the rag.yaml that declares the tenants + their IdPs.", + ), +) -> None: + """List each tenant's identity-federation (OIDC / SAML) config from rag.yaml.""" + try: + cfg = load(config) + except ConfigError as exc: + typer.echo(f"ERROR: {exc.message}", err=True) + raise typer.Exit(1) # noqa: B904 + + typer.echo(f"config: {config}") + typer.echo(f"sso.enabled: {cfg.sso.enabled}") + typer.echo(f"scim.enabled: {cfg.scim.enabled}") + federated = [t for t in cfg.tenants if t.sso is not None] + typer.echo(f"federated tenants: {len(federated)}") + for t in cfg.tenants: + sso = t.sso + if sso is None: + continue + if sso.protocol.value == "oidc" and sso.oidc is not None: + typer.echo( + f" - {t.id} oidc issuer={sso.oidc.issuer or '(unset)'} " + f"aud={sso.oidc.audience or '(unset)'} algs={','.join(sso.oidc.algorithms)}" + ) + elif sso.protocol.value == "saml" and sso.saml is not None: + typer.echo( + f" - {t.id} saml idp={sso.saml.idp_entity_id or '(unset)'} " + f"aud={sso.saml.audience or '(unset)'} signed={sso.saml.require_signature}" + ) + + +@sso_app.command("demo") +def sso_demo( + tenant: str = typer.Option("acme", "--tenant", help="Tenant to federate."), +) -> None: + """Demo Step 6.8 OIDC federation with an in-process HS256 IdP. + + Mints a signed ID token (groups ``engineering`` / ``admins``), verifies it + through a per-tenant ``FederatedAuth``, and prints the resulting Principal — + showing the group → ACL-label mapping that drives Step 6.3 push-down. Then + rejects a tampered token. No external IdP / services. + + Example:: + + ragctl sso demo --tenant acme + """ + import asyncio + import time + + from rag_core.errors import SsoError + from rag_core.types import TenantId + from rag_sso import FederatedAuth, OidcProvider, OidcSettings, encode_jwt_hs256 + + secret = "demo-shared-secret-not-for-production" # noqa: S105 - demo HS256 key + provider = OidcProvider( + OidcSettings( + issuer="https://demo-idp.local", + audience="agentcontextos", + hmac_secret=secret, + group_claim="groups", + ) + ) + auth = FederatedAuth({tenant: provider}, group_label_map={"engineering": "corpus-eng"}) + now = int(time.time()) + token = encode_jwt_hs256( + { + "sub": "u-123", + "email": "alice@demo.local", + "name": "Alice Example", + "groups": ["engineering", "admins"], + "iss": "https://demo-idp.local", + "aud": "agentcontextos", + "exp": now + 3600, + }, + secret, + ) + + async def _run() -> None: + principal = await auth.authenticate(token, TenantId(tenant)) + typer.echo(f"\nOIDC federation — tenant={tenant}") + typer.echo("─" * 64) + typer.echo(f" principal id: {principal.id}") + typer.echo(f" display name: {principal.display_name}") + typer.echo(f" email: {principal.email}") + typer.echo(f" roles: {', '.join(principal.roles)}") + typer.echo(f" acl_labels: {', '.join(sorted(principal.acl_labels))}") + try: + await auth.authenticate(token[:-2] + "xx", TenantId(tenant)) + typer.echo(" tamper check: FAIL (forged token accepted)") + except SsoError as exc: + typer.echo(f" tamper check: ok (rejected: {exc.context.get('reason')})") + + asyncio.run(_run()) + + +@app.command("scim") +def scim( + tenant: str = typer.Option("acme", "--tenant", help="Tenant to provision into."), + user_name: str = typer.Option("alice@demo.local", "--user", help="userName to provision."), +) -> None: + """Demo Step 6.8 SCIM 2.0 provisioning against an in-process directory. + + Drives the full ``ScimService`` flow over an in-memory ``NoopScimStore``: + create a user, list (with a ``userName eq`` filter), deactivate via PATCH + (the IdP deprovisioning path), then delete. Shows tenant-scoped CRUD with no + external IdP / services. + + Example:: + + ragctl scim --tenant acme --user alice@demo.local + """ + import asyncio + + from rag_core.spi.noop import NoopScimStore + from rag_core.types import ( + Principal, + PrincipalId, + PrincipalKind, + RequestContext, + ScimUser, + TenantId, + ) + from rag_sso import ScimService + + def _ctx(t: str) -> RequestContext: + tid = TenantId(t) + return RequestContext( + tenant_id=tid, + principal=Principal( + id=PrincipalId("scim-cli"), + kind=PrincipalKind.service, + display_name="scim-cli", + tenant_id=tid, + ), + ) + + service = ScimService(NoopScimStore()) + ctx = _ctx(tenant) + + async def _run() -> None: + typer.echo(f"\nSCIM 2.0 provisioning — tenant={tenant}") + typer.echo("─" * 64) + new_user = ScimUser(user_name=user_name, display_name="Alice") + created = await service.create_user(ctx, new_user) + typer.echo(f" create: id={created.id[:8]}… active={created.active}") + page, total = await service.list_users(ctx, scim_filter=f'userName eq "{user_name}"') + match = page[0].user_name if page else "(none)" + typer.echo(f" list(filter): total={total} match={match}") + patched = await service.patch_user( + ctx, created.id, [{"op": "replace", "value": {"active": False}}] + ) + typer.echo(f" deactivate: active={patched.active} (IdP deprovisioning)") + await service.delete_user(ctx, created.id) + _, after = await service.list_users(ctx) + typer.echo(f" delete: remaining={after}") + # Tenant isolation — another tenant never sees this user. + _, other = await service.list_users(_ctx("other-tenant")) + typer.echo(f" isolation: other-tenant sees {other} users") + + asyncio.run(_run()) + + # --------------------------------------------------------------------------- # Scaffold sub-apps — print a "delivered in Step X.Y" notice and exit 0. # diff --git a/packages/ragctl/tests/test_scim.py b/packages/ragctl/tests/test_scim.py new file mode 100644 index 0000000..51bfdfd --- /dev/null +++ b/packages/ragctl/tests/test_scim.py @@ -0,0 +1,23 @@ +"""Tests for ``ragctl scim`` — Step 6.8. + +Drives the full ScimService provisioning flow (create → list → deactivate → +delete) over an in-memory NoopScimStore, plus tenant isolation. No infrastructure. +""" + +from __future__ import annotations + +from ragctl.main import app +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_scim_demo_full_lifecycle() -> None: + result = runner.invoke(app, ["scim", "--tenant", "acme", "--user", "bob@demo.local"]) + assert result.exit_code == 0, result.output + assert "create:" in result.output + assert "active=True" in result.output + assert "total=1" in result.output + assert "deactivate: active=False" in result.output + assert "delete: remaining=0" in result.output + assert "isolation: other-tenant sees 0 users" in result.output diff --git a/packages/ragctl/tests/test_sso.py b/packages/ragctl/tests/test_sso.py new file mode 100644 index 0000000..c85d475 --- /dev/null +++ b/packages/ragctl/tests/test_sso.py @@ -0,0 +1,60 @@ +"""Tests for ``ragctl sso`` — Step 6.8. + +``sso demo`` federates an in-process HS256 IdP and prints the resulting Principal; +``sso list`` inspects per-tenant IdP config from a rag.yaml. No infrastructure. +""" + +from __future__ import annotations + +from pathlib import Path + +from ragctl.main import app +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_sso_demo_federates_and_maps_groups() -> None: + result = runner.invoke(app, ["sso", "demo", "--tenant", "acme"]) + assert result.exit_code == 0, result.output + assert "principal id: u-123" in result.output + # engineering → corpus-eng via the group_label_map; admins passes through 1:1. + assert "corpus-eng" in result.output + assert "admins" in result.output + assert "tamper check: ok" in result.output + + +def test_sso_list_reports_tenant_idps(tmp_path: Path) -> None: + cfg = tmp_path / "rag.yaml" + cfg.write_text( + """ +version: "1" +sso: + enabled: true +scim: + enabled: true +tenants: + - id: acme + name: Acme + sso: + protocol: oidc + oidc: + issuer: https://idp.acme.test + audience: acos + hmac_secret: shh + - id: globex + name: Globex + sso: + protocol: saml + saml: + idp_entity_id: https://idp.globex.test + audience: acos +""", + encoding="utf-8", + ) + result = runner.invoke(app, ["sso", "list", "-f", str(cfg)]) + assert result.exit_code == 0, result.output + assert "sso.enabled: True" in result.output + assert "federated tenants: 2" in result.output + assert "acme oidc issuer=https://idp.acme.test" in result.output + assert "globex saml idp=https://idp.globex.test" in result.output diff --git a/packages/sso/README.md b/packages/sso/README.md new file mode 100644 index 0000000..f649db5 --- /dev/null +++ b/packages/sso/README.md @@ -0,0 +1,21 @@ +# rag-sso + +Enterprise SSO for AgentContextOS (Step 6.8): **OIDC / SAML identity federation** +plus **SCIM 2.0 directory provisioning**, with **per-tenant IdP config**. + +- `FederatedAuth` — an `Auth` SPI backed by per-tenant `OidcProvider` / + `SamlProvider`. Wired as the gateway's auth backend, it verifies a presented + IdP token / assertion and returns a `Principal` whose `acl_labels` come from + the IdP's group claims (so Step 6.3 ACL push-down and Step 6.5 PII egress apply + unchanged). +- `ScimService` — SCIM 2.0 User / Group CRUD over the tenant-scoped `ScimStore` + SPI, the directory an IdP (Okta / Azure AD / OneLogin) provisions into. + +The defaults are dependency-free and fully tested: HS256 JWT verification via the +standard library, and SAML assertion validation via `defusedxml`. Asymmetric OIDC +(RS256 / ES256) needs the `[oidc]` extra (PyJWT); SAML XML-DSig needs the `[saml]` +extra (signxml). + +See [`docs/reference/sso.md`](../../docs/reference/sso.md), +[`docs/architecture/sso-scim.md`](../../docs/architecture/sso-scim.md), and +[ADR-0040](../../docs/adr/ADR-0040-sso-scim.md). diff --git a/packages/sso/pyproject.toml b/packages/sso/pyproject.toml new file mode 100644 index 0000000..3c5d3a9 --- /dev/null +++ b/packages/sso/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "rag-sso" +version = "0.1.0" +description = "AgentContextOS — SSO (OIDC/SAML) identity federation + SCIM 2.0 directory provisioning" +readme = "README.md" +requires-python = ">=3.12" +# rag-sso depends only on rag-core (Auth SPI + ScimStore SPI + the Principal / +# FederatedIdentity / Scim* domain types) and rag-observability (structured +# logger + the sso.* / scim.* events). The dependency-free defaults are a real, +# testable HS256 JWT verifier (stdlib hmac) and a SAML assertion validator +# (defusedxml — safe XML parsing is mandatory for SAML, so it is a core dep). +dependencies = [ + "rag-core", + "rag-observability", + "defusedxml>=0.7", +] + +[project.optional-dependencies] +# Asymmetric OIDC: RS256 / ES256 / PS256 ID-token verification against a +# configured public key (lazily imported; the HS256 default needs no extra). +oidc = [ + "pyjwt[crypto]>=2.8", +] +# SAML XML-DSig signature verification (lxml-based canonicalisation). Without +# it, SamlProvider validates conditions / audience / timestamps but requires an +# injected signature verifier when require_signature is on. +saml = [ + "signxml>=3.2", +] +dev = [ + "pytest>=9.0", + "pytest-asyncio>=1.3", + "mypy>=2.1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/rag_sso"] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.uv.sources] +rag-core = { workspace = true } +rag-observability = { workspace = true } diff --git a/packages/sso/src/rag_sso/__init__.py b/packages/sso/src/rag_sso/__init__.py new file mode 100644 index 0000000..c75f9db --- /dev/null +++ b/packages/sso/src/rag_sso/__init__.py @@ -0,0 +1,42 @@ +"""rag-sso — SSO (OIDC / SAML) identity federation + SCIM 2.0 provisioning (Step 6.8). + +Two cooperating surfaces: + +* **Federation** — :class:`FederatedAuth` is an ``Auth`` SPI backed by per-tenant + :class:`OidcProvider` / :class:`SamlProvider` instances. Wired as the gateway's + ``Auth`` backend, it turns a presented IdP token / assertion into a trusted + :class:`~rag_core.types.Principal` (group claims → ACL labels) at the existing + ``authenticate`` boundary. +* **Provisioning** — :class:`ScimService` implements SCIM 2.0 User / Group CRUD + over the tenant-scoped ``ScimStore`` SPI, the directory an IdP pushes into. + +The defaults are dependency-free and fully testable (HS256 JWT verification via +stdlib ``hmac``; SAML assertion validation via ``defusedxml``). Asymmetric OIDC +(``[oidc]`` extra → PyJWT) and SAML XML-DSig (``[saml]`` extra → signxml) are +optional. +""" + +from __future__ import annotations + +from rag_sso.federated_auth import FederatedAuth +from rag_sso.identity import IdentityProvider, identity_to_principal +from rag_sso.jwt import decode_jwt_unverified, encode_jwt_hs256, verify_jwt +from rag_sso.oidc import OidcProvider, OidcSettings +from rag_sso.saml import SamlProvider, SamlSettings, signxml_verifier +from rag_sso.scim import ScimService, parse_eq_filter + +__all__ = [ + "FederatedAuth", + "IdentityProvider", + "identity_to_principal", + "OidcProvider", + "OidcSettings", + "SamlProvider", + "SamlSettings", + "signxml_verifier", + "ScimService", + "parse_eq_filter", + "verify_jwt", + "encode_jwt_hs256", + "decode_jwt_unverified", +] diff --git a/packages/sso/src/rag_sso/federated_auth.py b/packages/sso/src/rag_sso/federated_auth.py new file mode 100644 index 0000000..c209bb5 --- /dev/null +++ b/packages/sso/src/rag_sso/federated_auth.py @@ -0,0 +1,124 @@ +"""FederatedAuth — an Auth SPI backed by per-tenant OIDC / SAML providers (Step 6.8). + +This is the seam Step 6.8 plugs into: the gateway middleware already calls +``auth.authenticate(bearer_token, tenant_id)`` at the request boundary, so wiring +``FederatedAuth`` as the gateway's ``Auth`` backend turns a presented IdP token +into a trusted :class:`~rag_core.types.Principal` with no middleware change. + +The principal's ``acl_labels`` are derived from the IdP's group claims, so the +Step 6.3 ACL push-down and Step 6.5 PII egress policies apply to federated users +exactly as they do to header-identified ones — federation only changes *how the +principal is established*, never what governs it in flight. +""" + +from __future__ import annotations + +import hashlib + +from rag_core.errors import SsoError +from rag_core.types import ACLAction, Principal, TenantId +from rag_observability.events import ( + EVT_SSO_AUTH_FAILED, + EVT_SSO_AUTHENTICATED, + SsoEvent, +) +from rag_observability.logging import get_logger + +from rag_sso.identity import IdentityProvider, identity_to_principal + +__all__ = ["FederatedAuth"] + +_log = get_logger(__name__) + + +def _subject_hash(subject: str) -> str: + return hashlib.sha256(subject.encode("utf-8")).hexdigest()[:16] + + +class FederatedAuth: + """Auth backend that federates each tenant to its configured IdP (Step 6.8). + + ``providers`` maps a tenant id to its :class:`IdentityProvider` (OIDC or SAML); + ``default_provider`` is the fallback for tenants without an explicit entry + (typically ``None``, so an unconfigured tenant is rejected rather than trusting + a token nobody verified). ``group_label_map`` optionally renames IdP groups to + ACL labels. + """ + + def __init__( + self, + providers: dict[str, IdentityProvider], + *, + default_provider: IdentityProvider | None = None, + group_label_map: dict[str, str] | None = None, + ) -> None: + self._providers = providers + self._default = default_provider + self._group_label_map = group_label_map + + def configured_tenants(self) -> frozenset[str]: + """Tenant ids with an explicit IdP (diagnostics for ``GET /v1/status/sso``).""" + return frozenset(self._providers) + + def provider_for(self, tenant_id: str) -> IdentityProvider | None: + return self._providers.get(str(tenant_id), self._default) + + async def authenticate(self, token: str, tenant_id: TenantId) -> Principal: + provider = self.provider_for(str(tenant_id)) + if provider is None: + self._emit_failure(tenant_id, protocol="", issuer="", subject="", reason="no_provider") + raise SsoError( + "no identity provider is configured for this tenant", + reason="no_provider", + ) + try: + identity = provider.verify(token) + except SsoError as exc: + self._emit_failure( + tenant_id, + protocol=str(provider.protocol), + issuer="", + subject="", + reason=str(exc.context.get("reason", "invalid")), + ) + raise + + principal = identity_to_principal( + identity, tenant_id, group_label_map=self._group_label_map + ) + _log.info( + SsoEvent( + event_name=EVT_SSO_AUTHENTICATED, + tenant_id=str(tenant_id), + protocol=str(identity.protocol), + issuer=identity.issuer, + subject_hash=_subject_hash(identity.subject), + outcome="authenticated", + groups_n=len(identity.groups), + ).model_dump_json() + ) + return principal + + async def authorize(self, principal: Principal, action: ACLAction, resource: str) -> bool: + # Coarse allow — fine-grained authorisation is the PolicyEngine's job + # (ACL labels, PII egress, quotas) once a RequestContext exists. Mirrors + # NoopAuth: federation establishes *who*, the PDP decides *what*. + return True + + async def health(self) -> bool: + return True + + def _emit_failure( + self, tenant_id: TenantId, *, protocol: str, issuer: str, subject: str, reason: str + ) -> None: + _log.warning( + SsoEvent( + event_name=EVT_SSO_AUTH_FAILED, + tenant_id=str(tenant_id), + protocol=protocol, + issuer=issuer, + subject_hash=_subject_hash(subject) if subject else "", + outcome="failed", + reason=reason, + ).model_dump_json() + ) diff --git a/packages/sso/src/rag_sso/identity.py b/packages/sso/src/rag_sso/identity.py new file mode 100644 index 0000000..6af20f7 --- /dev/null +++ b/packages/sso/src/rag_sso/identity.py @@ -0,0 +1,60 @@ +"""Identity-provider protocol + the FederatedIdentity → Principal mapping (Step 6.8).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Protocol, runtime_checkable + +from rag_core.types import ( + FederatedIdentity, + Principal, + PrincipalId, + PrincipalKind, + SsoProtocol, + TenantId, +) + +__all__ = ["IdentityProvider", "identity_to_principal"] + + +@runtime_checkable +class IdentityProvider(Protocol): + """A per-tenant IdP that turns a presented credential into a FederatedIdentity. + + Both :class:`rag_sso.oidc.OidcProvider` (credential = a JWT ID token) and + :class:`rag_sso.saml.SamlProvider` (credential = a base64 SAML response) + satisfy this protocol, so :class:`rag_sso.federated_auth.FederatedAuth` can + dispatch on the tenant's configured protocol without caring which it is. + """ + + @property + def protocol(self) -> SsoProtocol: ... + + def verify(self, credential: str, *, now: datetime | None = None) -> FederatedIdentity: ... + + +def identity_to_principal( + identity: FederatedIdentity, + tenant_id: TenantId, + *, + group_label_map: dict[str, str] | None = None, + kind: PrincipalKind = PrincipalKind.user, +) -> Principal: + """Map a verified :class:`FederatedIdentity` onto a trusted :class:`Principal`. + + ``subject`` becomes the principal id; the IdP ``groups`` become both the + principal's ``roles`` (verbatim, for display / RBAC) and its ``acl_labels`` + (the Step 6.3 push-down compares these against chunk labels). ``group_label_map`` + optionally renames a group to an ACL label; groups absent from the map pass + through unchanged, so the common 1:1 case needs no configuration. + """ + labels = frozenset((group_label_map or {}).get(group, group) for group in identity.groups) + return Principal( + id=PrincipalId(identity.subject), + kind=kind, + display_name=identity.display_name or identity.subject, + email=identity.email, + tenant_id=tenant_id, + roles=list(identity.groups), + acl_labels=labels, + ) diff --git a/packages/sso/src/rag_sso/jwt.py b/packages/sso/src/rag_sso/jwt.py new file mode 100644 index 0000000..1a2f5f1 --- /dev/null +++ b/packages/sso/src/rag_sso/jwt.py @@ -0,0 +1,197 @@ +"""Minimal, dependency-free JWT (JWS compact) verification (Step 6.8). + +The default OIDC path verifies HS256 ID tokens with nothing but the standard +library (``hmac`` / ``hashlib`` / ``base64`` / ``json``) so the gateway can +federate an IdP that issues symmetric tokens — and the test-suite — without any +heavyweight crypto dependency. Asymmetric algorithms (RS256 / ES256 / PS256) are +delegated to :mod:`jwt` (PyJWT, behind the ``[oidc]`` extra) and verified against +a configured public key; they are *not* available on the default install. + +Security posture: + +* The caller passes an **algorithm allowlist**. A token whose header ``alg`` is + not in the allowlist is rejected — this is the defense against the classic + algorithm-confusion downgrade (``alg: none``, or an RS256 token replayed as + HS256 against the public key). Symmetric and asymmetric verification take + *different* key material (``hmac_secret`` vs ``public_key``), so the two can + never be confused. +* ``exp`` / ``nbf`` are enforced with a small clock-skew ``leeway``; ``iss`` and + ``aud`` are checked when the caller supplies expected values. +* Signatures are compared with :func:`hmac.compare_digest` (constant time). +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +from datetime import UTC, datetime +from typing import Any + +from rag_core.errors import SsoError + +__all__ = ["decode_jwt_unverified", "verify_jwt", "encode_jwt_hs256"] + +# stdlib-verifiable symmetric algorithms → the hashlib digest they use. +_HS_ALGS: dict[str, str] = {"HS256": "sha256", "HS384": "sha384", "HS512": "sha512"} + + +def _b64url_decode(segment: str) -> bytes: + """Decode a base64url segment, tolerating missing padding.""" + padding = "=" * (-len(segment) % 4) + try: + return base64.urlsafe_b64decode(segment + padding) + except (ValueError, base64.binascii.Error) as exc: # type: ignore[attr-defined] + raise SsoError("malformed token segment", reason="invalid") from exc + + +def _b64url_encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _split(token: str) -> tuple[str, str, str]: + parts = token.split(".") + if len(parts) != 3: + raise SsoError("token is not a compact JWS (expected 3 segments)", reason="invalid") + return parts[0], parts[1], parts[2] + + +def _json_segment(segment: str, what: str) -> dict[str, Any]: + try: + obj = json.loads(_b64url_decode(segment)) + except (ValueError, UnicodeDecodeError) as exc: + raise SsoError(f"token {what} is not valid JSON", reason="invalid") from exc + if not isinstance(obj, dict): + raise SsoError(f"token {what} is not a JSON object", reason="invalid") + return obj + + +def decode_jwt_unverified(token: str) -> dict[str, Any]: + """Return the (unverified) claims of *token* — for diagnostics only.""" + _, payload_b64, _ = _split(token) + return _json_segment(payload_b64, "payload") + + +def _validate_claims( + claims: dict[str, Any], + *, + issuer: str | None, + audience: str | None, + leeway: int, + require_expiry: bool, + now: datetime, +) -> None: + epoch = now.timestamp() + + exp = claims.get("exp") + if exp is None: + if require_expiry: + raise SsoError("token has no exp claim", reason="invalid") + elif epoch > float(exp) + leeway: + raise SsoError("token has expired", reason="expired") + + nbf = claims.get("nbf") + if nbf is not None and epoch + leeway < float(nbf): + raise SsoError("token is not yet valid (nbf)", reason="expired") + + if issuer is not None and claims.get("iss") != issuer: + raise SsoError("token issuer mismatch", reason="wrong_issuer") + + if audience is not None: + aud = claims.get("aud") + ok = audience in aud if isinstance(aud, list) else aud == audience + if not ok: + raise SsoError("token audience mismatch", reason="wrong_audience") + + +def verify_jwt( + token: str, + *, + algorithms: tuple[str, ...], + hmac_secret: str | None = None, + public_key: str | None = None, + issuer: str | None = None, + audience: str | None = None, + leeway: int = 60, + require_expiry: bool = True, + now: datetime | None = None, +) -> dict[str, Any]: + """Verify *token*'s signature + standard claims and return its payload. + + Exactly one of ``hmac_secret`` (HS*) / ``public_key`` (RS*/ES*/PS*) supplies + the key material; ``algorithms`` is the allowlist the token's header ``alg`` + must be a member of. Raises :class:`~rag_core.errors.SsoError` on any failure. + """ + now = now or datetime.now(tz=UTC) + header_b64, payload_b64, sig_b64 = _split(token) + header = _json_segment(header_b64, "header") + + alg = header.get("alg") + if not isinstance(alg, str) or alg == "none": + raise SsoError("unsigned or algorithm-less token rejected", reason="bad_signature") + if alg not in algorithms: + raise SsoError(f"token algorithm {alg!r} not in the allowed set", reason="bad_signature") + + signing_input = f"{header_b64}.{payload_b64}".encode("ascii") + signature = _b64url_decode(sig_b64) + + if alg in _HS_ALGS: + if hmac_secret is None: + raise SsoError("HS* token requires a configured shared secret", reason="invalid") + expected = hmac.new(hmac_secret.encode("utf-8"), signing_input, _HS_ALGS[alg]).digest() + if not hmac.compare_digest(expected, signature): + raise SsoError("token signature is invalid", reason="bad_signature") + else: + _verify_asymmetric(signing_input, signature, alg=alg, public_key=public_key) + + claims = _json_segment(payload_b64, "payload") + _validate_claims( + claims, + issuer=issuer, + audience=audience, + leeway=leeway, + require_expiry=require_expiry, + now=now, + ) + return claims + + +def _verify_asymmetric( + signing_input: bytes, signature: bytes, *, alg: str, public_key: str | None +) -> None: + """Verify an RS*/ES*/PS* signature via PyJWT (the ``[oidc]`` extra).""" + if public_key is None: + raise SsoError(f"{alg} token requires a configured public_key", reason="invalid") + try: + from jwt.algorithms import get_default_algorithms + except ImportError as exc: # pragma: no cover - exercised only without the extra + raise SsoError( + f"verifying {alg} requires the rag-sso [oidc] extra (pyjwt[crypto])", + reason="invalid", + ) from exc + + algorithms = get_default_algorithms() + impl = algorithms.get(alg) + if impl is None: + raise SsoError(f"unsupported token algorithm {alg!r}", reason="bad_signature") + try: + key = impl.prepare_key(public_key) + if not impl.verify(signing_input, key, signature): + raise SsoError("token signature is invalid", reason="bad_signature") + except SsoError: + raise + except Exception as exc: # noqa: BLE001 - any crypto failure is a bad signature + raise SsoError("token signature is invalid", reason="bad_signature") from exc + + +def encode_jwt_hs256( + payload: dict[str, Any], secret: str, *, headers: dict[str, Any] | None = None +) -> str: + """Sign *payload* into an HS256 compact JWT — for tests, demos, and ``ragctl``.""" + header = {"alg": "HS256", "typ": "JWT", **(headers or {})} + header_b64 = _b64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8")) + payload_b64 = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + signing_input = f"{header_b64}.{payload_b64}".encode("ascii") + sig = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest() + return f"{header_b64}.{payload_b64}.{_b64url_encode(sig)}" diff --git a/packages/sso/src/rag_sso/oidc.py b/packages/sso/src/rag_sso/oidc.py new file mode 100644 index 0000000..0b469de --- /dev/null +++ b/packages/sso/src/rag_sso/oidc.py @@ -0,0 +1,89 @@ +"""OIDC identity provider — verify a signed JWT ID token → FederatedIdentity (Step 6.8).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from rag_core.errors import SsoError +from rag_core.types import FederatedIdentity, SsoProtocol + +from rag_sso.jwt import verify_jwt + +__all__ = ["OidcSettings", "OidcProvider"] + + +@dataclass(frozen=True) +class OidcSettings: + """Per-tenant OpenID Connect verification settings. + + The dependency-free default verifies an **HS256** token against + ``hmac_secret``. For production asymmetric tokens set ``algorithms`` to + ``("RS256",)`` (or ES*/PS*) and supply the IdP's ``public_key`` (PEM); that + path needs the ``[oidc]`` extra. ``issuer`` + ``audience`` are validated when + set (strongly recommended). The claim names default to the OIDC standard but + are configurable for IdPs that emit groups under a custom claim. + """ + + issuer: str = "" + audience: str = "" + algorithms: tuple[str, ...] = ("HS256",) + hmac_secret: str | None = None + public_key: str | None = None + subject_claim: str = "sub" + email_claim: str = "email" + name_claim: str = "name" + group_claim: str = "groups" + leeway_seconds: int = 60 + require_expiry: bool = True + default_groups: tuple[str, ...] = field(default_factory=tuple) + + +def _as_groups(value: Any) -> tuple[str, ...]: + """Normalise a groups claim — a list, a space/comma string, or a scalar.""" + if value is None: + return () + if isinstance(value, (list, tuple)): + return tuple(str(v) for v in value) + if isinstance(value, str): + return tuple(part for part in value.replace(",", " ").split()) + return (str(value),) + + +class OidcProvider: + """Verifies an OIDC ID token and extracts a :class:`FederatedIdentity`.""" + + protocol = SsoProtocol.oidc + + def __init__(self, settings: OidcSettings) -> None: + self._s = settings + + def verify(self, credential: str, *, now: datetime | None = None) -> FederatedIdentity: + s = self._s + claims = verify_jwt( + credential, + algorithms=s.algorithms, + hmac_secret=s.hmac_secret, + public_key=s.public_key, + issuer=s.issuer or None, + audience=s.audience or None, + leeway=s.leeway_seconds, + require_expiry=s.require_expiry, + now=now, + ) + subject = claims.get(s.subject_claim) + if not subject: + raise SsoError("token is missing the subject claim", reason="invalid") + + groups = _as_groups(claims.get(s.group_claim)) or s.default_groups + email = claims.get(s.email_claim) + name = claims.get(s.name_claim) + return FederatedIdentity( + subject=str(subject), + issuer=str(claims.get("iss", s.issuer)), + protocol=SsoProtocol.oidc, + email=str(email) if email else None, + display_name=str(name) if name else None, + groups=groups, + ) diff --git a/packages/sso/src/rag_sso/py.typed b/packages/sso/src/rag_sso/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/sso/src/rag_sso/saml.py b/packages/sso/src/rag_sso/saml.py new file mode 100644 index 0000000..60692a3 --- /dev/null +++ b/packages/sso/src/rag_sso/saml.py @@ -0,0 +1,202 @@ +"""SAML 2.0 identity provider — verify an assertion → FederatedIdentity (Step 6.8). + +The credential is a base64-encoded SAML ``Response`` (HTTP-POST binding). Parsing +goes through :mod:`defusedxml` so a hostile IdP response cannot mount an XXE or +billion-laughs attack. The provider validates the assertion's ``Issuer``, +``Conditions`` window (NotBefore / NotOnOrAfter, with leeway), and +``AudienceRestriction`` — and, when ``require_signature`` is on, demands a valid +XML-DSig. Real XML-DSig verification (C14N canonicalisation) is heavy, so it is +injected: pass a ``signature_verifier`` (e.g. :func:`signxml_verifier`, which +needs the ``[saml]`` extra). With ``require_signature`` off the provider still +validates everything *except* the signature — useful for tests and for IdPs that +sign at the transport layer. +""" + +from __future__ import annotations + +import base64 +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime + +from defusedxml.ElementTree import fromstring +from rag_core.errors import SsoError +from rag_core.types import FederatedIdentity, SsoProtocol + +__all__ = ["SamlSettings", "SamlProvider", "signxml_verifier"] + +_ASSERTION_NS = "urn:oasis:names:tc:SAML:2.0:assertion" +_A = f"{{{_ASSERTION_NS}}}" + + +@dataclass(frozen=True) +class SamlSettings: + """Per-tenant SAML 2.0 verification settings. + + ``idp_entity_id`` is the expected ``Issuer``; ``audience`` is this service's + SP entity id, checked against the assertion's ``AudienceRestriction``. The + attribute names locate the IdP's email / group / display-name claims (matched + against either the ``Name`` or ``FriendlyName`` of each SAML ``Attribute``). + ``require_signature`` (default on) demands a verified XML-DSig. + """ + + idp_entity_id: str = "" + audience: str = "" + email_attribute: str = "email" + group_attribute: str = "groups" + name_attribute: str | None = "displayName" + require_signature: bool = True + leeway_seconds: int = 60 + + +def _parse_dt(value: str) -> datetime: + dt = datetime.fromisoformat(value) + return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC) + + +def signxml_verifier(cert_pem: str) -> Callable[[bytes], bool]: + """Build an XML-DSig verifier from an IdP certificate (needs the ``[saml]`` extra). + + Returns a callable suitable for ``SamlProvider(signature_verifier=...)`` that + verifies the enveloped signature on the SAML XML against ``cert_pem``. + """ + + def _verify(xml_bytes: bytes) -> bool: + try: + from signxml import XMLVerifier + except ImportError as exc: # pragma: no cover - only without the extra + raise SsoError( + "SAML signature verification requires the rag-sso [saml] extra (signxml)", + reason="invalid", + ) from exc + try: + XMLVerifier().verify(xml_bytes, x509_cert=cert_pem) + return True + except Exception: # noqa: BLE001 - any verify failure is an invalid signature + return False + + return _verify + + +class SamlProvider: + """Validates a SAML 2.0 assertion and extracts a :class:`FederatedIdentity`.""" + + protocol = SsoProtocol.saml + + def __init__( + self, + settings: SamlSettings, + *, + signature_verifier: Callable[[bytes], bool] | None = None, + ) -> None: + self._s = settings + self._verify_sig = signature_verifier + + def verify(self, credential: str, *, now: datetime | None = None) -> FederatedIdentity: + now = now or datetime.now(tz=UTC) + xml_bytes = self._decode(credential) + self._check_signature(xml_bytes) + + root = self._parse(xml_bytes) + assertion = self._find_assertion(root) + self._check_issuer(assertion) + self._check_conditions(assertion, now=now) + + subject = self._subject(assertion) + attrs = self._attributes(assertion) + s = self._s + email = _first(attrs.get(s.email_attribute)) + name = _first(attrs.get(s.name_attribute)) if s.name_attribute else None + groups = tuple(attrs.get(s.group_attribute, ())) + return FederatedIdentity( + subject=subject, + issuer=self._issuer(assertion), + protocol=SsoProtocol.saml, + email=email, + display_name=name, + groups=groups, + ) + + # -- internals ----------------------------------------------------------- + @staticmethod + def _decode(credential: str) -> bytes: + try: + return base64.b64decode(credential, validate=True) + except (ValueError, base64.binascii.Error) as exc: # type: ignore[attr-defined] + raise SsoError("SAML response is not valid base64", reason="invalid") from exc + + def _check_signature(self, xml_bytes: bytes) -> None: + if not self._s.require_signature: + return + if self._verify_sig is None: + raise SsoError( + "SAML signature required but no signature verifier is configured", + reason="bad_signature", + ) + if not self._verify_sig(xml_bytes): + raise SsoError("SAML assertion signature is invalid", reason="bad_signature") + + @staticmethod + def _parse(xml_bytes: bytes) -> object: + try: + return fromstring(xml_bytes) + except Exception as exc: # noqa: BLE001 - defused parser raises various types + raise SsoError("SAML response is not well-formed XML", reason="invalid") from exc + + @staticmethod + def _find_assertion(root: object) -> object: + if getattr(root, "tag", None) == f"{_A}Assertion": + return root + for el in root.iter(f"{_A}Assertion"): # type: ignore[attr-defined] + return el + raise SsoError("SAML response has no Assertion", reason="invalid") + + def _issuer(self, assertion: object) -> str: + issuer = assertion.find(f"{_A}Issuer") # type: ignore[attr-defined] + return (issuer.text or "").strip() if issuer is not None else "" + + def _check_issuer(self, assertion: object) -> None: + expected = self._s.idp_entity_id + if expected and self._issuer(assertion) != expected: + raise SsoError("SAML issuer mismatch", reason="wrong_issuer") + + def _check_conditions(self, assertion: object, *, now: datetime) -> None: + leeway = self._s.leeway_seconds + conditions = assertion.find(f"{_A}Conditions") # type: ignore[attr-defined] + if conditions is None: + return + not_before = conditions.get("NotBefore") + not_on_or_after = conditions.get("NotOnOrAfter") + if not_before and (now - _parse_dt(not_before)).total_seconds() < -leeway: + raise SsoError("SAML assertion is not yet valid", reason="expired") + if not_on_or_after and (now - _parse_dt(not_on_or_after)).total_seconds() > leeway: + raise SsoError("SAML assertion has expired", reason="expired") + + if not self._s.audience: + return + audiences = [(a.text or "").strip() for a in conditions.iter(f"{_A}Audience")] + if audiences and self._s.audience not in audiences: + raise SsoError("SAML audience mismatch", reason="wrong_audience") + + @staticmethod + def _subject(assertion: object) -> str: + subject = assertion.find(f"{_A}Subject") # type: ignore[attr-defined] + name_id = subject.find(f"{_A}NameID") if subject is not None else None + value = (name_id.text or "").strip() if name_id is not None else "" + if not value: + raise SsoError("SAML assertion has no Subject NameID", reason="invalid") + return value + + @staticmethod + def _attributes(assertion: object) -> dict[str, list[str]]: + out: dict[str, list[str]] = {} + for attr in assertion.iter(f"{_A}Attribute"): # type: ignore[attr-defined] + values = [(v.text or "").strip() for v in attr.iter(f"{_A}AttributeValue") if v.text] + for key in (attr.get("Name"), attr.get("FriendlyName")): + if key: + out.setdefault(key, []).extend(values) + return out + + +def _first(values: list[str] | None) -> str | None: + return values[0] if values else None diff --git a/packages/sso/src/rag_sso/scim.py b/packages/sso/src/rag_sso/scim.py new file mode 100644 index 0000000..c00108c --- /dev/null +++ b/packages/sso/src/rag_sso/scim.py @@ -0,0 +1,275 @@ +"""SCIM 2.0 provisioning service over the ScimStore SPI (Step 6.8). + +The protocol layer between the SCIM REST surface and the tenant-scoped +:class:`~rag_core.spi.scim_store.ScimStore`: it enforces ``userName`` / +``displayName`` uniqueness, assigns server ids + ``meta``, applies the common +PATCH operations (notably the IdP deactivation patch ``active = false``), parses +the ``attr eq "value"`` list filter, and emits PII-free ``scim.*`` events. All +methods are ``ctx``-first and strictly tenant-scoped. +""" + +from __future__ import annotations + +import re +import uuid +from datetime import UTC, datetime +from typing import Any + +from rag_core.errors import ScimConflictError, ScimError, ScimNotFoundError +from rag_core.spi.scim_store import ScimStore +from rag_core.types import ( + RequestContext, + ScimGroup, + ScimResourceMeta, + ScimUser, +) +from rag_observability.events import ( + EVT_SCIM_GROUP_CHANGED, + EVT_SCIM_USER_DEPROVISIONED, + EVT_SCIM_USER_PROVISIONED, + ScimEvent, +) +from rag_observability.logging import get_logger + +__all__ = ["ScimService", "parse_eq_filter"] + +_log = get_logger(__name__) +_FILTER_RE = re.compile(r'^\s*(?P\w+)\s+eq\s+"(?P[^"]*)"\s*$', re.IGNORECASE) + + +def parse_eq_filter(scim_filter: str | None, attr: str) -> str | None: + """Extract the value from a SCIM ``attr eq "value"`` filter, or ``None``. + + Supports only the single equality filter SCIM clients use for reconciliation + (``userName eq "x"`` / ``displayName eq "y"``); any other filter — or a filter + on a different attribute — raises :class:`~rag_core.errors.ScimError` (HTTP 400), + per RFC 7644's "unsupported filter" handling. + """ + if not scim_filter: + return None + match = _FILTER_RE.match(scim_filter) + if match is None or match.group("attr").lower() != attr.lower(): + raise ScimError(f"unsupported SCIM filter: {scim_filter!r}") + return match.group("value") + + +def _now() -> datetime: + return datetime.now(tz=UTC) + + +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"true", "1", "yes"} + + +class ScimService: + """SCIM 2.0 User + Group provisioning over a :class:`ScimStore` (Step 6.8).""" + + def __init__(self, store: ScimStore, *, base_path: str = "/scim/v2") -> None: + self._store = store + self._base = base_path.rstrip("/") + + # -- Users --------------------------------------------------------------- + async def create_user(self, ctx: RequestContext, resource: ScimUser) -> ScimUser: + if await self._store.get_user_by_username(ctx, resource.user_name) is not None: + raise ScimConflictError(f"userName {resource.user_name!r} already exists") + user_id = str(uuid.uuid4()) + stored = resource.model_copy( + update={"id": user_id, "meta": self._meta("User", user_id, "Users")} + ) + await self._store.put_user(ctx, stored) + self._emit_user(ctx, "create", stored) + return stored + + async def get_user(self, ctx: RequestContext, user_id: str) -> ScimUser: + user = await self._store.get_user(ctx, user_id) + if user is None: + raise ScimNotFoundError(f"User {user_id!r} not found") + return user + + async def replace_user(self, ctx: RequestContext, user_id: str, resource: ScimUser) -> ScimUser: + existing = await self.get_user(ctx, user_id) + created = existing.meta.created if existing.meta else _now() + stored = resource.model_copy( + update={"id": user_id, "meta": self._meta("User", user_id, "Users", created=created)} + ) + await self._store.put_user(ctx, stored) + self._emit_user(ctx, "replace", stored, deprovisioned=existing.active and not stored.active) + return stored + + async def patch_user( + self, ctx: RequestContext, user_id: str, operations: list[dict[str, Any]] + ) -> ScimUser: + existing = await self.get_user(ctx, user_id) + updates = _collect_user_patch(operations) + created = existing.meta.created if existing.meta else _now() + stored = existing.model_copy( + update={**updates, "meta": self._meta("User", user_id, "Users", created=created)} + ) + await self._store.put_user(ctx, stored) + self._emit_user(ctx, "patch", stored, deprovisioned=existing.active and not stored.active) + return stored + + async def delete_user(self, ctx: RequestContext, user_id: str) -> None: + if not await self._store.delete_user(ctx, user_id): + raise ScimNotFoundError(f"User {user_id!r} not found") + _log.info( + ScimEvent( + event_name=EVT_SCIM_USER_DEPROVISIONED, + tenant_id=str(ctx.tenant_id), + operation="delete", + resource_type="User", + resource_id=user_id, + active=False, + ).model_dump_json() + ) + + async def list_users( + self, + ctx: RequestContext, + *, + start_index: int = 1, + count: int = 100, + scim_filter: str | None = None, + ) -> tuple[list[ScimUser], int]: + user_name = parse_eq_filter(scim_filter, "userName") + return await self._store.list_users( + ctx, start_index=start_index, count=count, user_name=user_name + ) + + # -- Groups -------------------------------------------------------------- + async def create_group(self, ctx: RequestContext, resource: ScimGroup) -> ScimGroup: + if await self._store.get_group_by_display_name(ctx, resource.display_name) is not None: + raise ScimConflictError(f"displayName {resource.display_name!r} already exists") + group_id = str(uuid.uuid4()) + stored = resource.model_copy( + update={"id": group_id, "meta": self._meta("Group", group_id, "Groups")} + ) + await self._store.put_group(ctx, stored) + self._emit_group(ctx, "create", stored) + return stored + + async def get_group(self, ctx: RequestContext, group_id: str) -> ScimGroup: + group = await self._store.get_group(ctx, group_id) + if group is None: + raise ScimNotFoundError(f"Group {group_id!r} not found") + return group + + async def replace_group( + self, ctx: RequestContext, group_id: str, resource: ScimGroup + ) -> ScimGroup: + existing = await self.get_group(ctx, group_id) + created = existing.meta.created if existing.meta else _now() + stored = resource.model_copy( + update={ + "id": group_id, + "meta": self._meta("Group", group_id, "Groups", created=created), + } + ) + await self._store.put_group(ctx, stored) + self._emit_group(ctx, "replace", stored) + return stored + + async def delete_group(self, ctx: RequestContext, group_id: str) -> None: + if not await self._store.delete_group(ctx, group_id): + raise ScimNotFoundError(f"Group {group_id!r} not found") + _log.info( + ScimEvent( + event_name=EVT_SCIM_GROUP_CHANGED, + tenant_id=str(ctx.tenant_id), + operation="delete", + resource_type="Group", + resource_id=group_id, + ).model_dump_json() + ) + + async def list_groups( + self, + ctx: RequestContext, + *, + start_index: int = 1, + count: int = 100, + scim_filter: str | None = None, + ) -> tuple[list[ScimGroup], int]: + display_name = parse_eq_filter(scim_filter, "displayName") + return await self._store.list_groups( + ctx, start_index=start_index, count=count, display_name=display_name + ) + + # -- helpers ------------------------------------------------------------- + def _meta( + self, + resource_type: str, + resource_id: str, + collection: str, + *, + created: datetime | None = None, + ) -> ScimResourceMeta: + now = _now() + return ScimResourceMeta( + resource_type=resource_type, + created=created or now, + last_modified=now, + location=f"{self._base}/{collection}/{resource_id}", + ) + + def _emit_user( + self, ctx: RequestContext, operation: str, user: ScimUser, *, deprovisioned: bool = False + ) -> None: + name = EVT_SCIM_USER_DEPROVISIONED if deprovisioned else EVT_SCIM_USER_PROVISIONED + _log.info( + ScimEvent( + event_name=name, + tenant_id=str(ctx.tenant_id), + operation=operation, + resource_type="User", + resource_id=user.id, + active=user.active, + ).model_dump_json() + ) + + def _emit_group(self, ctx: RequestContext, operation: str, group: ScimGroup) -> None: + _log.info( + ScimEvent( + event_name=EVT_SCIM_GROUP_CHANGED, + tenant_id=str(ctx.tenant_id), + operation=operation, + resource_type="Group", + resource_id=group.id, + ).model_dump_json() + ) + + +def _collect_user_patch(operations: list[dict[str, Any]]) -> dict[str, Any]: + """Translate SCIM PATCH operations into ScimUser field updates. + + Supports the operations IdPs actually send: ``replace``/``add`` of ``active``, + ``userName``, ``displayName`` — either as a pathed op (``path: "active"``) or a + pathless op whose ``value`` is an attribute map (Azure AD style). Unknown + paths are ignored (a SCIM-compliant no-op) rather than failing the sync. + """ + updates: dict[str, Any] = {} + field_by_path = {"active": "active", "username": "user_name", "displayname": "display_name"} + for entry in operations: + op = str(entry.get("op", "")).lower() + path = entry.get("path") + value = entry.get("value") + if op == "remove": + if path and str(path).lower() == "active": + updates["active"] = False + continue + if path: + field = field_by_path.get(str(path).lower()) + if field == "active": + updates["active"] = _as_bool(value) + elif field is not None: + updates[field] = str(value) + elif isinstance(value, dict): + for key, val in value.items(): + field = field_by_path.get(str(key).lower()) + if field == "active": + updates["active"] = _as_bool(val) + elif field is not None: + updates[field] = str(val) + return updates diff --git a/packages/sso/tests/test_jwt.py b/packages/sso/tests/test_jwt.py new file mode 100644 index 0000000..9c18336 --- /dev/null +++ b/packages/sso/tests/test_jwt.py @@ -0,0 +1,107 @@ +"""Unit tests for the dependency-free JWT verifier (Step 6.8).""" + +from __future__ import annotations + +import time + +import pytest +from rag_core.errors import SsoError +from rag_sso.jwt import decode_jwt_unverified, encode_jwt_hs256, verify_jwt + +SECRET = "unit-test-secret" +ALGS = ("HS256",) + + +def _token(**claims: object) -> str: + base = {"sub": "u1", "iss": "iss", "aud": "aud", "exp": int(time.time()) + 3600} + base.update(claims) + return encode_jwt_hs256(base, SECRET) + + +def test_hs256_round_trip() -> None: + claims = verify_jwt(_token(), algorithms=ALGS, hmac_secret=SECRET, issuer="iss", audience="aud") + assert claims["sub"] == "u1" + + +def test_expired_rejected() -> None: + tok = _token(exp=int(time.time()) - 3600) + with pytest.raises(SsoError) as ei: + verify_jwt(tok, algorithms=ALGS, hmac_secret=SECRET) + assert ei.value.context["reason"] == "expired" + + +def test_not_yet_valid_rejected() -> None: + tok = _token(nbf=int(time.time()) + 3600) + with pytest.raises(SsoError): + verify_jwt(tok, algorithms=ALGS, hmac_secret=SECRET) + + +def test_missing_exp_rejected_when_required() -> None: + tok = encode_jwt_hs256({"sub": "u1"}, SECRET) + with pytest.raises(SsoError): + verify_jwt(tok, algorithms=ALGS, hmac_secret=SECRET) + # ...accepted when expiry is not required. + assert verify_jwt(tok, algorithms=ALGS, hmac_secret=SECRET, require_expiry=False)["sub"] == "u1" + + +def test_wrong_issuer_rejected() -> None: + with pytest.raises(SsoError) as ei: + verify_jwt(_token(), algorithms=ALGS, hmac_secret=SECRET, issuer="other") + assert ei.value.context["reason"] == "wrong_issuer" + + +def test_wrong_audience_rejected() -> None: + with pytest.raises(SsoError) as ei: + verify_jwt(_token(), algorithms=ALGS, hmac_secret=SECRET, audience="other") + assert ei.value.context["reason"] == "wrong_audience" + + +def test_audience_list_accepted() -> None: + tok = _token(aud=["a", "b", "aud"]) + assert verify_jwt(tok, algorithms=ALGS, hmac_secret=SECRET, audience="aud")["sub"] == "u1" + + +def test_tampered_signature_rejected() -> None: + tok = _token() + forged = tok[:-2] + ("aa" if tok[-2:] != "aa" else "bb") + with pytest.raises(SsoError) as ei: + verify_jwt(forged, algorithms=ALGS, hmac_secret=SECRET) + assert ei.value.context["reason"] == "bad_signature" + + +def test_wrong_secret_rejected() -> None: + with pytest.raises(SsoError): + verify_jwt(_token(), algorithms=ALGS, hmac_secret="not-the-secret") + + +def test_alg_none_rejected() -> None: + # Craft an unsigned token: header alg=none. + import base64 + import json + + def b64(obj: dict[str, object]) -> str: + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode() + + tok = f"{b64({'alg': 'none', 'typ': 'JWT'})}.{b64({'sub': 'u1'})}." + with pytest.raises(SsoError) as ei: + verify_jwt(tok, algorithms=("none", "HS256"), hmac_secret=SECRET) + assert ei.value.context["reason"] == "bad_signature" + + +def test_alg_not_in_allowlist_rejected() -> None: + # An HS256 token rejected when only RS256 is allowed (downgrade defense). + with pytest.raises(SsoError) as ei: + verify_jwt(_token(), algorithms=("RS256",), hmac_secret=SECRET) + assert ei.value.context["reason"] == "bad_signature" + + +def test_malformed_token_rejected() -> None: + with pytest.raises(SsoError): + verify_jwt("not.a.jwt.too.many", algorithms=ALGS, hmac_secret=SECRET) + with pytest.raises(SsoError): + verify_jwt("only-one-segment", algorithms=ALGS, hmac_secret=SECRET) + + +def test_decode_unverified() -> None: + claims = decode_jwt_unverified(_token(custom="x")) + assert claims["custom"] == "x" diff --git a/packages/sso/tests/test_oidc_federated.py b/packages/sso/tests/test_oidc_federated.py new file mode 100644 index 0000000..837e06c --- /dev/null +++ b/packages/sso/tests/test_oidc_federated.py @@ -0,0 +1,109 @@ +"""Unit tests for OidcProvider + identity mapping + FederatedAuth (Step 6.8).""" + +from __future__ import annotations + +import time + +import pytest +from rag_core.errors import SsoError +from rag_core.types import ACLAction, SsoProtocol, TenantId +from rag_sso import ( + FederatedAuth, + OidcProvider, + OidcSettings, + encode_jwt_hs256, + identity_to_principal, +) + +SECRET = "oidc-secret" + + +def _settings(**kw: object) -> OidcSettings: + base = dict(issuer="iss", audience="aud", hmac_secret=SECRET, group_claim="groups") + base.update(kw) + return OidcSettings(**base) # type: ignore[arg-type] + + +def _token(**claims: object) -> str: + base = { + "sub": "u-1", + "email": "a@b.test", + "name": "Alice", + "groups": ["eng", "admin"], + "iss": "iss", + "aud": "aud", + "exp": int(time.time()) + 3600, + } + base.update(claims) + return encode_jwt_hs256(base, SECRET) + + +def test_oidc_verify_extracts_identity() -> None: + ident = OidcProvider(_settings()).verify(_token()) + assert ident.subject == "u-1" + assert ident.email == "a@b.test" + assert ident.display_name == "Alice" + assert ident.groups == ("eng", "admin") + assert ident.protocol is SsoProtocol.oidc + + +def test_oidc_groups_from_space_delimited_string() -> None: + ident = OidcProvider(_settings()).verify(_token(groups="eng admin ops")) + assert ident.groups == ("eng", "admin", "ops") + + +def test_oidc_missing_subject_rejected() -> None: + tok = encode_jwt_hs256({"iss": "iss", "aud": "aud", "exp": int(time.time()) + 99}, SECRET) + with pytest.raises(SsoError): + OidcProvider(_settings()).verify(tok) + + +def test_oidc_custom_group_claim() -> None: + s = _settings(group_claim="roles") + ident = OidcProvider(s).verify(_token(roles=["r1"], groups=["ignored"])) + assert ident.groups == ("r1",) + + +def test_identity_to_principal_maps_groups_to_labels() -> None: + ident = OidcProvider(_settings()).verify(_token()) + p = identity_to_principal(ident, TenantId("acme"), group_label_map={"eng": "corpus-eng"}) + assert str(p.id) == "u-1" + assert p.email == "a@b.test" + assert p.roles == ["eng", "admin"] + assert p.acl_labels == frozenset({"corpus-eng", "admin"}) + assert str(p.tenant_id) == "acme" + + +async def test_federated_auth_authenticate() -> None: + auth = FederatedAuth({"acme": OidcProvider(_settings())}) + p = await auth.authenticate(_token(), TenantId("acme")) + assert str(p.id) == "u-1" + assert p.acl_labels == frozenset({"eng", "admin"}) + assert await auth.authorize(p, ACLAction.read, "corpus-x") is True + + +async def test_federated_auth_no_provider_for_tenant() -> None: + auth = FederatedAuth({"acme": OidcProvider(_settings())}) + with pytest.raises(SsoError) as ei: + await auth.authenticate(_token(), TenantId("globex")) + assert ei.value.context["reason"] == "no_provider" + + +async def test_federated_auth_default_provider() -> None: + auth = FederatedAuth({}, default_provider=OidcProvider(_settings())) + p = await auth.authenticate(_token(), TenantId("anyone")) + assert str(p.id) == "u-1" + + +async def test_federated_auth_rejects_bad_token() -> None: + auth = FederatedAuth({"acme": OidcProvider(_settings())}) + with pytest.raises(SsoError) as ei: + await auth.authenticate(_token(exp=int(time.time()) - 99), TenantId("acme")) + assert ei.value.context["reason"] == "expired" + + +def test_federated_auth_configured_tenants() -> None: + auth = FederatedAuth({"acme": OidcProvider(_settings()), "globex": OidcProvider(_settings())}) + assert auth.configured_tenants() == frozenset({"acme", "globex"}) + assert auth.provider_for("acme") is not None + assert auth.provider_for("nope") is None diff --git a/packages/sso/tests/test_saml.py b/packages/sso/tests/test_saml.py new file mode 100644 index 0000000..b712611 --- /dev/null +++ b/packages/sso/tests/test_saml.py @@ -0,0 +1,135 @@ +"""Unit tests for the SAML 2.0 provider (Step 6.8). + +Builds minimal SAML assertions in-test (no signing) and exercises condition / +audience / issuer validation, attribute extraction, and the require_signature +posture (rejects without a verifier; accepts / rejects via an injected stub). +""" + +from __future__ import annotations + +import base64 +from datetime import UTC, datetime, timedelta + +import pytest +from rag_core.errors import SsoError +from rag_core.types import SsoProtocol +from rag_sso import SamlProvider, SamlSettings + +_NS = "urn:oasis:names:tc:SAML:2.0:assertion" + + +def _iso(dt: datetime) -> str: + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def build_assertion( + *, + issuer: str = "https://idp.test", + name_id: str = "user@test", + audience: str | None = "sp-entity", + not_before: datetime | None = None, + not_on_or_after: datetime | None = None, + email: str = "user@test", + groups: tuple[str, ...] = ("eng", "admin"), +) -> str: + now = datetime.now(tz=UTC) + nb = _iso(not_before or now - timedelta(minutes=5)) + noa = _iso(not_on_or_after or now + timedelta(hours=1)) + audience_xml = ( + f"{audience}" + "" + if audience is not None + else "" + ) + group_values = "".join(f"{g}" for g in groups) + xml = ( + f'' + f"{issuer}" + f"{name_id}" + f'{audience_xml}' + "" + f'{email}' + "" + f'{group_values}' + '' + "Display Name" + "" + "" + ) + return base64.b64encode(xml.encode("utf-8")).decode("ascii") + + +def _settings(**kw: object) -> SamlSettings: + base = dict( + idp_entity_id="https://idp.test", + audience="sp-entity", + require_signature=False, + ) + base.update(kw) + return SamlSettings(**base) # type: ignore[arg-type] + + +def test_saml_unsigned_validates_and_extracts() -> None: + ident = SamlProvider(_settings()).verify(build_assertion()) + assert ident.subject == "user@test" + assert ident.email == "user@test" + assert ident.display_name == "Display Name" + assert ident.groups == ("eng", "admin") + assert ident.protocol is SsoProtocol.saml + assert ident.issuer == "https://idp.test" + + +def test_saml_wrong_issuer_rejected() -> None: + with pytest.raises(SsoError) as ei: + SamlProvider(_settings()).verify(build_assertion(issuer="https://evil.test")) + assert ei.value.context["reason"] == "wrong_issuer" + + +def test_saml_wrong_audience_rejected() -> None: + with pytest.raises(SsoError) as ei: + SamlProvider(_settings()).verify(build_assertion(audience="someone-else")) + assert ei.value.context["reason"] == "wrong_audience" + + +def test_saml_expired_rejected() -> None: + past = datetime.now(tz=UTC) - timedelta(hours=2) + with pytest.raises(SsoError) as ei: + SamlProvider(_settings()).verify(build_assertion(not_on_or_after=past)) + assert ei.value.context["reason"] == "expired" + + +def test_saml_not_yet_valid_rejected() -> None: + future = datetime.now(tz=UTC) + timedelta(hours=2) + with pytest.raises(SsoError): + SamlProvider(_settings()).verify(build_assertion(not_before=future)) + + +def test_saml_missing_nameid_rejected() -> None: + with pytest.raises(SsoError): + SamlProvider(_settings()).verify(build_assertion(name_id="")) + + +def test_saml_not_well_formed_rejected() -> None: + with pytest.raises(SsoError): + SamlProvider(_settings()).verify(base64.b64encode(b" None: + with pytest.raises(SsoError) as ei: + SamlProvider(_settings(require_signature=True)).verify(build_assertion()) + assert ei.value.context["reason"] == "bad_signature" + + +def test_saml_signature_accepted_with_stub_verifier() -> None: + provider = SamlProvider(_settings(require_signature=True), signature_verifier=lambda _xml: True) + ident = provider.verify(build_assertion()) + assert ident.subject == "user@test" + + +def test_saml_signature_rejected_by_stub_verifier() -> None: + provider = SamlProvider( + _settings(require_signature=True), signature_verifier=lambda _xml: False + ) + with pytest.raises(SsoError) as ei: + provider.verify(build_assertion()) + assert ei.value.context["reason"] == "bad_signature" diff --git a/packages/sso/tests/test_scim_service.py b/packages/sso/tests/test_scim_service.py new file mode 100644 index 0000000..f6503a1 --- /dev/null +++ b/packages/sso/tests/test_scim_service.py @@ -0,0 +1,142 @@ +"""Unit tests for the SCIM 2.0 ScimService over NoopScimStore (Step 6.8).""" + +from __future__ import annotations + +import pytest +from rag_core.errors import ScimConflictError, ScimError, ScimNotFoundError +from rag_core.spi.noop import NoopScimStore +from rag_core.types import ( + Principal, + PrincipalId, + PrincipalKind, + RequestContext, + ScimGroup, + ScimMember, + ScimUser, + TenantId, +) +from rag_sso import ScimService, parse_eq_filter + + +def _ctx(tenant: str = "acme", principal: str = "scim") -> RequestContext: + tid = TenantId(tenant) + return RequestContext( + tenant_id=tid, + principal=Principal( + id=PrincipalId(principal), + kind=PrincipalKind.service, + display_name=principal, + tenant_id=tid, + ), + ) + + +def _svc() -> ScimService: + return ScimService(NoopScimStore()) + + +async def test_create_assigns_id_and_meta() -> None: + svc, ctx = _svc(), _ctx() + user = await svc.create_user(ctx, ScimUser(user_name="a@x.test", display_name="A")) + assert user.id + assert user.meta is not None + assert user.meta.resource_type == "User" + assert user.meta.location == f"/scim/v2/Users/{user.id}" + + +async def test_create_duplicate_username_conflicts() -> None: + svc, ctx = _svc(), _ctx() + await svc.create_user(ctx, ScimUser(user_name="dup@x.test")) + with pytest.raises(ScimConflictError): + await svc.create_user(ctx, ScimUser(user_name="dup@x.test")) + + +async def test_get_missing_raises_not_found() -> None: + svc, ctx = _svc(), _ctx() + with pytest.raises(ScimNotFoundError): + await svc.get_user(ctx, "nope") + + +async def test_replace_preserves_created_timestamp() -> None: + svc, ctx = _svc(), _ctx() + created = await svc.create_user(ctx, ScimUser(user_name="r@x.test")) + replaced = await svc.replace_user( + ctx, created.id, ScimUser(user_name="r@x.test", display_name="Renamed") + ) + assert replaced.id == created.id + assert replaced.display_name == "Renamed" + assert replaced.meta is not None and created.meta is not None + assert replaced.meta.created == created.meta.created + + +async def test_patch_deactivate_pathless_value() -> None: + svc, ctx = _svc(), _ctx() + user = await svc.create_user(ctx, ScimUser(user_name="p@x.test", active=True)) + patched = await svc.patch_user(ctx, user.id, [{"op": "replace", "value": {"active": False}}]) + assert patched.active is False + + +async def test_patch_deactivate_pathed() -> None: + svc, ctx = _svc(), _ctx() + user = await svc.create_user(ctx, ScimUser(user_name="p2@x.test", active=True)) + patched = await svc.patch_user( + ctx, user.id, [{"op": "replace", "path": "active", "value": "False"}] + ) + assert patched.active is False + + +async def test_delete_then_missing() -> None: + svc, ctx = _svc(), _ctx() + user = await svc.create_user(ctx, ScimUser(user_name="d@x.test")) + await svc.delete_user(ctx, user.id) + with pytest.raises(ScimNotFoundError): + await svc.delete_user(ctx, user.id) + + +async def test_list_with_filter() -> None: + svc, ctx = _svc(), _ctx() + for i in range(3): + await svc.create_user(ctx, ScimUser(user_name=f"u{i}@x.test")) + page, total = await svc.list_users(ctx, scim_filter='userName eq "u1@x.test"') + assert total == 1 and page[0].user_name == "u1@x.test" + + +async def test_unsupported_filter_raises() -> None: + svc, ctx = _svc(), _ctx() + with pytest.raises(ScimError): + await svc.list_users(ctx, scim_filter='userName sw "u"') + + +async def test_tenant_isolation() -> None: + svc = _svc() + acme, globex = _ctx("acme"), _ctx("globex") + await svc.create_user(acme, ScimUser(user_name="shared@x.test")) + # Same userName under a different tenant is allowed (no cross-tenant conflict). + await svc.create_user(globex, ScimUser(user_name="shared@x.test")) + _, acme_total = await svc.list_users(acme) + _, globex_total = await svc.list_users(globex) + assert acme_total == 1 and globex_total == 1 + + +async def test_group_crud() -> None: + svc, ctx = _svc(), _ctx() + group = await svc.create_group( + ctx, ScimGroup(display_name="eng", members=(ScimMember(value="u-1"),)) + ) + assert group.meta is not None and group.meta.resource_type == "Group" + with pytest.raises(ScimConflictError): + await svc.create_group(ctx, ScimGroup(display_name="eng")) + fetched = await svc.get_group(ctx, group.id) + assert fetched.members[0].value == "u-1" + await svc.delete_group(ctx, group.id) + with pytest.raises(ScimNotFoundError): + await svc.get_group(ctx, group.id) + + +def test_parse_eq_filter() -> None: + assert parse_eq_filter('userName eq "alice"', "userName") == "alice" + assert parse_eq_filter(None, "userName") is None + with pytest.raises(ScimError): + parse_eq_filter('displayName eq "x"', "userName") # wrong attr + with pytest.raises(ScimError): + parse_eq_filter('userName co "a"', "userName") # unsupported op diff --git a/pyproject.toml b/pyproject.toml index 0018d24..5a43233 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ members = [ "packages/drift", "packages/agent", "packages/webhooks", + "packages/sso", "apps/gateway", "sdks/python", ] @@ -170,6 +171,12 @@ module = [ "google.cloud.*", "azure.*", "hvac.*", + # Step 6.8 — SSO/SCIM. defusedxml (a rag-sso core dep) ships no type stubs; + # PyJWT + signxml live behind the [oidc] / [saml] extras and are lazily + # imported, so they are absent at lint time on the default install. + "defusedxml.*", + "jwt.*", + "signxml.*", # Step 2.9 — optional Leiden detector behind the [leiden] extra. "leidenalg.*", "igraph.*", @@ -207,7 +214,7 @@ ignore_errors = true [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests", "packages"] -pythonpath = ["packages/core/src", "packages/config/src", "packages/observability/src", "packages/policy/src", "packages/ragctl/src", "packages/backends/src", "packages/parsers/src", "packages/ocr/src", "packages/chunker/src", "packages/enricher/src", "packages/pii/src", "packages/embedders/src", "packages/ingest/src", "packages/retrieval/src", "packages/query/src", "packages/reranker/src", "packages/packer/src", "packages/graphrag/src", "packages/cache/src", "packages/guard/src", "packages/breaker/src", "packages/quota/src", "packages/provenance/src", "packages/feedback/src", "packages/drift/src", "packages/agent/src", "apps/gateway/src", "."] +pythonpath = ["packages/core/src", "packages/config/src", "packages/observability/src", "packages/policy/src", "packages/ragctl/src", "packages/backends/src", "packages/parsers/src", "packages/ocr/src", "packages/chunker/src", "packages/enricher/src", "packages/pii/src", "packages/embedders/src", "packages/ingest/src", "packages/retrieval/src", "packages/query/src", "packages/reranker/src", "packages/packer/src", "packages/graphrag/src", "packages/cache/src", "packages/guard/src", "packages/breaker/src", "packages/quota/src", "packages/provenance/src", "packages/feedback/src", "packages/drift/src", "packages/agent/src", "packages/webhooks/src", "packages/sso/src", "apps/gateway/src", "."] # spi_signature.py is the SPI signature linter (Step 1.1a); referenced by name in # docs/architecture/request-context.md. Collected alongside test_*.py files. # coverage.py is the PolicyEngine coverage linter (Step 1.1c). diff --git a/tests/contract/conftest.py b/tests/contract/conftest.py index 5798bf6..f598393 100644 --- a/tests/contract/conftest.py +++ b/tests/contract/conftest.py @@ -24,6 +24,7 @@ NoopQueue, NoopReranker, NoopRetrievalCache, + NoopScimStore, NoopSecrets, NoopStorage, NoopTelemetry, @@ -211,6 +212,11 @@ def corpus_store() -> NoopCorpusStore: return NoopCorpusStore() +@pytest.fixture() +def scim_store() -> NoopScimStore: + return NoopScimStore() + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/contract/test_scim_store.py b/tests/contract/test_scim_store.py new file mode 100644 index 0000000..77dbb0c --- /dev/null +++ b/tests/contract/test_scim_store.py @@ -0,0 +1,129 @@ +"""Conformance suite for the ScimStore SPI (Step 6.8). + +Exercises the NoopScimStore against the contract: tenant-scoped CRUD for Users +and Groups, ``(page, total)`` pagination + exact-attribute filtering, and strict +cross-tenant isolation. +""" + +from __future__ import annotations + +import pytest +from rag_core.spi.noop import NoopScimStore +from rag_core.types import ( + RequestContext, + ScimEmail, + ScimGroup, + ScimMember, + ScimUser, +) + +pytestmark = pytest.mark.contract + + +def _user(user_name: str, *, user_id: str | None = None, active: bool = True) -> ScimUser: + kwargs = {"id": user_id} if user_id is not None else {} + return ScimUser( + user_name=user_name, + display_name=user_name.split("@")[0], + emails=(ScimEmail(value=user_name, primary=True),), + active=active, + **kwargs, + ) + + +async def test_put_and_get_user(scim_store: NoopScimStore, ctx: RequestContext) -> None: + user = _user("alice@acme.test", user_id="u-1") + await scim_store.put_user(ctx, user) + + got = await scim_store.get_user(ctx, "u-1") + assert got is not None + assert got.user_name == "alice@acme.test" + assert await scim_store.get_user(ctx, "missing") is None + + +async def test_get_user_by_username(scim_store: NoopScimStore, ctx: RequestContext) -> None: + await scim_store.put_user(ctx, _user("bob@acme.test", user_id="u-2")) + got = await scim_store.get_user_by_username(ctx, "bob@acme.test") + assert got is not None and got.id == "u-2" + assert await scim_store.get_user_by_username(ctx, "nobody@acme.test") is None + + +async def test_put_user_upserts(scim_store: NoopScimStore, ctx: RequestContext) -> None: + await scim_store.put_user(ctx, _user("carol@acme.test", user_id="u-3", active=True)) + await scim_store.put_user(ctx, _user("carol@acme.test", user_id="u-3", active=False)) + got = await scim_store.get_user(ctx, "u-3") + assert got is not None and got.active is False + _, total = await scim_store.list_users(ctx) + assert total == 1 # same id overwrote, not duplicated + + +async def test_list_users_pagination_and_filter( + scim_store: NoopScimStore, ctx: RequestContext +) -> None: + for i in range(5): + await scim_store.put_user(ctx, _user(f"u{i}@acme.test", user_id=f"u-{i}")) + + page, total = await scim_store.list_users(ctx, start_index=1, count=2) + assert total == 5 + assert len(page) == 2 + + page2, _ = await scim_store.list_users(ctx, start_index=3, count=2) + assert [u.id for u in page2] == ["u-2", "u-3"] + + filtered, ftotal = await scim_store.list_users(ctx, user_name="u3@acme.test") + assert ftotal == 1 and filtered[0].id == "u-3" + + +async def test_delete_user(scim_store: NoopScimStore, ctx: RequestContext) -> None: + await scim_store.put_user(ctx, _user("dave@acme.test", user_id="u-9")) + assert await scim_store.delete_user(ctx, "u-9") is True + assert await scim_store.delete_user(ctx, "u-9") is False + assert await scim_store.get_user(ctx, "u-9") is None + + +async def test_users_are_tenant_scoped( + scim_store: NoopScimStore, ctx: RequestContext, other_ctx: RequestContext +) -> None: + await scim_store.put_user(ctx, _user("alice@acme.test", user_id="u-1")) + await scim_store.put_user(other_ctx, _user("mallory@evil.test", user_id="u-1")) + + # Same id, different tenant — no leakage in either direction. + mine = await scim_store.get_user(ctx, "u-1") + theirs = await scim_store.get_user(other_ctx, "u-1") + assert mine is not None and mine.user_name == "alice@acme.test" + assert theirs is not None and theirs.user_name == "mallory@evil.test" + + _, my_total = await scim_store.list_users(ctx) + _, their_total = await scim_store.list_users(other_ctx) + assert my_total == 1 and their_total == 1 + assert await scim_store.get_user_by_username(ctx, "mallory@evil.test") is None + + +async def test_group_crud_and_isolation( + scim_store: NoopScimStore, ctx: RequestContext, other_ctx: RequestContext +) -> None: + group = ScimGroup( + id="g-1", + display_name="engineering", + members=(ScimMember(value="u-1"),), + ) + await scim_store.put_group(ctx, group) + + got = await scim_store.get_group(ctx, "g-1") + assert got is not None and got.display_name == "engineering" + assert await scim_store.get_group_by_display_name(ctx, "engineering") is not None + + page, total = await scim_store.list_groups(ctx) + assert total == 1 and page[0].members[0].value == "u-1" + + # Cross-tenant isolation. + assert await scim_store.get_group(other_ctx, "g-1") is None + _, other_total = await scim_store.list_groups(other_ctx) + assert other_total == 0 + + assert await scim_store.delete_group(ctx, "g-1") is True + assert await scim_store.get_group(ctx, "g-1") is None + + +async def test_health(scim_store: NoopScimStore) -> None: + assert await scim_store.health() is True diff --git a/tests/logs/test_event_schema.py b/tests/logs/test_event_schema.py index 1f403cc..87f15f5 100644 --- a/tests/logs/test_event_schema.py +++ b/tests/logs/test_event_schema.py @@ -29,7 +29,12 @@ EVT_RETRIEVAL_COMPLETED, EVT_RETRIEVAL_FALLBACK, EVT_RETRIEVAL_STARTED, + EVT_SCIM_GROUP_CHANGED, + EVT_SCIM_USER_DEPROVISIONED, + EVT_SCIM_USER_PROVISIONED, EVT_SPI_CALL, + EVT_SSO_AUTH_FAILED, + EVT_SSO_AUTHENTICATED, BreakerEvent, CacheEvent, GuardEvent, @@ -39,7 +44,10 @@ QuotaEvent, RagEvent, RetrievalEvent, + ScimEvent, SpiCallEvent, + SsoEvent, + check_pii, ) ALL_EVENT_CONSTANTS = [ @@ -66,6 +74,11 @@ EVT_FEEDBACK_RECORDED, EVT_FEEDBACK_RECORD_DEGRADED, EVT_DRIFT_DETECTED, + EVT_SSO_AUTHENTICATED, + EVT_SSO_AUTH_FAILED, + EVT_SCIM_USER_PROVISIONED, + EVT_SCIM_USER_DEPROVISIONED, + EVT_SCIM_GROUP_CHANGED, ] @@ -351,3 +364,62 @@ def test_carries_no_query_or_answer_text(self) -> None: # Provenance telemetry carries only ids / counts — never query / answer text. for field in ("query", "answer", "claim", "text", "document", "query_text"): assert field not in ProvenanceEvent.model_fields + + +class TestSsoEvent: + def test_valid_sso_event(self) -> None: + evt = SsoEvent( + event_name=EVT_SSO_AUTHENTICATED, + tenant_id="acme", + protocol="oidc", + issuer="https://idp.acme.test", + subject_hash="ab12cd34", + outcome="authenticated", + groups_n=2, + ) + assert evt.protocol == "oidc" + assert evt.outcome == "authenticated" + assert evt.groups_n == 2 + + def test_carries_hashed_subject_not_pii(self) -> None: + # SSO telemetry carries a subject *hash* — never the raw subject / email. + for field in ("subject", "email", "token", "name", "user_name"): + assert field not in SsoEvent.model_fields + evt = SsoEvent( + event_name=EVT_SSO_AUTH_FAILED, + tenant_id="acme", + protocol="oidc", + subject_hash="deadbeef", + outcome="failed", + reason="expired", + ) + assert check_pii(evt.model_dump_json()) == [] + + +class TestScimEvent: + def test_valid_scim_event(self) -> None: + evt = ScimEvent( + event_name=EVT_SCIM_USER_PROVISIONED, + tenant_id="acme", + operation="create", + resource_type="User", + resource_id="89f619cf-d8dc-41af-9c2a-a3ccf2768f04", + active=True, + ) + assert evt.operation == "create" + assert evt.resource_type == "User" + assert evt.active is True + + def test_carries_no_identifying_attributes(self) -> None: + # SCIM telemetry carries only the server-assigned id — never userName / email. + for field in ("user_name", "userName", "email", "display_name", "displayName"): + assert field not in ScimEvent.model_fields + evt = ScimEvent( + event_name=EVT_SCIM_USER_DEPROVISIONED, + tenant_id="acme", + operation="delete", + resource_type="User", + resource_id="89f619cf-d8dc-41af-9c2a-a3ccf2768f04", + active=False, + ) + assert check_pii(evt.model_dump_json()) == [] diff --git a/uv.lock b/uv.lock index 1e143cc..439abbf 100644 --- a/uv.lock +++ b/uv.lock @@ -48,6 +48,7 @@ members = [ "rag-ragctl", "rag-reranker", "rag-retrieval", + "rag-sso", "rag-webhooks", ] @@ -7524,6 +7525,7 @@ dependencies = [ { name = "rag-quota" }, { name = "rag-reranker" }, { name = "rag-retrieval" }, + { name = "rag-sso" }, { name = "rag-webhooks" }, { name = "starlette" }, { name = "uvicorn", extra = ["standard"] }, @@ -7567,6 +7569,7 @@ requires-dist = [ { name = "rag-quota", editable = "packages/quota" }, { name = "rag-reranker", editable = "packages/reranker" }, { name = "rag-retrieval", editable = "packages/retrieval" }, + { name = "rag-sso", editable = "packages/sso" }, { name = "rag-webhooks", editable = "packages/webhooks" }, { name = "starlette", specifier = ">=1.0.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, @@ -7938,6 +7941,7 @@ dependencies = [ { name = "rag-quota" }, { name = "rag-reranker" }, { name = "rag-retrieval" }, + { name = "rag-sso" }, { name = "rag-webhooks" }, { name = "typer" }, ] @@ -7969,6 +7973,7 @@ requires-dist = [ { name = "rag-quota", editable = "packages/quota" }, { name = "rag-reranker", editable = "packages/reranker" }, { name = "rag-retrieval", editable = "packages/retrieval" }, + { name = "rag-sso", editable = "packages/sso" }, { name = "rag-webhooks", editable = "packages/webhooks" }, { name = "typer", specifier = ">=0.12" }, ] @@ -8033,6 +8038,42 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "rag-sso" +version = "0.1.0" +source = { editable = "packages/sso" } +dependencies = [ + { name = "defusedxml" }, + { name = "rag-core" }, + { name = "rag-observability" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] +oidc = [ + { name = "pyjwt", extra = ["crypto"] }, +] +saml = [ + { name = "signxml" }, +] + +[package.metadata] +requires-dist = [ + { name = "defusedxml", specifier = ">=0.7" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=2.1" }, + { name = "pyjwt", extras = ["crypto"], marker = "extra == 'oidc'", specifier = ">=2.8" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3" }, + { name = "rag-core", editable = "packages/core" }, + { name = "rag-observability", editable = "packages/observability" }, + { name = "signxml", marker = "extra == 'saml'", specifier = ">=3.2" }, +] +provides-extras = ["oidc", "saml", "dev"] + [[package]] name = "rag-webhooks" version = "0.1.0" @@ -8853,6 +8894,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "signxml" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cryptography" }, + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/f8/6c5a48961d07ffb6e96233be11bf37ad38ecfd6a49a61036a4a98254cd71/signxml-4.4.0.tar.gz", hash = "sha256:038781783520e5d7bae4aa9c1e35f24fe9062730459da72009ce55126b56fa0f", size = 1615052, upload-time = "2026-03-01T18:21:56.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/2a/94c32b7c357c146e52c12b8d8b95aaa042566bf9b3ac3bdbf29741012e24/signxml-4.4.0-py3-none-any.whl", hash = "sha256:16a711773928027838e59b3de64d7a3149267a0c337b2ac5bca3315903ddcab4", size = 60069, upload-time = "2026-03-01T18:21:54.645Z" }, +] + [[package]] name = "simsimd" version = "6.5.16"