Files
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

453 lines
18 KiB
Python

"""SCIM 2.0 user-provisioning endpoints (RFC 7643/7644 subset for IdP clients).
IdPs (Okta, Authentik, Entra) push user lifecycle into DocsGPT through
``/scim/v2``: create users ahead of first login and deactivate them on
offboarding. Deactivation also revokes live sessions via the Redis
denylist; login refuses inactive users elsewhere. Only ``userName`` and
``active`` are honored — everything else IdPs send is ignored.
"""
from __future__ import annotations
import hmac
import json
import logging
import re
from typing import Any, Optional
from flask import Blueprint, Response, request
from sqlalchemy import Connection
from application.api.oidc.denylist import deny_user
from application.core.settings import settings
from application.storage.db.repositories.auth_events import AuthEventsRepository
from application.storage.db.repositories.users import UsersRepository
from application.storage.db.session import db_readonly, db_session
logger = logging.getLogger(__name__)
_SCIM_MEDIA_TYPE = "application/scim+json"
_ERROR_URN = "urn:ietf:params:scim:api:messages:2.0:Error"
_LIST_RESPONSE_URN = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
_USER_URN = "urn:ietf:params:scim:schemas:core:2.0:User"
_DEFAULT_COUNT = 100
_MAX_COUNT = 200
# The only filter IdPs need for provisioning: exact userName lookup.
_USERNAME_EQ_FILTER = re.compile(r'^\s*userName\s+eq\s+"([^"]*)"\s*$', re.IGNORECASE)
_SERVICE_PROVIDER_CONFIG = {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"patch": {"supported": True},
"bulk": {"supported": False},
"filter": {"supported": True, "maxResults": _MAX_COUNT},
"changePassword": {"supported": False},
"sort": {"supported": False},
"etag": {"supported": False},
"authenticationSchemes": [
{
"type": "oauthbearertoken",
"name": "Bearer Token",
"description": "Authorization header carrying the configured SCIM bearer token",
}
],
}
_USER_RESOURCE_TYPE = {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
"id": "User",
"name": "User",
"endpoint": "/scim/v2/Users",
"schema": _USER_URN,
"meta": {"resourceType": "ResourceType", "location": "/scim/v2/ResourceTypes/User"},
}
_USER_SCHEMA = {
"id": _USER_URN,
"name": "User",
"description": "DocsGPT user account",
"attributes": [
{
"name": "userName",
"type": "string",
"multiValued": False,
"required": True,
# RFC 7643 §8.7.1 defines userName as caseExact=false; matching is
# case-insensitive (see UsersRepository.list_paginated / create).
"caseExact": False,
"mutability": "immutable",
"returned": "default",
"uniqueness": "server",
},
{
"name": "active",
"type": "boolean",
"multiValued": False,
"required": False,
"mutability": "readWrite",
"returned": "default",
},
],
"meta": {"resourceType": "Schema", "location": f"/scim/v2/Schemas/{_USER_URN}"},
}
# ----------------------------------------------------------------------
# Response helpers
# ----------------------------------------------------------------------
def _scim_response(payload: Optional[dict], status: int, headers: Optional[dict] = None) -> Response:
"""Build a response with the SCIM media type; ``None`` payload means empty body."""
body = "" if payload is None else json.dumps(payload)
response = Response(body, status=status, mimetype=_SCIM_MEDIA_TYPE)
for key, value in (headers or {}).items():
response.headers[key] = value
return response
def _scim_error(status: int, detail: str, scim_type: Optional[str] = None) -> Response:
"""Build an RFC 7644 error response."""
payload: dict = {"schemas": [_ERROR_URN], "status": str(status), "detail": detail}
if scim_type:
payload["scimType"] = scim_type
return _scim_response(payload, status)
def _static_list_response(resources: list) -> dict:
"""Wrap fixed resources in a SCIM ListResponse."""
return {
"schemas": [_LIST_RESPONSE_URN],
"totalResults": len(resources),
"startIndex": 1,
"itemsPerPage": len(resources),
"Resources": resources,
}
def _iso(value: Any) -> Optional[str]:
"""Return an ISO-8601 string (repository rows may carry str or datetime)."""
if value is None:
return None
return value if isinstance(value, str) else value.isoformat()
def _serialize_user(row: dict) -> dict:
"""Map a ``users`` row to a SCIM User resource."""
pk = str(row["id"])
user_name = row["user_id"]
resource = {
"schemas": [_USER_URN],
"id": pk,
"userName": user_name,
"active": bool(row["active"]),
}
if "@" in user_name:
resource["emails"] = [{"value": user_name, "primary": True}]
resource["meta"] = {
"resourceType": "User",
"created": _iso(row.get("created_at")),
"lastModified": _iso(row.get("updated_at")),
"location": f"/scim/v2/Users/{pk}",
}
return resource
# ----------------------------------------------------------------------
# Request parsing helpers
# ----------------------------------------------------------------------
def _coerce_active(value: Any) -> Optional[bool]:
"""Coerce a SCIM ``active`` value to bool; ``None`` when invalid (Okta sends strings)."""
if isinstance(value, bool):
return value
if isinstance(value, str) and value.lower() in ("true", "false"):
return value.lower() == "true"
return None
def _int_arg(name: str, default: int) -> int:
"""Read an integer query parameter, falling back to ``default``."""
raw = request.args.get(name)
if raw is None:
return default
try:
return int(raw)
except ValueError:
return default
def _parse_filter(raw: Optional[str]) -> tuple[Optional[str], Optional[Response]]:
"""Parse the ``filter`` query param; only ``userName eq "value"`` is supported."""
if raw is None or not raw.strip():
return None, None
match = _USERNAME_EQ_FILTER.match(raw)
if match is None:
return None, _scim_error(400, 'Only the filter userName eq "value" is supported', "invalidFilter")
return match.group(1), None
# ----------------------------------------------------------------------
# Side effects
# ----------------------------------------------------------------------
class _RevocationUnavailable(Exception):
"""A SCIM deactivation could not persist its session revocation (Redis down)."""
def _revocation_unavailable(_exc: _RevocationUnavailable) -> Response:
return _scim_error(503, "Session revocation backend unavailable; retry")
def _audit(conn: Connection, user_id: str, event: str) -> None:
"""Best-effort audit insert in a savepoint; failure never fails the request."""
try:
with conn.begin_nested():
AuthEventsRepository(conn).insert(user_id, event, metadata={"via": "scim"})
except Exception:
logger.error("SCIM audit insert failed for user %s event %s", user_id, event, exc_info=True)
def _apply_active(conn: Connection, row: dict, desired: bool) -> dict:
"""Apply an ``active`` transition; side effects run only when the value changes."""
if bool(row["active"]) == desired:
return row
updated = UsersRepository(conn).set_active(str(row["id"]), desired) or row
user_id = row["user_id"]
if desired:
# Reactivation needs no denylist write: deactivation set a revocation
# watermark that already lets a fresh login through (newer iat) while
# keeping the pre-deactivation sessions revoked.
_audit(conn, user_id, "scim_reactivated")
else:
if not deny_user(user_id):
# Redis is down — we cannot revoke live sessions. Fail the request
# (the surrounding transaction rolls the deactivation back) so the
# IdP retries instead of recording a deprovision that didn't revoke.
raise _RevocationUnavailable(user_id)
_audit(conn, user_id, "scim_deactivated")
return updated
# ----------------------------------------------------------------------
# Bearer-token gate
# ----------------------------------------------------------------------
def _enforce_scim_auth() -> Optional[Response]:
"""Gate every SCIM request on SCIM_ENABLED and the shared bearer token."""
if not settings.SCIM_ENABLED:
return _scim_error(404, "SCIM provisioning is not enabled")
token = settings.SCIM_TOKEN
if not token:
logger.error("SCIM is enabled but SCIM_TOKEN is not configured — rejecting request")
return _scim_error(503, "SCIM is enabled but no SCIM_TOKEN is configured")
scheme, _, presented = request.headers.get("Authorization", "").partition(" ")
if scheme.lower() != "bearer" or not hmac.compare_digest(
presented.strip().encode("utf-8"), token.encode("utf-8")
):
return _scim_error(401, "Invalid or missing bearer token")
return None
# ----------------------------------------------------------------------
# Discovery endpoints
# ----------------------------------------------------------------------
def service_provider_config():
"""Static service-provider capabilities document."""
return _scim_response(_SERVICE_PROVIDER_CONFIG, 200)
def resource_types():
"""Advertise the User resource type."""
return _scim_response(_static_list_response([_USER_RESOURCE_TYPE]), 200)
def schemas():
"""Advertise the User schema."""
return _scim_response(_static_list_response([_USER_SCHEMA]), 200)
# ----------------------------------------------------------------------
# Users
# ----------------------------------------------------------------------
def list_users():
"""List users with optional exact userName filter and 1-based pagination."""
user_name, error = _parse_filter(request.args.get("filter"))
if error is not None:
return error
start_index = max(1, _int_arg("startIndex", 1))
count = min(max(0, _int_arg("count", _DEFAULT_COUNT)), _MAX_COUNT)
with db_readonly() as conn:
total, rows = UsersRepository(conn).list_paginated(user_name, start_index - 1, count)
return _scim_response(
{
"schemas": [_LIST_RESPONSE_URN],
"totalResults": total,
"startIndex": start_index,
"itemsPerPage": len(rows),
"Resources": [_serialize_user(row) for row in rows],
},
200,
)
def create_user():
"""Create a user from ``userName`` (+ optional ``active``); 409 on duplicates."""
body = request.get_json(force=True, silent=True)
if not isinstance(body, dict):
return _scim_error(400, "Request body must be a JSON object", "invalidValue")
user_name = body.get("userName")
if not isinstance(user_name, str) or not user_name.strip():
return _scim_error(400, "userName is required", "invalidValue")
active = _coerce_active(body.get("active", True))
if active is None:
return _scim_error(400, "active must be a boolean", "invalidValue")
with db_session() as conn:
row = UsersRepository(conn).create(user_name, active=active)
if row is None:
return _scim_error(409, f"User {user_name} already exists", "uniqueness")
_audit(conn, user_name, "scim_created")
resource = _serialize_user(row)
return _scim_response(resource, 201, headers={"Location": resource["meta"]["location"]})
def get_user(user_pk: str):
"""Fetch one user by primary key."""
with db_readonly() as conn:
row = UsersRepository(conn).get_by_pk(user_pk)
if not row:
return _scim_error(404, "User not found")
return _scim_response(_serialize_user(row), 200)
def replace_user(user_pk: str):
"""Full replace; only ``active`` is honored and ``userName`` is immutable."""
body = request.get_json(force=True, silent=True)
if not isinstance(body, dict):
return _scim_error(400, "Request body must be a JSON object", "invalidValue")
desired: Optional[bool] = None
if "active" in body:
desired = _coerce_active(body["active"])
if desired is None:
return _scim_error(400, "active must be a boolean", "invalidValue")
with db_session() as conn:
row = UsersRepository(conn).get_by_pk(user_pk)
if not row:
return _scim_error(404, "User not found")
if "userName" in body and str(body["userName"]).lower() != str(row["user_id"]).lower():
# userName is case-insensitive (caseExact=false), so a PUT that
# echoes the stored name under different casing is NOT a change —
# rejecting it would block deprovision for case-divergent IdPs.
return _scim_error(400, "userName is immutable", "mutability")
if desired is not None:
row = _apply_active(conn, row, desired)
return _scim_response(_serialize_user(row), 200)
def patch_user(user_pk: str):
"""Apply PatchOp replace operations targeting ``active``."""
body = request.get_json(force=True, silent=True)
if not isinstance(body, dict):
return _scim_error(400, "Request body must be a JSON object", "invalidValue")
operations = body.get("Operations")
if not isinstance(operations, list) or not operations:
return _scim_error(400, "PatchOp body with Operations is required", "invalidValue")
desired: Optional[bool] = None
for operation in operations:
if not isinstance(operation, dict) or str(operation.get("op", "")).strip().lower() != "replace":
return _scim_error(400, "Only the replace operation is supported", "invalidPath")
path = str(operation.get("path") or "").strip()
if not path:
value = operation.get("value")
if not isinstance(value, dict):
return _scim_error(400, "replace without path requires an object value", "invalidValue")
if "active" not in value:
continue # Only "active" is honored; other attributes are ignored.
candidate = value["active"]
elif path.lower() == "active":
candidate = operation.get("value")
else:
return _scim_error(400, f"Unsupported path: {path}", "invalidPath")
coerced = _coerce_active(candidate)
if coerced is None:
return _scim_error(400, "active must be a boolean", "invalidValue")
desired = coerced
with db_session() as conn:
row = UsersRepository(conn).get_by_pk(user_pk)
if not row:
return _scim_error(404, "User not found")
if desired is not None:
row = _apply_active(conn, row, desired)
return _scim_response(_serialize_user(row), 200)
def delete_user(user_pk: str):
"""Soft delete: deactivate the user and revoke live sessions."""
with db_session() as conn:
row = UsersRepository(conn).get_by_pk(user_pk)
if not row:
return _scim_error(404, "User not found")
_apply_active(conn, row, False)
return _scim_response(None, 204)
# ----------------------------------------------------------------------
# Groups (not supported — answered so IdP probes don't hard-fail)
# ----------------------------------------------------------------------
def list_groups():
"""Groups are not provisioned; always an empty ListResponse."""
return _scim_response(_static_list_response([]), 200)
def create_group():
"""Group creation is not supported."""
return _scim_error(501, "Group provisioning is not supported")
def group_detail(group_id: str):
"""Individual groups never exist; mutations are unsupported."""
if request.method == "GET":
return _scim_error(404, "Group not found")
return _scim_error(501, "Group provisioning is not supported")
def register(bp: Blueprint) -> None:
"""Attach the SCIM routes and bearer-token gate to ``bp``."""
bp.before_request(_enforce_scim_auth)
bp.register_error_handler(_RevocationUnavailable, _revocation_unavailable)
bp.add_url_rule(
"/scim/v2/ServiceProviderConfig", view_func=service_provider_config, methods=["GET"],
endpoint="service_provider_config",
)
bp.add_url_rule(
"/scim/v2/ResourceTypes", view_func=resource_types, methods=["GET"], endpoint="resource_types",
)
bp.add_url_rule(
"/scim/v2/Schemas", view_func=schemas, methods=["GET"], endpoint="schemas",
)
bp.add_url_rule(
"/scim/v2/Users", view_func=list_users, methods=["GET"], endpoint="list_users",
)
bp.add_url_rule(
"/scim/v2/Users", view_func=create_user, methods=["POST"], endpoint="create_user",
)
bp.add_url_rule(
"/scim/v2/Users/<user_pk>", view_func=get_user, methods=["GET"], endpoint="get_user",
)
bp.add_url_rule(
"/scim/v2/Users/<user_pk>", view_func=replace_user, methods=["PUT"], endpoint="replace_user",
)
bp.add_url_rule(
"/scim/v2/Users/<user_pk>", view_func=patch_user, methods=["PATCH"], endpoint="patch_user",
)
bp.add_url_rule(
"/scim/v2/Users/<user_pk>", view_func=delete_user, methods=["DELETE"], endpoint="delete_user",
)
bp.add_url_rule(
"/scim/v2/Groups", view_func=list_groups, methods=["GET"], endpoint="list_groups",
)
bp.add_url_rule(
"/scim/v2/Groups", view_func=create_group, methods=["POST"], endpoint="create_group",
)
bp.add_url_rule(
"/scim/v2/Groups/<group_id>", view_func=group_detail,
methods=["GET", "PUT", "PATCH", "DELETE"], endpoint="group_detail",
)