From 3b4c90c72c6133b2f3a33b5ba9614dcb8f2fd7bd Mon Sep 17 00:00:00 2001 From: Bobcatsfan33 Date: Mon, 13 Jul 2026 21:28:59 -0400 Subject: [PATCH 01/10] =?UTF-8?q?Re-port=20#92=20(SAML/SCIM=20GA=20hardeni?= =?UTF-8?q?ng)=20onto=20refactored=20main=20=E2=80=94=20core=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAML replay/relay-state hardening, durable SCIM storage with role mapping, migrations, middleware bearer-key auth, deps (defusedxml, python3-saml). --- requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/requirements.txt b/requirements.txt index 97125a6..0c4f6e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,10 @@ python-multipart==0.0.31 # ── JWT / OIDC ──────────────────────────────────────────────────────────────── python-jose[cryptography]==3.5.0 cryptography==48.0.1 +defusedxml==0.7.1 + +# ── Enterprise SSO / provisioning ──────────────────────────────────────────── +python3-saml==1.16.0 # ── HTTP client (JWKS fetch, webhooks, AbuseIPDB, ip-api) ──────────────────── requests==2.33.0 From beec47eff1e96476b7940e215ff420015cc862c2 Mon Sep 17 00:00:00 2001 From: Bobcatsfan33 Date: Mon, 13 Jul 2026 21:31:15 -0400 Subject: [PATCH 02/10] Re-port #92: Dockerfile SAML libs, alembic + storage migrations for SAML/SCIM schemas --- Dockerfile | 6 +++++- alembic/versions/0001_baseline.py | 2 ++ modules/storage/migrations.py | 24 ++++++++++++++++++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index c503531..a009743 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ # -e DEV_MODE=true \ # ghcr.io/bobcatsfan33/tokendna:dev -# ── Stage 1: dependency builder ────────────────────────────────────────────── +# ── Stage 1: dependency builder ─────────────────────────────────────────────── FROM python:3.11-slim AS builder WORKDIR /build @@ -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/modules/storage/migrations.py b/modules/storage/migrations.py index 1e076e6..b52f5cc 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"), @@ -158,13 +160,17 @@ 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) init() - logger.info("migration baseline applied %s.%s", dotted, attr) + logger.info("migration init applied %s.%s", dotted, attr) except Exception as exc: # noqa: BLE001 failures.append(f"{dotted}.{attr}: {exc}") if failures: @@ -172,6 +178,15 @@ 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"), + ) + ) + + def _api_key_roles() -> None: """Add least-privilege roles to tenant API keys. @@ -203,6 +218,11 @@ def _api_key_roles() -> None: description="Add least-privilege role column to tenant API keys", apply=_api_key_roles, ), + Migration( + revision="202605230001_enterprise_idp_ga", + description="Initialize durable SAML/SCIM enterprise IdP schemas", + apply=_enterprise_idp_schema, + ), ) From a898c9bf4195a7ab454579c65f25c36e8c8fb37a Mon Sep 17 00:00:00 2001 From: Bobcatsfan33 Date: Mon, 13 Jul 2026 21:33:21 -0400 Subject: [PATCH 03/10] Re-port #92: SAML replay/relay-state hardening + tenant middleware bearer-key auth --- modules/auth/saml.py | 270 ++++++++++++++++++++++++++++++++-- modules/tenants/middleware.py | 24 ++- 2 files changed, 279 insertions(+), 15 deletions(-) diff --git a/modules/auth/saml.py b/modules/auth/saml.py index b378606..2d172aa 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,28 @@ 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 + +# SECURITY: SAML responses are attacker-controlled XML. We parse them ONLY +# with defusedxml (XXE / entity-expansion / DTD hardening). We deliberately do +# NOT fall back to the stdlib xml.etree parser on untrusted input -- if +# defusedxml is unavailable we refuse to extract state rather than expose an +# XXE / billion-laughs sink. defusedxml is a hard runtime dependency in +# production (see requirements.txt). +try: + from defusedxml import ElementTree as SafeElementTree # type: ignore +except Exception: # pragma: no cover - dependency is shipped in production image + SafeElementTree = None # type: ignore[assignment] + +from modules.storage.pg_connection import AdaptedCursor, ensure_sqlite_dir, get_db_conn logger = logging.getLogger(__name__) @@ -53,6 +66,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 +82,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 +112,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 +291,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 +306,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 +320,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 +330,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 +369,82 @@ 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. + """ + if SafeElementTree is None: # defusedxml missing -> refuse to parse untrusted XML + logger.error("defusedxml is not installed; refusing to parse SAML response state") + return {} + 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/tenants/middleware.py b/modules/tenants/middleware.py index 2ef0e7b..def1d3f 100644 --- a/modules/tenants/middleware.py +++ b/modules/tenants/middleware.py @@ -58,7 +58,7 @@ async def get_tenant( if DEV_MODE: return _DEV_TENANT - # ── Path 1: API key ─────────────────────────────────────────────────────── + # ── Path 1: API key ───────────────────────────────────────────────────────── if api_key: result = store.lookup_by_key(api_key) if not result: @@ -72,8 +72,28 @@ async def get_tenant( role=key_record.role, ) - # ── 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. A genuine + # JWT will never match a key hash, so this is safe; we swallow lookup + # errors so a JWT bearer never 500s on the (optional) key probe. + try: + result = store.lookup_by_key(bearer.credentials) + except Exception: + logger.debug("bearer API-key lookup failed; falling through to JWT", exc_info=True) + result = None + 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, + role=key_record.role, + ) + # Import here to avoid circular import from auth import _verify_jwt # type: ignore try: From 15233022bcadf54329b3898899d3bd16842bef7a Mon Sep 17 00:00:00 2001 From: Bobcatsfan33 Date: Mon, 13 Jul 2026 21:35:08 -0400 Subject: [PATCH 04/10] Re-port #92: durable SCIM storage integrated with main's scim_filter/scim_patch + group role mapping --- modules/auth/scim.py | 773 ++++++++++++++++++++++++++++--------------- 1 file changed, 510 insertions(+), 263 deletions(-) diff --git a/modules/auth/scim.py b/modules/auth/scim.py index e01a906..2570f2b 100644 --- a/modules/auth/scim.py +++ b/modules/auth/scim.py @@ -1,65 +1,93 @@ """ -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 logging import json +import logging import os -import threading 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" -ROLE_VALUES = {"owner", "admin", "analyst", "readonly"} +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" -_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]] = {} +# Roles TokenDNA recognises for RBAC. SCIM group membership can map to these +# via TOKENDNA_SCIM_GROUP_ROLE_MAP_JSON (least privilege by default). +ROLE_VALUES = {"owner", "admin", "analyst", "readonly"} -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}", - } +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 '[]', + manual_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 @@ -79,71 +107,359 @@ 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 _normalize_roles(raw: Any) -> list[str]: + """Coerce SCIM ``roles`` (strings or complex values) to known role names.""" + if isinstance(raw, str): + candidates: list[Any] = [raw] + else: + try: + candidates = list(raw or []) + except TypeError: + candidates = [] + out: list[str] = [] + for item in candidates: + value = str(item.get("value") if isinstance(item, dict) else item).strip().lower() + if value in ROLE_VALUES and value not in out: + out.append(value) + return out + + +def _group_role_map() -> dict[str, str]: + """Map lower-cased group displayName -> TokenDNA role from env config.""" + raw = os.getenv("TOKENDNA_SCIM_GROUP_ROLE_MAP_JSON", "").strip() + if not raw: + return {} + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + logger.warning("invalid TOKENDNA_SCIM_GROUP_ROLE_MAP_JSON ignored") + return {} + if not isinstance(parsed, dict): + return {} + out: dict[str, str] = {} + for group_name, role in parsed.items(): + normalized_role = str(role).strip().lower() + if normalized_role in ROLE_VALUES: + out[str(group_name).strip().lower()] = normalized_role + return out + + +def _member_id(member: Any) -> str | None: + if isinstance(member, dict): + value = member.get("value") or member.get("$ref") or member.get("id") + else: + value = member + return str(value) if value else None + + +def _fetch_manual_roles(user_id: str, tenant_id: str) -> list[str]: + with _cursor() as conn: + row = AdaptedCursor(conn.cursor()).execute( + "SELECT manual_roles_json FROM scim_users WHERE id=? AND tenant_id=?", + (user_id, tenant_id), + ).fetchone() + return _loads(row["manual_roles_json"], []) if row else [] + + +def _sync_roles(tenant_id: str) -> None: + """Recompute each user's effective roles = manual roles + group-derived roles. + + Group-derived roles come from TOKENDNA_SCIM_GROUP_ROLE_MAP_JSON applied to + current group membership. Least privilege: unmapped users gain nothing. + Effective roles are persisted to ``roles_json``; this deliberately does not + bump ``version``/``updated_at`` (membership sync is not a resource edit). + """ + role_map = _group_role_map() + with _cursor() as conn: + cur = AdaptedCursor(conn.cursor()) + groups = cur.execute( + "SELECT display_name, members_json FROM scim_groups WHERE tenant_id=?", + (tenant_id,), + ).fetchall() + users = cur.execute( + "SELECT id, manual_roles_json FROM scim_users WHERE tenant_id=?", + (tenant_id,), + ).fetchall() + derived: dict[str, set[str]] = {row["id"]: set() for row in users} + if role_map: + for group in groups: + role = role_map.get(str(group["display_name"] or "").strip().lower()) + if not role: + continue + for member in _loads(group["members_json"], []): + mid = _member_id(member) + if mid in derived: + derived[mid].add(role) + for row in users: + manual = _loads(row["manual_roles_json"], []) + effective = sorted(set(manual) | derived.get(row["id"], set())) + cur.execute( + "UPDATE scim_users SET roles_json=? WHERE id=? AND tenant_id=?", + (_json(effective, []), row["id"], tenant_id), + ) + + +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 [], - "roles": _normalize_roles(payload.get("roles") or []), - "_manual_roles": _normalize_roles(payload.get("roles") or []), - "_scim_group_roles": [], - "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() + manual_roles = _normalize_roles(payload.get("roles")) + 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, manual_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(manual_roles, []), + _json(manual_roles, []), + now, + now, + version, + ), + ) + _sync_roles(tenant_id) + _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"] - if "roles" in payload: - record["_manual_roles"] = _normalize_roles(payload["roles"]) - _sync_user_roles(record) - 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) + # Manual roles are only replaced when the caller explicitly supplies them; + # otherwise we preserve the existing manually-assigned roles. Group-derived + # roles are recomputed separately by _sync_roles and never treated as manual. + if "roles" in payload: + manual_roles = _normalize_roles(payload.get("roles")) + else: + manual_roles = _fetch_manual_roles(user_id, tenant_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=?, manual_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(manual_roles, []), + _json(manual_roles, []), + now, + version, + user_id, + tenant_id, + ), + ) + _sync_roles(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) + # NOTE: apply_patch echoes the full resource, so `patched` always carries + # the current effective `roles`. Passing them through replace_user therefore + # rebases them as manual roles — this intentionally matches main's prior + # in-memory patch semantics. _sync_roles then re-adds group-derived roles. + 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( @@ -153,154 +469,168 @@ 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) - if "roles" in patched: - patched["_manual_roles"] = _normalize_roles(patched["roles"]) - record.update(patched) - _sync_user_roles(record) - 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 - _sync_roles_locked(tenant_id) - 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, + ), + ) + _sync_roles(tenant_id) + _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() - _sync_roles_locked(tenant_id) - 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, + ), + ) + _sync_roles(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] - _sync_roles_locked(tenant_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), + ) + _sync_roles(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", @@ -319,7 +649,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", @@ -327,7 +657,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", @@ -340,92 +670,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) - out.pop("_manual_roles", None) - out.pop("_scim_group_roles", None) - return out - - def _reset_for_tests() -> None: - """Test helper — wipe in-memory state.""" - with _lock: - _users.clear() - _groups.clear() - - -def _normalize_roles(raw: Any) -> list[str]: - if isinstance(raw, str): - candidates = [raw] - else: - try: - candidates = list(raw) - except TypeError: - candidates = [] - out: list[str] = [] - for item in candidates: - value = str(item.get("value") if isinstance(item, dict) else item).strip().lower() - if value in ROLE_VALUES and value not in out: - out.append(value) - return out - - -def _group_role_map() -> dict[str, str]: - raw = os.getenv("TOKENDNA_SCIM_GROUP_ROLE_MAP_JSON", "").strip() - if not raw: - return {} - try: - parsed = json.loads(raw) - except json.JSONDecodeError: - logger.warning("invalid TOKENDNA_SCIM_GROUP_ROLE_MAP_JSON ignored") - return {} - if not isinstance(parsed, dict): - return {} - out: dict[str, str] = {} - for group_name, role in parsed.items(): - normalized_role = str(role).strip().lower() - if normalized_role in ROLE_VALUES: - out[str(group_name).strip().lower()] = normalized_role - return out - - -def _member_ids(group: dict[str, Any]) -> set[str]: - ids: set[str] = set() - for member in group.get("members") or []: - if isinstance(member, dict): - value = member.get("value") or member.get("$ref") or member.get("id") - else: - value = member - if value: - ids.add(str(value)) - return ids - - -def _sync_roles_locked(tenant_id: str) -> None: - role_map = _group_role_map() - for user in _users.values(): - if user.get("tenant_id") == tenant_id: - user["_scim_group_roles"] = [] - - for group in _groups.values(): - if group.get("tenant_id") != tenant_id: - continue - role = role_map.get(str(group.get("displayName", "")).strip().lower()) - if not role: - continue - for user_id in _member_ids(group): - user = _users.get(user_id) - if user and user.get("tenant_id") == tenant_id: - roles = user.setdefault("_scim_group_roles", []) - if role not in roles: - roles.append(role) - - for user in _users.values(): - if user.get("tenant_id") == tenant_id: - _sync_user_roles(user) - - -def _sync_user_roles(user: dict[str, Any]) -> None: - roles = sorted(set(user.get("_manual_roles") or []) | set(user.get("_scim_group_roles") or [])) - user["roles"] = roles + init_db() + with _cursor() as conn: + cur = AdaptedCursor(conn.cursor()) + cur.execute("DELETE FROM scim_groups") + cur.execute("DELETE FROM scim_users") From 5579924b31c63fa6a0e78723e94831673ebabe40 Mon Sep 17 00:00:00 2001 From: Bobcatsfan33 Date: Mon, 13 Jul 2026 21:36:08 -0400 Subject: [PATCH 05/10] Re-port #92: IdP GA validation harness + tenant middleware bearer test --- scripts/idp_ga_validation.py | 202 ++++++++++++++++++++++++++++++++ tests/test_tenant_middleware.py | 36 ++++++ 2 files changed, 238 insertions(+) create mode 100644 scripts/idp_ga_validation.py create mode 100644 tests/test_tenant_middleware.py 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/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 3c52165b3edaf581ac6b524eb900c2b37b697e1c Mon Sep 17 00:00:00 2001 From: Bobcatsfan33 Date: Mon, 13 Jul 2026 21:37:22 -0400 Subject: [PATCH 06/10] Re-port #92: SAML ACS relay-state + audit logging in enterprise router (routes moved from api.py) --- api_routers/enterprise.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/api_routers/enterprise.py b/api_routers/enterprise.py index a40f14a..e0a5ebd 100644 --- a/api_routers/enterprise.py +++ b/api_routers/enterprise.py @@ -140,17 +140,38 @@ 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, } From 80b872d33a33dc748063e669240662f117ae5fbf Mon Sep 17 00:00:00 2001 From: Bobcatsfan33 Date: Mon, 13 Jul 2026 21:39:30 -0400 Subject: [PATCH 07/10] Re-port #92: preflight SAML gates (deps, https SP URLs, relay allowlist, idp-initiated off) + tests --- scripts/preflight_prod.py | 58 ++++++++++++++++++++++++++++++++++++ tests/test_preflight_prod.py | 23 ++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/scripts/preflight_prod.py b/scripts/preflight_prod.py index ebf3e2a..19e3643 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 @@ -96,6 +97,29 @@ def _valid_json_object(env_var: str) -> tuple[bool, str]: return True, "ok" +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", + ) + ) + + def run_preflight(environment: str | None = None) -> dict[str, Any]: env = (environment or os.getenv("ENVIRONMENT", "dev")).strip().lower() compliance_profile = os.getenv("TOKENDNA_COMPLIANCE_PROFILE", "").strip().lower() @@ -262,6 +286,35 @@ def add_check(name: str, ok: bool, detail: str = "") -> None: "TLS_POSTGRES_CERT_PATH and TLS_POSTGRES_KEY_PATH required for DoD/FedRAMP High profiles", ) + 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_xml_defusedxml_available", + _python_module_available("defusedxml"), + "defusedxml runtime dependency required for safe SAML XML parsing", + ) + 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") + required_for_env = env in PROD_ENVS if not required_for_env: # For non-prod environments, degrade failing checks to informational only. @@ -283,6 +336,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 3bb339a..dca9528 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 @@ -50,6 +51,13 @@ def _clear(monkeypatch): "TLS_POSTGRES_KEY_PATH", "REDIS_TLS", "CLICKHOUSE_SECURE", + "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) @@ -221,3 +229,18 @@ def test_preflight_dod_il5_accepts_hardened_controls(monkeypatch): assert report["passed"] is True assert report["compliance_profile"] == "dod_il5" + + +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 From 778a40b362b7306d56311be4da9793b89cf09df0 Mon Sep 17 00:00:00 2001 From: Bobcatsfan33 Date: Mon, 13 Jul 2026 21:41:46 -0400 Subject: [PATCH 08/10] Re-port #92: SAML/SCIM tests (replay, relay-state, durable storage, role sync) + GA docs --- docs/PRODUCTION_ROLLOUT_CHECKLIST.md | 20 +++ docs/ops/saml-scim.md | 215 ++++++++++++++++----------- tests/test_saml_scim.py | 108 +++++++++++++- 3 files changed, 253 insertions(+), 90 deletions(-) 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 3fe4be5..5ee6d7b 100644 --- a/docs/ops/saml-scim.md +++ b/docs/ops/saml-scim.md @@ -1,81 +1,113 @@ -# 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 + +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. 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. +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. -### 1.4 Hardening checklist +IdP-initiated SAML is disabled by default. Enable it only by explicit customer exception and keep RelayState constrained to approved HTTPS return hosts. -* `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 setup ---- +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. SCIM 2.0 +## SCIM 2.0 Provisioning -### 2.1 Endpoints +### 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. For Okta, Microsoft +Entra ID, OneLogin, and most SCIM clients, configure bearer-token auth with +the tenant API key value: -Production OIDC tenant mapping is explicit and fail-closed: +```http +Authorization: Bearer +``` + +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 + +| 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. | + +### Role mapping and tenant isolation + +Production OIDC tenant mapping is explicit and fail-closed: + +```bash TOKENDNA_OIDC_TENANT_CLAIM=org_id TOKENDNA_OIDC_ROLE_CLAIM=tokendna_role,role TOKENDNA_OIDC_GROUPS_CLAIM=roles,groups @@ -86,52 +118,63 @@ TOKENDNA_OIDC_ALLOW_SUB_TENANT_FALLBACK=false Do not map tenant identity from `sub` / `client_id` in production; those identify a subject, not the enterprise tenant boundary. -### 2.3 Schemas - -* User — `urn:ietf:params:scim:schemas:core:2.0:User` -* Group — `urn:ietf:params:scim:schemas:core:2.0:Group` - -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. - SCIM groups can drive TokenDNA roles via an explicit map: -``` +```bash TOKENDNA_SCIM_GROUP_ROLE_MAP_JSON={"SOC Analysts":"analyst","Platform Owners":"owner"} ``` -When a mapped group is created, patched, or deleted, user roles are -recomputed from current membership. The default is least privilege: -unmapped users have no elevated role. +When a mapped group is created, patched, or deleted, each affected user's +effective roles are recomputed from current membership. Manually assigned +SCIM `roles` are preserved separately and unioned with group-derived roles. +The default is least privilege: unmapped users gain no elevated role. + +## Provider Validation Matrix -### 2.4 What is intentionally NOT supported (yet) +Run this matrix for every customer IdP tenant before declaring the integration complete. -| 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. | +| 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. | -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. +Evidence to retain for GA: ---- +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. -## 3. Integration test plan +The repeatable TokenDNA-side harness is: -For each new IdP: +```bash +python3 scripts/idp_ga_validation.py \ + --provider okta \ + --base-url https://tokendna.customer.example \ + --bearer "$TOKENDNA_TENANT_API_KEY" +``` -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. +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. -A green run on Okta + Azure AD + OneLogin is the gate before this -module flips from alpha to GA. +## 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/tests/test_saml_scim.py b/tests/test_saml_scim.py index 7f5f8e3..c321891 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() @@ -129,6 +192,42 @@ def test_scim_create_group_and_lookup(): assert fetched["id"] == g["id"] +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_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_group_membership_syncs_roles(monkeypatch): monkeypatch.setenv( "TOKENDNA_SCIM_GROUP_ROLE_MAP_JSON", @@ -157,6 +256,7 @@ 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 4f32af3b25d225fa14d9fe9b0f2e47b9f3df994e Mon Sep 17 00:00:00 2001 From: Bobcatsfan33 Date: Mon, 13 Jul 2026 21:45:04 -0400 Subject: [PATCH 09/10] Fix middleware.py comment separator to match workspace (byte-exact re-port) --- modules/tenants/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/tenants/middleware.py b/modules/tenants/middleware.py index def1d3f..6560f0e 100644 --- a/modules/tenants/middleware.py +++ b/modules/tenants/middleware.py @@ -58,7 +58,7 @@ async def get_tenant( if DEV_MODE: return _DEV_TENANT - # ── Path 1: API key ───────────────────────────────────────────────────────── + # ── Path 1: API key ─────────────────────────────────────────────────────── if api_key: result = store.lookup_by_key(api_key) if not result: From 069b34860cacad8885df5a88cf8fb95431b2b799 Mon Sep 17 00:00:00 2001 From: Bobcatsfan33 Date: Mon, 13 Jul 2026 21:45:58 -0400 Subject: [PATCH 10/10] Fix Dockerfile comment separator to match workspace (byte-exact re-port) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a009743..faae2b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ # -e DEV_MODE=true \ # ghcr.io/bobcatsfan33/tokendna:dev -# ── Stage 1: dependency builder ─────────────────────────────────────────────── +# ── Stage 1: dependency builder ───────────────────────────────────────────── FROM python:3.11-slim AS builder WORKDIR /build