From f2c2e4e1594631dd79b9f8d9e8ec73164bd4c92a Mon Sep 17 00:00:00 2001 From: alikahwaji Date: Sat, 24 Oct 2026 20:54:48 +1300 Subject: [PATCH] feat(scim): SCIM 2.0 user provisioning with a SCIM token Phase 2: SCIM after SSO. - Migration 024: api_keys may have the scope 'scim' (one scope per key); users.scim_external_id. - A SCIM token (Integrations > User provisioning) can call only /scim/v2; read keys cannot. It is swapped for a one-minute internal token with the role "scim"; SCIM endpoints require it. - /scim/v2: ServiceProviderConfig, ResourceTypes, Schemas; Users list (userName eq / externalId eq filters, paging), get, create (no usable password: SSO or a reset link), replace, PATCH (add/replace, with or without a path, "True"/"False" strings: Entra and Okta forms), delete (deactivates). SCIM error format and application/scim+json. - Emails unique across SCIRM (409 uniqueness); the last active admin cannot be deactivated or demoted (409 mutability); deactivation stops sign-in and session renewal. - SCIM writes are audited under the token (the audit middleware now also covers /scim/v2); SCIM and SSO events in the access category. - docs/scim.md; SSO docs point to it. Co-Authored-By: Claude Opus 5.5 --- alembic/versions/024_scim.py | 32 ++ apps/api-gateway/main.py | 291 +++++++++++++++++- .../src/components/ApiKeysSettings.tsx | 2 +- apps/frontend/src/components/ScimSettings.tsx | 81 +++++ apps/frontend/src/pages/IntegrationsPage.tsx | 2 + apps/frontend/src/services/api.ts | 7 +- docs/scim.md | 58 ++++ docs/single-sign-on.md | 9 +- libs/common/store.py | 86 +++++- mkdocs.yml | 1 + tests/db/test_database.py | 7 +- tests/security/test_scim.py | 161 ++++++++++ 12 files changed, 717 insertions(+), 20 deletions(-) create mode 100644 alembic/versions/024_scim.py create mode 100644 apps/frontend/src/components/ScimSettings.tsx create mode 100644 docs/scim.md create mode 100644 tests/security/test_scim.py diff --git a/alembic/versions/024_scim.py b/alembic/versions/024_scim.py new file mode 100644 index 0000000..c566b72 --- /dev/null +++ b/alembic/versions/024_scim.py @@ -0,0 +1,32 @@ +"""SCIM 2.0 user provisioning. + +- api_keys may now have the scope 'scim' (exactly one scope per key): a SCIM token, which + can call only /scim/v2 and nothing else; read keys cannot call /scim/v2. +- users.scim_external_id: the identity provider's own id for the user (externalId). + +Revision ID: 024 +Revises: 023 +Create Date: 2026-10-23 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "024" +down_revision: Union[str, None] = "023" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TABLE api_keys DROP CONSTRAINT api_keys_scopes_check") + op.execute("ALTER TABLE api_keys ADD CONSTRAINT api_keys_scopes_check " + "CHECK (scopes <@ ARRAY['read', 'scim']::text[] AND cardinality(scopes) = 1)") + op.execute("ALTER TABLE users ADD COLUMN scim_external_id text NULL CHECK (length(scim_external_id) <= 200)") + + +def downgrade() -> None: + op.execute("ALTER TABLE users DROP COLUMN IF EXISTS scim_external_id") + op.execute("DELETE FROM api_keys WHERE 'scim' = ANY(scopes)") + op.execute("ALTER TABLE api_keys DROP CONSTRAINT api_keys_scopes_check") + op.execute("ALTER TABLE api_keys ADD CONSTRAINT api_keys_scopes_check CHECK (scopes <@ ARRAY['read']::text[])") diff --git a/apps/api-gateway/main.py b/apps/api-gateway/main.py index 36c9067..a8016d4 100644 --- a/apps/api-gateway/main.py +++ b/apps/api-gateway/main.py @@ -144,7 +144,7 @@ async def sample_features_gate(request: Request, call_next): async def audit_writes(request: Request, call_next): """Record every state-changing API call — who, which tenant, what, and the outcome.""" response = await call_next(request) - if (request.method in _AUDITED_METHODS and request.url.path.startswith("/api/v1/") + if (request.method in _AUDITED_METHODS and request.url.path.startswith(("/api/v1/", "/scim/v2/")) and request.url.path not in _SELF_AUDITED_PATHS): caller = _caller(request) route = request.scope.get("route") @@ -228,12 +228,16 @@ async def refuse(status: int, detail: str): if not found or not found.get("live"): return await refuse(401, "Invalid, expired or revoked API key") - if (request.method, template) not in API_KEY_ROUTES: + scim = "scim" in (found.get("scopes") or []) + if scim: # a SCIM token: user provisioning only + if not (template or "").startswith("/scim/v2/"): + return await refuse(403, "A SCIM token can only call /scim/v2.") + elif (request.method, template) not in API_KEY_ROUTES: return await refuse(403, "API keys can only read suppliers, their public records, alerts, risks and the " "intelligence feed. See the API documentation.") token = create_access_token(data={ "sub": str(found["id"]), "email": f"api-key:{found['name']} ({found['prefix']})", "name": found["name"], - "roles": ["viewer"], "org_id": str(found["organization_id"]), "api_key": True, + "roles": ["scim"] if scim else ["viewer"], "org_id": str(found["organization_id"]), "api_key": True, }, expires_delta=timedelta(minutes=1)) request.scope["headers"] = [(k, v) for k, v in request.scope["headers"] if k not in (b"authorization", b"x-api-key")] request.scope["headers"].append((b"authorization", f"Bearer {token}".encode())) @@ -663,6 +667,8 @@ async def reset_member_mfa(member_id: str, token: str = Depends(security)): class ApiKeyRequest(BaseModel): name: str = Field(..., min_length=1, max_length=100) expires_in_days: Optional[int] = Field(None, ge=1, le=730) + # read: the public API; scim: user provisioning by an identity provider (/scim/v2 only). + scope: str = Field("read", pattern="^(read|scim)$") @app.get("/api/v1/org/api-keys") @@ -679,7 +685,8 @@ async def create_api_key(body: ApiKeyRequest, token: str = Depends(security)): user = await verify_token(token.credentials) _require_admin(user, "manage API keys") expires = (datetime.utcnow() + timedelta(days=body.expires_in_days)).isoformat() if body.expires_in_days else None - row, key = await audit_store.create_api_key(user.organization_id, body.name.strip(), user.email, expires) + row, key = await audit_store.create_api_key(user.organization_id, body.name.strip(), user.email, expires, + scope=body.scope) await _audit_event(user.organization_id, user.id, user.email, "api_key.created", target=str(row["id"])) return {"api_key": row, "key": key, "note": "Copy this key now: it cannot be shown again. Send it as 'Authorization: Bearer '."} @@ -905,6 +912,282 @@ async def send_report_now(schedule_id: str, token: str = Depends(security)): return {"schedule": _schedule_view(row), "note": "Queued: it is sent within about a minute."} +# --------------------------------------------------------------------------- +# SCIM 2.0 user provisioning (RFC 7643/7644): /scim/v2, with a SCIM token (an API key +# with the scope "scim"). Users only; groups are not supported. DELETE deactivates. +# --------------------------------------------------------------------------- + +SCIM_USER = "urn:ietf:params:scim:schemas:core:2.0:User" +SCIM_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse" +SCIM_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error" +SCIM_PATCH = "urn:ietf:params:scim:api:messages:2.0:PatchOp" +SCIM_MEDIA = "application/scim+json" + + +class ScimError(Exception): + def __init__(self, status: int, detail: str, scim_type: Optional[str] = None): + super().__init__(detail) + self.status, self.detail, self.scim_type = status, detail, scim_type + + +def _scim(body: Any, status: int = 200): + from fastapi.responses import JSONResponse, Response + if status == 204: + return Response(status_code=204) + return JSONResponse(body, status_code=status, media_type=SCIM_MEDIA) + + +@app.exception_handler(ScimError) +async def _scim_error(request: Request, exc: ScimError): + body = {"schemas": [SCIM_ERROR], "status": str(exc.status), "detail": exc.detail} + if exc.scim_type: + body["scimType"] = exc.scim_type + return _scim(body, exc.status) + + +async def _scim_caller(token: str): + user = await verify_token(token) + if "scim" not in user.roles: + raise ScimError(403, "A SCIM token is required (Administration > Integrations > User provisioning).") + return user + + +def _scim_resource(u: Dict[str, Any]) -> Dict[str, Any]: + location = f"{PUBLIC_API_URL}/scim/v2/Users/{u['id']}" if PUBLIC_API_URL else f"/scim/v2/Users/{u['id']}" + out = {"schemas": [SCIM_USER], "id": str(u["id"]), "userName": u["email"], + "name": {"formatted": u.get("name") or ""}, "displayName": u.get("name") or "", + "active": bool(u.get("is_active")), + "emails": [{"value": u["email"], "type": "work", "primary": True}], + "roles": [{"value": r} for r in u.get("roles") or []], + "meta": {"resourceType": "User", "created": str(u.get("created_at") or ""), + "lastModified": str(u.get("updated_at") or u.get("created_at") or ""), "location": location}} + if u.get("scim_external_id"): + out["externalId"] = u["scim_external_id"] + return out + + +def _scim_bool(value: Any) -> bool: + """Entra sends "True"/"False" as strings in PATCH.""" + if isinstance(value, bool): + return value + if isinstance(value, str) and value.strip().lower() in ("true", "false"): + return value.strip().lower() == "true" + raise ScimError(400, "active must be true or false", "invalidValue") + + +def _scim_name(body: Dict[str, Any]) -> Optional[str]: + name = body.get("name") if isinstance(body.get("name"), dict) else {} + joined = " ".join(p for p in (name.get("givenName"), name.get("familyName")) if p) + found = (name.get("formatted") or joined or body.get("displayName") or "").strip() + return found[:255] or None + + +def _scim_roles(value: Any) -> List[str]: + items = value if isinstance(value, list) else [value] + roles = [str(i.get("value") if isinstance(i, dict) else i).strip().lower() for i in items if i] + unknown = [r for r in roles if r not in ROLES] + if unknown or not roles: + raise ScimError(400, f"roles must be among: {', '.join(ROLES)}", "invalidValue") + return list(dict.fromkeys(roles)) + + +def _scim_email(value: Any) -> str: + email = str(value or "").strip().lower() + local, _, domain = email.partition("@") + if not local or "." not in domain or " " in email: + raise ScimError(400, "userName must be the user's email address", "invalidValue") + return email + + +async def _scim_apply(org: str, user_id: str, changes: Dict[str, Any]) -> Dict[str, Any]: + """Write changes (email, name, external_id, roles, active) and return the resource.""" + try: + if {"email", "name", "external_id"} & changes.keys(): + await audit_store.scim_set_profile(org, user_id, email=changes.get("email"), name=changes.get("name"), + external_id=changes.get("external_id")) + if {"roles", "active"} & changes.keys(): + if not await audit_store.update_member(org, user_id, roles=changes.get("roles"), + is_active=changes.get("active")): + raise ScimError(404, "User not found") + except EmailExists: + raise ScimError(409, "Another account already uses that email", "uniqueness") + except LastAdmin: + raise ScimError(409, "The organization must keep at least one active administrator", "mutability") + user = await audit_store.scim_user(org, user_id) + if not user: + raise ScimError(404, "User not found") + return _scim_resource(user) + + +@app.get("/scim/v2/ServiceProviderConfig") +async def scim_config(token: str = Depends(security)): + await _scim_caller(token.credentials) + return _scim({"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], + "patch": {"supported": True}, "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, + "filter": {"supported": True, "maxResults": 200}, "changePassword": {"supported": False}, + "sort": {"supported": False}, "etag": {"supported": False}, + "authenticationSchemes": [{"type": "oauthbearertoken", "name": "SCIM token", + "description": "Bearer token from Administration > Integrations"}]}) + + +@app.get("/scim/v2/ResourceTypes") +async def scim_resource_types(token: str = Depends(security)): + await _scim_caller(token.credentials) + return _scim({"schemas": [SCIM_LIST], "totalResults": 1, "Resources": [ + {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], "id": "User", "name": "User", + "endpoint": "/Users", "schema": SCIM_USER}]}) + + +@app.get("/scim/v2/Schemas") +async def scim_schemas(token: str = Depends(security)): + await _scim_caller(token.credentials) + attrs = [{"name": n, "type": t, "multiValued": m, "required": r, "mutability": "readWrite", "returned": "default"} + for n, t, m, r in (("userName", "string", False, True), ("name", "complex", False, False), + ("displayName", "string", False, False), ("active", "boolean", False, False), + ("emails", "complex", True, False), ("roles", "complex", True, False), + ("externalId", "string", False, False))] + return _scim({"schemas": [SCIM_LIST], "totalResults": 1, "Resources": [ + {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], "id": SCIM_USER, "name": "User", + "attributes": attrs}]}) + + +@app.get("/scim/v2/Users") +async def scim_list_users(filter: Optional[str] = None, startIndex: int = 1, count: int = 100, + token: str = Depends(security)): + """Filters: userName eq "..." or externalId eq "..." (what identity providers send).""" + user = await _scim_caller(token.credentials) + start, count = max(startIndex, 1), min(max(count, 0), 200) + user_name = external_id = None + if filter: + match = re.fullmatch(r'\s*(userName|externalId)\s+eq\s+"([^"]*)"\s*', filter, re.IGNORECASE) + if not match: + raise ScimError(400, 'Only userName eq "..." and externalId eq "..." filters are supported', "invalidFilter") + if match.group(1).lower() == "username": + user_name = match.group(2) + else: + external_id = match.group(2) + if external_id is not None: + everyone, _ = await audit_store.scim_users(user.organization_id, None, 1, 100000) + found = [u for u in everyone if u.get("scim_external_id") == external_id] + rows, total = found[start - 1:start - 1 + count], len(found) + else: + rows, total = await audit_store.scim_users(user.organization_id, user_name, start, count) + return _scim({"schemas": [SCIM_LIST], "totalResults": total, "startIndex": start, "itemsPerPage": len(rows), + "Resources": [_scim_resource(u) for u in rows]}) + + +@app.get("/scim/v2/Users/{user_id}") +async def scim_get_user(user_id: str, token: str = Depends(security)): + user = await _scim_caller(token.credentials) + found = await audit_store.scim_user(user.organization_id, user_id) + if not found: + raise ScimError(404, "User not found") + return _scim(_scim_resource(found)) + + +@app.post("/scim/v2/Users") +async def scim_create_user(request: Request, token: str = Depends(security)): + """Create an account. It has no usable password: the person signs in with single + sign-on (or sets a password with a reset link).""" + user = await _scim_caller(token.credentials) + body = await request.json() + email = _scim_email(body.get("userName")) + roles = _scim_roles(body["roles"]) if body.get("roles") else ["viewer"] + try: + created = await audit_store.create_user(user.organization_id, email, _scim_name(body) or email, + get_password_hash(secrets.token_urlsafe(32)), roles) + except EmailExists: + raise ScimError(409, "An account with that email already exists", "uniqueness") + changes: Dict[str, Any] = {} + if body.get("externalId"): + changes["external_id"] = str(body["externalId"])[:200] + if "active" in body and not _scim_bool(body["active"]): + changes["active"] = False + resource = await _scim_apply(user.organization_id, str(created["id"]), changes) if changes else \ + _scim_resource(await audit_store.scim_user(user.organization_id, str(created["id"]))) + return _scim(resource, 201) + + +@app.put("/scim/v2/Users/{user_id}") +async def scim_replace_user(user_id: str, request: Request, token: str = Depends(security)): + user = await _scim_caller(token.credentials) + if not await audit_store.scim_user(user.organization_id, user_id): + raise ScimError(404, "User not found") + body = await request.json() + changes: Dict[str, Any] = {"email": _scim_email(body.get("userName"))} + if _scim_name(body): + changes["name"] = _scim_name(body) + if "active" in body: + changes["active"] = _scim_bool(body["active"]) + if body.get("roles"): + changes["roles"] = _scim_roles(body["roles"]) + if body.get("externalId"): + changes["external_id"] = str(body["externalId"])[:200] + return _scim(await _scim_apply(user.organization_id, user_id, changes)) + + +@app.patch("/scim/v2/Users/{user_id}") +async def scim_patch_user(user_id: str, request: Request, token: str = Depends(security)): + """PatchOp: add/replace on active, userName, name, displayName, externalId, roles and the + work email; with or without a path (Entra and Okta send both forms).""" + user = await _scim_caller(token.credentials) + if not await audit_store.scim_user(user.organization_id, user_id): + raise ScimError(404, "User not found") + body = await request.json() + operations = body.get("Operations") or body.get("operations") + if not isinstance(operations, list) or not operations: + raise ScimError(400, "A PatchOp with Operations is required", "invalidSyntax") + changes: Dict[str, Any] = {} + + def apply(path: str, value: Any) -> None: + key = path.lower() + if key == "active": + changes["active"] = _scim_bool(value) + elif key in ("username", 'emails[type eq "work"].value', "emails"): + changes["email"] = _scim_email(value[0].get("value") if isinstance(value, list) and value + and isinstance(value[0], dict) else value) + elif key in ("name.formatted", "displayName".lower()): + changes["name"] = str(value).strip()[:255] or changes.get("name") + elif key in ("name.givenname", "name.familyname"): + changes.setdefault("_name_parts", {})[key] = str(value).strip() + elif key == "name" and isinstance(value, dict): + changes["name"] = _scim_name({"name": value}) or changes.get("name") + elif key == "externalid": + changes["external_id"] = str(value)[:200] + elif key == "roles": + changes["roles"] = _scim_roles(value) + # other attributes (title, phone numbers, ...) are not stored and are ignored + + for operation in operations: + op_name = str(operation.get("op", "")).lower() + if op_name not in ("add", "replace"): + raise ScimError(400, f"Unsupported PATCH op {operation.get('op')!r}", "invalidValue") + path, value = operation.get("path"), operation.get("value") + if path: + apply(str(path), value) + elif isinstance(value, dict): + for key, item in value.items(): + apply(key, item) + parts = changes.pop("_name_parts", None) + if parts and "name" not in changes: + current = await audit_store.scim_user(user.organization_id, user_id) + given, family = parts.get("name.givenname"), parts.get("name.familyname") + old = (current.get("name") or "").split(" ", 1) + changes["name"] = " ".join(p for p in (given or old[0], family or (old[1] if len(old) > 1 else "")) if p) + return _scim(await _scim_apply(user.organization_id, user_id, {k: v for k, v in changes.items() if v is not None})) + + +@app.delete("/scim/v2/Users/{user_id}") +async def scim_delete_user(user_id: str, token: str = Depends(security)): + """Deactivate (accounts are kept for the audit trail). Sessions end within the access + token's lifetime: refreshing is refused for inactive accounts.""" + user = await _scim_caller(token.credentials) + if not await audit_store.scim_user(user.organization_id, user_id): + raise ScimError(404, "User not found") + await _scim_apply(user.organization_id, user_id, {"active": False}) + return _scim(None, 204) + + @app.post("/api/v1/org/invitations") async def create_invitation(body: InviteRequest, token: str = Depends(security)): """Invite someone to this organization. Returns the invitation link once. There is no diff --git a/apps/frontend/src/components/ApiKeysSettings.tsx b/apps/frontend/src/components/ApiKeysSettings.tsx index a87a1fc..8b96454 100644 --- a/apps/frontend/src/components/ApiKeysSettings.tsx +++ b/apps/frontend/src/components/ApiKeysSettings.tsx @@ -28,7 +28,7 @@ export default function ApiKeysSettings() { const [busy, setBusy] = useState(false); const [error, setError] = useState(''); - const load = useCallback(() => api.listApiKeys().then((r) => setKeys(r.api_keys)) + const load = useCallback(() => api.listApiKeys().then((r) => setKeys(r.api_keys.filter((k) => !k.scopes.includes('scim')))) .catch((err) => setError(errorText(err, t('apiKeys.loadFailed', 'Could not load the API keys.')))), [t]); useEffect(() => { load(); }, [load]); diff --git a/apps/frontend/src/components/ScimSettings.tsx b/apps/frontend/src/components/ScimSettings.tsx new file mode 100644 index 0000000..b6388a4 --- /dev/null +++ b/apps/frontend/src/components/ScimSettings.tsx @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { api, ApiKey, SCIM_BASE_URL } from '../services/api'; +import { utcDate } from '../services/time'; + +function errorText(err: any, fallback: string): string { + return err?.response?.data?.detail || fallback; +} + +// SCIM 2.0: the identity provider creates, updates and deactivates accounts (admins). +export default function ScimSettings() { + const { t } = useTranslation(); + const [tokens, setTokens] = useState([]); + const [created, setCreated] = useState(''); + const [error, setError] = useState(''); + + const load = useCallback(() => api.listApiKeys().then((r) => setTokens(r.api_keys.filter((k) => k.scopes.includes('scim')))) + .catch((err) => setError(errorText(err, t('scim.loadFailed', 'Could not load the SCIM tokens.')))), [t]); + useEffect(() => { load(); }, [load]); + + const create = async () => { + setError(''); + try { + const { key } = await api.createApiKey('SCIM provisioning', 365, 'scim'); + setCreated(key); + await load(); + } catch (err) { + setError(errorText(err, t('scim.createFailed', 'Could not create the token.'))); + } + }; + + const revoke = async (k: ApiKey) => { + setError(''); + try { + await api.revokeApiKey(k.id); + await load(); + } catch (err) { + setError(errorText(err, t('scim.revokeFailed', 'Could not revoke the token.'))); + } + }; + + const active = tokens.filter((k) => !k.revoked_at); + return ( +
+

