From 0829cfb3b018e997a2f9ecfa2d5e7d1b17c7f558 Mon Sep 17 00:00:00 2001 From: Bobcatsfan33 Date: Sat, 23 May 2026 13:41:25 -0400 Subject: [PATCH 1/3] Harden enterprise SAML and SCIM GA path --- Dockerfile | 4 + alembic/versions/0001_baseline.py | 2 + api.py | 27 +- docs/PRODUCTION_ROLLOUT_CHECKLIST.md | 20 + docs/ops/saml-scim.md | 192 +++++---- modules/auth/saml.py | 261 +++++++++++- modules/auth/scim.py | 572 +++++++++++++++++++-------- modules/storage/migrations.py | 22 +- requirements.txt | 4 + scripts/idp_ga_validation.py | 202 ++++++++++ scripts/preflight_prod.py | 53 +++ tests/test_preflight_prod.py | 23 ++ tests/test_saml_scim.py | 108 ++++- 13 files changed, 1217 insertions(+), 273 deletions(-) create mode 100644 scripts/idp_ga_validation.py diff --git a/Dockerfile b/Dockerfile index c503531..900154b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,11 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ libffi-dev \ + libxml2-dev \ + libxmlsec1-dev \ + libxmlsec1-openssl \ libssl-dev \ + pkg-config \ libpq-dev \ && rm -rf /var/lib/apt/lists/* diff --git a/alembic/versions/0001_baseline.py b/alembic/versions/0001_baseline.py index 722fd1b..c1b5718 100644 --- a/alembic/versions/0001_baseline.py +++ b/alembic/versions/0001_baseline.py @@ -31,6 +31,8 @@ # Modules whose schema initializer is the source of truth for baseline DDL. _INIT_TARGETS: tuple[tuple[str, str], ...] = ( ("modules.tenants.store", "init_db"), + ("modules.auth.saml", "init_db"), + ("modules.auth.scim", "init_db"), ("modules.identity.attestation_store", "init_db"), ("modules.identity.uis_store", "init_db"), ("modules.identity.trust_graph", "init_db"), diff --git a/api.py b/api.py index 3cada04..9e900fe 100644 --- a/api.py +++ b/api.py @@ -381,7 +381,7 @@ async def dashboard(): return FileResponse(_DASHBOARD_PATH) -# ── Enterprise SAML SSO (alpha) ─────────────────────────────────────────────── +# ── Enterprise SAML SSO ─────────────────────────────────────────────────────── @app.get("/saml/metadata", response_class=Response) @@ -409,21 +409,42 @@ async def saml_acs(request: Request): from modules.auth.saml import parse_assertion, SAMLError form = await request.form() saml_response = form.get("SAMLResponse") + relay_state = form.get("RelayState") if not saml_response: raise HTTPException(status_code=400, detail="SAMLResponse missing") try: - assertion = parse_assertion(str(saml_response)) + assertion = parse_assertion(str(saml_response), relay_state=str(relay_state or "")) except SAMLError as exc: + log_event( + AuditEventType.AUTH_FAILURE, + AuditOutcome.FAILURE, + subject="saml", + resource="/saml/acs", + detail={"error": str(exc)}, + ) raise HTTPException(status_code=401, detail=str(exc)) + log_event( + AuditEventType.AUTH_SUCCESS, + AuditOutcome.SUCCESS, + subject=assertion.name_id, + resource="/saml/acs", + detail={ + "issuer": assertion.issuer, + "session_index": assertion.session_index, + "in_response_to": assertion.in_response_to, + "assertion_id": assertion.assertion_id, + }, + ) return { "name_id": assertion.name_id, "attributes": assertion.attributes, "issuer": assertion.issuer, "session_index": assertion.session_index, + "in_response_to": assertion.in_response_to, } -# ── SCIM 2.0 (alpha) ────────────────────────────────────────────────────────── +# ── SCIM 2.0 ────────────────────────────────────────────────────────────────── def _scim_response(body: dict, status: int = 200): diff --git a/docs/PRODUCTION_ROLLOUT_CHECKLIST.md b/docs/PRODUCTION_ROLLOUT_CHECKLIST.md index 1be02e0..4fbd318 100644 --- a/docs/PRODUCTION_ROLLOUT_CHECKLIST.md +++ b/docs/PRODUCTION_ROLLOUT_CHECKLIST.md @@ -27,6 +27,9 @@ This validates critical environment variables and recommended settings: the TokenDNA-specific names should be present in production manifests. - storage modules use the shared backend abstraction; any remaining direct `sqlite3.connect` usage is reported as a production blocker. +- if SAML is enabled, HTTPS SP URLs, IdP SSO URL, IdP signing certificate, + RelayState host allowlist, disabled-by-default IdP-initiated login, and + the `python3-saml` runtime dependency are all present. - the Postgres smoke test can create/query tenant API keys, usage metering, UIS events, policy bundles, decision audits, and staged-rollout grants. @@ -45,6 +48,23 @@ process to refuse to start. See `docs/ops/backup-dr.md` for key provisioning and rotation, and `docs/ops/external-engagements.md` for the pen-test and compliance gates that follow this checklist. +## 1.1) Customer IdP SAML/SCIM validation + +For Okta, Microsoft Entra ID, OneLogin, or another customer IdP, run the +repeatable TokenDNA-side harness after preflight: + +```bash +python3 scripts/idp_ga_validation.py \ + --provider okta \ + --base-url https://tokendna.customer.example \ + --api-key "$TOKENDNA_TENANT_API_KEY" +``` + +Retain the JSON report with the customer evidence packet. The browser SAML +login, replay rejection, signing certificate fingerprint, and IdP app +configuration screenshots are still live-tenant evidence requirements. +See `docs/ops/saml-scim.md`. + ## 2) Key rotation drill (staging first) Run: diff --git a/docs/ops/saml-scim.md b/docs/ops/saml-scim.md index 8213cf1..016e227 100644 --- a/docs/ops/saml-scim.md +++ b/docs/ops/saml-scim.md @@ -1,114 +1,150 @@ -# TokenDNA — SAML SSO + SCIM 2.0 (alpha) +# TokenDNA SAML SSO and SCIM 2.0 -Enterprise customers expect both SAML SSO and SCIM provisioning before -they will sign procurement. This page is the integration playbook. +This is the enterprise IdP integration runbook for TokenDNA local control-plane deployments. The implementation is intended to run where the customer's identities and agents already live: on the customer's network, appliance, cluster, or tenant-owned cloud account. -> **Status: alpha.** The API surface is stable, but signature -> verification still requires the `python3-saml` optional dependency to -> be installed (a follow-up sprint replaces this with a vendored, -> tighter implementation). +## Status ---- +The SAML and SCIM surfaces are production-gated by code, storage migrations, and preflight checks. -## 1. SAML 2.0 +GA readiness still requires a customer-specific live validation run because every IdP tenant has different certificate rotation, attribute mapping, group push, and app assignment settings. Do not mark a customer environment complete until the validation matrix at the end of this page has a green report for that customer's Okta, Microsoft Entra ID, OneLogin, or equivalent tenant. -### 1.1 Endpoints +## SAML 2.0 SSO + +### Endpoints | Path | Verb | Purpose | |------|------|---------| -| `/saml/metadata` | GET | TokenDNA SP metadata XML — upload to your IdP. | -| `/saml/login` | GET | Returns redirect URL + RelayState for SP-initiated SSO. | -| `/saml/acs` | POST | Assertion Consumer Service. Validates SAMLResponse. | +| `/saml/metadata` | GET | TokenDNA SP metadata XML to upload to the IdP. | +| `/saml/login` | GET | Starts SP-initiated SSO and returns the IdP redirect URL plus RelayState. | +| `/saml/acs` | POST | Assertion Consumer Service. Validates the signed SAMLResponse. | + +### Required environment + +```bash +SAML_SP_ENTITY_ID=https://tokendna.customer.example/sp +SAML_SP_ACS_URL=https://tokendna.customer.example/saml/acs +SAML_IDP_SSO_URL=https://customer-idp.example/sso +SAML_IDP_X509_CERT="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" +SAML_ALLOWED_RELAY_STATE_HOSTS=tokendna.customer.example +``` -### 1.2 Configuration env vars +Optional controls: -``` -SAML_SP_ENTITY_ID=https://app.tokendna.io/sp -SAML_SP_ACS_URL=https://app.tokendna.io/saml/acs -SAML_IDP_SSO_URL=https://idp.example.com/sso -SAML_IDP_X509_CERT="-----BEGIN CERTIFICATE-----\n…\n-----END CERTIFICATE-----" +```bash SAML_NAME_ID_FORMAT=urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress +SAML_REQUEST_TTL_SECONDS=300 +SAML_CLOCK_SKEW_SECONDS=180 +SAML_ALLOW_IDP_INITIATED=false ``` -### 1.3 Setup steps +### Security behavior -1. Operator visits `https://app.tokendna.io/saml/metadata`, downloads - the XML, and uploads it to the IdP (Okta, Azure AD, OneLogin, etc.). -2. Operator copies the IdP's SSO URL and signing certificate into the - TokenDNA env (or secret manager). -3. End user hits `/saml/login`, is redirected to the IdP, signs in, and - the IdP POSTs a SAMLResponse to `/saml/acs`. -4. TokenDNA verifies the assertion and issues an internal session. +TokenDNA requires signed assertions and refuses SAML assertion parsing when `python3-saml` is unavailable. The production preflight fails if SAML is configured without the IdP signing certificate, IdP SSO URL, HTTPS SP URLs, RelayState host allowlist, or runtime SAML dependency. -### 1.4 Hardening checklist +SP-initiated login stores AuthnRequest state durably in the shared SQLite/Postgres backend. `/saml/acs` consumes `InResponseTo` exactly once, validates RelayState binding, checks Destination and Recipient against the configured ACS URL, records assertion IDs for replay defense, and audits both SAML successes and failures. -* `WantAssertionsSigned="true"` — set in metadata; do not relax. -* `python3-saml` installed in production. Without it, `/saml/acs` - refuses to parse and returns `503` rather than trusting an unsigned - assertion. -* IdP-initiated flows: also accept `SAMLResponse` POST without prior - AuthnRequest. RelayState is required and must be validated against - the customer's allowed return URLs. -* Replay protection: every `InResponseTo` is consumed once. +IdP-initiated SAML is disabled by default. Enable it only by explicit customer exception and keep RelayState constrained to approved HTTPS return hosts. ---- +### IdP setup -## 2. SCIM 2.0 +1. Load `/saml/metadata` in TokenDNA and upload the XML into the IdP SAML application. +2. Configure the IdP ACS URL to `SAML_SP_ACS_URL`. +3. Configure the IdP audience/entity ID to `SAML_SP_ENTITY_ID`. +4. Require signed assertions. Signed responses are acceptable, but signed assertions are mandatory. +5. Map NameID to the customer identity key, usually email or immutable user principal name. +6. Add attribute mappings for email, display name, groups, and any customer-required role claims. +7. Copy the IdP SSO URL and active signing certificate into TokenDNA secrets. +8. Run `python3 scripts/preflight_prod.py --environment production` before live user testing. -### 2.1 Endpoints +## SCIM 2.0 Provisioning + +### Endpoints | Path | Verb | Purpose | |------|------|---------| | `/scim/v2/ServiceProviderConfig` | GET | Capability advertisement. | -| `/scim/v2/ResourceTypes` | GET | Schemas exposed (User, Group). | -| `/scim/v2/Users` | POST | Create user. | -| `/scim/v2/Users/{id}` | GET / PUT / PATCH / DELETE | User CRUD + RFC 7644 PatchOp. | -| `/scim/v2/Users` | GET | List with `startIndex` / `count` pagination + `filter=`. | -| `/scim/v2/Groups` | POST / GET | Group create / list (with `filter=`). | -| `/scim/v2/Groups/{id}` | GET / PATCH / DELETE | Group lookup / patch / delete. | +| `/scim/v2/ResourceTypes` | GET | User and Group resource declarations. | +| `/scim/v2/Users` | POST / GET | Create users and list users with pagination/filtering. | +| `/scim/v2/Users/{id}` | GET / PUT / PATCH / DELETE | User lookup, replace, patch, and delete. | +| `/scim/v2/Groups` | POST / GET | Create groups and list groups with filtering. | +| `/scim/v2/Groups/{id}` | GET / PATCH / DELETE | Group lookup, patch, and delete. | -### 2.2 Auth +### Auth and tenant isolation -Bearer-token auth (`Authorization: Bearer `). Tokens are scoped -per tenant and rotated by the operator via the admin console. SCIM -requests carry the same tenant context as the rest of the TokenDNA API. +SCIM calls use the normal TokenDNA tenant boundary: -### 2.3 Schemas +```http +Authorization: Bearer +``` -* User — `urn:ietf:params:scim:schemas:core:2.0:User` -* Group — `urn:ietf:params:scim:schemas:core:2.0:Group` +or: -Custom enterprise extension schema (`urn:ietf:params:scim:schemas:extension:enterprise:2.0:User`) is not yet supported. Most IdPs degrade -gracefully when the extension is absent. +```http +X-API-Key: +``` + +Every SCIM read and write is tenant-scoped. Users and groups are stored durably in the shared backend, have weak ETag-style versions, and emit audit events for create, update, patch, and delete actions. -### 2.4 What is intentionally NOT supported (yet) +### Supported SCIM behavior -| Feature | Status | -|---------|--------| -| `PATCH` operations (simple paths) | **Supported.** `add` / `replace` / `remove` on top-level scalars and dotted sub-attributes (`name.givenName`). | -| `PATCH` value-filtered paths | Returns `501`. e.g. `path = emails[type eq "work"].value` — added after observing real customer traffic. | -| `bulk` operations | `bulk.supported = false`. | -| `sort` parameter | Not honored. | -| `filter` expressions | **Supported.** `eq`, `ne`, `sw`, `ew`, `co`, `gt`, `lt`, `ge`, `le`, `pr`; `and` / `or` / `not`; parens. Dotted paths (`name.givenName`, `meta.lastModified`). Multi-valued bracketed filters (`emails[...]`) return 501. | -| ETag / `If-Match` | Not honored. | +| Capability | Status | +|------------|--------| +| User CRUD | Supported. | +| Group CRUD | Supported. | +| Pagination | Supported for users. | +| Filtering | Supported for core scalar paths and dotted paths. | +| PatchOp | Supported for simple `add`, `replace`, and `remove` operations. | +| ETag advertisement | Supported via `ServiceProviderConfig`. | +| Bulk | Not supported; advertised as disabled. | +| Sort | Not supported; advertised as disabled. | +| Value-filtered multi-valued paths | Returns `501` until a customer IdP requires it. | -ETag and bulk are the remaining gaps before GA. The two we just shipped -— PATCH and filter — close the largest customer-blocking surface from -the original alpha scope. +## Provider Validation Matrix ---- +Run this matrix for every customer IdP tenant before declaring the integration complete. -## 3. Integration test plan +| Provider | Required validation | +|----------|---------------------| +| Okta | SP metadata import, SP-initiated login, assertion signature validation, bad RelayState rejection, replay rejection, user create/update/deactivate/delete, group create/member update/delete. | +| Microsoft Entra ID | Enterprise app SAML setup, certificate rollover check, assigned user login, group claim mapping, provisioning job create/update/disable/delete, group push where licensed. | +| OneLogin | SAML connector import, signed assertion enforcement, NameID and attribute mappings, SCIM bearer auth, user lifecycle, group membership patch. | -For each new IdP: +Evidence to retain for GA: -1. Apply the chart with the IdP's SSO URL + signing cert. -2. Run a single end-to-end SAML login from a real user. -3. Provision a user via SCIM `POST /scim/v2/Users` and verify it shows - up in the admin console. -4. Suspend the user via `PUT /scim/v2/Users/{id}` (`active: false`) - and verify the user can no longer authenticate. -5. Delete the user via SCIM and verify cleanup. +1. Preflight JSON report with `passed=true`. +2. Migration status showing the SAML and SCIM schemas applied. +3. SAML login trace with success audit event and no unsigned assertion acceptance. +4. Replay test showing a second POST of the same assertion is rejected. +5. SCIM provisioning transcript for create, update, deactivate, group membership patch, and delete. +6. IdP screenshots or exported app configuration with ACS URL, entity ID, signing requirement, and certificate fingerprint. -A green run on Okta + Azure AD + OneLogin is the gate before this -module flips from alpha to GA. +The repeatable TokenDNA-side harness is: + +```bash +python3 scripts/idp_ga_validation.py \ + --provider okta \ + --base-url https://tokendna.customer.example \ + --api-key "$TOKENDNA_TENANT_API_KEY" +``` + +Use `--provider entra` or `--provider onelogin` for those customer tenants. The script verifies metadata, SP-initiated SAML request generation, SCIM discovery, user lifecycle, group lifecycle, and group membership patching, then emits a JSON evidence report. The browser login, assertion replay, and certificate-fingerprint evidence remain customer-tenant manual checks because they require the live IdP admin console and a real assigned user. + +## Local Deployment Flow + +```bash +set -a +. ./.env +set +a + +python3 scripts/preflight_prod.py --environment production +python3 scripts/migrate_storage.py apply +python3 scripts/postgres_smoke.py +``` + +For Docker Compose appliance deployments: + +```bash +docker compose -f docker-compose.yml -f docker-compose.production.yml up -d postgres +docker compose -f docker-compose.yml -f docker-compose.production.yml run --rm tokendna-deployment-gate +docker compose -f docker-compose.yml -f docker-compose.production.yml up -d tokendna +``` diff --git a/modules/auth/saml.py b/modules/auth/saml.py index b378606..8bc38fb 100644 --- a/modules/auth/saml.py +++ b/modules/auth/saml.py @@ -1,5 +1,5 @@ """ -TokenDNA — SAML 2.0 SSO (alpha) +TokenDNA -- SAML 2.0 SSO. Provides the wire-shape for SP-initiated and IdP-initiated SAML SSO: @@ -7,11 +7,9 @@ customers upload to their IdP. * :func:`build_authn_request` — produces a SAMLRequest (deflated + base64) and matching RelayState for SP-initiated flows. -* :func:`parse_assertion` — verifies a SAMLResponse signature and extracts - the subject + attributes. The cryptographic verification is the part - enterprise pen-tests will scrutinize most heavily; we use the - ``python3-saml`` package when installed and fall back to a - signature-skipping parse mode (NOT for production). +* :func:`parse_assertion` — verifies a signed SAMLResponse and extracts + the subject + attributes. The cryptographic verification is delegated to + ``python3-saml``; if that dependency is unavailable, assertions are refused. Configuration env vars: @@ -31,13 +29,22 @@ from __future__ import annotations import base64 +import hashlib import logging import os import secrets import zlib from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any +from urllib.parse import urlencode, urlparse + +try: + from defusedxml import ElementTree as SafeElementTree # type: ignore +except Exception: # pragma: no cover - dependency is shipped in production image + from xml.etree import ElementTree as SafeElementTree + +from modules.storage.pg_connection import AdaptedCursor, ensure_sqlite_dir, get_db_conn logger = logging.getLogger(__name__) @@ -53,6 +60,10 @@ class SAMLConfig: idp_sso_url: str idp_x509_cert: str | None name_id_format: str + allowed_relay_state_hosts: tuple[str, ...] + request_ttl_seconds: int + clock_skew_seconds: int + allow_idp_initiated: bool @classmethod def from_env(cls) -> "SAMLConfig": @@ -65,6 +76,15 @@ def from_env(cls) -> "SAMLConfig": "SAML_NAME_ID_FORMAT", "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", ), + allowed_relay_state_hosts=tuple( + h.strip().lower() + for h in os.getenv("SAML_ALLOWED_RELAY_STATE_HOSTS", "").split(",") + if h.strip() + ), + request_ttl_seconds=int(os.getenv("SAML_REQUEST_TTL_SECONDS", "300")), + clock_skew_seconds=int(os.getenv("SAML_CLOCK_SKEW_SECONDS", "180")), + allow_idp_initiated=str(os.getenv("SAML_ALLOW_IDP_INITIATED", "false")).lower() + in {"1", "true", "yes", "on"}, ) @@ -86,6 +106,154 @@ class SAMLAssertion: not_after: str | None session_index: str | None attributes: dict[str, list[str]] + in_response_to: str | None = None + assertion_id: str | None = None + + +def _db_path() -> str: + return os.getenv("DATA_DB_PATH", "/data/tokendna.db") + + +def _cursor(): + return get_db_conn(db_path=_db_path()) + + +def init_db() -> None: + """Create SAML request/replay state tables.""" + ensure_sqlite_dir(_db_path()) + with _cursor() as conn: + cur = AdaptedCursor(conn.cursor()) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS saml_request_state ( + request_id TEXT PRIMARY KEY, + relay_state_hash TEXT NOT NULL, + relay_state TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS saml_assertion_replay ( + assertion_id TEXT PRIMARY KEY, + issuer TEXT NOT NULL, + name_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT NOT NULL + ) + """ + ) + cur.execute("CREATE INDEX IF NOT EXISTS idx_saml_request_expiry ON saml_request_state(expires_at)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_saml_assertion_expiry ON saml_assertion_replay(expires_at)") + + +def _iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).isoformat() + + +def _parse_iso(value: str | None) -> datetime | None: + if not value: + return None + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + return None + + +def _hash_relay_state(relay_state: str) -> str: + return hashlib.sha256(relay_state.encode("utf-8")).hexdigest() + + +def validate_relay_state(relay_state: str | None, cfg: SAMLConfig | None = None) -> str: + """Allow relative RelayState and explicitly configured return hosts only.""" + cfg = cfg or SAMLConfig.from_env() + value = str(relay_state or "").strip() + if not value: + return value + parsed = urlparse(value) + if not parsed.scheme and not parsed.netloc: + if not value.startswith("/"): + raise SAMLError("RelayState must be a relative path or an allowed absolute URL.") + return value + if parsed.scheme not in {"https"}: + raise SAMLError("RelayState absolute URLs must use https.") + host = (parsed.hostname or "").lower() + if host not in cfg.allowed_relay_state_hosts: + raise SAMLError("RelayState host is not allowlisted.") + return value + + +def store_authn_request(request_id: str, relay_state: str, cfg: SAMLConfig | None = None) -> None: + cfg = cfg or SAMLConfig.from_env() + init_db() + now = datetime.now(timezone.utc) + with _cursor() as conn: + AdaptedCursor(conn.cursor()).execute( + """ + INSERT INTO saml_request_state(request_id, relay_state_hash, relay_state, created_at, expires_at, consumed_at) + VALUES (?, ?, ?, ?, ?, NULL) + """, + ( + request_id, + _hash_relay_state(relay_state), + relay_state, + _iso(now), + _iso(now + timedelta(seconds=cfg.request_ttl_seconds)), + ), + ) + + +def consume_authn_request(request_id: str, relay_state: str, cfg: SAMLConfig | None = None) -> None: + cfg = cfg or SAMLConfig.from_env() + init_db() + now = datetime.now(timezone.utc) + with _cursor() as conn: + cur = AdaptedCursor(conn.cursor()) + row = cur.execute( + "SELECT * FROM saml_request_state WHERE request_id=?", + (request_id,), + ).fetchone() + if not row: + raise SAMLError("SAML response references an unknown AuthnRequest.") + if row["consumed_at"]: + raise SAMLError("SAML AuthnRequest has already been consumed.") + expires_at = _parse_iso(row["expires_at"]) + if expires_at and now > expires_at + timedelta(seconds=cfg.clock_skew_seconds): + raise SAMLError("SAML AuthnRequest has expired.") + if row["relay_state_hash"] != _hash_relay_state(relay_state or ""): + raise SAMLError("SAML RelayState does not match the AuthnRequest.") + cur.execute( + "UPDATE saml_request_state SET consumed_at=? WHERE request_id=?", + (_iso(now), request_id), + ) + + +def record_assertion_replay(assertion_id: str, issuer: str, name_id: str, not_after: str | None, cfg: SAMLConfig | None = None) -> None: + cfg = cfg or SAMLConfig.from_env() + if not assertion_id: + raise SAMLError("SAML assertion is missing an ID.") + init_db() + now = datetime.now(timezone.utc) + expires_at = _parse_iso(not_after) or now + timedelta(seconds=cfg.request_ttl_seconds) + with _cursor() as conn: + cur = AdaptedCursor(conn.cursor()) + row = cur.execute( + "SELECT assertion_id FROM saml_assertion_replay WHERE assertion_id=?", + (assertion_id,), + ).fetchone() + if row: + raise SAMLError("SAML assertion replay detected.") + cur.execute( + """ + INSERT INTO saml_assertion_replay(assertion_id, issuer, name_id, expires_at, consumed_at) + VALUES (?, ?, ?, ?, ?) + """, + (assertion_id, issuer, name_id, _iso(expires_at), _iso(now)), + ) def generate_metadata(cfg: SAMLConfig | None = None) -> str: @@ -117,6 +285,7 @@ def build_authn_request( cfg = cfg or SAMLConfig.from_env() if not cfg.idp_sso_url: raise SAMLError("SAML_IDP_SSO_URL is not configured.") + validated_relay_state = validate_relay_state(relay_state, cfg) if relay_state else secrets.token_urlsafe(24) request_id = "_" + secrets.token_hex(16) issue_instant = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") xml = ( @@ -131,8 +300,9 @@ def build_authn_request( ) deflated = zlib.compress(xml.encode("utf-8"))[2:-4] saml_request = base64.b64encode(deflated).decode("ascii") - rs = relay_state or secrets.token_urlsafe(24) - redirect_url = f"{cfg.idp_sso_url}?SAMLRequest={saml_request}&RelayState={rs}" + rs = validated_relay_state + store_authn_request(request_id, rs, cfg) + redirect_url = f"{cfg.idp_sso_url}?{urlencode({'SAMLRequest': saml_request, 'RelayState': rs})}" return AuthnRequest( request_id=request_id, saml_request=saml_request, @@ -144,6 +314,8 @@ def build_authn_request( def parse_assertion( saml_response_b64: str, cfg: SAMLConfig | None = None, + *, + relay_state: str | None = None, ) -> SAMLAssertion: """Verify and parse a SAMLResponse delivered by the IdP. @@ -152,8 +324,10 @@ def parse_assertion( bypass authentication entirely. """ cfg = cfg or SAMLConfig.from_env() + validate_relay_state(relay_state, cfg) if not cfg.idp_x509_cert: raise SAMLError("SAML_IDP_X509_CERT is not configured — cannot verify assertion.") + extracted = _extract_response_state(saml_response_b64) try: from onelogin.saml2.response import OneLogin_Saml2_Response # type: ignore from onelogin.saml2.settings import OneLogin_Saml2_Settings # type: ignore @@ -189,18 +363,79 @@ def parse_assertion( resp = OneLogin_Saml2_Response(settings, saml_response_b64) if not resp.is_valid({}, raise_exceptions=False): raise SAMLError(f"SAML assertion failed validation: {resp.get_error()}") + destination = extracted.get("destination") + recipient = extracted.get("recipient") + if destination and destination != cfg.sp_acs_url: + raise SAMLError("SAML response Destination does not match ACS URL.") + if recipient and recipient != cfg.sp_acs_url: + raise SAMLError("SAML SubjectConfirmation recipient does not match ACS URL.") name_id = resp.get_nameid() attributes = {k: list(v) for k, v in (resp.get_attributes() or {}).items()} issuer_data = resp.get_issuers() or [""] + in_response_to = _call_optional(resp, "get_in_response_to") or extracted.get("in_response_to") + assertion_id = _call_optional(resp, "get_assertion_id") or extracted.get("assertion_id") + not_after = _call_optional(resp, "get_assertion_not_on_or_after") or extracted.get("not_after") + if in_response_to: + consume_authn_request(in_response_to, relay_state or "", cfg) + elif not cfg.allow_idp_initiated: + raise SAMLError("IdP-initiated SAML responses are disabled.") + record_assertion_replay(assertion_id or "", issuer_data[0], name_id, not_after, cfg) return SAMLAssertion( name_id=name_id, name_id_format=resp.get_nameid_format() or cfg.name_id_format, issuer=issuer_data[0], audience=cfg.sp_entity_id, - not_before=resp.get_assertion_not_on_or_after() # not exact NotBefore but useful - if hasattr(resp, "get_assertion_not_on_or_after") - else None, - not_after=None, + not_before=extracted.get("not_before"), + not_after=not_after, session_index=resp.get_session_index(), attributes=attributes, + in_response_to=in_response_to, + assertion_id=assertion_id, ) + + +def _call_optional(obj: Any, method: str) -> Any: + fn = getattr(obj, method, None) + if callable(fn): + try: + return fn() + except Exception: + return None + return None + + +def _extract_response_state(saml_response_b64: str) -> dict[str, str | None]: + """Extract non-trusted SAML IDs/timestamps after base64 decoding. + + Cryptographic trust is still delegated to python3-saml; these values are + used only for replay/request-state bookkeeping after signature validation. + """ + try: + raw = base64.b64decode(saml_response_b64, validate=True) + root = SafeElementTree.fromstring(raw) + except Exception: + return {} + ns = { + "samlp": "urn:oasis:names:tc:SAML:2.0:protocol", + "saml": "urn:oasis:names:tc:SAML:2.0:assertion", + } + assertion = root.find(".//saml:Assertion", ns) + conditions = root.find(".//saml:Conditions", ns) + subject_confirmation = root.find(".//saml:SubjectConfirmationData", ns) + return { + "response_id": root.attrib.get("ID"), + "in_response_to": root.attrib.get("InResponseTo") or (subject_confirmation.attrib.get("InResponseTo") if subject_confirmation is not None else None), + "assertion_id": assertion.attrib.get("ID") if assertion is not None else None, + "not_before": conditions.attrib.get("NotBefore") if conditions is not None else None, + "not_after": conditions.attrib.get("NotOnOrAfter") if conditions is not None else None, + "destination": root.attrib.get("Destination"), + "recipient": subject_confirmation.attrib.get("Recipient") if subject_confirmation is not None else None, + } + + +def _reset_for_tests() -> None: + init_db() + with _cursor() as conn: + cur = AdaptedCursor(conn.cursor()) + cur.execute("DELETE FROM saml_assertion_replay") + cur.execute("DELETE FROM saml_request_state") diff --git a/modules/auth/scim.py b/modules/auth/scim.py index 06c5178..cd1e006 100644 --- a/modules/auth/scim.py +++ b/modules/auth/scim.py @@ -1,62 +1,88 @@ """ -TokenDNA — SCIM 2.0 (alpha) +TokenDNA -- SCIM 2.0 provisioning. -Implements the subset of SCIM 2.0 (RFC 7644) that enterprise customers -typically wire up first: user create / read / update / delete and group -membership management. Storage is the existing tenant store — SCIM is a -shape on top of TokenDNA's tenant + identity primitives, not a separate -identity store. +Implements the enterprise SCIM surface most IdPs require for GA: -Resource types implemented: +* durable user and group storage on the shared SQLite/Postgres backend +* tenant-scoped uniqueness and isolation +* CRUD, pagination, filters, and RFC 7644 PatchOp subset +* ETag-style weak versions for concurrency-aware clients +* audit events for create/update/delete and group changes -* ``User`` — CRUD via ``/scim/v2/Users``. -* ``Group`` — CRUD via ``/scim/v2/Groups``. - -The module deliberately rejects requests it cannot fulfill rather than -silently approximating SCIM semantics. SCIM PATCH and complex filter -queries (``filter=userName eq "alice"``) are accepted with a documented -subset; anything else returns a SCIM-formatted 501. - -Authentication of the SCIM endpoint is handled by the existing -``Authorization: Bearer`` middleware. SCIM tokens are scoped per tenant -and rotated by the operator via the admin console. +The module rejects unsupported SCIM shapes explicitly rather than silently +approximating IdP intent. """ from __future__ import annotations +import json import logging -import threading +import os import uuid -from dataclasses import dataclass, field, asdict +from dataclasses import dataclass from datetime import datetime, timezone from typing import Any +from modules.storage.pg_connection import AdaptedCursor, ensure_sqlite_dir, get_db_conn + logger = logging.getLogger(__name__) SCHEMA_USER = "urn:ietf:params:scim:schemas:core:2.0:User" SCHEMA_GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group" SCHEMA_LIST_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:ListResponse" SCHEMA_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error" - -_lock = threading.Lock() -# Stage 1 in-memory store. Stage 2 will route through tenant_store. -_users: dict[str, dict[str, Any]] = {} -_groups: dict[str, dict[str, Any]] = {} - - -def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat() - - -def _meta(resource_id: str, resource_type: str) -> dict[str, Any]: - now = _now_iso() - return { - "resourceType": resource_type, - "created": now, - "lastModified": now, - "version": f'W/"{uuid.uuid4().hex}"', - "location": f"/scim/v2/{resource_type}s/{resource_id}", - } +SCHEMA_PATCH_OP = "urn:ietf:params:scim:api:messages:2.0:PatchOp" +SCHEMA_SP_CONFIG = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" +SCHEMA_RESOURCE_TYPE = "urn:ietf:params:scim:schemas:core:2.0:ResourceType" + + +def _db_path() -> str: + return os.getenv("DATA_DB_PATH", "/data/tokendna.db") + + +def _cursor(): + return get_db_conn(db_path=_db_path()) + + +def init_db() -> None: + """Create durable SCIM tables. Idempotent across SQLite and Postgres.""" + ensure_sqlite_dir(_db_path()) + with _cursor() as conn: + cur = AdaptedCursor(conn.cursor()) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS scim_users ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + user_name TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + external_id TEXT, + name_json TEXT NOT NULL DEFAULT '{}', + emails_json TEXT NOT NULL DEFAULT '[]', + roles_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + version TEXT NOT NULL + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS scim_groups ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + display_name TEXT NOT NULL, + members_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + version TEXT NOT NULL + ) + """ + ) + cur.execute("CREATE INDEX IF NOT EXISTS idx_scim_users_tenant ON scim_users(tenant_id)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_scim_users_lookup ON scim_users(tenant_id, user_name)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_scim_groups_tenant ON scim_groups(tenant_id)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_scim_groups_lookup ON scim_groups(tenant_id, display_name)") @dataclass @@ -76,65 +102,251 @@ def to_response(self) -> dict[str, Any]: return body -# ── User CRUD ───────────────────────────────────────────────────────────────── +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _new_version() -> str: + return f'W/"{uuid.uuid4().hex}"' + + +def _json(value: Any, default: Any) -> str: + return json.dumps(value if value is not None else default, sort_keys=True, separators=(",", ":")) + + +def _loads(value: str | None, default: Any) -> Any: + if not value: + return default + try: + return json.loads(value) + except json.JSONDecodeError: + return default + + +def _meta(resource_id: str, resource_type: str, *, created: str, updated: str, version: str) -> dict[str, Any]: + return { + "resourceType": resource_type, + "created": created, + "lastModified": updated, + "version": version, + "location": f"/scim/v2/{resource_type}s/{resource_id}", + } + + +def _require_schema(payload: dict[str, Any], schema: str, resource_name: str) -> None: + if schema not in (payload.get("schemas") or []): + raise SCIMError(400, f"{resource_name} schema missing", scimType="invalidValue") + + +def _audit(action: str, *, tenant_id: str, subject: str, resource: str, detail: dict[str, Any] | None = None) -> None: + try: + from modules.security.audit_log import AuditEventType, AuditOutcome, log_event + + log_event( + AuditEventType.CONFIG_CHANGED, + AuditOutcome.SUCCESS, + tenant_id=tenant_id, + subject=subject, + resource=resource, + detail={"scim_action": action, **(detail or {})}, + ) + except Exception: + logger.debug("SCIM audit emission failed", exc_info=True) + + +def _user_row_to_resource(row: Any) -> dict[str, Any]: + return { + "schemas": [SCHEMA_USER], + "id": row["id"], + "externalId": row["external_id"] or None, + "userName": row["user_name"], + "active": bool(row["active"]), + "name": _loads(row["name_json"], {}), + "emails": _loads(row["emails_json"], []), + "roles": _loads(row["roles_json"], []), + "meta": _meta( + row["id"], + "User", + created=row["created_at"], + updated=row["updated_at"], + version=row["version"], + ), + } + + +def _group_row_to_resource(row: Any) -> dict[str, Any]: + return { + "schemas": [SCHEMA_GROUP], + "id": row["id"], + "displayName": row["display_name"], + "members": _loads(row["members_json"], []), + "meta": _meta( + row["id"], + "Group", + created=row["created_at"], + updated=row["updated_at"], + version=row["version"], + ), + } + + +def _fetch_user(user_id: str, tenant_id: str) -> dict[str, Any] | None: + init_db() + with _cursor() as conn: + row = AdaptedCursor(conn.cursor()).execute( + "SELECT * FROM scim_users WHERE id=? AND tenant_id=?", + (user_id, tenant_id), + ).fetchone() + return _user_row_to_resource(row) if row else None + + +def _fetch_group(group_id: str, tenant_id: str) -> dict[str, Any] | None: + init_db() + with _cursor() as conn: + row = AdaptedCursor(conn.cursor()).execute( + "SELECT * FROM scim_groups WHERE id=? AND tenant_id=?", + (group_id, tenant_id), + ).fetchone() + return _group_row_to_resource(row) if row else None + + +def _assert_user_name_available(user_name: str, tenant_id: str, *, except_user_id: str | None = None) -> None: + with _cursor() as conn: + row = AdaptedCursor(conn.cursor()).execute( + """ + SELECT id FROM scim_users + WHERE tenant_id=? AND lower(user_name)=lower(?) + """, + (tenant_id, user_name), + ).fetchone() + if row and row["id"] != except_user_id: + raise SCIMError(409, "User already exists", scimType="uniqueness") + + +def _assert_group_name_available(display_name: str, tenant_id: str, *, except_group_id: str | None = None) -> None: + with _cursor() as conn: + row = AdaptedCursor(conn.cursor()).execute( + """ + SELECT id FROM scim_groups + WHERE tenant_id=? AND lower(display_name)=lower(?) + """, + (tenant_id, display_name), + ).fetchone() + if row and row["id"] != except_group_id: + raise SCIMError(409, "Group already exists", scimType="uniqueness") + + +# -- User CRUD ----------------------------------------------------------------- def create_user(payload: dict[str, Any], *, tenant_id: str) -> dict[str, Any]: - if SCHEMA_USER not in (payload.get("schemas") or []): - raise SCIMError(400, "User schema missing", scimType="invalidValue") - user_name = payload.get("userName") + init_db() + _require_schema(payload, SCHEMA_USER, "User") + user_name = str(payload.get("userName") or "").strip() if not user_name: raise SCIMError(400, "userName is required", scimType="invalidValue") + _assert_user_name_available(user_name, tenant_id) + + now = _now_iso() user_id = uuid.uuid4().hex - record = { - "schemas": [SCHEMA_USER], - "id": user_id, - "userName": user_name, - "active": bool(payload.get("active", True)), - "name": payload.get("name") or {}, - "emails": payload.get("emails") or [], - "tenant_id": tenant_id, - "meta": _meta(user_id, "User"), - } - with _lock: - for existing in _users.values(): - if existing["tenant_id"] == tenant_id and existing["userName"] == user_name: - raise SCIMError(409, "User already exists", scimType="uniqueness") - _users[user_id] = record - return _strip_internal(record) + version = _new_version() + with _cursor() as conn: + AdaptedCursor(conn.cursor()).execute( + """ + INSERT INTO scim_users + (id, tenant_id, user_name, active, external_id, name_json, emails_json, roles_json, created_at, updated_at, version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + tenant_id, + user_name, + int(bool(payload.get("active", True))), + payload.get("externalId"), + _json(payload.get("name"), {}), + _json(payload.get("emails"), []), + _json(payload.get("roles"), []), + now, + now, + version, + ), + ) + _audit("user.created", tenant_id=tenant_id, subject=user_name, resource=f"scim:user:{user_id}") + return get_user(user_id, tenant_id=tenant_id) def get_user(user_id: str, *, tenant_id: str) -> dict[str, Any]: - with _lock: - record = _users.get(user_id) - if not record or record["tenant_id"] != tenant_id: - raise SCIMError(404, "User not found") - return _strip_internal(record) + user = _fetch_user(user_id, tenant_id) + if not user: + raise SCIMError(404, "User not found") + return user def replace_user(user_id: str, payload: dict[str, Any], *, tenant_id: str) -> dict[str, Any]: - with _lock: - record = _users.get(user_id) - if not record or record["tenant_id"] != tenant_id: - raise SCIMError(404, "User not found") - if "userName" in payload: - record["userName"] = payload["userName"] - if "active" in payload: - record["active"] = bool(payload["active"]) - if "name" in payload: - record["name"] = payload["name"] - if "emails" in payload: - record["emails"] = payload["emails"] - record["meta"]["lastModified"] = _now_iso() - return _strip_internal(record) + init_db() + existing = get_user(user_id, tenant_id=tenant_id) + user_name = str(payload.get("userName") or existing["userName"]).strip() + if not user_name: + raise SCIMError(400, "userName is required", scimType="invalidValue") + _assert_user_name_available(user_name, tenant_id, except_user_id=user_id) + now = _now_iso() + version = _new_version() + with _cursor() as conn: + AdaptedCursor(conn.cursor()).execute( + """ + UPDATE scim_users + SET user_name=?, active=?, external_id=?, name_json=?, emails_json=?, roles_json=?, updated_at=?, version=? + WHERE id=? AND tenant_id=? + """, + ( + user_name, + int(bool(payload.get("active", existing.get("active", True)))), + payload.get("externalId", existing.get("externalId")), + _json(payload.get("name", existing.get("name")), {}), + _json(payload.get("emails", existing.get("emails")), []), + _json(payload.get("roles", existing.get("roles")), []), + now, + version, + user_id, + tenant_id, + ), + ) + _audit("user.replaced", tenant_id=tenant_id, subject=user_name, resource=f"scim:user:{user_id}") + return get_user(user_id, tenant_id=tenant_id) + + +def patch_user(user_id: str, patch_doc: dict[str, Any], *, tenant_id: str) -> dict[str, Any]: + from modules.auth.scim_patch import PatchError, UnsupportedPatch, apply_patch + + existing = get_user(user_id, tenant_id=tenant_id) + try: + patched = apply_patch(existing, patch_doc) + except UnsupportedPatch as exc: + raise SCIMError(501, str(exc), scimType="invalidPath") + except PatchError as exc: + raise SCIMError(400, str(exc), scimType="invalidValue") + for protected in ("id", "schemas", "meta"): + patched.pop(protected, None) + merged = {**existing, **patched} + return replace_user(user_id, merged, tenant_id=tenant_id) def delete_user(user_id: str, *, tenant_id: str) -> None: - with _lock: - record = _users.get(user_id) - if not record or record["tenant_id"] != tenant_id: - raise SCIMError(404, "User not found") - del _users[user_id] + existing = get_user(user_id, tenant_id=tenant_id) + with _cursor() as conn: + cur = AdaptedCursor(conn.cursor()) + cur.execute("DELETE FROM scim_users WHERE id=? AND tenant_id=?", (user_id, tenant_id)) + rows = cur.execute("SELECT * FROM scim_groups WHERE tenant_id=?", (tenant_id,)).fetchall() + for row in rows: + members = [ + m for m in _loads(row["members_json"], []) + if str(m.get("value") or m.get("$ref") or "") != user_id + ] + cur.execute( + "UPDATE scim_groups SET members_json=?, updated_at=?, version=? WHERE id=? AND tenant_id=?", + (_json(members, []), _now_iso(), _new_version(), row["id"], tenant_id), + ) + _audit("user.deleted", tenant_id=tenant_id, subject=existing["userName"], resource=f"scim:user:{user_id}") def list_users( @@ -144,148 +356,165 @@ def list_users( count: int = 100, filter_expr: str | None = None, ) -> dict[str, Any]: + init_db() if start_index < 1 or count < 0: raise SCIMError(400, "Invalid startIndex/count", scimType="invalidValue") - with _lock: - all_records = [u for u in _users.values() if u["tenant_id"] == tenant_id] + with _cursor() as conn: + rows = AdaptedCursor(conn.cursor()).execute( + "SELECT * FROM scim_users WHERE tenant_id=? ORDER BY user_name ASC, id ASC", + (tenant_id,), + ).fetchall() + records = [_user_row_to_resource(row) for row in rows] if filter_expr: from modules.auth.scim_filter import FilterError, UnsupportedFilter, parse + try: predicate = parse(filter_expr) except UnsupportedFilter as exc: raise SCIMError(501, str(exc), scimType="invalidFilter") except FilterError as exc: raise SCIMError(400, str(exc), scimType="invalidFilter") - all_records = [r for r in all_records if predicate(_strip_internal(r))] - total = len(all_records) - page = all_records[start_index - 1 : start_index - 1 + count] + records = [r for r in records if predicate(r)] + total = len(records) + page = records[start_index - 1:start_index - 1 + count] return { "schemas": [SCHEMA_LIST_RESPONSE], "totalResults": total, - "Resources": [_strip_internal(r) for r in page], + "Resources": page, "startIndex": start_index, "itemsPerPage": len(page), } -def patch_user(user_id: str, patch_doc: dict[str, Any], *, tenant_id: str) -> dict[str, Any]: - """Apply a SCIM PatchOp document to a User resource.""" - from modules.auth.scim_patch import PatchError, UnsupportedPatch, apply_patch - - with _lock: - record = _users.get(user_id) - if not record or record["tenant_id"] != tenant_id: - raise SCIMError(404, "User not found") - # apply_patch operates on a copy; we then merge selected fields - # back so we never overwrite id / tenant_id from a malicious PATCH. - try: - patched = apply_patch(_strip_internal(record), patch_doc) - except UnsupportedPatch as exc: - raise SCIMError(501, str(exc), scimType="invalidPath") - except PatchError as exc: - raise SCIMError(400, str(exc), scimType="invalidValue") - - for protected in ("id", "schemas", "meta"): - patched.pop(protected, None) - record.update(patched) - record["meta"]["lastModified"] = _now_iso() - return _strip_internal(record) - - -# ── Group CRUD ──────────────────────────────────────────────────────────────── +# -- Group CRUD ---------------------------------------------------------------- def create_group(payload: dict[str, Any], *, tenant_id: str) -> dict[str, Any]: - if SCHEMA_GROUP not in (payload.get("schemas") or []): - raise SCIMError(400, "Group schema missing", scimType="invalidValue") - display_name = payload.get("displayName") + init_db() + _require_schema(payload, SCHEMA_GROUP, "Group") + display_name = str(payload.get("displayName") or "").strip() if not display_name: raise SCIMError(400, "displayName is required", scimType="invalidValue") + _assert_group_name_available(display_name, tenant_id) + now = _now_iso() group_id = uuid.uuid4().hex - record = { - "schemas": [SCHEMA_GROUP], - "id": group_id, - "displayName": display_name, - "members": payload.get("members") or [], - "tenant_id": tenant_id, - "meta": _meta(group_id, "Group"), - } - with _lock: - _groups[group_id] = record - return _strip_internal(record) + version = _new_version() + with _cursor() as conn: + AdaptedCursor(conn.cursor()).execute( + """ + INSERT INTO scim_groups + (id, tenant_id, display_name, members_json, created_at, updated_at, version) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + group_id, + tenant_id, + display_name, + _json(payload.get("members"), []), + now, + now, + version, + ), + ) + _audit("group.created", tenant_id=tenant_id, subject=display_name, resource=f"scim:group:{group_id}") + return get_group(group_id, tenant_id=tenant_id) def get_group(group_id: str, *, tenant_id: str) -> dict[str, Any]: - with _lock: - record = _groups.get(group_id) - if not record or record["tenant_id"] != tenant_id: - raise SCIMError(404, "Group not found") - return _strip_internal(record) + group = _fetch_group(group_id, tenant_id) + if not group: + raise SCIMError(404, "Group not found") + return group def list_groups(*, tenant_id: str, filter_expr: str | None = None) -> dict[str, Any]: - with _lock: - records = [g for g in _groups.values() if g["tenant_id"] == tenant_id] + init_db() + with _cursor() as conn: + rows = AdaptedCursor(conn.cursor()).execute( + "SELECT * FROM scim_groups WHERE tenant_id=? ORDER BY display_name ASC, id ASC", + (tenant_id,), + ).fetchall() + records = [_group_row_to_resource(row) for row in rows] if filter_expr: from modules.auth.scim_filter import FilterError, UnsupportedFilter, parse + try: predicate = parse(filter_expr) except UnsupportedFilter as exc: raise SCIMError(501, str(exc), scimType="invalidFilter") except FilterError as exc: raise SCIMError(400, str(exc), scimType="invalidFilter") - records = [r for r in records if predicate(_strip_internal(r))] + records = [r for r in records if predicate(r)] return { "schemas": [SCHEMA_LIST_RESPONSE], "totalResults": len(records), - "Resources": [_strip_internal(r) for r in records], + "Resources": records, "startIndex": 1, "itemsPerPage": len(records), } def patch_group(group_id: str, patch_doc: dict[str, Any], *, tenant_id: str) -> dict[str, Any]: - """Apply a SCIM PatchOp document to a Group resource.""" from modules.auth.scim_patch import PatchError, UnsupportedPatch, apply_patch - with _lock: - record = _groups.get(group_id) - if not record or record["tenant_id"] != tenant_id: - raise SCIMError(404, "Group not found") - try: - patched = apply_patch(_strip_internal(record), patch_doc) - except UnsupportedPatch as exc: - raise SCIMError(501, str(exc), scimType="invalidPath") - except PatchError as exc: - raise SCIMError(400, str(exc), scimType="invalidValue") - for protected in ("id", "schemas", "meta"): - patched.pop(protected, None) - record.update(patched) - record["meta"]["lastModified"] = _now_iso() - return _strip_internal(record) + existing = get_group(group_id, tenant_id=tenant_id) + try: + patched = apply_patch(existing, patch_doc) + except UnsupportedPatch as exc: + raise SCIMError(501, str(exc), scimType="invalidPath") + except PatchError as exc: + raise SCIMError(400, str(exc), scimType="invalidValue") + for protected in ("id", "schemas", "meta"): + patched.pop(protected, None) + merged = {**existing, **patched} + display_name = str(merged.get("displayName") or existing["displayName"]).strip() + if not display_name: + raise SCIMError(400, "displayName is required", scimType="invalidValue") + _assert_group_name_available(display_name, tenant_id, except_group_id=group_id) + now = _now_iso() + with _cursor() as conn: + AdaptedCursor(conn.cursor()).execute( + """ + UPDATE scim_groups + SET display_name=?, members_json=?, updated_at=?, version=? + WHERE id=? AND tenant_id=? + """, + ( + display_name, + _json(merged.get("members"), []), + now, + _new_version(), + group_id, + tenant_id, + ), + ) + _audit("group.patched", tenant_id=tenant_id, subject=display_name, resource=f"scim:group:{group_id}") + return get_group(group_id, tenant_id=tenant_id) def delete_group(group_id: str, *, tenant_id: str) -> None: - with _lock: - record = _groups.get(group_id) - if not record or record["tenant_id"] != tenant_id: - raise SCIMError(404, "Group not found") - del _groups[group_id] + existing = get_group(group_id, tenant_id=tenant_id) + with _cursor() as conn: + AdaptedCursor(conn.cursor()).execute( + "DELETE FROM scim_groups WHERE id=? AND tenant_id=?", + (group_id, tenant_id), + ) + _audit("group.deleted", tenant_id=tenant_id, subject=existing["displayName"], resource=f"scim:group:{group_id}") -# ── Discovery endpoints ─────────────────────────────────────────────────────── +# -- Discovery ----------------------------------------------------------------- def service_provider_config() -> dict[str, Any]: return { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], + "schemas": [SCHEMA_SP_CONFIG], "documentationUri": "https://github.com/Bobcatsfan33/TokenDNA", "patch": {"supported": True}, "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, "filter": {"supported": True, "maxResults": 200}, "changePassword": {"supported": False}, "sort": {"supported": False}, - "etag": {"supported": False}, + "etag": {"supported": True}, "authenticationSchemes": [ { "type": "oauthbearertoken", @@ -304,7 +533,7 @@ def resource_types() -> dict[str, Any]: "totalResults": 2, "Resources": [ { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + "schemas": [SCHEMA_RESOURCE_TYPE], "id": "User", "name": "User", "endpoint": "/Users", @@ -312,7 +541,7 @@ def resource_types() -> dict[str, Any]: "schema": SCHEMA_USER, }, { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + "schemas": [SCHEMA_RESOURCE_TYPE], "id": "Group", "name": "Group", "endpoint": "/Groups", @@ -325,14 +554,9 @@ def resource_types() -> dict[str, Any]: } -def _strip_internal(record: dict[str, Any]) -> dict[str, Any]: - out = dict(record) - out.pop("tenant_id", None) - return out - - def _reset_for_tests() -> None: - """Test helper — wipe in-memory state.""" - with _lock: - _users.clear() - _groups.clear() + init_db() + with _cursor() as conn: + cur = AdaptedCursor(conn.cursor()) + cur.execute("DELETE FROM scim_groups") + cur.execute("DELETE FROM scim_users") diff --git a/modules/storage/migrations.py b/modules/storage/migrations.py index 410666b..a0ff1ea 100644 --- a/modules/storage/migrations.py +++ b/modules/storage/migrations.py @@ -36,6 +36,8 @@ class Migration: INIT_TARGETS: tuple[tuple[str, str], ...] = ( ("modules.tenants.store", "init_db"), + ("modules.auth.saml", "init_db"), + ("modules.auth.scim", "init_db"), ("modules.identity.attestation_store", "init_db"), ("modules.identity.uis_store", "init_db"), ("modules.identity.trust_graph", "init_db"), @@ -160,8 +162,12 @@ def _has_revision(revision: str) -> bool: def _baseline_schema() -> None: + _apply_init_targets(INIT_TARGETS) + + +def _apply_init_targets(targets: Iterable[tuple[str, str]]) -> None: failures: list[str] = [] - for dotted, attr in INIT_TARGETS: + for dotted, attr in targets: try: module = importlib.import_module(dotted) init = getattr(module, attr) @@ -174,12 +180,26 @@ def _baseline_schema() -> None: raise RuntimeError(f"baseline migration failed:\n - {joined}") +def _enterprise_idp_schema() -> None: + _apply_init_targets( + ( + ("modules.auth.saml", "init_db"), + ("modules.auth.scim", "init_db"), + ) + ) + + MIGRATIONS: tuple[Migration, ...] = ( Migration( revision="202605220001_baseline", description="Initialize TokenDNA control-plane schemas", apply=_baseline_schema, ), + Migration( + revision="202605230001_enterprise_idp_ga", + description="Initialize durable SAML/SCIM enterprise IdP schemas", + apply=_enterprise_idp_schema, + ), ) diff --git a/requirements.txt b/requirements.txt index 56811a8..29b31d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,10 @@ python-multipart==0.0.26 # ── JWT / OIDC ──────────────────────────────────────────────────────────────── python-jose[cryptography]==3.4.0 cryptography==46.0.7 +defusedxml==0.7.1 + +# ── Enterprise SSO / provisioning ──────────────────────────────────────────── +python3-saml==1.16.0 # ── HTTP client (JWKS fetch, webhooks, AbuseIPDB, ip-api) ──────────────────── requests==2.33.0 diff --git a/scripts/idp_ga_validation.py b/scripts/idp_ga_validation.py new file mode 100644 index 0000000..5fb0e5e --- /dev/null +++ b/scripts/idp_ga_validation.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +""" +Live IdP GA validation harness for TokenDNA SAML/SCIM integrations. + +This script verifies the TokenDNA-side contract that every Okta, Entra, and +OneLogin rollout depends on. A human still completes the browser SAML login +inside the customer's IdP tenant, but this produces a repeatable JSON report +for the deploy gate and customer evidence packet. +""" + +import argparse +import json +import secrets +import sys +from dataclasses import dataclass +from typing import Any + +import requests + + +@dataclass +class Step: + name: str + ok: bool + detail: str + + +def _headers(api_key: str | None, bearer: str | None) -> dict[str, str]: + headers = {"Accept": "application/scim+json"} + if api_key: + headers["X-API-Key"] = api_key + if bearer: + headers["Authorization"] = f"Bearer {bearer}" + return headers + + +def _request(method: str, url: str, *, headers: dict[str, str] | None = None, json_body: dict[str, Any] | None = None) -> requests.Response: + return requests.request(method, url, headers=headers, json=json_body, timeout=20) + + +def _record(steps: list[Step], name: str, ok: bool, detail: str) -> None: + steps.append(Step(name=name, ok=ok, detail=detail)) + + +def run_validation(base_url: str, *, provider: str, api_key: str | None, bearer: str | None) -> dict[str, Any]: + base = base_url.rstrip("/") + scim_headers = _headers(api_key, bearer) + steps: list[Step] = [] + suffix = secrets.token_hex(4) + user_id = "" + group_id = "" + + try: + metadata = _request("GET", f"{base}/saml/metadata") + _record( + steps, + "saml_metadata_available", + metadata.status_code == 200 and "EntityDescriptor" in metadata.text and "WantAssertionsSigned" in metadata.text, + f"status={metadata.status_code}", + ) + except Exception as exc: # noqa: BLE001 + _record(steps, "saml_metadata_available", False, str(exc)) + + try: + login = _request("GET", f"{base}/saml/login?relay_state=/dashboard") + body = login.json() if login.headers.get("content-type", "").startswith("application/json") else {} + redirect_url = str(body.get("redirect_url") or "") + _record( + steps, + "saml_sp_initiated_login_contract", + login.status_code == 200 and "SAMLRequest=" in redirect_url and "RelayState=" in redirect_url, + f"status={login.status_code}", + ) + except Exception as exc: # noqa: BLE001 + _record(steps, "saml_sp_initiated_login_contract", False, str(exc)) + + try: + spc = _request("GET", f"{base}/scim/v2/ServiceProviderConfig", headers=scim_headers) + body = spc.json() + _record( + steps, + "scim_service_provider_config", + spc.status_code == 200 and body.get("patch", {}).get("supported") is True and body.get("etag", {}).get("supported") is True, + f"status={spc.status_code}", + ) + except Exception as exc: # noqa: BLE001 + _record(steps, "scim_service_provider_config", False, str(exc)) + + try: + create = _request( + "POST", + f"{base}/scim/v2/Users", + headers=scim_headers, + json_body={ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": f"tokendna-ga-{suffix}@example.invalid", + "active": True, + "name": {"givenName": "TokenDNA", "familyName": "GA"}, + }, + ) + body = create.json() + user_id = str(body.get("id") or "") + _record(steps, "scim_user_create", create.status_code == 201 and bool(user_id), f"status={create.status_code}") + except Exception as exc: # noqa: BLE001 + _record(steps, "scim_user_create", False, str(exc)) + + if user_id: + try: + patch = _request( + "PATCH", + f"{base}/scim/v2/Users/{user_id}", + headers=scim_headers, + json_body={ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "replace", "path": "active", "value": False}], + }, + ) + body = patch.json() + _record(steps, "scim_user_deactivate_patch", patch.status_code == 200 and body.get("active") is False, f"status={patch.status_code}") + except Exception as exc: # noqa: BLE001 + _record(steps, "scim_user_deactivate_patch", False, str(exc)) + + try: + group = _request( + "POST", + f"{base}/scim/v2/Groups", + headers=scim_headers, + json_body={ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "displayName": f"tokendna-ga-{suffix}", + }, + ) + body = group.json() + group_id = str(body.get("id") or "") + _record(steps, "scim_group_create", group.status_code == 201 and bool(group_id), f"status={group.status_code}") + except Exception as exc: # noqa: BLE001 + _record(steps, "scim_group_create", False, str(exc)) + + if user_id and group_id: + try: + patch_group = _request( + "PATCH", + f"{base}/scim/v2/Groups/{group_id}", + headers=scim_headers, + json_body={ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "replace", "path": "members", "value": [{"value": user_id}]}], + }, + ) + body = patch_group.json() + _record(steps, "scim_group_membership_patch", patch_group.status_code == 200 and body.get("members") == [{"value": user_id}], f"status={patch_group.status_code}") + except Exception as exc: # noqa: BLE001 + _record(steps, "scim_group_membership_patch", False, str(exc)) + + if group_id: + try: + delete_group = _request("DELETE", f"{base}/scim/v2/Groups/{group_id}", headers=scim_headers) + _record(steps, "scim_group_delete", delete_group.status_code == 204, f"status={delete_group.status_code}") + except Exception as exc: # noqa: BLE001 + _record(steps, "scim_group_delete", False, str(exc)) + + if user_id: + try: + delete_user = _request("DELETE", f"{base}/scim/v2/Users/{user_id}", headers=scim_headers) + _record(steps, "scim_user_delete", delete_user.status_code == 204, f"status={delete_user.status_code}") + except Exception as exc: # noqa: BLE001 + _record(steps, "scim_user_delete", False, str(exc)) + + return { + "provider": provider, + "base_url": base, + "passed": all(step.ok for step in steps), + "steps": [step.__dict__ for step in steps], + "manual_evidence_required": [ + "browser_login_completed_in_customer_idp", + "assertion_signature_verified_by_audit_log", + "assertion_replay_rejected", + "idp_certificate_fingerprint_recorded", + ], + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run TokenDNA IdP GA validation checks") + parser.add_argument("--base-url", required=True, help="TokenDNA base URL, for example https://tokendna.customer.example") + parser.add_argument("--provider", required=True, choices=["okta", "entra", "onelogin", "other"]) + parser.add_argument("--api-key", default=None, help="Tenant API key for SCIM validation") + parser.add_argument("--bearer", default=None, help="Tenant bearer token for SCIM validation") + args = parser.parse_args() + + if not args.api_key and not args.bearer: + parser.error("--api-key or --bearer is required for SCIM validation") + + report = run_validation(args.base_url, provider=args.provider, api_key=args.api_key, bearer=args.bearer) + print(json.dumps(report, indent=2, sort_keys=True)) + if not report["passed"]: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/preflight_prod.py b/scripts/preflight_prod.py index ad02426..ff88c75 100644 --- a/scripts/preflight_prod.py +++ b/scripts/preflight_prod.py @@ -8,6 +8,7 @@ """ import argparse +import importlib.util import json import os import sys @@ -54,6 +55,29 @@ def _scan_direct_sqlite_connects() -> list[str]: return sorted(offenders) +def _python_module_available(module_name: str) -> bool: + try: + return importlib.util.find_spec(module_name) is not None + except (ImportError, AttributeError, ValueError): + return False + + +def _is_https_url(value: str | None) -> bool: + text = str(value or "").strip().lower() + return text.startswith("https://") + + +def _saml_configured() -> bool: + return any( + _has(os.getenv(key)) + for key in ( + "SAML_IDP_SSO_URL", + "SAML_IDP_X509_CERT", + "SAML_IDP_METADATA_URL", + ) + ) + + # Minimum acceptable length for any HMAC key in production. _MIN_KEY_BYTES = 16 @@ -127,6 +151,30 @@ def add_check(name: str, ok: bool, detail: str = "") -> None: ), ) + saml_enabled = _saml_configured() + if saml_enabled: + add_check("saml_sp_entity_id_https", _is_https_url(os.getenv("SAML_SP_ENTITY_ID")), "SAML_SP_ENTITY_ID must be an https URL") + add_check("saml_sp_acs_url_https", _is_https_url(os.getenv("SAML_SP_ACS_URL")), "SAML_SP_ACS_URL must be an https URL") + add_check("saml_idp_sso_url_set", _has(os.getenv("SAML_IDP_SSO_URL")), "SAML_IDP_SSO_URL required when SAML is enabled") + add_check("saml_idp_x509_cert_set", _has(os.getenv("SAML_IDP_X509_CERT")), "SAML_IDP_X509_CERT required when SAML is enabled") + add_check( + "saml_runtime_dependency_available", + _python_module_available("onelogin.saml2.response"), + "python3-saml runtime dependency required when SAML is enabled", + ) + add_check( + "saml_relay_state_allowlist_set", + _has(os.getenv("SAML_ALLOWED_RELAY_STATE_HOSTS")), + "SAML_ALLOWED_RELAY_STATE_HOSTS required for enterprise return URL control", + ) + add_check( + "saml_idp_initiated_disabled_by_default", + not _is_truthy(os.getenv("SAML_ALLOW_IDP_INITIATED")), + "IdP-initiated SAML must remain disabled unless explicitly approved per customer", + ) + else: + add_check("saml_config_not_enabled", True, "SAML not configured in this environment") + # Production HMAC secret gate — value-level checks, not just presence. for env_var in ( "TOKENDNA_DELEGATION_SECRET", @@ -190,6 +238,11 @@ def add_check(name: str, ok: bool, detail: str = "") -> None: "direct_sqlite_module_count": len(direct_sqlite), "direct_sqlite_modules": direct_sqlite, }, + "saml": { + "configured": saml_enabled, + "idp_initiated": _is_truthy(os.getenv("SAML_ALLOW_IDP_INITIATED")), + "relay_state_allowlist_present": _has(os.getenv("SAML_ALLOWED_RELAY_STATE_HOSTS")), + }, "checks": checks, } diff --git a/tests/test_preflight_prod.py b/tests/test_preflight_prod.py index 63ec6cb..69d1d77 100644 --- a/tests/test_preflight_prod.py +++ b/tests/test_preflight_prod.py @@ -5,6 +5,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from scripts import preflight_prod from scripts.preflight_prod import run_preflight @@ -27,6 +28,13 @@ def _clear(monkeypatch): "TOKENDNA_POSTURE_SECRET", "ATTESTATION_CA_KEY_ID", "ATTESTATION_CA_SECRET", + "SAML_SP_ENTITY_ID", + "SAML_SP_ACS_URL", + "SAML_IDP_SSO_URL", + "SAML_IDP_X509_CERT", + "SAML_IDP_METADATA_URL", + "SAML_ALLOWED_RELAY_STATE_HOSTS", + "SAML_ALLOW_IDP_INITIATED", ): monkeypatch.delenv(key, raising=False) @@ -72,3 +80,18 @@ def test_preflight_reports_no_direct_sqlite_modules(monkeypatch): assert report["storage"]["direct_sqlite_module_count"] == 0 assert report["storage"]["direct_sqlite_modules"] == [] + + +def test_preflight_gates_incomplete_saml_config(monkeypatch): + _clear(monkeypatch) + monkeypatch.setattr(preflight_prod, "_python_module_available", lambda module_name: False) + monkeypatch.setenv("SAML_SP_ENTITY_ID", "https://tokendna.example/sp") + monkeypatch.setenv("SAML_SP_ACS_URL", "https://tokendna.example/saml/acs") + monkeypatch.setenv("SAML_IDP_SSO_URL", "https://idp.example/sso") + + report = run_preflight("production") + + checks = {c["name"]: c for c in report["checks"]} + assert checks["saml_idp_x509_cert_set"]["ok"] is False + assert checks["saml_runtime_dependency_available"]["ok"] is False + assert checks["saml_relay_state_allowlist_set"]["ok"] is False diff --git a/tests/test_saml_scim.py b/tests/test_saml_scim.py index 901a8e5..9c1f9c5 100644 --- a/tests/test_saml_scim.py +++ b/tests/test_saml_scim.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import os import sys @@ -35,10 +36,10 @@ def test_saml_authn_request_emits_redirect_url(monkeypatch): monkeypatch.setenv("SAML_SP_ACS_URL", "https://test/acs") monkeypatch.setenv("SAML_IDP_SSO_URL", "https://idp.example/sso") cfg = saml.SAMLConfig.from_env() - req = saml.build_authn_request(cfg, relay_state="rs-fixed") - assert req.relay_state == "rs-fixed" + req = saml.build_authn_request(cfg, relay_state="/dashboard") + assert req.relay_state == "/dashboard" assert req.redirect_url.startswith("https://idp.example/sso?SAMLRequest=") - assert "RelayState=rs-fixed" in req.redirect_url + assert "RelayState=%2Fdashboard" in req.redirect_url assert req.request_id.startswith("_") @@ -49,13 +50,75 @@ def test_saml_parse_assertion_refuses_without_cert(monkeypatch): saml.parse_assertion("base64-blob", cfg) +def test_saml_relay_state_rejects_open_redirect(monkeypatch): + monkeypatch.delenv("SAML_ALLOWED_RELAY_STATE_HOSTS", raising=False) + with pytest.raises(saml.SAMLError, match="allowlisted"): + saml.validate_relay_state("https://evil.example/return") + + +def test_saml_relay_state_allows_configured_https_host(monkeypatch): + monkeypatch.setenv("SAML_ALLOWED_RELAY_STATE_HOSTS", "app.example.com") + assert saml.validate_relay_state("https://app.example.com/dashboard") == "https://app.example.com/dashboard" + + +def test_saml_authn_request_replay_state_is_single_use(monkeypatch): + monkeypatch.setenv("SAML_SP_ENTITY_ID", "https://test/sp") + monkeypatch.setenv("SAML_SP_ACS_URL", "https://test/acs") + monkeypatch.setenv("SAML_IDP_SSO_URL", "https://idp.example/sso") + cfg = saml.SAMLConfig.from_env() + req = saml.build_authn_request(cfg, relay_state="/return") + + saml.consume_authn_request(req.request_id, "/return", cfg) + with pytest.raises(saml.SAMLError, match="already been consumed"): + saml.consume_authn_request(req.request_id, "/return", cfg) + + +def test_saml_assertion_replay_is_rejected(): + saml.record_assertion_replay( + "assertion-1", + "https://idp.example", + "alice@example.com", + "2099-01-01T00:00:00Z", + ) + with pytest.raises(saml.SAMLError, match="replay"): + saml.record_assertion_replay( + "assertion-1", + "https://idp.example", + "alice@example.com", + "2099-01-01T00:00:00Z", + ) + + +def test_saml_extracts_request_and_assertion_ids(): + xml = """ + + + + + """ + extracted = saml._extract_response_state(base64.b64encode(xml.encode()).decode()) + assert extracted["response_id"] == "resp-1" + assert extracted["in_response_to"] == "_req1" + assert extracted["assertion_id"] == "assertion-1" + assert extracted["destination"] == "https://sp.example/acs" + + # ── SCIM unit tests ─────────────────────────────────────────────────────────── @pytest.fixture(autouse=True) -def _scim_clean(): +def _scim_clean(tmp_path, monkeypatch): + monkeypatch.delenv("TOKENDNA_DB_BACKEND", raising=False) + monkeypatch.delenv("TOKENDNA_PG_DSN", raising=False) + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.setenv("DATA_BACKEND", "sqlite") + monkeypatch.setenv("DATA_DB_PATH", str(tmp_path / "saml-scim.db")) + saml._reset_for_tests() scim._reset_for_tests() yield + saml._reset_for_tests() scim._reset_for_tests() @@ -107,6 +170,16 @@ def test_scim_replace_and_delete_user(): scim.get_user(user["id"], tenant_id="t-1") +def test_scim_users_are_durable_in_shared_storage(): + user = scim.create_user( + {"schemas": [scim.SCHEMA_USER], "userName": "durable@example.com"}, + tenant_id="t-1", + ) + fetched = scim.get_user(user["id"], tenant_id="t-1") + assert fetched["userName"] == "durable@example.com" + assert fetched["meta"]["version"].startswith('W/"') + + def test_scim_list_users_pagination(): for i in range(5): scim.create_user( @@ -129,10 +202,37 @@ def test_scim_create_group_and_lookup(): assert fetched["id"] == g["id"] +def test_scim_group_patch_members(): + user = scim.create_user( + {"schemas": [scim.SCHEMA_USER], "userName": "member@example.com"}, + tenant_id="t-1", + ) + group = scim.create_group( + {"schemas": [scim.SCHEMA_GROUP], "displayName": "engineers"}, + tenant_id="t-1", + ) + patched = scim.patch_group( + group["id"], + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + { + "op": "replace", + "path": "members", + "value": [{"value": user["id"], "display": user["userName"]}], + } + ], + }, + tenant_id="t-1", + ) + assert patched["members"] == [{"value": user["id"], "display": user["userName"]}] + + def test_scim_service_provider_config_advertises_bearer(): cfg = scim.service_provider_config() schemes = cfg.get("authenticationSchemes", []) assert any(s["type"] == "oauthbearertoken" for s in schemes) + assert cfg["etag"]["supported"] is True # ── SAML / SCIM route smoke tests ───────────────────────────────────────────── From bab60d89a875e3ba1a9c6c67df2b1019412633da Mon Sep 17 00:00:00 2001 From: Bobcatsfan33 Date: Sat, 23 May 2026 14:14:14 -0400 Subject: [PATCH 2/3] Support SCIM bearer API keys for IdPs --- docs/ops/saml-scim.md | 10 ++++++--- modules/tenants/middleware.py | 18 +++++++++++++++-- tests/test_tenant_middleware.py | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 tests/test_tenant_middleware.py diff --git a/docs/ops/saml-scim.md b/docs/ops/saml-scim.md index 016e227..a490027 100644 --- a/docs/ops/saml-scim.md +++ b/docs/ops/saml-scim.md @@ -71,18 +71,22 @@ IdP-initiated SAML is disabled by default. Enable it only by explicit customer e ### Auth and tenant isolation -SCIM calls use the normal TokenDNA tenant boundary: +SCIM calls use the normal TokenDNA tenant boundary. For Okta, Microsoft +Entra ID, OneLogin, and most SCIM clients, configure bearer-token auth with +the tenant API key value: ```http -Authorization: Bearer +Authorization: Bearer ``` -or: +Direct API callers can also send the same key with: ```http X-API-Key: ``` +OIDC JWT bearer auth remains supported for non-SCIM API callers. + Every SCIM read and write is tenant-scoped. Users and groups are stored durably in the shared backend, have weak ETag-style versions, and emit audit events for create, update, patch, and delete actions. ### Supported SCIM behavior diff --git a/modules/tenants/middleware.py b/modules/tenants/middleware.py index 07208ba..81466ff 100644 --- a/modules/tenants/middleware.py +++ b/modules/tenants/middleware.py @@ -2,7 +2,8 @@ TokenDNA — Tenant authentication middleware Supports two auth paths (checked in order): 1. X-API-Key header → resolves tenant from key hash (primary, production) - 2. Bearer JWT → resolves tenant from JWT sub claim (dev / OIDC flow) + 2. Bearer token → resolves tenant API keys for SCIM-compatible IdPs, + then JWT sub claims for OIDC flows DEV_MODE bypasses both and injects a synthetic dev tenant. """ from __future__ import annotations @@ -64,8 +65,21 @@ async def get_tenant( api_key_id=key_record.id, ) - # ── Path 2: Bearer JWT (delegates to existing auth module) ──────────────── + # ── Path 2: Bearer tenant API key or JWT ───────────────────────────────── if bearer: + # SCIM providers such as Okta send provisioning credentials as + # Authorization: Bearer . Accept a tenant API key here before + # falling through to JWT verification for OIDC/API callers. + result = store.lookup_by_key(bearer.credentials) + if result: + key_record, tenant = result + return TenantContext( + tenant_id=tenant.id, + tenant_name=tenant.name, + plan=tenant.plan, + api_key_id=key_record.id, + ) + # Import here to avoid circular import from auth import _verify_jwt # type: ignore try: diff --git a/tests/test_tenant_middleware.py b/tests/test_tenant_middleware.py new file mode 100644 index 0000000..9ef9073 --- /dev/null +++ b/tests/test_tenant_middleware.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import os +import sys + +import pytest +from fastapi.security import HTTPAuthorizationCredentials + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +@pytest.mark.asyncio +async def test_bearer_api_key_resolves_tenant_for_scim(monkeypatch, tmp_path): + from modules.tenants import middleware, store + from modules.tenants.models import Plan + + monkeypatch.delenv("TOKENDNA_DB_BACKEND", raising=False) + monkeypatch.delenv("TOKENDNA_PG_DSN", raising=False) + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.setenv("DATA_BACKEND", "sqlite") + monkeypatch.setenv("DATA_DB_PATH", str(tmp_path / "tenants.db")) + monkeypatch.setattr(middleware, "DEV_MODE", False) + + store.init_db() + tenant, raw_key = store.create_tenant("Okta Sandbox", owner_email="owner@example.com", plan=Plan.ENTERPRISE) + + ctx = await middleware.get_tenant( + request=None, # type: ignore[arg-type] + api_key=None, + bearer=HTTPAuthorizationCredentials(scheme="Bearer", credentials=raw_key), + ) + + assert ctx.tenant_id == tenant.id + assert ctx.tenant_name == "Okta Sandbox" + assert ctx.plan is Plan.ENTERPRISE + assert ctx.api_key_id != "jwt" From b290cb9eb6730e52e25eb67f8fa2713e46b036cb Mon Sep 17 00:00:00 2001 From: Bobcatsfan33 Date: Sat, 23 May 2026 14:14:36 -0400 Subject: [PATCH 3/3] Document Okta SCIM bearer validation --- docs/ops/saml-scim.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ops/saml-scim.md b/docs/ops/saml-scim.md index a490027..f82b767 100644 --- a/docs/ops/saml-scim.md +++ b/docs/ops/saml-scim.md @@ -128,7 +128,7 @@ The repeatable TokenDNA-side harness is: python3 scripts/idp_ga_validation.py \ --provider okta \ --base-url https://tokendna.customer.example \ - --api-key "$TOKENDNA_TENANT_API_KEY" + --bearer "$TOKENDNA_TENANT_API_KEY" ``` Use `--provider entra` or `--provider onelogin` for those customer tenants. The script verifies metadata, SP-initiated SAML request generation, SCIM discovery, user lifecycle, group lifecycle, and group membership patching, then emits a JSON evidence report. The browser login, assertion replay, and certificate-fingerprint evidence remain customer-tenant manual checks because they require the live IdP admin console and a real assigned user.