{t('scim.title', 'User provisioning (SCIM)')}

+

+ {t('scim.hint', 'Your identity provider (Entra, Okta, ...) creates accounts, updates names, emails and roles, and deactivates people who leave. Accounts it creates sign in with single sign-on.')} +

+

+ {t('scim.url', 'Tenant URL:')} {SCIM_BASE_URL} +

+ {error &&

{error}

} + {created && ( +
+

{t('scim.token', 'Secret token')}

+ e.currentTarget.select()} + className="w-full mt-2 px-2 py-1 border border-amber-300 rounded bg-white font-mono text-[11px]" /> +

{t('scim.once', 'Paste it into your identity provider now: it will not be shown again. It expires in a year.')}

+
+ )} + {active.length > 0 && ( +
    + {active.map((k) => ( +
  • + + {k.prefix}… + · {k.last_used_at + ? t('scim.lastUsed', 'last used {{at}}', { at: utcDate(k.last_used_at).toLocaleString() }) + : t('scim.unused', 'not used yet')} + + +
  • + ))} +
+ )} + +
+ ); +} diff --git a/apps/frontend/src/pages/IntegrationsPage.tsx b/apps/frontend/src/pages/IntegrationsPage.tsx index c22f091..01cdb2c 100644 --- a/apps/frontend/src/pages/IntegrationsPage.tsx +++ b/apps/frontend/src/pages/IntegrationsPage.tsx @@ -8,6 +8,7 @@ import ApiKeysSettings from '../components/ApiKeysSettings'; import WebhooksSettings from '../components/WebhooksSettings'; import ReportsSettings from '../components/ReportsSettings'; import SsoSettings from '../components/SsoSettings'; +import ScimSettings from '../components/ScimSettings'; type Tone = 'ok' | 'warn' | 'bad' | 'off'; const TONES: Record = { @@ -129,6 +130,7 @@ export default function IntegrationsPage() { + diff --git a/apps/frontend/src/services/api.ts b/apps/frontend/src/services/api.ts index 11b3f86..5a765b5 100644 --- a/apps/frontend/src/services/api.ts +++ b/apps/frontend/src/services/api.ts @@ -5,6 +5,9 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; /** Where the browser goes to start single sign-on (a full-page navigation to the gateway). * bindingHash is the SHA-256 of a value kept in this tab (see services/ssoBinding.ts). */ +/** The SCIM 2.0 base URL to enter at an identity provider. */ +export const SCIM_BASE_URL = `${API_BASE_URL}/scim/v2`; + export function ssoStartUrl(email: string, next: string, bindingHash: string): string { return `${API_BASE_URL}/api/v1/auth/sso/start?${new URLSearchParams({ email, next, binding: bindingHash })}`; } @@ -638,8 +641,8 @@ export const api = { }, /** The key itself is returned only here, once. */ - async createApiKey(name: string, expiresInDays?: number): Promise<{ api_key: ApiKey; key: string }> { - return (await apiClient.post('/api/v1/org/api-keys', { name, expires_in_days: expiresInDays ?? null })).data; + async createApiKey(name: string, expiresInDays?: number, scope: 'read' | 'scim' = 'read'): Promise<{ api_key: ApiKey; key: string }> { + return (await apiClient.post('/api/v1/org/api-keys', { name, expires_in_days: expiresInDays ?? null, scope })).data; }, async revokeApiKey(id: string): Promise { diff --git a/docs/scim.md b/docs/scim.md new file mode 100644 index 0000000..dffacca --- /dev/null +++ b/docs/scim.md @@ -0,0 +1,58 @@ +# User provisioning (SCIM) + +Your identity provider (Microsoft Entra ID, Okta, OneLogin, …) can manage SCIRM accounts +with **SCIM 2.0**: +- creating them when someone is assigned the application +- updating names, emails and roles +- deactivating people who leave + +Use it together with [single sign-on](single-sign-on.md). Accounts that SCIM creates have +no usable password: people sign in with SSO, or set a password with a reset link where +password sign-in is allowed. + +## Setting up + +In **Administration → Integrations → User provisioning (SCIM)**: +1. **Create SCIM token.** It is shown once and expires in a year. +2. In your identity provider's provisioning settings, enter: + - **Tenant URL**: `/scim/v2`, as shown on the card + - **Secret token**: the token +3. Map the user's email to `userName`. + +A SCIM token is an API key with the scope `scim`. It can call **only** `/scim/v2` (no +other part of the API), and read keys cannot call `/scim/v2`. It is listed with its last +use, and revoking it stops provisioning at the next request. + +## What is supported + +| | | +|---|---| +| `GET /Users` | Filters `userName eq "…"` and `externalId eq "…"`; paging with `startIndex` and `count` (up to 200). | +| `GET /Users/{id}` | | +| `POST /Users` | `userName` (the email, unique across SCIRM), `name` or `displayName`, `active`, `externalId`, `roles`. | +| `PUT /Users/{id}` | Replace the attributes above. | +| `PATCH /Users/{id}` | `add`/`replace` on `active`, `userName`, `name.*`, `displayName`, `externalId`, `roles`, the work email. Both forms identity providers use: with a `path`, and without one (a value object). `"True"`/`"False"` strings are accepted. | +| `DELETE /Users/{id}` | Deactivates (the account is kept for the audit trail). | +| `ServiceProviderConfig`, `ResourceTypes`, `Schemas` | | + +**Roles** are SCIRM's: `admin`, `analyst`, `viewer`, `auditor` (as `roles: [{"value": "analyst"}]`). +Without roles, a new account is a `viewer`. + +**Not supported:** groups (map roles to users instead), bulk operations, sorting, ETags, +`remove` operations, and changing passwords. + +## Behaviour + +- **Deactivating** (`active: false` or `DELETE`): + - the person can no longer sign in, and their sessions cannot renew, so they end within + 60 minutes (the access token's lifetime) + - reactivating restores access +- **The last active administrator** of an organization cannot be deactivated or demoted + through SCIM (`409`, `scimType: mutability`), so the organization is never left without + one. +- **Emails are unique across SCIRM.** Creating or renaming to an email used by any account, + in any organization, returns `409` (`uniqueness`). +- **Errors** use the SCIM error format (`urn:ietf:params:scim:api:messages:2.0:Error`, with + `status`, `scimType` and `detail`). Responses are `application/scim+json`. +- **Audit:** every change is recorded in the [audit log](audit-log.md) under *Access and + team*, with the token as the actor (`api-key: ()`). diff --git a/docs/single-sign-on.md b/docs/single-sign-on.md index 68b614a..856e706 100644 --- a/docs/single-sign-on.md +++ b/docs/single-sign-on.md @@ -21,7 +21,7 @@ sign-in; SCIRM's two-step sign-in is not asked for after single sign-on. keep it. 3. Invite your members as usual. **SSO does not create accounts.** Someone without an active SCIRM account in your organization is refused after signing in at the provider. - Automatic provisioning comes with SCIM. In an organization that requires single sign-on, + To provision them automatically, use [SCIM](scim.md). In an organization that requires single sign-on, accepting an invitation creates the account but does not sign the person in with the password; they continue with **Sign in with SSO**. @@ -96,8 +96,7 @@ Without it, first sign-ins are refused (`no_verified_email`). ## Not built yet -- Creating accounts at first sign-in (SCIM will provision them). +- Creating accounts at first sign-in: use [SCIM](scim.md) to provision them. - Domain verification. -- Ending SCIRM sessions when the identity provider disables someone (SCIM will deactivate - them). -- Single logout. +- Single logout. People disabled at the identity provider are deactivated through + [SCIM](scim.md); their sessions then end within 60 minutes. diff --git a/libs/common/store.py b/libs/common/store.py index b3407cd..79c637c 100644 --- a/libs/common/store.py +++ b/libs/common/store.py @@ -120,6 +120,7 @@ def api_key_hash(key: str) -> str: _SSO_PUBLIC = ("issuer", "client_id", "enforce", "created_by", "updated_at") +_SCIM_FIELDS = ("id", "email", "name", "roles", "is_active", "scim_external_id", "created_at") _SSO_MEMBER = ("id", "email", "name", "roles", "is_active", "sso_issuer", "sso_subject") @@ -580,6 +581,38 @@ async def create_user(self, org: str, email: str, name: str, hashed_password: st self.users[user["id"]] = user return _member(user) + # --- SCIM provisioning ------------------------------------------------- + def _scim_view(self, u: Dict[str, Any]) -> Dict[str, Any]: + return {**{k: u.get(k) for k in _SCIM_FIELDS}, "updated_at": u.get("updated_at") or u.get("created_at")} + + async def scim_users(self, org: str, user_name: Optional[str] = None, start: int = 1, + count: int = 100) -> Tuple[List[Dict[str, Any]], int]: + found = [u for u in self.users.values() if u["organization_id"] == org + and (user_name is None or u["email"] == user_name.lower())] + found.sort(key=lambda u: (u["created_at"], u["id"])) + return [self._scim_view(u) for u in found[start - 1:start - 1 + count]], len(found) + + async def scim_user(self, org: str, user_id: str) -> Optional[Dict[str, Any]]: + u = self.users.get(str(user_id)) + return self._scim_view(u) if u and u["organization_id"] == org else None + + async def scim_set_profile(self, org: str, user_id: str, email: Optional[str] = None, name: Optional[str] = None, + external_id: Optional[str] = None) -> bool: + """Name, email and the provider's externalId (roles and active go through update_member).""" + u = self.users.get(str(user_id)) + if not u or u["organization_id"] != org: + return False + if email is not None and email.lower() != u["email"]: + if self._user_by_email(email): + raise EmailExists(email) + u["email"] = email.lower() + if name is not None: + u["name"] = name + if external_id is not None: + u["scim_external_id"] = external_id + u["updated_at"] = _now() + return True + async def list_members(self, org: str) -> List[Dict[str, Any]]: return [_member(u) for u in self.users.values() if u["organization_id"] == org] @@ -836,10 +869,10 @@ async def record_erp_result(self, org: str, system: str, kind: str, result: Dict # --- API keys ----------------------------------------------------------- async def create_api_key(self, org: str, name: str, created_by: Optional[str], - expires_at: Optional[str] = None) -> Tuple[Dict[str, Any], str]: + expires_at: Optional[str] = None, scope: str = "read") -> Tuple[Dict[str, Any], str]: key, prefix, digest = new_api_key() row = {"id": str(uuid.uuid4()), "organization_id": org, "name": name, "prefix": prefix, "key_hash": digest, - "scopes": ["read"], "created_by": created_by, "created_at": _now(), "expires_at": expires_at, + "scopes": [scope], "created_by": created_by, "created_at": _now(), "expires_at": expires_at, "last_used_at": None, "revoked_at": None} self.api_keys[row["id"]] = row return {k: row[k] for k in _API_KEY_PUBLIC}, key @@ -1160,7 +1193,7 @@ def _jsonable(row: Dict[str, Any]) -> Dict[str, Any]: AUDIT_CATEGORIES: Dict[str, Tuple[str, ...]] = { "sign_in": ("auth.%",), "access": ("mfa.%", "invitation.%", "api_key.%", "% /api/v1/org/%", "% /api/v1/auth/%", - "% /api/v1/invitations/%"), + "% /api/v1/invitations/%", "% /scim/v2/%", "sso.%"), "integrations": ("integrations.%", "suppliers.import.%", "% /api/v1/integrations/%"), } AUDIT_CATEGORY_NAMES = (*AUDIT_CATEGORIES, "changes") @@ -1875,6 +1908,45 @@ async def create_user(self, org: str, email: str, name: str, hashed_password: st raise EmailExists(email) return self._account(rows[0]) + # --- SCIM provisioning ------------------------------------------------- + _SCIM_COLUMNS = "id, email, name, roles::text[] AS roles, is_active, scim_external_id, created_at, updated_at" + + async def scim_users(self, org: str, user_name: Optional[str] = None, start: int = 1, + count: int = 100) -> Tuple[List[Dict[str, Any]], int]: + where = "WHERE CAST(:u AS text) IS NULL OR lower(email) = lower(:u)" + rows = await self._all(org, (f"SELECT {self._SCIM_COLUMNS} FROM users {where} " + "ORDER BY created_at, id OFFSET :o LIMIT :n"), + {"u": user_name, "o": max(start - 1, 0), "n": count}) + total = (await self._all(org, f"SELECT count(*) AS n FROM users {where}", {"u": user_name}))[0]["n"] + return [self._account(r) for r in rows], total + + async def scim_user(self, org: str, user_id: str) -> Optional[Dict[str, Any]]: + if not _is_uuid(user_id): + return None + rows = await self._all(org, f"SELECT {self._SCIM_COLUMNS} FROM users WHERE id = CAST(:i AS uuid)", + {"i": user_id}) + return self._account(rows[0]) if rows else None + + async def scim_set_profile(self, org: str, user_id: str, email: Optional[str] = None, name: Optional[str] = None, + external_id: Optional[str] = None) -> bool: + """Name, email and the provider's externalId (roles and active go through update_member).""" + from sqlalchemy.exc import IntegrityError + if not _is_uuid(user_id): + return False + if email is not None: + other = await self.find_login_user(email) + if other and str(other["id"]) != str(user_id): + raise EmailExists(email) + try: + rows = await self._all(org, ( + "UPDATE users SET email = COALESCE(:e, email), name = COALESCE(:n, name), " + "scim_external_id = COALESCE(:x, scim_external_id), updated_at = now() " + "WHERE id = CAST(:i AS uuid) RETURNING id"), + {"e": email.lower() if email else None, "n": name, "x": external_id, "i": user_id}) + except IntegrityError: + raise EmailExists(email or "") + return bool(rows) + async def list_members(self, org: str) -> List[Dict[str, Any]]: rows = await self._all(org, f"SELECT {self._MEMBER_COLUMNS} FROM users ORDER BY created_at, email") return [self._account(r) for r in rows] @@ -2189,14 +2261,14 @@ async def record_audit(self, org: Optional[str], entry: Dict[str, Any]) -> None: _API_KEY_COLUMNS = ", ".join(_API_KEY_PUBLIC) async def create_api_key(self, org: str, name: str, created_by: Optional[str], - expires_at: Optional[str] = None) -> Tuple[Dict[str, Any], str]: + expires_at: Optional[str] = None, scope: str = "read") -> Tuple[Dict[str, Any], str]: key, prefix, digest = new_api_key() expires = _naive_utc(expires_at) rows = await self._all(org, ( - "INSERT INTO api_keys (organization_id, name, prefix, key_hash, created_by, expires_at) " - "VALUES (CAST(:org AS uuid), :n, :p, :h, :by, :exp) " + "INSERT INTO api_keys (organization_id, name, prefix, key_hash, scopes, created_by, expires_at) " + "VALUES (CAST(:org AS uuid), :n, :p, :h, CAST(:sc AS text[]), :by, :exp) " f"RETURNING {self._API_KEY_COLUMNS}"), - {"org": org, "n": name, "p": prefix, "h": digest, "by": created_by, + {"org": org, "n": name, "p": prefix, "h": digest, "sc": [scope], "by": created_by, "exp": expires.replace(tzinfo=timezone.utc) if expires else None}) return self._api_key_row(rows[0]), key diff --git a/mkdocs.yml b/mkdocs.yml index c0c5745..c07faee 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,6 +21,7 @@ nav: - Two-step sign-in: two-step-sign-in.md - Passwords: passwords.md - Single sign-on: single-sign-on.md + - User provisioning (SCIM): scim.md - Audit log: audit-log.md - Public API: public-api.md - Webhooks: webhooks.md diff --git a/tests/db/test_database.py b/tests/db/test_database.py index 66391bc..24ffbb0 100644 --- a/tests/db/test_database.py +++ b/tests/db/test_database.py @@ -330,11 +330,16 @@ def test_lookup_works_before_any_tenant_is_known_and_flags_dead_keys(self, db): assert as_org(db["app"], "", lookup, ("0" * 64,)) == [(db["a"], False)] assert as_org(db["app"], "", lookup, ("7" * 64,)) == [] - def test_only_read_scope_and_hashes_are_accepted(self, db): + def test_only_known_scopes_and_hashes_are_accepted(self, db): bad_scope = as_org(db["app"], db["a"], "INSERT INTO api_keys (organization_id, name, prefix, key_hash, scopes) " "VALUES (%s, 'k', 'scirm_ABCDEFGH', %s, '{write}')", (db["a"], "1" * 64)) assert isinstance(bad_scope, psycopg2.errors.CheckViolation) assert isinstance(self._key(db, db["a"], "not-a-hash"), psycopg2.errors.CheckViolation) + insert = ("INSERT INTO api_keys (organization_id, name, prefix, key_hash, scopes) " + "VALUES (%s, 'k', 'scirm_ABCDEFGH', %s, CAST(%s AS text[]))") + assert as_org(db["app"], db["a"], insert, (db["a"], "2" * 64, "{scim}")) == 1 # migration 024 + both = as_org(db["app"], db["a"], insert, (db["a"], "3" * 64, "{read,scim}")) + assert isinstance(both, psycopg2.errors.CheckViolation) # one scope per key def test_webhook_and_report_tables_are_tenant_scoped_with_forced_rls(db): diff --git a/tests/security/test_scim.py b/tests/security/test_scim.py new file mode 100644 index 0000000..f925b00 --- /dev/null +++ b/tests/security/test_scim.py @@ -0,0 +1,161 @@ +""" +SCIM 2.0 user provisioning with a SCIM token: what identity providers send (Entra and +Okta PATCH forms), uniqueness, deactivation ending sign-in, the last-admin guard, tenant +isolation and the token's narrow reach. Runs on both backends, like the security suite. +""" + +import asyncio +import uuid + +import pytest + + +def bearer(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +SCIM = {"Content-Type": "application/scim+json"} + + +@pytest.fixture +def scim(client, sarah): + created = client.post("/api/v1/org/api-keys", json={"name": "Entra provisioning", "scope": "scim"}, + headers=bearer(sarah["access_token"])) + assert created.status_code == 200, created.text + assert created.json()["api_key"]["scopes"] == ["scim"] + return {**bearer(created.json()["key"]), **SCIM} + + +def new_user(client, scim, **extra): + email = f"scim-{uuid.uuid4().hex[:8]}@example.com" + body = {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": email, + "name": {"givenName": "Ada", "familyName": "Lovelace"}, "active": True, **extra} + response = client.post("/scim/v2/Users", json=body, headers=scim) + assert response.status_code == 201, response.text + assert response.headers["content-type"].startswith("application/scim+json") + return response.json() + + +class TestToken: + def test_a_scim_token_reaches_only_scim_and_read_keys_cannot_reach_scim(self, client, sarah, scim): + assert client.get("/scim/v2/ServiceProviderConfig", headers=scim).status_code == 200 + assert client.get("/api/v1/suppliers", headers=scim).status_code == 403 + read = client.post("/api/v1/org/api-keys", json={"name": "BI"}, headers=bearer(sarah["access_token"])).json()["key"] + assert client.get("/scim/v2/Users", headers=bearer(read)).status_code == 403 + assert client.get("/scim/v2/Users", headers=bearer(sarah["access_token"])).status_code == 403 # a person's token + + def test_unknown_scopes_are_refused(self, client, sarah): + assert client.post("/api/v1/org/api-keys", json={"name": "x", "scope": "admin"}, + headers=bearer(sarah["access_token"])).status_code == 422 + + +class TestUsers: + def test_create_find_and_read(self, client, scim): + user = new_user(client, scim, externalId="entra-object-1", roles=[{"value": "analyst"}]) + assert user["name"]["formatted"] == "Ada Lovelace" and user["active"] is True + assert [r["value"] for r in user["roles"]] == ["analyst"] and user["externalId"] == "entra-object-1" + by_name = client.get("/scim/v2/Users", params={"filter": f'userName eq "{user["userName"].upper()}"'}, + headers=scim).json() + assert by_name["totalResults"] == 1 and by_name["Resources"][0]["id"] == user["id"] + by_ext = client.get("/scim/v2/Users", params={"filter": 'externalId eq "entra-object-1"'}, headers=scim).json() + assert [u["id"] for u in by_ext["Resources"]] == [user["id"]] + assert client.get(f"/scim/v2/Users/{user['id']}", headers=scim).json()["userName"] == user["userName"] + + def test_defaults_to_viewer_and_refuses_unknown_roles(self, client, scim): + assert [r["value"] for r in new_user(client, scim)["roles"]] == ["viewer"] + bad = client.post("/scim/v2/Users", json={"userName": f"r-{uuid.uuid4().hex[:6]}@example.com", + "roles": [{"value": "superuser"}]}, headers=scim) + assert bad.status_code == 400 and bad.json()["scimType"] == "invalidValue" + + def test_emails_are_unique_in_and_across_organizations(self, client, lisa, scim): + user = new_user(client, scim) + for email in (user["userName"], user["userName"].upper()): + again = client.post("/scim/v2/Users", json={"userName": email}, headers=scim) + assert again.status_code == 409 and again.json()["scimType"] == "uniqueness" + # An account in another organization (MedTech) cannot be created or taken over here. + elsewhere = f"medtech-{uuid.uuid4().hex[:6]}@example.com" + invite = client.post("/api/v1/org/invitations", json={"email": elsewhere, "roles": ["viewer"]}, + headers=bearer(lisa["access_token"])).json()["invite_url"].split("#", 1)[1] + client.post("/api/v1/invitations/accept", json={"token": invite, "name": "M", "password": "a-good-password"}) + taken = client.post("/scim/v2/Users", json={"userName": elsewhere}, headers=scim) + assert taken.status_code == 409 and taken.json()["scimType"] == "uniqueness" + moved = client.put(f"/scim/v2/Users/{user['id']}", json={"userName": elsewhere}, headers=scim) + assert moved.status_code == 409 + + def test_bad_filters_are_refused(self, client, scim): + bad = client.get("/scim/v2/Users", params={"filter": 'displayName co "a"'}, headers=scim) + assert bad.status_code == 400 and bad.json()["scimType"] == "invalidFilter" + + +class TestChanges: + def test_entra_patch_deactivates_and_sign_in_stops(self, client, login, sarah, scim): + email = f"scim-pw-{uuid.uuid4().hex[:6]}@example.com" + invite = client.post("/api/v1/org/invitations", json={"email": email, "roles": ["analyst"]}, + headers=bearer(sarah["access_token"])).json()["invite_url"].split("#", 1)[1] + tokens = client.post("/api/v1/invitations/accept", json={"token": invite, "name": "P", "password": "a-good-password"}).json() + uid = client.get("/scim/v2/Users", params={"filter": f'userName eq "{email}"'}, headers=scim).json()["Resources"][0]["id"] + patch = {"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "Replace", "path": "active", "value": "False"}]} + assert client.patch(f"/scim/v2/Users/{uid}", json=patch, headers=scim).json()["active"] is False + assert login(email, "a-good-password").status_code == 401 + assert client.post("/api/v1/auth/refresh", json={"refresh_token": tokens["refresh_token"]}).status_code == 401 + + def test_okta_patch_without_a_path_and_name_parts(self, client, scim): + user = new_user(client, scim) + okta = {"Operations": [{"op": "replace", "value": {"active": False, "roles": [{"value": "auditor"}]}}, + {"op": "replace", "path": "name.familyName", "value": "Byron"}]} + changed = client.patch(f"/scim/v2/Users/{user['id']}", json=okta, headers=scim).json() + assert changed["active"] is False and [r["value"] for r in changed["roles"]] == ["auditor"] + assert changed["name"]["formatted"] == "Ada Byron" + + def test_put_replaces_name_and_email(self, client, scim): + user = new_user(client, scim) + email = f"renamed-{uuid.uuid4().hex[:6]}@example.com" + replaced = client.put(f"/scim/v2/Users/{user['id']}", json={"userName": email, "displayName": "Ada K.", + "active": True}, headers=scim).json() + assert replaced["userName"] == email and replaced["name"]["formatted"] == "Ada K." + + def test_delete_deactivates(self, client, scim): + user = new_user(client, scim) + assert client.delete(f"/scim/v2/Users/{user['id']}", headers=scim).status_code == 204 + assert client.get(f"/scim/v2/Users/{user['id']}", headers=scim).json()["active"] is False + + def test_unsupported_ops_are_refused(self, client, scim): + user = new_user(client, scim) + bad = client.patch(f"/scim/v2/Users/{user['id']}", json={"Operations": [{"op": "remove", "path": "active"}]}, + headers=scim) + assert bad.status_code == 400 + + def test_changes_are_audited_under_the_token(self, client, sarah, scim): + user = new_user(client, scim) + log = client.get("/api/v1/audit", params={"category": "access", "limit": 50}, + headers=bearer(sarah["access_token"])).json()["entries"] + mine = [e for e in log if e["action"] == "POST /scim/v2/Users"] + assert mine and mine[0]["actor"].startswith("api-key:Entra provisioning") + + +class TestBoundaries: + def test_another_organizations_users_are_invisible(self, client, lisa, scim): + other = client.post("/api/v1/org/api-keys", json={"name": "other", "scope": "scim"}, + headers=bearer(lisa["access_token"])).json()["key"] + user = new_user(client, scim) + h = {**bearer(other), **SCIM} + assert client.get(f"/scim/v2/Users/{user['id']}", headers=h).status_code == 404 + assert client.patch(f"/scim/v2/Users/{user['id']}", json={"Operations": [ + {"op": "replace", "path": "active", "value": False}]}, headers=h).status_code == 404 + listed = client.get("/scim/v2/Users", params={"filter": f'userName eq "{user["userName"]}"'}, headers=h).json() + assert listed["totalResults"] == 0 + + def test_the_last_administrator_cannot_be_deprovisioned(self, client, apps): + store = apps["gateway"].audit_store + + async def setup(): + org = await store.create_organization(f"SCIM org {uuid.uuid4().hex[:6]}") + _, key = await store.create_api_key(org, "scim", "t", scope="scim") + return key + + h = {**bearer(asyncio.run(setup())), **SCIM} + admin = client.post("/scim/v2/Users", json={"userName": f"only-admin-{uuid.uuid4().hex[:6]}@example.com", + "roles": [{"value": "admin"}]}, headers=h).json() + refused = client.delete(f"/scim/v2/Users/{admin['id']}", headers=h) + assert refused.status_code == 409 and refused.json()["scimType"] == "mutability"