Browse Source

feat: establish enterprise identity and sso controls

马小龙 2 weeks ago
parent
commit
6bc4144dc1
42 changed files with 6363 additions and 44 deletions
  1. 1 0
      app/api/system/__init__.py
  2. 648 0
      app/api/system/enterprise_identity.py
  3. 34 1
      app/api/system/users.py
  4. 131 6
      app/core/system/auth.py
  5. 345 0
      app/core/system/enterprise_identity.py
  6. 570 0
      app/core/system/identity_repository.py
  7. 103 0
      app/core/system/identity_sessions.py
  8. 261 0
      app/core/system/oidc.py
  9. 31 1
      app/core/system/permissions.py
  10. 14 4
      app/core/system/tokens.py
  11. 1 0
      deployment/app/api/system/__init__.py
  12. 648 0
      deployment/app/api/system/enterprise_identity.py
  13. 34 1
      deployment/app/api/system/users.py
  14. 131 6
      deployment/app/core/system/auth.py
  15. 345 0
      deployment/app/core/system/enterprise_identity.py
  16. 570 0
      deployment/app/core/system/identity_repository.py
  17. 103 0
      deployment/app/core/system/identity_sessions.py
  18. 261 0
      deployment/app/core/system/oidc.py
  19. 31 1
      deployment/app/core/system/permissions.py
  20. 14 4
      deployment/app/core/system/tokens.py
  21. 140 0
      deployment/migrations/versions/20260802_470_enterprise_identity.py
  22. 24 0
      deployment/migrations/versions/20260802_471_enterprise_identity_hardening.py
  23. 13 9
      docs/DATAOPS_PHASE3_6_MONTH_DEVELOPMENT_PLAN_20260802.md
  24. 472 1
      docs/architecture/OPENAPI.yaml
  25. 1 1
      docs/phase3/P3_WP00_REQUIREMENTS.json
  26. 66 0
      docs/phase3/P3_WP02_ENTERPRISE_IDENTITY.md
  27. 52 0
      docs/validation/P3_WP02_ENTERPRISE_IDENTITY_EVIDENCE.md
  28. 12 0
      frontend/src/api/enterpriseIdentity.js
  29. 6 0
      frontend/src/api/user.js
  30. 6 0
      frontend/src/router/defaultRoute.js
  31. 18 1
      frontend/src/router/routes.js
  32. 31 5
      frontend/src/store/modules/user.js
  33. 11 2
      frontend/src/utils/auth.js
  34. 29 1
      frontend/src/utils/request.js
  35. 34 0
      frontend/src/views/login/callback.vue
  36. 17 0
      frontend/src/views/login/components/login.vue
  37. 71 0
      frontend/src/views/systemManage/enterpriseIdentity/index.vue
  38. 140 0
      migrations/versions/20260802_470_enterprise_identity.py
  39. 24 0
      migrations/versions/20260802_471_enterprise_identity_hardening.py
  40. 353 0
      tests/integration/test_phase3_wp02_enterprise_identity_postgres.py
  41. 476 0
      tests/test_phase3_wp02_enterprise_identity.py
  42. 91 0
      tests/test_phase3_wp02_enterprise_identity_contract.py

+ 1 - 0
app/api/system/__init__.py

@@ -5,6 +5,7 @@ from flask import Blueprint
 bp = Blueprint("system", __name__)
 
 from app.api.system import governance_audit  # noqa: E402, F401
+from app.api.system import enterprise_identity  # noqa: E402, F401
 from app.api.system import responsibilities  # noqa: E402, F401
 from app.api.system import routes  # noqa: E402, F401
 from app.api.system import security_governance  # noqa: E402, F401

+ 648 - 0
app/api/system/enterprise_identity.py

@@ -0,0 +1,648 @@
+"""Enterprise identity APIs. Public routes are explicitly enumerated in policy."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import secrets
+import uuid
+from datetime import UTC, datetime, timedelta
+from functools import wraps
+from urllib.parse import urlsplit
+
+from flask import current_app, g, jsonify, redirect, request
+from itsdangerous import BadSignature, URLSafeTimedSerializer
+from sqlalchemy import text
+from sqlalchemy.exc import IntegrityError, StatementError
+
+from app import db
+from app.api.system import bp
+from app.core.common.identifiers import new_governance_uid
+from app.core.system.enterprise_identity import (
+    ClaimMapper,
+    DirectorySynchronizer,
+    EmergencyAccess,
+    IdentityPolicyError,
+    IdentityUpstreamError,
+    IdpConfig,
+    sanitize_audit_detail,
+)
+from app.core.system.identity_repository import PostgresIdentityRepository
+from app.core.system.identity_sessions import SessionManager
+from app.core.system.oidc import OidcClient, OidcTransport
+from app.core.system.permissions import (
+    IDENTITY_MANAGE,
+    IDENTITY_OPERATE,
+    IDENTITY_READ,
+    require_permissions,
+)
+from app.core.system.tokens import issue_access_token
+from app.models.result import failed, success
+
+
+def _body() -> dict:
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict):
+        raise IdentityPolicyError("JSON object body is required")
+    return value
+
+
+def _required_str(data: dict, key: str, *, maximum: int = 500) -> str:
+    value = data.get(key)
+    if not isinstance(value, str) or not value.strip() or len(value.strip()) > maximum:
+        raise IdentityPolicyError(f"{key} is required or invalid")
+    return value.strip()
+
+
+def _optional_str(data: dict, key: str, default: str, *, maximum: int) -> str:
+    if key not in data:
+        return default
+    return _required_str(data, key, maximum=maximum)
+
+
+def _uuid(value, field: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (ValueError, TypeError, AttributeError) as exc:
+        raise IdentityPolicyError(f"{field} must be a UUID") from exc
+
+
+def _positive_int(value, field: str, *, maximum: int = 2_147_483_647) -> int:
+    if isinstance(value, bool):
+        raise IdentityPolicyError(f"{field} must be a positive integer")
+    try:
+        result = int(value)
+    except (ValueError, TypeError) as exc:
+        raise IdentityPolicyError(f"{field} must be a positive integer") from exc
+    if result < 1 or result > maximum:
+        raise IdentityPolicyError(f"{field} must be a positive integer")
+    return result
+
+
+def _timestamp(value, field: str) -> datetime:
+    if not isinstance(value, str):
+        raise IdentityPolicyError(f"{field} must be an ISO-8601 timestamp")
+    try:
+        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+    except ValueError as exc:
+        raise IdentityPolicyError(f"{field} must be an ISO-8601 timestamp") from exc
+    if parsed.tzinfo is None or parsed.utcoffset() is None:
+        raise IdentityPolicyError(f"{field} must include a timezone")
+    return parsed
+
+
+def _string_array(data: dict, key: str, *, default=None) -> tuple[str, ...]:
+    value = data.get(key, default)
+    if not isinstance(value, list) or not value or any(not isinstance(item, str) or not item.strip() for item in value):
+        raise IdentityPolicyError(f"{key} must be a non-empty string array")
+    return tuple(item.strip() for item in value)
+
+
+def _validate_mapping(version: str, group_rules) -> ClaimMapper:
+    if not isinstance(group_rules, dict):
+        raise IdentityPolicyError("group_rules must be an object")
+    return ClaimMapper(version=version, group_rules=group_rules)
+
+
+def _identity_input_guard(view):
+    @wraps(view)
+    def guarded(*args, **kwargs):
+        try:
+            return view(*args, **kwargs)
+        except IdentityPolicyError:
+            raise
+        except (KeyError, TypeError, ValueError, IntegrityError, StatementError) as exc:
+            db.session.rollback()
+            raise IdentityPolicyError("invalid enterprise identity request") from exc
+
+    return guarded
+
+
+def _repo() -> PostgresIdentityRepository:
+    return PostgresIdentityRepository(db.session)
+
+
+def _config(row) -> IdpConfig:
+    return IdpConfig(provider_uid=str(row["provider_uid"]), version=row["version"], issuer=row["issuer"],
+                     client_id=row["client_id"], secret_ref=row["secret_ref"],
+                     authorization_endpoint=row["authorization_endpoint"], token_endpoint=row["token_endpoint"],
+                     jwks_uri=row["jwks_uri"], redirect_uris=tuple(row["redirect_uris"]),
+                     post_login_redirect_uris=tuple(dict(row["config"]).get("post_login_redirect_uris", ())),
+                     algorithms=tuple(row["algorithms"]), mapping_version=row["mapping_version"], status=row["status"])
+
+
+def _load_config(provider_uid: str, *, version: int | None = None, active: bool = True):
+    clause = "AND status='active'" if active else ""
+    version_clause = "AND version=:version" if version is not None else ""
+    return db.session.execute(text(f"""
+        SELECT provider_uid::text,version,status,issuer,client_id,secret_ref,authorization_endpoint,
+               token_endpoint,jwks_uri,redirect_uris,algorithms,mapping_version,config
+        FROM public.identity_idp_config_versions WHERE provider_uid=CAST(:provider AS uuid) {version_clause} {clause}
+        ORDER BY version DESC LIMIT 1
+    """), {"provider": provider_uid, "version": version}).mappings().one_or_none()
+
+
+def _audit(event_type: str, outcome: str, *, actor_uid: str | None = None,
+           provider_uid: str | None = None, resource_uid: str | None = None,
+           enterprise_subject: str | None = None, detail: dict | None = None) -> None:
+    _repo().audit({"event_type": event_type, "outcome": outcome, "actor_uid": actor_uid,
+                   "provider_uid": provider_uid, "resource_type": "enterprise_identity",
+                   "resource_uid": resource_uid,
+                   "subject_digest": (hashlib.sha256(enterprise_subject.encode()).hexdigest()
+                                      if enterprise_subject else None),
+                   "safe_detail": sanitize_audit_detail(detail or {})})
+
+
+def _cookie_secure() -> bool:
+    return bool(current_app.config.get("IDENTITY_COOKIE_SECURE", True))
+
+
+def _origin(uri: str) -> str:
+    parts = urlsplit(uri)
+    return f"{parts.scheme}://{parts.netloc}"
+
+
+def _browser_origin_allowed(origin: str) -> bool:
+    rows = db.session.execute(text("SELECT config FROM public.identity_idp_config_versions WHERE status='active'")).scalars().all()
+    allowed = {_origin(uri) for config in rows for uri in dict(config).get("post_login_redirect_uris", ())}
+    allowed.update(current_app.config.get("IDENTITY_ALLOWED_TEST_ORIGINS", ()))
+    return origin in allowed
+
+
+@bp.errorhandler(IdentityPolicyError)
+def identity_policy_error(exc):
+    try:
+        db.session.rollback()
+        _audit("identity_policy_denied", "denied", actor_uid=getattr(g, "current_user", {}).get("id"),
+               detail={"path": request.path, "reason": str(exc)[:160]})
+    except Exception:  # pragma: no cover - failure reporting must not expose internals
+        db.session.rollback()
+    return jsonify(failed(str(exc), code=400)), 400
+
+
+@bp.errorhandler(IdentityUpstreamError)
+def identity_upstream_error(exc):
+    db.session.rollback()
+    try:
+        _audit("identity_upstream_failure", "failure", actor_uid=getattr(g, "current_user", {}).get("id"),
+               detail={"path": request.path})
+    except Exception:  # pragma: no cover
+        db.session.rollback()
+    return jsonify(failed("enterprise identity provider is unavailable", code=502)), 502
+
+
+@bp.route("/identity/providers", methods=["GET"])
+def identity_providers():
+    rows = db.session.execute(text("""
+        SELECT provider_uid::text,version,issuer,config->>'display_name' display_name
+        FROM public.identity_idp_config_versions WHERE status='active' ORDER BY provider_uid
+    """)).mappings().all()
+    return jsonify(success([{"provider_uid": row["provider_uid"], "version": row["version"],
+                             "issuer": row["issuer"], "display_name": row["display_name"] or "企业单点登录"} for row in rows]))
+
+
+@bp.route("/identity/authorize", methods=["POST"])
+@_identity_input_guard
+def identity_authorize():
+    data = _body()
+    provider_uid = _uuid(data.get("provider_uid"), "provider_uid")
+    row = _load_config(provider_uid)
+    if not row:
+        raise IdentityPolicyError("active identity provider not found")
+    config = _config(row)
+    callback_uri = _required_str(data, "redirect_uri", maximum=2048)
+    post_login_uri = _required_str(data, "post_login_uri", maximum=2048)
+    if post_login_uri not in config.post_login_redirect_uris:
+        raise IdentityPolicyError("post-login redirect URI is not allowlisted")
+    started = OidcClient(_repo()).begin(config, callback_uri)
+    serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt="oidc-flow")
+    response = jsonify(success({"authorization_url": started.url, "provider_uid": config.provider_uid}))
+    response.set_cookie("dataops_oidc_flow", serializer.dumps({"state": started.state, "verifier": started.code_verifier,
+                                                                 "provider_uid": config.provider_uid,
+                                                                 "redirect_uri": callback_uri,
+                                                                 "post_login_uri": post_login_uri}),
+                        httponly=True, secure=_cookie_secure(), samesite="Lax", max_age=300, path="/api/system/identity/callback")
+    _audit("oidc_authorize", "success", provider_uid=config.provider_uid, detail={"redirect_uri": data["redirect_uri"]})
+    return response
+
+
+@bp.route("/identity/callback", methods=["GET"])
+@_identity_input_guard
+def identity_callback():
+    serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt="oidc-flow")
+    try:
+        flow = serializer.loads(request.cookies.get("dataops_oidc_flow", ""), max_age=300)
+    except BadSignature as exc:
+        raise IdentityPolicyError("OIDC browser flow binding is invalid or expired") from exc
+    if not isinstance(flow, dict) or request.args.get("state") != flow.get("state"):
+        raise IdentityPolicyError("OIDC state mismatch")
+    provider_uid = _uuid(flow.get("provider_uid"), "provider_uid")
+    row = _load_config(provider_uid)
+    if not row:
+        raise IdentityPolicyError("active identity provider not found")
+    row = dict(row)
+    config = _config(row)
+    config.validate()
+    callback_uri = _required_str(flow, "redirect_uri", maximum=2048)
+    verifier = _required_str(flow, "verifier", maximum=512)
+    state = _required_str(flow, "state", maximum=512)
+    post_login_uri = _required_str(flow, "post_login_uri", maximum=2048)
+    code = request.args.get("code")
+    if not isinstance(code, str) or not code or len(code) > 4096:
+        raise IdentityPolicyError("OIDC authorization code is required")
+    # Do not keep a database transaction open while calling the external IdP.
+    db.session.rollback()
+    transport = OidcTransport()
+    id_token = transport.exchange_code(config, code=code, redirect_uri=callback_uri, verifier=verifier)
+    jwks = transport.fetch_jwks(config)
+    repo = _repo()
+    try:
+        claims = OidcClient(repo).verify_callback(config, state=state, redirect_uri=callback_uri,
+                                                   code_verifier=verifier, id_token=id_token, jwks=jwks,
+                                                   commit=False)
+        mapped = ClaimMapper(version=config.mapping_version,
+                             group_rules=dict(row["config"]).get("group_rules", {})).map(claims)
+        link = repo.get_identity(config.provider_uid, mapped.subject, for_update=True)
+        if link and link["status"] != "active":
+            raise IdentityPolicyError("enterprise identity is disabled")
+        scope = {"business_domain_uids": mapped.business_domain_uids, "object_types": mapped.object_types,
+                 "environments": mapped.environments, "data_scopes": mapped.data_scopes}
+        changed = bool(link and (sorted(link["roles"]) != sorted(mapped.roles)
+                                 or dict(link["authorization_scope"]) != json.loads(json.dumps(scope))))
+        user_uid = link["user_uid"] if link else new_governance_uid()
+        token_version = (int(link["token_version"]) + 1 if changed else int(link["token_version"])) if link else 1
+        repo.put_identity(config.provider_uid, mapped.subject, {
+            "provider_uid": config.provider_uid, "user_uid": user_uid, "username": mapped.username,
+            "display_name": mapped.display_name, "department": mapped.department, "groups": list(mapped.groups),
+            "roles": list(mapped.roles), "authorization_scope": scope, "mapping_version": mapped.mapping_version,
+            "claims_digest": mapped.claims_digest, "status": "active", "token_version": token_version,
+        }, commit=False)
+        if changed:
+            repo.revoke_subject_sessions(config.provider_uid, mapped.subject,
+                                         reason="claims_mapping_changed", commit=False)
+        credentials = SessionManager(repo, secret=current_app.config["SECRET_KEY"]).create(
+            subject=mapped.subject, user_uid=user_uid, roles=list(mapped.roles), identity_source="oidc",
+            token_version=token_version, provider_uid=config.provider_uid, commit=False)
+        exchange = secrets.token_urlsafe(40)
+        repo.insert_exchange_code({"uid": new_governance_uid(),
+                                   "code_hash": hashlib.sha256(exchange.encode()).hexdigest(),
+                                   "session_uid": credentials.session_uid, "redirect_uri": post_login_uri,
+                                   "expires_at": datetime.now(UTC) + timedelta(minutes=2)}, commit=False)
+        db.session.commit()
+    except IdentityPolicyError:
+        db.session.rollback()
+        raise
+    except Exception as exc:
+        db.session.rollback()
+        raise IdentityPolicyError("OIDC callback persistence failed") from exc
+    _audit("oidc_login", "success", provider_uid=config.provider_uid, resource_uid=user_uid,
+           detail={"mapping_version": mapped.mapping_version, "claims_digest": mapped.claims_digest})
+    response = redirect(f"{flow['post_login_uri']}?exchange_code={exchange}", code=302)
+    response.set_cookie("dataops_refresh", credentials.refresh_token, httponly=True, secure=_cookie_secure(), samesite="Strict",
+                        max_age=8 * 3600, path="/api/system/identity")
+    response.delete_cookie("dataops_oidc_flow", path="/api/system/identity/callback")
+    return response
+
+
+@bp.route("/identity/exchange", methods=["POST"])
+@_identity_input_guard
+def identity_exchange():
+    data = _body()
+    exchange_code = _required_str(data, "exchange_code", maximum=1024)
+    redirect_uri = _required_str(data, "redirect_uri", maximum=2048)
+    digest = hashlib.sha256(exchange_code.encode()).hexdigest()
+    row = db.session.execute(text("""
+        UPDATE public.identity_exchange_codes SET status='consumed',consumed_at=CURRENT_TIMESTAMP
+        WHERE code_hash=:digest AND status='pending' AND expires_at>CURRENT_TIMESTAMP
+          AND redirect_uri=:redirect RETURNING session_uid::text,redirect_uri
+    """), {"digest": digest, "redirect": redirect_uri}).mappings().one_or_none()
+    if not row or request.headers.get("Origin") != _origin(row["redirect_uri"]):
+        db.session.rollback()
+        raise IdentityPolicyError("exchange code or browser origin is invalid")
+    db.session.commit()
+    session = _repo().get_session(row["session_uid"])
+    if not session or session["status"] != "active":
+        raise IdentityPolicyError("identity session is unavailable")
+    token = issue_access_token(user_id=session["user_uid"], roles=list(session["roles"]), secret=current_app.config["SECRET_KEY"],
+                               session_uid=session["uid"], token_version=session["token_version"], identity_source=session["identity_source"])
+    return jsonify(success({"token": token, "identity_source": session["identity_source"]}))
+
+
+@bp.route("/identity/refresh", methods=["POST"])
+def identity_refresh():
+    origin = request.headers.get("Origin", "")
+    if not _browser_origin_allowed(origin):
+        raise IdentityPolicyError("browser origin is not allowlisted")
+    credentials = SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).refresh(request.cookies.get("dataops_refresh", ""))
+    session = _repo().get_session(credentials.session_uid)
+    _audit("identity_refresh", "success", actor_uid=session["user_uid"] if session else None,
+           provider_uid=session.get("provider_uid") if session else None,
+           resource_uid=credentials.session_uid,
+           detail={"rotated_from_uid": session.get("rotated_from_uid") if session else None})
+    response = jsonify(success({"token": credentials.access_token}))
+    response.set_cookie("dataops_refresh", credentials.refresh_token, httponly=True, secure=_cookie_secure(), samesite="Strict",
+                        max_age=8 * 3600, path="/api/system/identity")
+    return response
+
+
+@bp.route("/identity/logout", methods=["POST"])
+@require_permissions(IDENTITY_READ)
+def identity_logout():
+    session_uid = g.current_user.get("sid")
+    session = _repo().get_session(session_uid) if session_uid else None
+    if session_uid:
+        SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).revoke(session_uid)
+    _audit("identity_logout", "success", actor_uid=g.current_user.get("id"),
+           provider_uid=session.get("provider_uid") if session else None, resource_uid=session_uid)
+    response = jsonify(success({"revoked": True}))
+    response.delete_cookie("dataops_refresh", path="/api/system/identity")
+    return response
+
+
+@bp.route("/identity/idp-versions", methods=["GET"])
+@require_permissions(IDENTITY_MANAGE)
+def identity_idp_versions():
+    rows = db.session.execute(text("SELECT provider_uid::text,version,status,issuer,client_id,secret_ref,redirect_uris,algorithms,mapping_version,created_at FROM public.identity_idp_config_versions ORDER BY provider_uid,version DESC")).mappings().all()
+    return jsonify(success([{**dict(row), "secret_ref": "env:DATAOPS_OIDC_***"} for row in rows]))
+
+
+@bp.route("/identity/idp-versions", methods=["POST"])
+@require_permissions(IDENTITY_MANAGE)
+@_identity_input_guard
+def identity_idp_version_create():
+    data = _body()
+    provider_uid = _uuid(data["provider_uid"], "provider_uid") if data.get("provider_uid") else new_governance_uid()
+    mapping_version = _required_str(data, "mapping_version", maximum=120)
+    group_rules = data.get("group_rules")
+    _validate_mapping(mapping_version, group_rules)
+    config = IdpConfig(provider_uid=provider_uid, version=_positive_int(data.get("version"), "version"),
+        issuer=_required_str(data, "issuer", maximum=2048), client_id=_required_str(data, "client_id", maximum=300),
+        secret_ref=_required_str(data, "secret_ref", maximum=160),
+        authorization_endpoint=_required_str(data, "authorization_endpoint", maximum=2048),
+        token_endpoint=_required_str(data, "token_endpoint", maximum=2048),
+        jwks_uri=_required_str(data, "jwks_uri", maximum=2048), redirect_uris=_string_array(data, "redirect_uris"),
+        post_login_redirect_uris=_string_array(data, "post_login_redirect_uris"),
+        algorithms=_string_array(data, "algorithms", default=["RS256"]), mapping_version=mapping_version).validate()
+    db.session.execute(text("""
+        INSERT INTO public.identity_idp_config_versions(uid,provider_uid,version,status,issuer,client_id,secret_ref,authorization_endpoint,
+        token_endpoint,jwks_uri,redirect_uris,algorithms,mapping_version,config,created_by)
+        VALUES(CAST(:uid AS uuid),CAST(:provider AS uuid),:version,'draft',:issuer,:client,:secret,:auth,:token,:jwks,
+        CAST(:redirects AS jsonb),CAST(:algorithms AS jsonb),:mapping,CAST(:config AS jsonb),CAST(:actor AS uuid))
+    """), {"uid": new_governance_uid(), "provider": config.provider_uid, "version": config.version, "issuer": config.issuer,
+             "client": config.client_id, "secret": config.secret_ref, "auth": config.authorization_endpoint,
+             "token": config.token_endpoint, "jwks": config.jwks_uri, "redirects": json.dumps(config.redirect_uris),
+             "algorithms": json.dumps(config.algorithms), "mapping": config.mapping_version,
+             "config": json.dumps({"display_name": data.get("display_name"), "group_rules": group_rules,
+                                   "post_login_redirect_uris": data.get("post_login_redirect_uris", [])}), "actor": g.current_user["id"]})
+    db.session.commit()
+    _audit("idp_version_created", "success", actor_uid=g.current_user["id"], provider_uid=config.provider_uid)
+    return jsonify(success(config.public_dict())), 201
+
+
+@bp.route("/identity/idp-versions/<provider_uid>/<int:version>/<action>", methods=["POST"])
+@require_permissions(IDENTITY_MANAGE)
+@_identity_input_guard
+def identity_idp_action(provider_uid: str, version: int, action: str):
+    provider_uid = _uuid(provider_uid, "provider_uid")
+    version = _positive_int(version, "version")
+    row = _load_config(provider_uid, version=version, active=False)
+    if not row:
+        raise IdentityPolicyError("IdP configuration version not found")
+    config = _config(row)
+    config.validate()
+    _validate_mapping(config.mapping_version, dict(row["config"]).get("group_rules"))
+    if action == "dry-run":
+        result = {"valid": True, "secret_available": bool(config.resolve_secret())}
+        _audit("idp_dry_run", "success", actor_uid=g.current_user["id"], provider_uid=provider_uid,
+               resource_uid=f"{provider_uid}:{version}", detail={"version": version})
+        return jsonify(success(result))
+    if action in {"activate", "rotate"}:
+        config.resolve_secret()
+        db.session.execute(text("UPDATE public.identity_idp_config_versions SET status='superseded' WHERE provider_uid=CAST(:p AS uuid) AND status='active'"), {"p": provider_uid})
+        db.session.execute(text("UPDATE public.identity_idp_config_versions SET status='active',activated_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:p AS uuid) AND version=:v"), {"p": provider_uid, "v": version})
+        db.session.execute(text("UPDATE public.enterprise_identity_links SET token_version=token_version+1,updated_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:p AS uuid)"), {"p": provider_uid})
+        db.session.execute(text("""
+            UPDATE public.identity_sessions SET status='revoked',revoke_reason='provider_configuration_changed',revoked_at=CURRENT_TIMESTAMP
+            WHERE status='active' AND identity_link_uid IN
+              (SELECT uid FROM public.enterprise_identity_links WHERE provider_uid=CAST(:p AS uuid))
+        """), {"p": provider_uid})
+    elif action == "disable":
+        disabled = db.session.execute(text("UPDATE public.identity_idp_config_versions SET status='disabled',disabled_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:p AS uuid) AND version=:v AND status='active' RETURNING provider_uid"), {"p": provider_uid, "v": version}).scalar_one_or_none()
+        if not disabled:
+            raise IdentityPolicyError("only the selected active IdP version can be disabled")
+        db.session.execute(text("UPDATE public.enterprise_identity_links SET token_version=token_version+1,updated_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:p AS uuid)"), {"p": provider_uid})
+        db.session.execute(text("""
+            UPDATE public.identity_sessions SET status='revoked',revoke_reason='provider_disabled',revoked_at=CURRENT_TIMESTAMP
+            WHERE status='active' AND identity_link_uid IN
+              (SELECT uid FROM public.enterprise_identity_links WHERE provider_uid=CAST(:p AS uuid))
+        """), {"p": provider_uid})
+    elif action == "connectivity":
+        payload = OidcTransport().fetch_jwks(config)
+        result = {"reachable": True, "key_count": len(payload.get("keys", []))}
+        _audit("idp_connectivity", "success", actor_uid=g.current_user["id"], provider_uid=provider_uid,
+               resource_uid=f"{provider_uid}:{version}", detail={"version": version, **result})
+        return jsonify(success(result))
+    else:
+        raise IdentityPolicyError("unsupported IdP version action")
+    db.session.commit()
+    _audit(f"idp_{action}", "success", actor_uid=g.current_user["id"], provider_uid=provider_uid)
+    return jsonify(success({"provider_uid": provider_uid, "version": version, "action": action}))
+
+
+@bp.route("/identity/claims/dry-run", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_claims_dry_run():
+    data = _body()
+    mapping_version = _required_str(data, "mapping_version", maximum=120)
+    claims = data.get("claims")
+    if not isinstance(claims, dict):
+        raise IdentityPolicyError("claims must be an object")
+    mapped = _validate_mapping(mapping_version, data.get("group_rules")).map(claims)
+    result = {"roles": mapped.roles, "business_domain_uids": mapped.business_domain_uids,
+              "object_types": mapped.object_types, "environments": mapped.environments,
+              "data_scopes": mapped.data_scopes, "mapping_version": mapped.mapping_version,
+              "claims_digest": mapped.claims_digest}
+    _audit("claims_dry_run", "success", actor_uid=g.current_user["id"],
+           enterprise_subject=mapped.subject,
+           detail={"mapping_version": mapped.mapping_version, "claims_digest": mapped.claims_digest,
+                   "role_count": len(mapped.roles), "scope_count": sum(len(result[key]) for key in
+                   ("business_domain_uids", "object_types", "environments", "data_scopes"))})
+    return jsonify(success(result))
+
+
+@bp.route("/identity/directory/delta", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_directory_delta():
+    data = _body()
+    event_type = _required_str(data, "event_type", maximum=20).upper()
+    provider_uid = _uuid(data.get("provider_uid"), "provider_uid")
+    params = {
+        "provider_uid": provider_uid,
+        "source": _required_str(data, "source", maximum=80),
+        "source_event_id": _required_str(data, "source_event_id", maximum=300),
+        "cursor": _required_str(data, "cursor", maximum=500),
+        "cursor_sequence": _positive_int(data.get("cursor_sequence"), "cursor_sequence"),
+        "event_type": event_type,
+    }
+    if event_type in {"JOINER", "MOVER", "RESTORE"}:
+        row = _load_config(provider_uid)
+        if not row:
+            raise IdentityPolicyError("active identity provider not found")
+        config = _config(row)
+        mapped = ClaimMapper(
+            version=config.mapping_version,
+            group_rules=dict(row["config"]).get("group_rules", {}),
+        ).map(data.get("claims", {}))
+        params["subject"] = mapped.subject
+        params["attributes"] = {
+            "provider_uid": config.provider_uid,
+            "username": mapped.username,
+            "display_name": mapped.display_name,
+            "department": mapped.department,
+            "groups": mapped.groups,
+            "roles": mapped.roles,
+            "mapping_version": mapped.mapping_version,
+            "claims_digest": mapped.claims_digest,
+            "authorization_scope": {
+                "business_domain_uids": mapped.business_domain_uids,
+                "object_types": mapped.object_types,
+                "environments": mapped.environments,
+                "data_scopes": mapped.data_scopes,
+            },
+        }
+    elif event_type in {"LEAVER", "DISABLE"}:
+        params["subject"] = _required_str(data, "subject", maximum=500)
+        params["attributes"] = {"username": data.get("username"), "roles": []}
+    elif event_type in {"DEPARTMENT", "GROUP"}:
+        if not isinstance(data.get("attributes"), dict):
+            raise IdentityPolicyError("organization attributes must be an object")
+        params["attributes"] = data["attributes"]
+    else:
+        raise IdentityPolicyError("unsupported directory event type")
+    result = DirectorySynchronizer(_repo(), SessionManager(_repo(), secret=current_app.config["SECRET_KEY"])).apply(**params)
+    _audit("directory_delta", "success", actor_uid=g.current_user["id"],
+           provider_uid=provider_uid, resource_uid=params["source_event_id"],
+           enterprise_subject=str(params["subject"]) if params.get("subject") else None,
+           detail={"event_type": event_type, "result": result})
+    return jsonify(success(result))
+
+
+@bp.route("/identity/sessions", methods=["GET"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_sessions():
+    subject = request.args.get("subject")
+    provider_uid = request.args.get("provider_uid")
+    if subject and not provider_uid:
+        raise IdentityPolicyError("provider_uid is required with subject")
+    if provider_uid:
+        provider_uid = _uuid(provider_uid, "provider_uid")
+    if subject and len(subject) > 500:
+        raise IdentityPolicyError("subject is invalid")
+    rows = _repo().list_sessions(provider_uid, subject)
+    visible = ("uid", "user_uid", "identity_source", "status", "created_at", "last_seen_at", "expires_at")
+    return jsonify(success([{key: row.get(key) for key in visible} for row in rows]))
+
+
+@bp.route("/identity/sessions/<session_uid>/revoke", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_session_revoke(session_uid: str):
+    session_uid = _uuid(session_uid, "session_uid")
+    data = _body()
+    session = _repo().get_session(session_uid)
+    reason = _optional_str(data, "reason", "operator_revoke", maximum=120)
+    SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).revoke(session_uid, reason=reason)
+    _audit("identity_session_revoke", "success", actor_uid=g.current_user["id"],
+           provider_uid=session.get("provider_uid") if session else None, resource_uid=session_uid,
+           detail={"reason_supplied": bool(data.get("reason"))})
+    return jsonify(success({"session_uid": session_uid, "revoked": True}))
+
+
+@bp.route("/identity/sessions/<session_uid>/risk-terminate", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_session_risk(session_uid: str):
+    session_uid = _uuid(session_uid, "session_uid")
+    data = _body()
+    session = _repo().get_session(session_uid)
+    reason = _optional_str(data, "reason", "risk_signal", maximum=300)
+    SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).risk_revoke(session_uid, reason=reason)
+    _audit("identity_session_risk_terminate", "success", actor_uid=g.current_user["id"],
+           provider_uid=session.get("provider_uid") if session else None, resource_uid=session_uid,
+           detail={"reason_supplied": bool(data.get("reason"))})
+    return jsonify(success({"session_uid": session_uid, "revoked": True, "risk": True}))
+
+
+@bp.route("/identity/emergency/requests", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_emergency_request():
+    data = _body()
+    account_uid = _uuid(data.get("account_uid"), "account_uid")
+    reason = _required_str(data, "reason", maximum=2000)
+    expires_at = _timestamp(data.get("expires_at"), "expires_at")
+    eligible = db.session.execute(text("""
+        SELECT EXISTS(
+          SELECT 1 FROM public.users u JOIN public.user_roles ur ON ur.user_id=u.id
+          JOIN public.roles r ON r.id=ur.role_id
+          WHERE u.id=CAST(:account AS uuid) AND u.status='active' AND r.name='admin'
+            AND NOT EXISTS(SELECT 1 FROM public.enterprise_identity_links l WHERE l.user_uid=u.id)
+        )
+    """), {"account": account_uid}).scalar()
+    record = EmergencyAccess(_repo()).request(requester_uid=g.current_user["id"], account_uid=account_uid,
+        reason=reason, expires_at=expires_at,
+        account_is_local_active_admin=bool(eligible))
+    _audit("emergency_request", "success", actor_uid=g.current_user["id"], resource_uid=record["uid"],
+           detail={"account_uid": record["account_uid"], "expires_at": record["expires_at"].isoformat()})
+    return jsonify(success(record)), 201
+
+
+@bp.route("/identity/emergency/<request_uid>/<action>", methods=["POST"])
+@require_permissions(IDENTITY_MANAGE)
+@_identity_input_guard
+def identity_emergency_action(request_uid: str, action: str):
+    request_uid = _uuid(request_uid, "request_uid")
+    service = EmergencyAccess(_repo())
+    data = _body()
+    if action == "approve":
+        result = service.approve(request_uid, approver_uid=g.current_user["id"])
+    elif action == "activate":
+        result = service.activate(request_uid)
+    elif action == "review":
+        result = service.review(request_uid, reviewer_uid=g.current_user["id"],
+                                outcome=_required_str(data, "outcome", maximum=2000))
+    elif action == "close":
+        result = service.close(request_uid, actor_uid=g.current_user["id"])
+    else:
+        raise IdentityPolicyError("unsupported emergency action")
+    _audit(f"emergency_{action}", "warning" if action == "activate" else "success", actor_uid=g.current_user["id"], resource_uid=request_uid)
+    return jsonify(success(result))
+
+
+@bp.route("/identity/sessions/revoke-all", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_session_revoke_all():
+    data = _body()
+    provider_uid = _uuid(data.get("provider_uid"), "provider_uid")
+    subject = _required_str(data, "subject", maximum=500)
+    reason = _optional_str(data, "reason", "operator_revoke_all", maximum=120)
+    SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).revoke_subject(
+        provider_uid, subject, reason=reason
+    )
+    _audit("identity_session_revoke_all", "success", actor_uid=g.current_user["id"],
+           provider_uid=provider_uid, enterprise_subject=subject,
+           detail={"reason_supplied": bool(data.get("reason"))})
+    return jsonify(success({"revoked": True}))
+
+
+@bp.route("/identity/audit", methods=["GET"])
+@require_permissions(IDENTITY_MANAGE)
+@_identity_input_guard
+def identity_audit():
+    rows = db.session.execute(text("SELECT uid::text,event_type,outcome,actor_uid::text,provider_uid::text,resource_type,resource_uid,safe_detail,created_at FROM public.identity_audit_events ORDER BY created_at DESC LIMIT :limit"),
+                              {"limit": _positive_int(request.args.get("limit", 100), "limit", maximum=500)}).mappings().all()
+    return jsonify(success([dict(row) for row in rows]))

+ 34 - 1
app/api/system/users.py

@@ -4,7 +4,7 @@ import json
 
 from flask import g, jsonify, request
 from sqlalchemy import text
-from sqlalchemy.exc import IntegrityError
+from sqlalchemy.exc import IntegrityError, ProgrammingError
 
 from app import db
 from app.api.system import bp
@@ -33,6 +33,35 @@ def _audit_access_control(action, resource_uid, status, safe_detail):
             "detail": json.dumps(safe_detail, ensure_ascii=False),
         },
     )
+    try:
+        with db.session.begin_nested():
+            db.session.execute(
+                text(
+                    "INSERT INTO public.identity_audit_events "
+                    "(uid,event_type,outcome,actor_uid,resource_type,resource_uid,safe_detail) VALUES "
+                    "(CAST(:uid AS uuid),:action,:outcome,CAST(:actor AS uuid),'user',:resource_uid,CAST(:detail AS jsonb))"
+                ),
+                {"uid": new_governance_uid(), "action": action, "outcome": "success" if status == "success" else "failure",
+                 "actor": g.current_user["id"], "resource_uid": resource_uid,
+                 "detail": json.dumps(safe_detail, ensure_ascii=False)},
+            )
+    except ProgrammingError:
+        pass
+
+
+def _revoke_identity_sessions(user_id: str, reason: str) -> None:
+    try:
+        with db.session.begin_nested():
+            db.session.execute(
+                text("UPDATE public.enterprise_identity_links SET token_version=token_version+1,updated_at=CURRENT_TIMESTAMP WHERE user_uid=CAST(:id AS uuid)"),
+                {"id": user_id},
+            )
+            db.session.execute(
+                text("UPDATE public.identity_sessions SET status='revoked',revoke_reason=:reason,revoked_at=CURRENT_TIMESTAMP WHERE user_uid=CAST(:id AS uuid) AND status='active'"),
+                {"id": user_id, "reason": reason},
+            )
+    except ProgrammingError:
+        pass
 
 
 def _active_admin_count(session) -> int:
@@ -186,6 +215,8 @@ def update_user(user_id: str):
             "password_value_retained": False,
         },
     )
+    if status == "disabled":
+        _revoke_identity_sessions(user_id, "account_disabled")
     db.session.commit()
     return jsonify(success(message="用户更新成功"))
 
@@ -233,5 +264,7 @@ def update_user_roles(user_id: str):
         "success",
         {"before_roles": previous_roles, "after_roles": sorted(roles)},
     )
+    if sorted(previous_roles) != sorted(roles):
+        _revoke_identity_sessions(user_id, "roles_changed")
     db.session.commit()
     return jsonify(success(message="角色更新成功"))

+ 131 - 6
app/core/system/auth.py

@@ -2,6 +2,8 @@
 
 from __future__ import annotations
 
+import hashlib
+import json
 import logging
 import threading
 import time
@@ -12,11 +14,12 @@ from argon2 import PasswordHasher
 from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
 from flask import current_app
 from sqlalchemy import text
+from sqlalchemy.exc import ProgrammingError, SQLAlchemyError
 
 from app import db
+from app.core.common.identifiers import new_governance_uid
 from app.core.system.tokens import TokenError, decode_access_token, issue_access_token
 
-
 logger = logging.getLogger(__name__)
 _password_hasher = PasswordHasher()
 _failure_lock = threading.Lock()
@@ -87,6 +90,30 @@ def _audit(
             "detail": (detail or "")[:500] or None,
         },
     )
+    try:
+        with db.session.begin_nested():
+            db.session.execute(
+                text(
+                    "INSERT INTO public.identity_audit_events "
+                    "(uid,event_type,outcome,actor_uid,enterprise_subject_digest,resource_type,resource_uid,safe_detail) "
+                    "VALUES (CAST(:uid AS uuid),:event_type,:outcome,CAST(:actor AS uuid),:subject_digest,"
+                    "'authentication',:resource_uid,CAST(:detail AS jsonb))"
+                ),
+                {
+                    "uid": new_governance_uid(),
+                    "event_type": event_type,
+                    "outcome": "success" if success else "failure",
+                    "actor": user_id,
+                    "subject_digest": hashlib.sha256(username.strip().lower().encode()).hexdigest(),
+                    "resource_uid": user_id,
+                    "detail": json.dumps({"ip_address": (ip_address or "")[:64] or None,
+                                          "user_agent": (user_agent or "")[:300] or None,
+                                          "reason": (detail or "")[:160] or None}),
+                },
+            )
+    except ProgrammingError:
+        # Rolling deployment before migration 470 retains the legacy audit row.
+        pass
 
 
 def authenticate_user(
@@ -127,6 +154,36 @@ def authenticate_user(
         db.session.commit()
         return None
 
+    # Once an enterprise IdP is active, local authentication is a controlled
+    # break-glass path only. Missing identity tables mean pre-migration/no-IdP
+    # compatibility; an active IdP always fails closed.
+    try:
+        active_provider = db.session.execute(
+            text("SELECT EXISTS (SELECT 1 FROM public.identity_idp_config_versions WHERE status='active')")
+        ).scalar()
+    except SQLAlchemyError:
+        db.session.rollback()
+        logger.exception("enterprise identity state unavailable; local login denied")
+        return None
+    if active_provider:
+        emergency_request = db.session.execute(
+            text(
+                "SELECT uid::text,expires_at FROM public.identity_emergency_requests "
+                "WHERE emergency_account_uid=CAST(:id AS uuid) AND status='active' "
+                "AND starts_at<=CURRENT_TIMESTAMP AND expires_at>CURRENT_TIMESTAMP "
+                "AND EXISTS (SELECT 1 FROM public.user_roles ur JOIN public.roles r ON r.id=ur.role_id "
+                "WHERE ur.user_id=CAST(:id AS uuid) AND r.name='admin') "
+                "AND NOT EXISTS (SELECT 1 FROM public.enterprise_identity_links l WHERE l.user_uid=CAST(:id AS uuid)) "
+                "ORDER BY expires_at ASC LIMIT 1"
+            ),
+            {"id": row[0]},
+        ).one_or_none()
+        if not emergency_request:
+            _audit(user_id=row[0], username=row[1], event_type="local_login_blocked_by_idp", success=False,
+                   ip_address=ip_address, user_agent=user_agent, detail="active enterprise IdP requires approved emergency access")
+            db.session.commit()
+            return None
+
     db.session.execute(
         text("UPDATE public.users SET last_login_at = CURRENT_TIMESTAMP WHERE id = CAST(:id AS uuid)"),
         {"id": row[0]},
@@ -142,14 +199,40 @@ def authenticate_user(
     db.session.commit()
     _clear_failures(key)
     roles = list(row[5])
+    token = issue_access_token(
+        user_id=row[0], roles=roles, secret=current_app.config["SECRET_KEY"]
+    )
+    if active_provider:
+        from app.core.system.identity_repository import PostgresIdentityRepository
+        from app.core.system.identity_sessions import SessionManager
+
+        credentials = SessionManager(
+            PostgresIdentityRepository(db.session), secret=current_app.config["SECRET_KEY"]
+        ).create(
+            subject=row[0],
+            user_uid=row[0],
+            roles=roles,
+            identity_source="emergency",
+            emergency_request_uid=emergency_request[0],
+            emergency_expires_at=emergency_request[1],
+        )
+        token = credentials.access_token
+        PostgresIdentityRepository(db.session).audit(
+            {
+                "event_type": "emergency_local_login",
+                "outcome": "warning",
+                "actor_uid": row[0],
+                "resource_type": "emergency_access",
+                "resource_uid": row[0],
+                "safe_detail": {"alert_required": True},
+            }
+        )
     return {
         "id": row[0],
         "username": row[1],
         "display_name": row[2],
         "roles": roles,
-        "token": issue_access_token(
-            user_id=row[0], roles=roles, secret=current_app.config["SECRET_KEY"]
-        ),
+        "token": token,
     }
 
 
@@ -176,7 +259,49 @@ def load_identity_from_token(token: str, *, secret: str) -> dict[str, Any] | Non
         claims = decode_access_token(token, secret=secret)
     except TokenError:
         return None
-    return load_identity(claims["sub"])
+    try:
+        active_provider = bool(db.session.execute(text(
+            "SELECT EXISTS (SELECT 1 FROM public.identity_idp_config_versions WHERE status='active')"
+        )).scalar())
+    except SQLAlchemyError:
+        db.session.rollback()
+        logger.exception("enterprise identity state unavailable; bearer token denied")
+        return None
+    if active_provider and not claims.get("sid"):
+        return None
+    if claims.get("sid"):
+        try:
+            session = db.session.execute(
+                text("""
+                SELECT s.status,s.token_version,s.user_uid::text,s.identity_source,
+                  CASE WHEN s.identity_source='emergency' THEN
+                    (e.status='active' AND e.starts_at<=CURRENT_TIMESTAMP AND e.expires_at>CURRENT_TIMESTAMP
+                     AND u.status='active'
+                     AND EXISTS (SELECT 1 FROM public.user_roles ur JOIN public.roles r ON r.id=ur.role_id
+                                 WHERE ur.user_id=u.id AND r.name='admin')
+                     AND NOT EXISTS (SELECT 1 FROM public.enterprise_identity_links l WHERE l.user_uid=u.id))
+                  ELSE TRUE END emergency_valid
+                FROM public.identity_sessions s LEFT JOIN public.identity_emergency_requests e
+                  ON e.uid=s.emergency_request_uid LEFT JOIN public.users u ON u.id=s.user_uid
+                WHERE s.uid=CAST(:sid AS uuid)
+                """),
+                {"sid": claims["sid"]},
+            ).one_or_none()
+        except SQLAlchemyError:
+            db.session.rollback()
+            return None
+        if session and session[3] == "emergency" and not session[4]:
+            db.session.execute(text("UPDATE public.identity_sessions SET status='revoked',revoke_reason='emergency_window_closed',revoked_at=CURRENT_TIMESTAMP WHERE uid=CAST(:sid AS uuid)"), {"sid": claims["sid"]})
+            db.session.commit()
+            return None
+        if (not session or session[0] != "active" or session[2] != claims["sub"]
+                or int(session[1]) != int(claims.get("token_version", -1))):
+            return None
+    identity = load_identity(claims["sub"])
+    if identity and claims.get("sid"):
+        identity.update(sid=claims["sid"], token_version=claims["token_version"],
+                        identity_source=claims.get("identity_source", "oidc"))
+    return identity
 
 
 # Compatibility aliases retained only for imports during the route migration.
@@ -199,6 +324,6 @@ def init_db() -> bool:
 
 
 def require_auth(view):
-    from app.core.system.permissions import require_permissions, READ_GOVERNANCE
+    from app.core.system.permissions import READ_GOVERNANCE, require_permissions
 
     return require_permissions(READ_GOVERNANCE)(view)

+ 345 - 0
app/core/system/enterprise_identity.py

@@ -0,0 +1,345 @@
+"""Fail-closed enterprise identity policy, mapping, lifecycle and emergency access."""
+
+from __future__ import annotations
+
+import hashlib
+import ipaddress
+import json
+import os
+import re
+from collections.abc import Callable, Mapping
+from dataclasses import dataclass, field
+from datetime import UTC, datetime, timedelta
+from typing import Any
+from urllib.parse import urlsplit
+
+from app.core.common.identifiers import new_governance_uid
+
+
+class IdentityPolicyError(ValueError):
+    pass
+
+
+class IdentityUpstreamError(IdentityPolicyError):
+    """Safe boundary error for unavailable or malformed enterprise IdP responses."""
+
+    pass
+
+
+_SENSITIVE_KEYS = {"code", "token", "id_token", "access_token", "refresh_token", "verifier", "code_verifier",
+                   "secret", "client_secret", "claims", "cookie", "password", "assertion", "authorization_url"}
+
+
+def _utc(value: datetime) -> datetime:
+    if value.tzinfo is None or value.utcoffset() is None:
+        raise IdentityPolicyError("timestamp must be timezone-aware")
+    return value.astimezone(UTC)
+
+
+def _safe_https_url(value: str, *, redirect: bool = False) -> None:
+    parsed = urlsplit(value)
+    if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
+        raise IdentityPolicyError("identity endpoints require absolute HTTPS URLs")
+    if parsed.fragment or (redirect and parsed.query):
+        raise IdentityPolicyError("redirect and identity URLs may not contain query/fragment")
+    host = parsed.hostname.lower()
+    if host == "localhost" or host.endswith(".localhost"):
+        raise IdentityPolicyError("localhost identity endpoints are forbidden")
+    try:
+        address = ipaddress.ip_address(host)
+    except ValueError:
+        return
+    if not address.is_global:
+        raise IdentityPolicyError("private or reserved identity endpoint is forbidden")
+
+
+@dataclass(frozen=True)
+class IdpConfig:
+    provider_uid: str
+    version: int
+    issuer: str
+    client_id: str
+    secret_ref: str = field(repr=False)
+    authorization_endpoint: str
+    token_endpoint: str
+    jwks_uri: str
+    redirect_uris: tuple[str, ...]
+    post_login_redirect_uris: tuple[str, ...]
+    algorithms: tuple[str, ...]
+    mapping_version: str
+    status: str = "draft"
+
+    def validate(self) -> IdpConfig:
+        if self.version < 1 or not self.provider_uid or not self.client_id or not self.mapping_version:
+            raise IdentityPolicyError("incomplete IdP configuration")
+        for endpoint in (self.issuer, self.authorization_endpoint, self.token_endpoint, self.jwks_uri):
+            _safe_https_url(endpoint)
+        if not self.redirect_uris:
+            raise IdentityPolicyError("redirect allowlist is required")
+        for uri in self.redirect_uris:
+            _safe_https_url(uri, redirect=True)
+        if not self.post_login_redirect_uris:
+            raise IdentityPolicyError("post-login redirect allowlist is required")
+        for uri in self.post_login_redirect_uris:
+            _safe_https_url(uri, redirect=True)
+        if not re.fullmatch(r"env:DATAOPS_OIDC_[A-Z0-9_]+", self.secret_ref):
+            raise IdentityPolicyError("client secret must use a dedicated DATAOPS_OIDC environment reference")
+        if not self.algorithms or any(alg not in {"RS256", "RS384", "RS512", "ES256", "ES384"} for alg in self.algorithms):
+            raise IdentityPolicyError("only explicitly configured asymmetric algorithms are allowed")
+        return self
+
+    def resolve_secret(self) -> str:
+        self.validate()
+        value = os.environ.get(self.secret_ref[4:])
+        if not value:
+            raise IdentityPolicyError("configured secret reference is unavailable")
+        return value
+
+    def public_dict(self) -> dict[str, Any]:
+        return {"provider_uid": self.provider_uid, "version": self.version, "issuer": self.issuer,
+                "client_id": self.client_id, "authorization_endpoint": self.authorization_endpoint,
+                "redirect_uris": list(self.redirect_uris), "algorithms": list(self.algorithms),
+                "post_login_redirect_uris": list(self.post_login_redirect_uris),
+                "mapping_version": self.mapping_version, "status": self.status,
+                "secret_ref": "env:DATAOPS_OIDC_***"}
+
+
+def sanitize_audit_detail(value: Any) -> Any:
+    if isinstance(value, Mapping):
+        safe: dict[str, Any] = {}
+        for key, item in value.items():
+            lowered = str(key).lower()
+            if lowered in _SENSITIVE_KEYS or any(part in lowered for part in ("secret", "token", "verifier")):
+                continue
+            if lowered.endswith("claims"):
+                continue
+            safe[str(key)] = sanitize_audit_detail(item)
+        return safe
+    if isinstance(value, (list, tuple)):
+        return [sanitize_audit_detail(item) for item in value]
+    return value
+
+
+@dataclass(frozen=True)
+class MappedIdentity:
+    subject: str
+    username: str
+    display_name: str
+    department: str
+    groups: tuple[str, ...]
+    roles: tuple[str, ...]
+    business_domain_uids: tuple[str, ...]
+    object_types: tuple[str, ...]
+    environments: tuple[str, ...]
+    data_scopes: tuple[str, ...]
+    mapping_version: str
+    claims_digest: str
+    evidence: Mapping[str, Any] = field(repr=False)
+
+
+class ClaimMapper:
+    REQUIRED = ("sub", "preferred_username", "name", "department", "groups")
+    RULE_FIELDS = {"roles", "business_domain_uids", "object_types", "environments", "data_scopes"}
+
+    def __init__(self, *, version: str, group_rules: Mapping[str, Mapping[str, Any]]) -> None:
+        if not isinstance(version, str) or not version.strip():
+            raise IdentityPolicyError("mapping version is required")
+        if not isinstance(group_rules, Mapping) or not group_rules:
+            raise IdentityPolicyError("at least one claims group rule is required")
+        normalized: dict[str, dict[str, tuple[str, ...]]] = {}
+        for group, raw_rule in group_rules.items():
+            if not isinstance(group, str) or not group.strip() or not isinstance(raw_rule, Mapping):
+                raise IdentityPolicyError("claims group rules must be named objects")
+            if set(raw_rule) - self.RULE_FIELDS:
+                raise IdentityPolicyError("claims group rule contains unsupported fields")
+            rule: dict[str, tuple[str, ...]] = {}
+            for field_name in self.RULE_FIELDS:
+                values = raw_rule.get(field_name, ())
+                if not isinstance(values, (list, tuple)) or isinstance(values, (str, bytes)):
+                    raise IdentityPolicyError("claims mapping scope fields must be string arrays")
+                if any(not isinstance(value, str) or not value.strip() for value in values):
+                    raise IdentityPolicyError("claims mapping scope values must be non-empty strings")
+                rule[field_name] = tuple(sorted(set(values)))
+            if not rule["roles"] or not set(rule["roles"]).issubset({"admin", "editor", "viewer"}):
+                raise IdentityPolicyError("mapping contains an unsupported or empty platform role")
+            normalized[group.strip()] = rule
+        self.version = version.strip()
+        self.group_rules = normalized
+
+    def map(self, claims: Mapping[str, Any]) -> MappedIdentity:
+        if not isinstance(claims, Mapping):
+            raise IdentityPolicyError("identity claims must be an object")
+        limits = {"sub": 500, "preferred_username": 64, "name": 300, "department": 300}
+        if any(not isinstance(claims.get(key), str) or not claims[key].strip()
+               or len(claims[key].strip()) > maximum for key, maximum in limits.items()):
+            raise IdentityPolicyError("required identity claim is missing")
+        raw_groups = claims.get("groups")
+        if (not isinstance(raw_groups, (list, tuple)) or isinstance(raw_groups, (str, bytes)) or not raw_groups
+                or any(not isinstance(item, str) or not item.strip() or len(item.strip()) > 300 for item in raw_groups)):
+            raise IdentityPolicyError("identity groups claim must be a non-empty string array")
+        groups = tuple(sorted({item.strip() for item in raw_groups}))
+        matched = [self.group_rules[group] for group in groups if group in self.group_rules]
+        if not matched:
+            raise IdentityPolicyError("claims do not match an approved mapping rule")
+        def collect(key: str) -> tuple[str, ...]:
+            return tuple(sorted({str(value) for rule in matched for value in rule.get(key, ())}))
+        roles = collect("roles")
+        if not roles or not set(roles).issubset({"admin", "editor", "viewer"}):
+            raise IdentityPolicyError("mapping resolves to no approved platform role")
+        canonical = json.dumps(claims, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
+        digest = hashlib.sha256(canonical.encode()).hexdigest()
+        evidence = {"mapping_version": self.version, "claims_digest": digest,
+                    "matched_group_digests": [hashlib.sha256(group.encode()).hexdigest() for group in groups if group in self.group_rules]}
+        return MappedIdentity(claims["sub"].strip(), claims["preferred_username"].strip(), claims["name"].strip(),
+                              claims["department"].strip(), groups, roles, collect("business_domain_uids"),
+                              collect("object_types"), collect("environments"), collect("data_scopes"),
+                              self.version, digest, evidence)
+
+
+class DirectorySynchronizer:
+    EVENTS = {"JOINER", "MOVER", "LEAVER", "DISABLE", "RESTORE"}
+    ORGANIZATION_EVENTS = {"DEPARTMENT", "GROUP"}
+    ORGANIZATION_ACTIONS = {"UPSERT", "DISABLE", "RESTORE"}
+
+    def __init__(self, repository: Any, sessions: Any, *, clock: Callable[[], datetime] | None = None) -> None:
+        self.repository = repository
+        self.sessions = sessions
+        self.clock = clock or (lambda: datetime.now(UTC))
+
+    def apply(self, *, provider_uid: str, source: str, source_event_id: str, cursor: str,
+              cursor_sequence: int, event_type: str, attributes: Mapping[str, Any],
+              subject: str | None = None) -> dict[str, Any]:
+        event_type = event_type.upper()
+        if event_type not in self.EVENTS | self.ORGANIZATION_EVENTS or not all((provider_uid, source, source_event_id, cursor)):
+            raise IdentityPolicyError("invalid directory delta event")
+        if event_type in self.EVENTS and not subject:
+            raise IdentityPolicyError("person lifecycle event requires enterprise subject")
+        if not isinstance(cursor_sequence, int) or cursor_sequence < 1:
+            raise IdentityPolicyError("directory cursor sequence must be a positive integer")
+        digest = hashlib.sha256(json.dumps({"cursor": cursor, "event_type": event_type, "subject": subject,
+                                             "attributes": attributes}, sort_keys=True, default=str).encode()).hexdigest()
+        event = {"provider_uid": provider_uid, "source": source, "source_event_id": source_event_id,
+                 "cursor": cursor, "cursor_sequence": cursor_sequence, "event_type": event_type,
+                 "subject": subject, "payload_digest": digest, "processed_at": _utc(self.clock()).isoformat()}
+
+        def mutate_organization() -> dict[str, Any]:
+            node_type = event_type.lower()
+            external_id = str(attributes.get("external_id") or "").strip()
+            action = str(attributes.get("lifecycle_action") or attributes.get("action") or "UPSERT").upper()
+            if not external_id or action not in self.ORGANIZATION_ACTIONS:
+                raise IdentityPolicyError("invalid organization node delta")
+            existing = self.repository.get_organization_node(provider_uid, node_type, external_id, for_update=True)
+            if action in {"DISABLE", "RESTORE"} and not existing:
+                raise IdentityPolicyError("organization node lifecycle target does not exist")
+            display_name = str(attributes.get("display_name") or (existing or {}).get("display_name") or "").strip()
+            if not display_name:
+                raise IdentityPolicyError("organization node display name is required")
+            node = {
+                "uid": (existing or {}).get("uid") or new_governance_uid(),
+                "provider_uid": provider_uid,
+                "external_id": external_id,
+                "node_type": node_type,
+                "parent_external_id": attributes.get("parent_external_id"),
+                "display_name": display_name,
+                "status": "disabled" if action == "DISABLE" else "active",
+                "attributes": dict(attributes.get("attributes", {})),
+                "updated_at": _utc(self.clock()),
+            }
+            self.repository.put_organization_node(node, commit=False)
+            return {"node_uid": node["uid"], "node_type": node_type, "external_id": external_id,
+                    "status": node["status"], "lifecycle_action": action, "idempotent": False}
+
+        def mutate_identity() -> dict[str, Any]:
+            identity = self.repository.get_identity(provider_uid, str(subject), for_update=True)
+            if not identity and event_type != "JOINER":
+                raise IdentityPolicyError("person lifecycle target does not exist")
+            identity = identity or {"user_uid": new_governance_uid(), "token_version": 0}
+            old_roles = tuple(identity.get("roles", ()))
+            roles = sorted(set(attributes.get("roles", ())))
+            if not set(roles).issubset({"admin", "editor", "viewer"}):
+                raise IdentityPolicyError("directory event contains an unsupported platform role")
+            username = attributes.get("username") or identity.get("username")
+            if not username:
+                raise IdentityPolicyError("person lifecycle username is required")
+            identity.update(subject=subject, username=username,
+                            department=attributes.get("department", identity.get("department", "")),
+                            display_name=attributes.get("display_name") or identity.get("display_name") or username,
+                            provider_uid=provider_uid, groups=sorted(attributes.get("groups", identity.get("groups", ()))),
+                            roles=roles, mapping_version=attributes.get("mapping_version") or identity.get("mapping_version"),
+                            claims_digest=attributes.get("claims_digest") or identity.get("claims_digest"),
+                            authorization_scope=dict(attributes.get("authorization_scope", identity.get("authorization_scope", {}))),
+                            status="disabled" if event_type in {"LEAVER", "DISABLE"} else "active",
+                            token_version=int(identity.get("token_version", 0)) + 1,
+                            updated_at=_utc(self.clock()).isoformat())
+            # RESTORE deliberately recalculates from current input and never restores old roles.
+            self.repository.put_identity(provider_uid, str(subject), identity, commit=False)
+            if event_type in {"MOVER", "LEAVER", "DISABLE", "RESTORE"} or old_roles != tuple(identity["roles"]):
+                self.repository.revoke_subject_sessions(provider_uid, str(subject),
+                                                        reason=f"directory_{event_type.lower()}", commit=False)
+            return {"user_uid": identity["user_uid"], "status": identity["status"],
+                    "token_version": identity["token_version"], "idempotent": False}
+
+        mutation = mutate_organization if event_type in self.ORGANIZATION_EVENTS else mutate_identity
+        return self.repository.apply_directory_event_atomic(event, mutation)
+
+
+class EmergencyAccess:
+    MAX_DURATION = timedelta(hours=2)
+    def __init__(self, repository: Any, *, clock: Callable[[], datetime] | None = None) -> None:
+        self.repository = repository
+        self.clock = clock or (lambda: datetime.now(UTC))
+
+    def request(self, *, requester_uid: str, account_uid: str, reason: str, expires_at: datetime,
+                account_is_local_active_admin: bool) -> dict[str, Any]:
+        now = _utc(self.clock())
+        expiry = _utc(expires_at)
+        if (requester_uid == account_uid or not reason.strip() or not now < expiry
+                or expiry - now > self.MAX_DURATION or not account_is_local_active_admin):
+            raise IdentityPolicyError("invalid emergency access request")
+        record = {"uid": new_governance_uid(), "requester_uid": requester_uid, "account_uid": account_uid,
+                  "reason": reason.strip(), "status": "pending", "approver_uids": [],
+                  "requested_at": now, "expires_at": expiry, "reviewed_at": None}
+        self.repository.put_emergency(record)
+        return record
+
+    def approve(self, request_uid: str, *, approver_uid: str) -> dict[str, Any]:
+        record = self.repository.get_emergency(request_uid)
+        if not record or record["status"] != "pending" or _utc(self.clock()) >= record["expires_at"]:
+            raise IdentityPolicyError("emergency request is not approvable")
+        forbidden = {record["requester_uid"], record["account_uid"], *record["approver_uids"]}
+        if approver_uid in forbidden:
+            raise IdentityPolicyError("emergency access requires two distinct non-self approvers")
+        record["approver_uids"].append(approver_uid)
+        self.repository.put_emergency(record)
+        return record
+
+    def activate(self, request_uid: str) -> dict[str, Any]:
+        record = self.repository.get_emergency(request_uid)
+        if not record or record["status"] != "pending" or len(set(record["approver_uids"])) != 2 or _utc(self.clock()) >= record["expires_at"]:
+            raise IdentityPolicyError("two approvals in the active time window are required")
+        record.update(status="active", activated_at=_utc(self.clock()), alert_required=True)
+        self.repository.put_emergency(record)
+        return record
+
+    def review(self, request_uid: str, *, reviewer_uid: str, outcome: str) -> dict[str, Any]:
+        record = self.repository.get_emergency(request_uid)
+        if record and record["status"] == "active" and _utc(self.clock()) >= record["expires_at"]:
+            record["status"] = "expired"
+            self.repository.put_emergency(record)
+        if not record or record["status"] not in {"closed", "expired"} or reviewer_uid == record["account_uid"]:
+            raise IdentityPolicyError("invalid emergency review")
+        record.update(status="reviewed", reviewed_at=_utc(self.clock()), reviewer_uid=reviewer_uid, review_outcome=outcome)
+        self.repository.put_emergency(record)
+        return record
+
+    def close(self, request_uid: str, *, actor_uid: str) -> dict[str, Any]:
+        record = self.repository.get_emergency(request_uid)
+        if record and record["status"] == "active" and _utc(self.clock()) >= record["expires_at"]:
+            record["status"] = "expired"
+            self.repository.put_emergency(record)
+            raise IdentityPolicyError("emergency access already expired")
+        if not record or record["status"] != "active" or actor_uid == record["account_uid"]:
+            raise IdentityPolicyError("invalid emergency access closure")
+        record.update(status="closed", closed_at=_utc(self.clock()), closed_by_uid=actor_uid)
+        self.repository.put_emergency(record)
+        return record

+ 570 - 0
app/core/system/identity_repository.py

@@ -0,0 +1,570 @@
+"""Repository contracts and a deterministic test adapter for enterprise identity."""
+
+from __future__ import annotations
+
+import copy
+import json
+import secrets
+import threading
+from collections import defaultdict
+from collections.abc import Callable
+from typing import Any
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.system.enterprise_identity import IdentityPolicyError
+
+
+class MemoryIdentityRepository:
+    """Small adapter used by protocol tests; production persistence is PostgreSQL."""
+
+    def __init__(self) -> None:
+        self.flows: dict[str, dict[str, Any]] = {}
+        self.sessions: dict[str, dict[str, Any]] = {}
+        self.directory_events: dict[tuple[str, str, str], dict[str, Any]] = {}
+        self.directory_checkpoints: dict[tuple[str, str], dict[str, Any]] = {}
+        self.directory_conflicts: list[dict[str, Any]] = []
+        self.identities: dict[tuple[str, str], dict[str, Any]] = {}
+        self.organization_nodes: dict[tuple[str, str, str], dict[str, Any]] = {}
+        self.emergency_requests: dict[str, dict[str, Any]] = {}
+        self.audits: list[dict[str, Any]] = []
+        self.refresh_index: dict[str, str] = {}
+        self.family_members: dict[str, set[str]] = defaultdict(set)
+        self._session_lock = threading.RLock()
+        self._directory_lock = threading.RLock()
+
+    def put_flow(self, flow: dict[str, Any]) -> None:
+        self.flows[flow["state_hash"]] = copy.deepcopy(flow)
+
+    def consume_flow(self, state_hash: str, *, commit: bool = True) -> dict[str, Any] | None:
+        del commit
+        flow = self.flows.get(state_hash)
+        if not flow or flow.get("consumed_at"):
+            return None
+        flow["consumed_at"] = True
+        return copy.deepcopy(flow)
+
+    def put_session(self, session: dict[str, Any]) -> None:
+        self.sessions[session["uid"]] = copy.deepcopy(session)
+        self.refresh_index[session["refresh_hash"]] = session["uid"]
+        self.family_members[session["family_uid"]].add(session["uid"])
+
+    def create_session(self, session: dict[str, Any], *, max_sessions: int, commit: bool = True) -> None:
+        del commit
+        with self._session_lock:
+            self.put_session(session)
+            active = sorted((x for x in self.sessions.values()
+                             if x["user_uid"] == session["user_uid"] and x["status"] == "active"),
+                            key=lambda x: (x["created_at"], x["uid"]), reverse=True)
+            for old in active[max_sessions:]:
+                self.update_session(old["uid"], status="revoked", revoke_reason="concurrency_limit")
+
+    def rotate_refresh(self, old_hash: str, new_session: dict[str, Any]) -> str:
+        old = self.get_session_by_refresh(old_hash)
+        if not old:
+            return "unknown"
+        if old["status"] != "active":
+            self.revoke_family(old["family_uid"], reason="refresh_reuse")
+            return "reuse"
+        self.update_session(old["uid"], status="rotated", rotated_at=new_session["created_at"])
+        self.put_session(new_session)
+        return "rotated"
+
+    def get_session(self, uid: str) -> dict[str, Any] | None:
+        value = self.sessions.get(uid)
+        return copy.deepcopy(value) if value else None
+
+    def get_session_by_refresh(self, refresh_hash: str) -> dict[str, Any] | None:
+        uid = self.refresh_index.get(refresh_hash)
+        return self.get_session(uid) if uid else None
+
+    def update_session(self, uid: str, **changes: Any) -> dict[str, Any]:
+        self.sessions[uid].update(copy.deepcopy(changes))
+        return self.get_session(uid) or {}
+
+    def list_sessions(self, provider_uid: str | None = None, subject: str | None = None) -> list[dict[str, Any]]:
+        rows = self.sessions.values()
+        if provider_uid is not None:
+            rows = (item for item in rows if item.get("provider_uid") == provider_uid)
+        if subject is not None:
+            rows = (item for item in rows if item["subject"] == subject)
+        return [copy.deepcopy(item) for item in rows]
+
+    def revoke_family(self, family_uid: str, *, reason: str) -> None:
+        for uid in self.family_members.get(family_uid, ()):
+            self.sessions[uid].update(status="revoked", revoke_reason=reason)
+
+    def revoke_subject_sessions(self, provider_uid: str | None, subject: str, *, reason: str,
+                                commit: bool = True) -> None:
+        del commit
+        for record in self.list_sessions(provider_uid, subject):
+            if record["status"] == "active":
+                self.update_session(record["uid"], status="revoked", revoke_reason=reason)
+
+    def get_directory_event(self, provider_uid: str, source: str, source_event_id: str) -> dict[str, Any] | None:
+        value = self.directory_events.get((provider_uid, source, source_event_id))
+        return copy.deepcopy(value) if value else None
+
+    def put_directory_event(self, event: dict[str, Any]) -> None:
+        self.directory_events[(event["provider_uid"], event["source"], event["source_event_id"])] = copy.deepcopy(event)
+        self.directory_checkpoints[(event["provider_uid"], event["source"])] = {
+            "cursor": event["cursor"], "cursor_sequence": event["cursor_sequence"]}
+
+    def get_directory_checkpoint(self, provider_uid: str, source: str) -> dict[str, Any] | None:
+        value = self.directory_checkpoints.get((provider_uid, source))
+        return copy.deepcopy(value) if value else None
+
+    def record_directory_conflict(self, **record: Any) -> None:
+        self.directory_conflicts.append(copy.deepcopy(record))
+
+    def put_identity(self, provider_uid: str, subject: str, identity: dict[str, Any], *, commit: bool = True) -> None:
+        del commit
+        self.identities[(provider_uid, subject)] = copy.deepcopy(identity)
+
+    def get_identity(self, provider_uid: str, subject: str, *, for_update: bool = False) -> dict[str, Any] | None:
+        del for_update
+        value = self.identities.get((provider_uid, subject))
+        return copy.deepcopy(value) if value else None
+
+    def get_organization_node(self, provider_uid: str, node_type: str, external_id: str,
+                              *, for_update: bool = False) -> dict[str, Any] | None:
+        del for_update
+        value = self.organization_nodes.get((provider_uid, node_type, external_id))
+        return copy.deepcopy(value) if value else None
+
+    def put_organization_node(self, node: dict[str, Any], *, commit: bool = True) -> None:
+        del commit
+        key = (node["provider_uid"], node["node_type"], node["external_id"])
+        self.organization_nodes[key] = copy.deepcopy(node)
+
+    def apply_directory_event_atomic(self, event: dict[str, Any],
+                                     mutation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
+        """Apply the lifecycle mutation and checkpoint as one in-memory unit of work."""
+        with self._directory_lock:
+            previous = self.get_directory_event(event["provider_uid"], event["source"], event["source_event_id"])
+            if previous:
+                if previous["payload_digest"] != event["payload_digest"]:
+                    self.record_directory_conflict(**event, reason="source_event_id_payload_mismatch")
+                    raise IdentityPolicyError("directory event idempotency conflict")
+                return {**previous["result"], "idempotent": True}
+            checkpoint = self.get_directory_checkpoint(event["provider_uid"], event["source"])
+            if checkpoint and event["cursor_sequence"] <= checkpoint["cursor_sequence"]:
+                self.record_directory_conflict(**event, reason="cursor_not_monotonic")
+                raise IdentityPolicyError("directory cursor is not monotonic")
+            snapshot = copy.deepcopy((self.identities, self.organization_nodes, self.sessions,
+                                      self.refresh_index, self.family_members,
+                                      self.directory_events, self.directory_checkpoints))
+            try:
+                result = mutation()
+                self.put_directory_event({**event, "result": result})
+                return result
+            except Exception:
+                (self.identities, self.organization_nodes, self.sessions, self.refresh_index,
+                 self.family_members, self.directory_events, self.directory_checkpoints) = snapshot
+                raise
+
+    def put_emergency(self, request: dict[str, Any]) -> None:
+        self.emergency_requests[request["uid"]] = copy.deepcopy(request)
+        if request["status"] in {"closed", "expired", "reviewed"}:
+            for session in self.sessions.values():
+                if session.get("emergency_request_uid") == request["uid"] and session["status"] == "active":
+                    session.update(status="revoked", revoke_reason=f"emergency_{request['status']}")
+
+    def get_emergency(self, uid: str) -> dict[str, Any] | None:
+        value = self.emergency_requests.get(uid)
+        return copy.deepcopy(value) if value else None
+
+    def audit(self, event: dict[str, Any]) -> None:
+        self.audits.append(copy.deepcopy(event))
+
+    def emergency_session_active(self, request_uid: str, account_uid: str, now: Any) -> bool:
+        request = self.emergency_requests.get(request_uid)
+        return bool(request and request["account_uid"] == account_uid and request["status"] == "active"
+                    and request["requested_at"] <= now < request["expires_at"])
+
+
+class PostgresIdentityRepository:
+    """PostgreSQL adapter for OIDC flows and server-side sessions."""
+
+    def __init__(self, session: Any) -> None:
+        self.session = session
+
+    def put_flow(self, flow: dict[str, Any]) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_oidc_flows
+            (uid,state_hash,provider_uid,provider_version,nonce_hash,pkce_verifier_hash,redirect_uri,status,expires_at)
+            VALUES (CAST(:uid AS uuid),:state_hash,CAST(:provider_uid AS uuid),:provider_version,:nonce_hash,
+                    :verifier_hash,:redirect_uri,'pending',:expires_at)
+        """), {**flow, "uid": new_governance_uid(), "verifier_hash": flow["verifier_hash"]})
+        self.session.commit()
+
+    def consume_flow(self, state_hash: str, *, commit: bool = True) -> dict[str, Any] | None:
+        row = self.session.execute(text("""
+            UPDATE public.identity_oidc_flows SET status='consumed', consumed_at=CURRENT_TIMESTAMP
+            WHERE state_hash=:state_hash AND status='pending' AND expires_at>CURRENT_TIMESTAMP
+            RETURNING provider_uid::text,provider_version,nonce_hash,pkce_verifier_hash,redirect_uri,expires_at
+        """), {"state_hash": state_hash}).mappings().one_or_none()
+        if commit:
+            self.session.commit()
+        return ({"provider_uid": row["provider_uid"], "provider_version": row["provider_version"],
+                 "nonce_hash": row["nonce_hash"], "verifier_hash": row["pkce_verifier_hash"],
+                 "redirect_uri": row["redirect_uri"], "expires_at": row["expires_at"]} if row else None)
+
+    def _insert_session(self, record: dict[str, Any]) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_sessions
+            (uid,family_uid,identity_link_uid,user_uid,identity_source,token_version,refresh_hash,status,
+             rotated_from_uid,emergency_request_uid,created_at,last_seen_at,expires_at)
+            VALUES (CAST(:uid AS uuid),CAST(:family_uid AS uuid),
+                    COALESCE(CAST(:identity_link_uid AS uuid),
+                    (SELECT uid FROM public.enterprise_identity_links WHERE provider_uid=CAST(:provider_uid AS uuid)
+                     AND enterprise_subject=:subject ORDER BY updated_at DESC LIMIT 1)),
+                    CAST(:user_uid AS uuid),:identity_source,:token_version,
+                    :refresh_hash,:status,CAST(:rotated_from_uid AS uuid),CAST(:emergency_request_uid AS uuid),
+                    :created_at,:last_seen_at,:expires_at)
+        """), {**record, "identity_link_uid": record.get("identity_link_uid"),
+                 "emergency_request_uid": record.get("emergency_request_uid")})
+
+    def put_session(self, record: dict[str, Any]) -> None:
+        self._insert_session(record)
+        self.session.commit()
+
+    def create_session(self, record: dict[str, Any], *, max_sessions: int, commit: bool = True) -> None:
+        # Serialize all session creations for one account. Without this lock two
+        # concurrent transactions can both observe the same pre-insert count.
+        locked = self.session.execute(
+            text("SELECT id FROM public.users WHERE id=CAST(:user AS uuid) FOR UPDATE"),
+            {"user": record["user_uid"]},
+        ).scalar_one_or_none()
+        if not locked:
+            raise IdentityPolicyError("identity session user does not exist")
+        self._insert_session(record)
+        self.session.execute(text("""
+            WITH excess AS (
+              SELECT uid FROM public.identity_sessions WHERE user_uid=CAST(:user AS uuid) AND status='active'
+              ORDER BY created_at DESC,uid DESC OFFSET :maximum
+            ) UPDATE public.identity_sessions s SET status='revoked',revoke_reason='concurrency_limit',
+              revoked_at=CURRENT_TIMESTAMP FROM excess WHERE s.uid=excess.uid
+        """), {"user": record["user_uid"], "maximum": max_sessions})
+        if commit:
+            self.session.commit()
+
+    def insert_exchange_code(self, record: dict[str, Any], *, commit: bool = True) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_exchange_codes(uid,code_hash,session_uid,redirect_uri,status,expires_at)
+            VALUES(CAST(:uid AS uuid),:code_hash,CAST(:session_uid AS uuid),:redirect_uri,'pending',:expires_at)
+        """), record)
+        if commit:
+            self.session.commit()
+
+    def rotate_refresh(self, old_hash: str, new_session: dict[str, Any]) -> str:
+        row = self.session.execute(text("""
+            UPDATE public.identity_sessions SET status='rotated',rotated_at=:rotated_at
+            WHERE refresh_hash=:refresh_hash AND status='active' AND expires_at>:rotated_at
+            RETURNING family_uid::text
+        """), {"refresh_hash": old_hash, "rotated_at": new_session["created_at"]}).mappings().one_or_none()
+        if row:
+            self._insert_session(new_session)
+            self.session.commit()
+            return "rotated"
+        old = self.session.execute(text("SELECT family_uid::text,status FROM public.identity_sessions WHERE refresh_hash=:hash FOR UPDATE"),
+                                   {"hash": old_hash}).mappings().one_or_none()
+        if not old:
+            self.session.rollback()
+            return "unknown"
+        self.session.execute(text("UPDATE public.identity_sessions SET status='revoked',revoke_reason='refresh_reuse',revoked_at=CURRENT_TIMESTAMP WHERE family_uid=CAST(:family AS uuid)"),
+                             {"family": old["family_uid"]})
+        self.session.commit()
+        return "reuse"
+
+    @staticmethod
+    def _session(row: Any) -> dict[str, Any] | None:
+        if not row:
+            return None
+        result = dict(row)
+        for key in ("uid", "family_uid", "identity_link_uid", "user_uid", "rotated_from_uid", "emergency_request_uid"):
+            result[key] = str(result[key]) if result.get(key) else None
+        result.setdefault("roles", [])
+        result.setdefault("subject", result["user_uid"])
+        return result
+
+    def get_session(self, uid: str) -> dict[str, Any] | None:
+        row = self.session.execute(text("""
+            SELECT s.*,l.provider_uid::text provider_uid,COALESCE(l.enterprise_subject,s.user_uid::text) subject,
+              COALESCE(array_agg(r.name) FILTER (WHERE r.name IS NOT NULL),ARRAY[]::varchar[]) roles
+            FROM public.identity_sessions s LEFT JOIN public.user_roles ur ON ur.user_id=s.user_uid
+            LEFT JOIN public.roles r ON r.id=ur.role_id
+            LEFT JOIN public.enterprise_identity_links l ON l.uid=s.identity_link_uid
+            WHERE s.uid=CAST(:uid AS uuid) GROUP BY s.uid,l.provider_uid,l.enterprise_subject
+        """), {"uid": uid}).mappings().one_or_none()
+        return self._session(row)
+
+    def get_session_by_refresh(self, refresh_hash: str) -> dict[str, Any] | None:
+        uid = self.session.execute(text("SELECT uid::text FROM public.identity_sessions WHERE refresh_hash=:value"),
+                                   {"value": refresh_hash}).scalar_one_or_none()
+        return self.get_session(uid) if uid else None
+
+    def update_session(self, uid: str, *, commit: bool = True, **changes: Any) -> dict[str, Any]:
+        allowed = {"status", "revoke_reason", "risk_reason", "last_seen_at", "rotated_at", "revoked_at"}
+        safe = {key: value for key, value in changes.items() if key in allowed}
+        if safe:
+            assignments = ",".join(f"{key}=:{key}" for key in safe)
+            self.session.execute(text(f"UPDATE public.identity_sessions SET {assignments} WHERE uid=CAST(:uid AS uuid)"), {**safe, "uid": uid})
+            if commit:
+                self.session.commit()
+        return self.get_session(uid) or {}
+
+    def list_sessions(self, provider_uid: str | None = None, subject: str | None = None) -> list[dict[str, Any]]:
+        sql = "SELECT s.uid::text FROM public.identity_sessions s LEFT JOIN public.enterprise_identity_links l ON l.uid=s.identity_link_uid"
+        params: dict[str, Any] = {}
+        clauses: list[str] = []
+        if provider_uid:
+            clauses.append("l.provider_uid=CAST(:provider AS uuid)")
+            params["provider"] = provider_uid
+        if subject:
+            clauses.append("l.enterprise_subject=:subject")
+            params["subject"] = subject
+        if clauses:
+            sql += " WHERE " + " AND ".join(clauses)
+        uids = self.session.execute(text(sql), params).scalars().all()
+        return [record for uid in uids if (record := self.get_session(uid))]
+
+    def revoke_family(self, family_uid: str, *, reason: str) -> None:
+        self.session.execute(text("UPDATE public.identity_sessions SET status='revoked',revoke_reason=:reason,revoked_at=CURRENT_TIMESTAMP WHERE family_uid=CAST(:family AS uuid)"), {"family": family_uid, "reason": reason})
+        self.session.commit()
+
+    def revoke_subject_sessions(self, provider_uid: str | None, subject: str, *, reason: str,
+                                commit: bool = True) -> None:
+        if not provider_uid:
+            return
+        self.session.execute(text("""
+            UPDATE public.identity_sessions SET status='revoked',revoke_reason=:reason,revoked_at=CURRENT_TIMESTAMP
+            WHERE status='active' AND identity_link_uid IN (
+              SELECT uid FROM public.enterprise_identity_links
+              WHERE provider_uid=CAST(:provider AS uuid) AND enterprise_subject=:subject
+            )
+        """), {"provider": provider_uid, "subject": subject, "reason": reason})
+        if commit:
+            self.session.commit()
+
+    def get_directory_event(self, provider_uid: str, source: str, source_event_id: str) -> dict[str, Any] | None:
+        row = self.session.execute(text("SELECT payload_digest,safe_result FROM public.identity_directory_events WHERE provider_uid=CAST(:provider AS uuid) AND source=:source AND source_event_id=:event"),
+                                   {"provider": provider_uid, "source": source, "event": source_event_id}).mappings().one_or_none()
+        return {"payload_digest": row["payload_digest"], "result": dict(row["safe_result"])} if row else None
+
+    def put_directory_event(self, event: dict[str, Any]) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_directory_events
+            (uid,provider_uid,source,source_event_id,cursor_value,cursor_sequence,event_type,enterprise_subject,payload_digest,status,safe_result,processed_at)
+            VALUES (CAST(:uid AS uuid),CAST(:provider_uid AS uuid),:source,:source_event_id,:cursor,:cursor_sequence,
+                    :event_type,:subject,:payload_digest,'applied',CAST(:result AS jsonb),:processed_at)
+        """), {**event, "uid": new_governance_uid(), "event_type": event.get("event_type", "MOVER"),
+                 "subject": event.get("subject"), "result": json.dumps(event["result"])})
+        self.session.execute(text("""
+            INSERT INTO public.identity_directory_checkpoints(provider_uid,source,cursor_value,cursor_sequence,updated_at)
+            VALUES(CAST(:provider_uid AS uuid),:source,:cursor,:cursor_sequence,CURRENT_TIMESTAMP)
+            ON CONFLICT(provider_uid,source) DO UPDATE SET cursor_value=EXCLUDED.cursor_value,
+              cursor_sequence=EXCLUDED.cursor_sequence,updated_at=CURRENT_TIMESTAMP
+        """), event)
+        self.session.commit()
+
+    def get_directory_checkpoint(self, provider_uid: str, source: str) -> dict[str, Any] | None:
+        row = self.session.execute(text("SELECT cursor_value cursor,cursor_sequence FROM public.identity_directory_checkpoints WHERE provider_uid=CAST(:provider AS uuid) AND source=:source"),
+                                   {"provider": provider_uid, "source": source}).mappings().one_or_none()
+        return dict(row) if row else None
+
+    def record_directory_conflict(self, *, commit: bool = True, **record: Any) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_directory_conflicts
+            (uid,provider_uid,source,source_event_id,cursor_value,cursor_sequence,payload_digest,reason)
+            VALUES(CAST(:uid AS uuid),CAST(:provider_uid AS uuid),:source,:source_event_id,:cursor,:cursor_sequence,:payload_digest,:reason)
+        """), {**record, "uid": new_governance_uid()})
+        if commit:
+            self.session.commit()
+
+    def get_identity(self, provider_uid: str, subject: str, *, for_update: bool = False) -> dict[str, Any] | None:
+        lock = " FOR UPDATE" if for_update else ""
+        row = self.session.execute(text("""
+            SELECT user_uid::text,username,display_name,department_external_id department,groups,
+                   role_names roles,authorization_scope,mapping_version,claims_digest,status,token_version
+            FROM public.enterprise_identity_links WHERE provider_uid=CAST(:provider AS uuid) AND enterprise_subject=:subject
+        """ + lock), {"provider": provider_uid, "subject": subject}).mappings().one_or_none()
+        return dict(row) if row else None
+
+    def put_identity(self, provider_uid: str, subject: str, identity: dict[str, Any], *, commit: bool = True) -> None:
+        existing = self.get_identity(provider_uid, subject, for_update=not commit)
+        if not existing:
+            if not all(identity.get(key) for key in ("provider_uid", "username", "display_name", "mapping_version", "claims_digest")):
+                raise IdentityPolicyError("JOINER requires an approved claims mapping result")
+            from app.core.system.auth import hash_password
+
+            self.session.execute(text("INSERT INTO public.users(id,username,display_name,password_hash,status) VALUES (CAST(:uid AS uuid),:username,:display,:password,'active')"),
+                                 {"uid": identity["user_uid"], "username": identity["username"],
+                                  "display": identity["display_name"], "password": hash_password(secrets.token_urlsafe(48))})
+            self.session.execute(text("""
+                INSERT INTO public.enterprise_identity_links
+                (uid,provider_uid,enterprise_subject,user_uid,username,display_name,department_external_id,groups,role_names,
+                 authorization_scope,mapping_version,claims_digest,status,token_version)
+                VALUES (CAST(:uid AS uuid),CAST(:provider AS uuid),:subject,CAST(:user AS uuid),:username,:display,
+                 COALESCE(:department,''),CAST(:groups AS jsonb),CAST(:roles AS jsonb),CAST(:scope AS jsonb),:mapping,
+                 :digest,:status,:token_version)
+            """), {"uid": new_governance_uid(), "provider": identity["provider_uid"], "subject": subject,
+                    "user": identity["user_uid"], "username": identity["username"], "display": identity["display_name"],
+                    "department": identity.get("department"), "groups": json.dumps(identity.get("groups", [])),
+                    "roles": json.dumps(identity.get("roles", [])), "scope": json.dumps(identity.get("authorization_scope", {})),
+                    "mapping": identity["mapping_version"], "digest": identity["claims_digest"],
+                    "status": identity["status"], "token_version": identity["token_version"]})
+        else:
+            self.session.execute(text("""
+            UPDATE public.enterprise_identity_links SET username=:username,department_external_id=COALESCE(:department,''),
+                display_name=COALESCE(:display_name,display_name),groups=CAST(:groups AS jsonb),role_names=CAST(:roles AS jsonb),
+                authorization_scope=CAST(:scope AS jsonb),mapping_version=COALESCE(:mapping_version,mapping_version),
+                claims_digest=COALESCE(:claims_digest,claims_digest),status=:status,token_version=:token_version,
+                updated_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:provider AS uuid) AND enterprise_subject=:subject
+            """), {"provider": provider_uid, "subject": subject, "username": identity["username"], "department": identity.get("department"),
+                     "display_name": identity.get("display_name"), "groups": json.dumps(identity.get("groups", [])),
+                     "roles": json.dumps(identity.get("roles", [])), "scope": json.dumps(identity.get("authorization_scope", {})),
+                     "mapping_version": identity.get("mapping_version"), "claims_digest": identity.get("claims_digest"),
+                     "status": identity["status"], "token_version": identity["token_version"]})
+        self.session.execute(text("DELETE FROM public.user_roles WHERE user_id=CAST(:user AS uuid)"), {"user": identity["user_uid"]})
+        self.session.execute(text("INSERT INTO public.user_roles(user_id,role_id) SELECT CAST(:user AS uuid),id FROM public.roles WHERE name=ANY(:roles)"),
+                             {"user": identity["user_uid"], "roles": identity.get("roles", [])})
+        self.session.execute(
+            text("UPDATE public.users SET status=:status,updated_at=CURRENT_TIMESTAMP WHERE id=CAST(:user AS uuid)"),
+            {"user": identity["user_uid"], "status": "active" if identity["status"] == "active" else "disabled"},
+        )
+        if commit:
+            self.session.commit()
+
+    def get_organization_node(self, provider_uid: str, node_type: str, external_id: str,
+                              *, for_update: bool = False) -> dict[str, Any] | None:
+        lock = " FOR UPDATE" if for_update else ""
+        row = self.session.execute(text("""
+            SELECT uid::text,provider_uid::text,external_id,node_type,parent_external_id,display_name,status,attributes,updated_at
+            FROM public.identity_organization_nodes
+            WHERE provider_uid=CAST(:provider AS uuid) AND node_type=:node_type AND external_id=:external_id
+        """ + lock), {"provider": provider_uid, "node_type": node_type,
+                       "external_id": external_id}).mappings().one_or_none()
+        return dict(row) if row else None
+
+    def put_organization_node(self, node: dict[str, Any], *, commit: bool = True) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_organization_nodes
+            (uid,provider_uid,external_id,node_type,parent_external_id,display_name,status,attributes,updated_at)
+            VALUES(CAST(:uid AS uuid),CAST(:provider_uid AS uuid),:external_id,:node_type,:parent_external_id,
+                   :display_name,:status,CAST(:attributes AS jsonb),:updated_at)
+            ON CONFLICT(provider_uid,node_type,external_id) DO UPDATE SET
+              parent_external_id=EXCLUDED.parent_external_id,display_name=EXCLUDED.display_name,
+              status=EXCLUDED.status,attributes=EXCLUDED.attributes,updated_at=EXCLUDED.updated_at
+        """), {**node, "attributes": json.dumps(node.get("attributes", {}))})
+        if commit:
+            self.session.commit()
+
+    def _insert_directory_event(self, event: dict[str, Any], result: dict[str, Any]) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_directory_events
+            (uid,provider_uid,source,source_event_id,cursor_value,cursor_sequence,event_type,enterprise_subject,payload_digest,status,safe_result,processed_at)
+            VALUES (CAST(:uid AS uuid),CAST(:provider_uid AS uuid),:source,:source_event_id,:cursor,:cursor_sequence,
+                    :event_type,:subject,:payload_digest,'applied',CAST(:result AS jsonb),:processed_at)
+        """), {**event, "uid": new_governance_uid(), "subject": event.get("subject"),
+                 "result": json.dumps(result)})
+        self.session.execute(text("""
+            INSERT INTO public.identity_directory_checkpoints(provider_uid,source,cursor_value,cursor_sequence,updated_at)
+            VALUES(CAST(:provider_uid AS uuid),:source,:cursor,:cursor_sequence,CURRENT_TIMESTAMP)
+            ON CONFLICT(provider_uid,source) DO UPDATE SET cursor_value=EXCLUDED.cursor_value,
+              cursor_sequence=EXCLUDED.cursor_sequence,updated_at=CURRENT_TIMESTAMP
+        """), event)
+
+    def apply_directory_event_atomic(self, event: dict[str, Any],
+                                     mutation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
+        """Serialize one provider/source feed and commit its complete effect once."""
+        try:
+            scope = f"{event['provider_uid']}:{event['source']}"
+            self.session.execute(text("SELECT pg_advisory_xact_lock(hashtextextended(:scope,0))"), {"scope": scope})
+            previous = self.get_directory_event(event["provider_uid"], event["source"], event["source_event_id"])
+            if previous:
+                if previous["payload_digest"] != event["payload_digest"]:
+                    self.record_directory_conflict(commit=False, **event, reason="source_event_id_payload_mismatch")
+                    self.session.commit()
+                    raise IdentityPolicyError("directory event idempotency conflict")
+                self.session.commit()
+                return {**previous["result"], "idempotent": True}
+            checkpoint = self.get_directory_checkpoint(event["provider_uid"], event["source"])
+            if checkpoint and event["cursor_sequence"] <= checkpoint["cursor_sequence"]:
+                self.record_directory_conflict(commit=False, **event, reason="cursor_not_monotonic")
+                self.session.commit()
+                raise IdentityPolicyError("directory cursor is not monotonic")
+            result = mutation()
+            self._insert_directory_event(event, result)
+            self.session.commit()
+            return result
+        except IdentityPolicyError:
+            # Policy conflicts are committed above so operators retain evidence;
+            # mutation failures are rolled back with every partial lifecycle write.
+            if self.session.in_transaction():
+                self.session.rollback()
+            raise
+        except Exception:
+            self.session.rollback()
+            raise
+
+    def put_emergency(self, record: dict[str, Any]) -> None:
+        existing = self.get_emergency(record["uid"])
+        if not existing:
+            self.session.execute(text("""
+                INSERT INTO public.identity_emergency_requests
+                (uid,requester_uid,emergency_account_uid,reason,status,starts_at,expires_at)
+                VALUES (CAST(:uid AS uuid),CAST(:requester_uid AS uuid),CAST(:account_uid AS uuid),:reason,:status,:starts_at,:expires_at)
+            """), {**record, "starts_at": record["requested_at"]})
+        else:
+            self.session.execute(text("UPDATE public.identity_emergency_requests SET status=:status,activated_at=:activated_at,closed_at=:closed_at,reviewer_uid=CAST(:reviewer_uid AS uuid),review_outcome=:review_outcome WHERE uid=CAST(:uid AS uuid)"),
+                                 {"uid": record["uid"], "status": record["status"], "activated_at": record.get("activated_at"),
+                                  "closed_at": record.get("closed_at"), "reviewer_uid": record.get("reviewer_uid"),
+                                  "review_outcome": record.get("review_outcome")})
+            for approver in set(record.get("approver_uids", [])) - set(existing.get("approver_uids", [])):
+                self.session.execute(text("INSERT INTO public.identity_emergency_approvals(request_uid,approver_uid) VALUES (CAST(:request AS uuid),CAST(:approver AS uuid)) ON CONFLICT DO NOTHING"), {"request": record["uid"], "approver": approver})
+            if record["status"] in {"closed", "expired", "reviewed"}:
+                self.session.execute(text("""
+                    UPDATE public.identity_sessions SET status='revoked',revoke_reason=:reason,revoked_at=CURRENT_TIMESTAMP
+                    WHERE emergency_request_uid=CAST(:request AS uuid) AND status='active'
+                """), {"request": record["uid"], "reason": f"emergency_{record['status']}"})
+        self.session.commit()
+
+    def get_emergency(self, uid: str) -> dict[str, Any] | None:
+        row = self.session.execute(text("""
+            SELECT r.uid::text,r.requester_uid::text,r.emergency_account_uid::text account_uid,r.reason,r.status,
+                   r.starts_at requested_at,r.expires_at,r.activated_at,r.reviewer_uid::text,r.review_outcome,
+                   COALESCE(array_agg(a.approver_uid::text) FILTER (WHERE a.approver_uid IS NOT NULL),ARRAY[]::text[]) approver_uids
+            FROM public.identity_emergency_requests r LEFT JOIN public.identity_emergency_approvals a ON a.request_uid=r.uid
+            WHERE r.uid=CAST(:uid AS uuid) GROUP BY r.uid
+        """), {"uid": uid}).mappings().one_or_none()
+        return dict(row) if row else None
+
+    def audit(self, event: dict[str, Any]) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_audit_events
+            (uid,event_type,outcome,actor_uid,provider_uid,enterprise_subject_digest,resource_type,resource_uid,correlation_uid,safe_detail)
+            VALUES (CAST(:uid AS uuid),:event_type,:outcome,CAST(:actor_uid AS uuid),CAST(:provider_uid AS uuid),:subject_digest,
+                    :resource_type,:resource_uid,CAST(:correlation_uid AS uuid),CAST(:safe_detail AS jsonb))
+        """), {"uid": new_governance_uid(), "event_type": event["event_type"], "outcome": event["outcome"],
+                 "actor_uid": event.get("actor_uid"), "provider_uid": event.get("provider_uid"),
+                 "subject_digest": event.get("subject_digest"), "resource_type": event.get("resource_type", "identity"),
+                 "resource_uid": event.get("resource_uid"), "correlation_uid": event.get("correlation_uid"),
+                 "safe_detail": json.dumps(event.get("safe_detail", {}))})
+        self.session.commit()
+
+    def emergency_session_active(self, request_uid: str, account_uid: str, now: Any) -> bool:
+        return bool(self.session.execute(text("""
+            SELECT 1 FROM public.identity_emergency_requests e
+            JOIN public.users u ON u.id=e.emergency_account_uid
+            WHERE e.uid=CAST(:request AS uuid) AND e.emergency_account_uid=CAST(:account AS uuid)
+              AND e.status='active' AND e.starts_at<=:now AND e.expires_at>:now AND u.status='active'
+              AND EXISTS (
+                SELECT 1 FROM public.user_roles ur JOIN public.roles r ON r.id=ur.role_id
+                WHERE ur.user_id=u.id AND r.name='admin'
+              )
+              AND NOT EXISTS (
+                SELECT 1 FROM public.enterprise_identity_links l WHERE l.user_uid=u.id
+              )
+        """), {"request": request_uid, "account": account_uid, "now": now}).scalar())

+ 103 - 0
app/core/system/identity_sessions.py

@@ -0,0 +1,103 @@
+"""Server-side session ledger with opaque rotating refresh credentials."""
+
+from __future__ import annotations
+
+import hashlib
+import secrets
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.system.enterprise_identity import IdentityPolicyError
+from app.core.system.tokens import issue_access_token
+
+
+@dataclass(frozen=True)
+class SessionCredentials:
+    session_uid: str
+    access_token: str
+    refresh_token: str
+
+
+class SessionManager:
+    def __init__(self, repository: Any, *, secret: str, clock: Callable[[], datetime] | None = None,
+                 max_sessions: int = 5, refresh_lifetime: timedelta = timedelta(hours=8)) -> None:
+        self.repository = repository
+        self.secret = secret
+        self.clock = clock or (lambda: datetime.now(UTC))
+        self.max_sessions = max_sessions
+        self.refresh_lifetime = refresh_lifetime
+
+    @staticmethod
+    def _digest(token: str) -> str:
+        return hashlib.sha256(token.encode()).hexdigest()
+
+    def _access(self, record: dict[str, Any]) -> str:
+        return issue_access_token(user_id=record["user_uid"], roles=list(record["roles"]), secret=self.secret,
+                                  now=self.clock(), session_uid=record["uid"], token_version=record["token_version"],
+                                  identity_source=record["identity_source"])
+
+    def create(self, *, subject: str, user_uid: str, roles: list[str], identity_source: str,
+               token_version: int = 1, provider_uid: str | None = None,
+               emergency_request_uid: str | None = None,
+               emergency_expires_at: datetime | None = None, commit: bool = True) -> SessionCredentials:
+        if identity_source not in {"oidc", "local", "emergency"}:
+            raise IdentityPolicyError("unsupported identity source")
+        if identity_source == "oidc" and not provider_uid:
+            raise IdentityPolicyError("OIDC session requires provider binding")
+        if identity_source == "emergency" and (not emergency_request_uid or not emergency_expires_at):
+            raise IdentityPolicyError("emergency session requires request and expiry binding")
+        now = self.clock()
+        if identity_source == "emergency" and not self.repository.emergency_session_active(
+                str(emergency_request_uid), user_uid, now):
+            raise IdentityPolicyError("emergency account is no longer an active local administrator")
+        refresh = secrets.token_urlsafe(48)
+        record = {"uid": new_governance_uid(), "family_uid": new_governance_uid(), "subject": subject,
+                  "user_uid": user_uid, "roles": sorted(set(roles)), "identity_source": identity_source,
+                  "provider_uid": provider_uid, "emergency_request_uid": emergency_request_uid,
+                  "token_version": token_version, "refresh_hash": self._digest(refresh), "status": "active",
+                  "created_at": now, "last_seen_at": now,
+                  "expires_at": min(now + self.refresh_lifetime, emergency_expires_at) if emergency_expires_at else now + self.refresh_lifetime,
+                  "rotated_from_uid": None, "risk_reason": None}
+        self.repository.create_session(record, max_sessions=self.max_sessions, commit=commit)
+        return SessionCredentials(record["uid"], self._access(record), refresh)
+
+    def refresh(self, refresh_token: str) -> SessionCredentials:
+        record = self.repository.get_session_by_refresh(self._digest(refresh_token))
+        if not record:
+            raise IdentityPolicyError("unknown refresh credential")
+        if record["identity_source"] == "emergency" and not self.repository.emergency_session_active(
+                record["emergency_request_uid"], record["user_uid"], self.clock()):
+            self.repository.revoke_family(record["family_uid"], reason="emergency_window_closed")
+            raise IdentityPolicyError("emergency access window is not active")
+        if record["status"] != "active":
+            self.repository.revoke_family(record["family_uid"], reason="refresh_reuse")
+            raise IdentityPolicyError("refresh reuse detected")
+        if self.clock() >= record["expires_at"]:
+            self.repository.update_session(record["uid"], status="revoked", revoke_reason="expired")
+            raise IdentityPolicyError("refresh credential expired")
+        refresh = secrets.token_urlsafe(48)
+        next_expiry = self.clock() + self.refresh_lifetime
+        if record["identity_source"] == "emergency":
+            next_expiry = min(record["expires_at"], next_expiry)
+        next_record = {**record, "uid": new_governance_uid(), "refresh_hash": self._digest(refresh),
+                       "status": "active", "created_at": self.clock(), "last_seen_at": self.clock(),
+                       "expires_at": next_expiry, "rotated_from_uid": record["uid"]}
+        outcome = self.repository.rotate_refresh(self._digest(refresh_token), next_record)
+        if outcome == "unknown":
+            raise IdentityPolicyError("unknown refresh credential")
+        if outcome == "reuse":
+            raise IdentityPolicyError("refresh reuse detected")
+        return SessionCredentials(next_record["uid"], self._access(next_record), refresh)
+
+    def revoke(self, session_uid: str, *, reason: str = "logout") -> None:
+        if self.repository.get_session(session_uid):
+            self.repository.update_session(session_uid, status="revoked", revoke_reason=reason)
+
+    def risk_revoke(self, session_uid: str, *, reason: str) -> None:
+        self.repository.update_session(session_uid, status="revoked", revoke_reason="risk", risk_reason=reason)
+
+    def revoke_subject(self, provider_uid: str | None, subject: str, *, reason: str) -> None:
+        self.repository.revoke_subject_sessions(provider_uid, subject, reason=reason)

+ 261 - 0
app/core/system/oidc.py

@@ -0,0 +1,261 @@
+"""OIDC Authorization Code + PKCE protocol helpers with strict token validation."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import ipaddress
+import json
+import secrets
+import socket
+from collections.abc import Callable, Mapping
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from typing import Any
+from urllib.parse import urlencode, urlsplit
+
+import jwt
+import requests
+import urllib3
+
+from app.core.system.enterprise_identity import (
+    IdentityPolicyError,
+    IdentityUpstreamError,
+    IdpConfig,
+)
+
+
+def _digest(value: str) -> str:
+    return hashlib.sha256(value.encode()).hexdigest()
+
+
+@dataclass(frozen=True)
+class AuthorizationStart:
+    url: str
+    state: str
+    nonce: str
+    code_verifier: str
+    code_challenge: str
+    code_challenge_method: str = "S256"
+
+
+class OidcClient:
+    def __init__(self, repository: Any, *, clock: Callable[[], datetime] | None = None,
+                 flow_lifetime: timedelta = timedelta(minutes=5)) -> None:
+        self.repository = repository
+        self.clock = clock or (lambda: datetime.now(UTC))
+        self.flow_lifetime = flow_lifetime
+
+    def begin(self, config: IdpConfig, redirect_uri: str) -> AuthorizationStart:
+        config.validate()
+        if redirect_uri not in config.redirect_uris:
+            raise IdentityPolicyError("redirect URI is not allowlisted")
+        state, nonce, verifier = (secrets.token_urlsafe(32) for _ in range(3))
+        challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
+        self.repository.put_flow({"state_hash": _digest(state), "provider_uid": config.provider_uid,
+                                  "provider_version": config.version, "nonce_hash": _digest(nonce),
+                                  "verifier_hash": _digest(verifier), "redirect_uri": redirect_uri,
+                                  "expires_at": self.clock() + self.flow_lifetime, "consumed_at": None})
+        params = {"response_type": "code", "client_id": config.client_id, "redirect_uri": redirect_uri,
+                  "scope": "openid profile", "state": state, "nonce": nonce,
+                  "code_challenge": challenge, "code_challenge_method": "S256"}
+        return AuthorizationStart(f"{config.authorization_endpoint}?{urlencode(params)}", state, nonce, verifier, challenge)
+
+    def verify_callback(self, config: IdpConfig, *, state: str, redirect_uri: str, code_verifier: str,
+                        id_token: str, jwks: Mapping[str, Any], commit: bool = True) -> dict[str, Any]:
+        flow = self.repository.consume_flow(_digest(state), commit=commit)
+        if not flow:
+            raise IdentityPolicyError("state is invalid or already consumed")
+        if self.clock() >= flow["expires_at"] or flow["provider_uid"] != config.provider_uid or flow["provider_version"] != config.version:
+            raise IdentityPolicyError("state is expired or bound to another provider")
+        if redirect_uri != flow["redirect_uri"] or redirect_uri not in config.redirect_uris or _digest(code_verifier) != flow["verifier_hash"]:
+            raise IdentityPolicyError("callback binding or PKCE verifier is invalid")
+        try:
+            header = jwt.get_unverified_header(id_token)
+            algorithm = header.get("alg")
+            if algorithm not in config.algorithms or algorithm == "none":
+                raise IdentityPolicyError("ID token algorithm is not allowed")
+            candidates = [key for key in jwks.get("keys", ()) if key.get("kid") == header.get("kid") and key.get("alg", algorithm) == algorithm]
+            if len(candidates) != 1:
+                raise IdentityPolicyError("ID token signing key is ambiguous or missing")
+            public_key = jwt.algorithms.get_default_algorithms()[algorithm].from_jwk(candidates[0])
+            claims = jwt.decode(id_token, public_key, algorithms=[algorithm], audience=config.client_id,
+                                issuer=config.issuer, options={"verify_exp": False,
+                                "require": ["iss", "aud", "sub", "nonce", "iat", "exp"]})
+        except IdentityPolicyError:
+            raise
+        except jwt.PyJWTError as exc:
+            raise IdentityPolicyError("ID token validation failed") from exc
+        if _digest(str(claims["nonce"])) != flow["nonce_hash"]:
+            raise IdentityPolicyError("ID token nonce mismatch")
+        now = int(self.clock().timestamp())
+        if int(claims["exp"]) <= now:
+            raise IdentityPolicyError("ID token expired")
+        if int(claims["iat"]) > now + 30:
+            raise IdentityPolicyError("ID token issued in the future")
+        return dict(claims)
+
+
+class OidcTransport:
+    """Bounded HTTPS transport whose production connection is pinned to validated IPs."""
+
+    def __init__(self, http: Any | None = None, *, connect_timeout: float = 3.0,
+                 read_timeout: float = 7.0, max_response_bytes: int = 1024 * 1024,
+                 resolver: Callable[..., Any] = socket.getaddrinfo,
+                 pool_factory: Callable[..., Any] = urllib3.HTTPSConnectionPool,
+                 allowed_hosts: set[str] | None = None) -> None:
+        self.http = http
+        self.connect_timeout = connect_timeout
+        self.read_timeout = read_timeout
+        self.timeout = (connect_timeout, read_timeout)
+        self.max_response_bytes = max_response_bytes
+        self.resolver = resolver
+        self.pool_factory = pool_factory
+        self.allowed_hosts = {host.lower() for host in allowed_hosts} if allowed_hosts else None
+
+    def _validate_destination(self, url: str) -> tuple[Any, tuple[str, ...]]:
+        parsed = urlsplit(url)
+        if parsed.scheme != "https" or not parsed.hostname:
+            raise IdentityPolicyError("OIDC destination must be an absolute HTTPS URL")
+        hostname = parsed.hostname.lower()
+        if self.allowed_hosts is not None and hostname not in self.allowed_hosts:
+            raise IdentityPolicyError("enterprise identity provider host is not server-allowlisted")
+        try:
+            addresses = {
+                result[4][0]
+                for result in self.resolver(hostname, parsed.port or 443, type=socket.SOCK_STREAM)
+            }
+        except (OSError, socket.gaierror) as exc:
+            raise IdentityUpstreamError("enterprise identity provider DNS resolution failed") from exc
+        if not addresses:
+            raise IdentityUpstreamError("enterprise identity provider DNS returned no addresses")
+        try:
+            parsed_addresses = [ipaddress.ip_address(address) for address in addresses]
+        except ValueError as exc:
+            raise IdentityUpstreamError("enterprise identity provider DNS response is invalid") from exc
+        if any(not address.is_global for address in parsed_addresses):
+            raise IdentityPolicyError("enterprise identity provider resolved to a non-public address")
+        return parsed, tuple(sorted(str(address) for address in parsed_addresses))
+
+    def _decode_json_response(self, *, status: int, headers: Mapping[str, Any], chunks: Any) -> Mapping[str, Any]:
+        if 300 <= status < 400:
+            raise IdentityUpstreamError("enterprise identity provider redirect is forbidden")
+        if status < 200 or status >= 300:
+            raise IdentityUpstreamError("enterprise identity provider returned an HTTP error")
+        content_type = str(headers.get("Content-Type", "")).lower()
+        if "application/json" not in content_type:
+            raise IdentityUpstreamError("enterprise identity provider response must be JSON")
+        content_length = headers.get("Content-Length")
+        if content_length:
+            try:
+                parsed_length = int(content_length)
+            except (TypeError, ValueError) as exc:
+                raise IdentityUpstreamError("enterprise identity provider response length is invalid") from exc
+            if parsed_length < 0:
+                raise IdentityUpstreamError("enterprise identity provider response length is invalid")
+            if parsed_length > self.max_response_bytes:
+                raise IdentityUpstreamError("enterprise identity provider response is too large")
+        body = bytearray()
+        try:
+            for chunk in chunks:
+                if chunk:
+                    body.extend(chunk)
+                    if len(body) > self.max_response_bytes:
+                        raise IdentityUpstreamError("enterprise identity provider response is too large")
+        except (requests.RequestException, urllib3.exceptions.HTTPError, OSError) as exc:
+            raise IdentityUpstreamError("enterprise identity provider response read failed") from exc
+        try:
+            payload = json.loads(body.decode("utf-8"))
+        except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+            raise IdentityUpstreamError("enterprise identity provider returned invalid JSON") from exc
+        if not isinstance(payload, Mapping):
+            raise IdentityUpstreamError("enterprise identity provider JSON object is required")
+        return payload
+
+    def _pinned_request_json(self, method: str, parsed: Any, addresses: tuple[str, ...],
+                             data: Mapping[str, Any] | None) -> Mapping[str, Any]:
+        hostname = parsed.hostname.lower()
+        port = parsed.port or 443
+        request_target = parsed.path or "/"
+        if parsed.query:
+            request_target += f"?{parsed.query}"
+        host_header = hostname if port == 443 else f"{hostname}:{port}"
+        body = urlencode(data).encode("utf-8") if data is not None else None
+        headers = {"Host": host_header, "Accept": "application/json"}
+        if body is not None:
+            headers["Content-Type"] = "application/x-www-form-urlencoded"
+        last_error: Exception | None = None
+        for address in addresses:
+            pool = self.pool_factory(
+                host=address,
+                port=port,
+                server_hostname=hostname,
+                assert_hostname=hostname,
+                cert_reqs="CERT_REQUIRED",
+                ca_certs=requests.certs.where(),
+                timeout=urllib3.Timeout(connect=self.connect_timeout, read=self.read_timeout),
+                retries=False,
+                maxsize=1,
+                block=True,
+            )
+            try:
+                response = pool.urlopen(
+                    method.upper(), request_target, body=body, headers=headers,
+                    redirect=False, retries=False, assert_same_host=False,
+                    preload_content=False, decode_content=True,
+                    timeout=urllib3.Timeout(connect=self.connect_timeout, read=self.read_timeout),
+                )
+            except (urllib3.exceptions.HTTPError, OSError) as exc:
+                last_error = exc
+                pool.close()
+                continue
+            try:
+                return self._decode_json_response(
+                    status=int(response.status), headers=response.headers,
+                    chunks=response.stream(amt=64 * 1024, decode_content=True),
+                )
+            finally:
+                response.release_conn()
+                pool.close()
+        raise IdentityUpstreamError("enterprise identity provider connection failed") from last_error
+
+    def _request_json(self, method: str, url: str, **kwargs: Any) -> Mapping[str, Any]:
+        parsed, addresses = self._validate_destination(url)
+        if self.http is None:
+            return self._pinned_request_json(method, parsed, addresses, kwargs.get("data"))
+        try:
+            response = getattr(self.http, method)(url, timeout=self.timeout, allow_redirects=False,
+                                                  stream=True, **kwargs)
+        except requests.RequestException as exc:
+            raise IdentityUpstreamError("enterprise identity provider request failed") from exc
+        try:
+            try:
+                status = int(getattr(response, "status_code", 0))
+            except (TypeError, ValueError) as exc:
+                raise IdentityUpstreamError("enterprise identity provider HTTP status is invalid") from exc
+            return self._decode_json_response(
+                status=status, headers=getattr(response, "headers", {}),
+                chunks=response.iter_content(chunk_size=64 * 1024),
+            )
+        finally:
+            close = getattr(response, "close", None)
+            if callable(close):
+                close()
+
+    def exchange_code(self, config: IdpConfig, *, code: str, redirect_uri: str, verifier: str) -> str:
+        payload = self._request_json(
+            "post", config.token_endpoint,
+            data={"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri,
+                  "client_id": config.client_id, "client_secret": config.resolve_secret(),
+                  "code_verifier": verifier},
+        )
+        id_token = payload.get("id_token")
+        if not isinstance(id_token, str) or not id_token:
+            raise IdentityPolicyError("token endpoint omitted ID token")
+        return id_token
+
+    def fetch_jwks(self, config: IdpConfig) -> Mapping[str, Any]:
+        payload = self._request_json("get", config.jwks_uri)
+        if not isinstance(payload, Mapping) or not isinstance(payload.get("keys"), list):
+            raise IdentityPolicyError("JWKS response is invalid")
+        return payload

+ 31 - 1
app/core/system/permissions.py

@@ -70,6 +70,9 @@ AGENTS_MANAGE = "agents:manage"
 SECURITY_GOVERNANCE_READ = "security-governance:read"
 SECURITY_GOVERNANCE_OPERATE = "security-governance:operate"
 SECURITY_GOVERNANCE_MANAGE = "security-governance:manage"
+IDENTITY_READ = "identity:read"
+IDENTITY_MANAGE = "identity:manage"
+IDENTITY_OPERATE = "identity:operate"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -84,6 +87,7 @@ ROLE_PERMISSIONS = {
             DATA_PRODUCTS_READ,
             AGENTS_READ,
             SECURITY_GOVERNANCE_READ,
+            IDENTITY_READ,
         }
     ),
     "editor": frozenset(
@@ -121,6 +125,8 @@ ROLE_PERMISSIONS = {
             AGENTS_OPERATE,
             SECURITY_GOVERNANCE_READ,
             SECURITY_GOVERNANCE_OPERATE,
+            IDENTITY_READ,
+            IDENTITY_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -188,6 +194,9 @@ ROLE_PERMISSIONS = {
             SECURITY_GOVERNANCE_READ,
             SECURITY_GOVERNANCE_OPERATE,
             SECURITY_GOVERNANCE_MANAGE,
+            IDENTITY_READ,
+            IDENTITY_OPERATE,
+            IDENTITY_MANAGE,
         }
     ),
 }
@@ -198,8 +207,29 @@ PUBLIC = "public"
 def permission_for_request(path: str, method: str) -> tuple[str, ...]:
     """Classify every API request in one auditable, deny-by-default policy."""
     method = method.upper()
-    if path in {"/api/system/health", "/api/system/auth/login"}:
+    public_methods = {
+        ("/api/system/health", "GET"),
+        ("/api/system/auth/login", "POST"),
+        ("/api/system/identity/providers", "GET"),
+        ("/api/system/identity/authorize", "POST"),
+        ("/api/system/identity/callback", "GET"),
+        ("/api/system/identity/exchange", "POST"),
+        ("/api/system/identity/refresh", "POST"),
+    }
+    if (path, method) in public_methods:
         return (PUBLIC,)
+    if path.startswith("/api/system/identity"):
+        if path == "/api/system/identity/logout" and method == "POST":
+            return (IDENTITY_READ,)
+        if path in {"/api/system/identity/idp-versions", "/api/system/identity/audit"} and method == "GET":
+            return (IDENTITY_MANAGE,)
+        if path == "/api/system/identity/sessions" and method == "GET":
+            return (IDENTITY_OPERATE,)
+        if method == "GET":
+            return (IDENTITY_READ,)
+        if any(marker in path for marker in ("/idp-versions", "/emergency/")):
+            return (IDENTITY_MANAGE,)
+        return (IDENTITY_OPERATE,)
     if path.startswith("/api/system/responsibilities/"):
         if method == "GET":
             return (RESPONSIBILITIES_READ,)

+ 14 - 4
app/core/system/tokens.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
 
 import jwt
 
@@ -18,8 +18,11 @@ def issue_access_token(
     secret: str,
     now: datetime | None = None,
     lifetime: timedelta = timedelta(minutes=30),
+    session_uid: str | None = None,
+    token_version: int | None = None,
+    identity_source: str | None = None,
 ) -> str:
-    now = now or datetime.now(timezone.utc)
+    now = now or datetime.now(UTC)
     claims = {
         "sub": str(user_id),
         "roles": sorted(set(roles)),
@@ -27,6 +30,12 @@ def issue_access_token(
         "exp": int((now + lifetime).timestamp()),
         "jti": new_governance_uid(),
     }
+    if session_uid is not None:
+        claims["sid"] = str(session_uid)
+    if token_version is not None:
+        claims["token_version"] = int(token_version)
+    if identity_source is not None:
+        claims["identity_source"] = identity_source
     return jwt.encode(claims, secret, algorithm="HS256")
 
 
@@ -46,11 +55,12 @@ def decode_access_token(
     except jwt.PyJWTError as exc:
         raise TokenError("invalid access token") from exc
 
-    current = int((now or datetime.now(timezone.utc)).timestamp())
+    current = int((now or datetime.now(UTC)).timestamp())
     if int(claims["exp"]) <= current:
         raise TokenError("access token expired")
     if int(claims["iat"]) > current + 30:
         raise TokenError("access token issued in the future")
     if not isinstance(claims.get("roles"), list):
         raise TokenError("invalid role claims")
-    return {key: claims[key] for key in ("sub", "roles", "iat", "exp", "jti")}
+    allowed = ("sub", "roles", "iat", "exp", "jti", "sid", "token_version", "identity_source")
+    return {key: claims[key] for key in allowed if key in claims}

+ 1 - 0
deployment/app/api/system/__init__.py

@@ -5,6 +5,7 @@ from flask import Blueprint
 bp = Blueprint("system", __name__)
 
 from app.api.system import governance_audit  # noqa: E402, F401
+from app.api.system import enterprise_identity  # noqa: E402, F401
 from app.api.system import responsibilities  # noqa: E402, F401
 from app.api.system import routes  # noqa: E402, F401
 from app.api.system import security_governance  # noqa: E402, F401

+ 648 - 0
deployment/app/api/system/enterprise_identity.py

@@ -0,0 +1,648 @@
+"""Enterprise identity APIs. Public routes are explicitly enumerated in policy."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import secrets
+import uuid
+from datetime import UTC, datetime, timedelta
+from functools import wraps
+from urllib.parse import urlsplit
+
+from flask import current_app, g, jsonify, redirect, request
+from itsdangerous import BadSignature, URLSafeTimedSerializer
+from sqlalchemy import text
+from sqlalchemy.exc import IntegrityError, StatementError
+
+from app import db
+from app.api.system import bp
+from app.core.common.identifiers import new_governance_uid
+from app.core.system.enterprise_identity import (
+    ClaimMapper,
+    DirectorySynchronizer,
+    EmergencyAccess,
+    IdentityPolicyError,
+    IdentityUpstreamError,
+    IdpConfig,
+    sanitize_audit_detail,
+)
+from app.core.system.identity_repository import PostgresIdentityRepository
+from app.core.system.identity_sessions import SessionManager
+from app.core.system.oidc import OidcClient, OidcTransport
+from app.core.system.permissions import (
+    IDENTITY_MANAGE,
+    IDENTITY_OPERATE,
+    IDENTITY_READ,
+    require_permissions,
+)
+from app.core.system.tokens import issue_access_token
+from app.models.result import failed, success
+
+
+def _body() -> dict:
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict):
+        raise IdentityPolicyError("JSON object body is required")
+    return value
+
+
+def _required_str(data: dict, key: str, *, maximum: int = 500) -> str:
+    value = data.get(key)
+    if not isinstance(value, str) or not value.strip() or len(value.strip()) > maximum:
+        raise IdentityPolicyError(f"{key} is required or invalid")
+    return value.strip()
+
+
+def _optional_str(data: dict, key: str, default: str, *, maximum: int) -> str:
+    if key not in data:
+        return default
+    return _required_str(data, key, maximum=maximum)
+
+
+def _uuid(value, field: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (ValueError, TypeError, AttributeError) as exc:
+        raise IdentityPolicyError(f"{field} must be a UUID") from exc
+
+
+def _positive_int(value, field: str, *, maximum: int = 2_147_483_647) -> int:
+    if isinstance(value, bool):
+        raise IdentityPolicyError(f"{field} must be a positive integer")
+    try:
+        result = int(value)
+    except (ValueError, TypeError) as exc:
+        raise IdentityPolicyError(f"{field} must be a positive integer") from exc
+    if result < 1 or result > maximum:
+        raise IdentityPolicyError(f"{field} must be a positive integer")
+    return result
+
+
+def _timestamp(value, field: str) -> datetime:
+    if not isinstance(value, str):
+        raise IdentityPolicyError(f"{field} must be an ISO-8601 timestamp")
+    try:
+        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+    except ValueError as exc:
+        raise IdentityPolicyError(f"{field} must be an ISO-8601 timestamp") from exc
+    if parsed.tzinfo is None or parsed.utcoffset() is None:
+        raise IdentityPolicyError(f"{field} must include a timezone")
+    return parsed
+
+
+def _string_array(data: dict, key: str, *, default=None) -> tuple[str, ...]:
+    value = data.get(key, default)
+    if not isinstance(value, list) or not value or any(not isinstance(item, str) or not item.strip() for item in value):
+        raise IdentityPolicyError(f"{key} must be a non-empty string array")
+    return tuple(item.strip() for item in value)
+
+
+def _validate_mapping(version: str, group_rules) -> ClaimMapper:
+    if not isinstance(group_rules, dict):
+        raise IdentityPolicyError("group_rules must be an object")
+    return ClaimMapper(version=version, group_rules=group_rules)
+
+
+def _identity_input_guard(view):
+    @wraps(view)
+    def guarded(*args, **kwargs):
+        try:
+            return view(*args, **kwargs)
+        except IdentityPolicyError:
+            raise
+        except (KeyError, TypeError, ValueError, IntegrityError, StatementError) as exc:
+            db.session.rollback()
+            raise IdentityPolicyError("invalid enterprise identity request") from exc
+
+    return guarded
+
+
+def _repo() -> PostgresIdentityRepository:
+    return PostgresIdentityRepository(db.session)
+
+
+def _config(row) -> IdpConfig:
+    return IdpConfig(provider_uid=str(row["provider_uid"]), version=row["version"], issuer=row["issuer"],
+                     client_id=row["client_id"], secret_ref=row["secret_ref"],
+                     authorization_endpoint=row["authorization_endpoint"], token_endpoint=row["token_endpoint"],
+                     jwks_uri=row["jwks_uri"], redirect_uris=tuple(row["redirect_uris"]),
+                     post_login_redirect_uris=tuple(dict(row["config"]).get("post_login_redirect_uris", ())),
+                     algorithms=tuple(row["algorithms"]), mapping_version=row["mapping_version"], status=row["status"])
+
+
+def _load_config(provider_uid: str, *, version: int | None = None, active: bool = True):
+    clause = "AND status='active'" if active else ""
+    version_clause = "AND version=:version" if version is not None else ""
+    return db.session.execute(text(f"""
+        SELECT provider_uid::text,version,status,issuer,client_id,secret_ref,authorization_endpoint,
+               token_endpoint,jwks_uri,redirect_uris,algorithms,mapping_version,config
+        FROM public.identity_idp_config_versions WHERE provider_uid=CAST(:provider AS uuid) {version_clause} {clause}
+        ORDER BY version DESC LIMIT 1
+    """), {"provider": provider_uid, "version": version}).mappings().one_or_none()
+
+
+def _audit(event_type: str, outcome: str, *, actor_uid: str | None = None,
+           provider_uid: str | None = None, resource_uid: str | None = None,
+           enterprise_subject: str | None = None, detail: dict | None = None) -> None:
+    _repo().audit({"event_type": event_type, "outcome": outcome, "actor_uid": actor_uid,
+                   "provider_uid": provider_uid, "resource_type": "enterprise_identity",
+                   "resource_uid": resource_uid,
+                   "subject_digest": (hashlib.sha256(enterprise_subject.encode()).hexdigest()
+                                      if enterprise_subject else None),
+                   "safe_detail": sanitize_audit_detail(detail or {})})
+
+
+def _cookie_secure() -> bool:
+    return bool(current_app.config.get("IDENTITY_COOKIE_SECURE", True))
+
+
+def _origin(uri: str) -> str:
+    parts = urlsplit(uri)
+    return f"{parts.scheme}://{parts.netloc}"
+
+
+def _browser_origin_allowed(origin: str) -> bool:
+    rows = db.session.execute(text("SELECT config FROM public.identity_idp_config_versions WHERE status='active'")).scalars().all()
+    allowed = {_origin(uri) for config in rows for uri in dict(config).get("post_login_redirect_uris", ())}
+    allowed.update(current_app.config.get("IDENTITY_ALLOWED_TEST_ORIGINS", ()))
+    return origin in allowed
+
+
+@bp.errorhandler(IdentityPolicyError)
+def identity_policy_error(exc):
+    try:
+        db.session.rollback()
+        _audit("identity_policy_denied", "denied", actor_uid=getattr(g, "current_user", {}).get("id"),
+               detail={"path": request.path, "reason": str(exc)[:160]})
+    except Exception:  # pragma: no cover - failure reporting must not expose internals
+        db.session.rollback()
+    return jsonify(failed(str(exc), code=400)), 400
+
+
+@bp.errorhandler(IdentityUpstreamError)
+def identity_upstream_error(exc):
+    db.session.rollback()
+    try:
+        _audit("identity_upstream_failure", "failure", actor_uid=getattr(g, "current_user", {}).get("id"),
+               detail={"path": request.path})
+    except Exception:  # pragma: no cover
+        db.session.rollback()
+    return jsonify(failed("enterprise identity provider is unavailable", code=502)), 502
+
+
+@bp.route("/identity/providers", methods=["GET"])
+def identity_providers():
+    rows = db.session.execute(text("""
+        SELECT provider_uid::text,version,issuer,config->>'display_name' display_name
+        FROM public.identity_idp_config_versions WHERE status='active' ORDER BY provider_uid
+    """)).mappings().all()
+    return jsonify(success([{"provider_uid": row["provider_uid"], "version": row["version"],
+                             "issuer": row["issuer"], "display_name": row["display_name"] or "企业单点登录"} for row in rows]))
+
+
+@bp.route("/identity/authorize", methods=["POST"])
+@_identity_input_guard
+def identity_authorize():
+    data = _body()
+    provider_uid = _uuid(data.get("provider_uid"), "provider_uid")
+    row = _load_config(provider_uid)
+    if not row:
+        raise IdentityPolicyError("active identity provider not found")
+    config = _config(row)
+    callback_uri = _required_str(data, "redirect_uri", maximum=2048)
+    post_login_uri = _required_str(data, "post_login_uri", maximum=2048)
+    if post_login_uri not in config.post_login_redirect_uris:
+        raise IdentityPolicyError("post-login redirect URI is not allowlisted")
+    started = OidcClient(_repo()).begin(config, callback_uri)
+    serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt="oidc-flow")
+    response = jsonify(success({"authorization_url": started.url, "provider_uid": config.provider_uid}))
+    response.set_cookie("dataops_oidc_flow", serializer.dumps({"state": started.state, "verifier": started.code_verifier,
+                                                                 "provider_uid": config.provider_uid,
+                                                                 "redirect_uri": callback_uri,
+                                                                 "post_login_uri": post_login_uri}),
+                        httponly=True, secure=_cookie_secure(), samesite="Lax", max_age=300, path="/api/system/identity/callback")
+    _audit("oidc_authorize", "success", provider_uid=config.provider_uid, detail={"redirect_uri": data["redirect_uri"]})
+    return response
+
+
+@bp.route("/identity/callback", methods=["GET"])
+@_identity_input_guard
+def identity_callback():
+    serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt="oidc-flow")
+    try:
+        flow = serializer.loads(request.cookies.get("dataops_oidc_flow", ""), max_age=300)
+    except BadSignature as exc:
+        raise IdentityPolicyError("OIDC browser flow binding is invalid or expired") from exc
+    if not isinstance(flow, dict) or request.args.get("state") != flow.get("state"):
+        raise IdentityPolicyError("OIDC state mismatch")
+    provider_uid = _uuid(flow.get("provider_uid"), "provider_uid")
+    row = _load_config(provider_uid)
+    if not row:
+        raise IdentityPolicyError("active identity provider not found")
+    row = dict(row)
+    config = _config(row)
+    config.validate()
+    callback_uri = _required_str(flow, "redirect_uri", maximum=2048)
+    verifier = _required_str(flow, "verifier", maximum=512)
+    state = _required_str(flow, "state", maximum=512)
+    post_login_uri = _required_str(flow, "post_login_uri", maximum=2048)
+    code = request.args.get("code")
+    if not isinstance(code, str) or not code or len(code) > 4096:
+        raise IdentityPolicyError("OIDC authorization code is required")
+    # Do not keep a database transaction open while calling the external IdP.
+    db.session.rollback()
+    transport = OidcTransport()
+    id_token = transport.exchange_code(config, code=code, redirect_uri=callback_uri, verifier=verifier)
+    jwks = transport.fetch_jwks(config)
+    repo = _repo()
+    try:
+        claims = OidcClient(repo).verify_callback(config, state=state, redirect_uri=callback_uri,
+                                                   code_verifier=verifier, id_token=id_token, jwks=jwks,
+                                                   commit=False)
+        mapped = ClaimMapper(version=config.mapping_version,
+                             group_rules=dict(row["config"]).get("group_rules", {})).map(claims)
+        link = repo.get_identity(config.provider_uid, mapped.subject, for_update=True)
+        if link and link["status"] != "active":
+            raise IdentityPolicyError("enterprise identity is disabled")
+        scope = {"business_domain_uids": mapped.business_domain_uids, "object_types": mapped.object_types,
+                 "environments": mapped.environments, "data_scopes": mapped.data_scopes}
+        changed = bool(link and (sorted(link["roles"]) != sorted(mapped.roles)
+                                 or dict(link["authorization_scope"]) != json.loads(json.dumps(scope))))
+        user_uid = link["user_uid"] if link else new_governance_uid()
+        token_version = (int(link["token_version"]) + 1 if changed else int(link["token_version"])) if link else 1
+        repo.put_identity(config.provider_uid, mapped.subject, {
+            "provider_uid": config.provider_uid, "user_uid": user_uid, "username": mapped.username,
+            "display_name": mapped.display_name, "department": mapped.department, "groups": list(mapped.groups),
+            "roles": list(mapped.roles), "authorization_scope": scope, "mapping_version": mapped.mapping_version,
+            "claims_digest": mapped.claims_digest, "status": "active", "token_version": token_version,
+        }, commit=False)
+        if changed:
+            repo.revoke_subject_sessions(config.provider_uid, mapped.subject,
+                                         reason="claims_mapping_changed", commit=False)
+        credentials = SessionManager(repo, secret=current_app.config["SECRET_KEY"]).create(
+            subject=mapped.subject, user_uid=user_uid, roles=list(mapped.roles), identity_source="oidc",
+            token_version=token_version, provider_uid=config.provider_uid, commit=False)
+        exchange = secrets.token_urlsafe(40)
+        repo.insert_exchange_code({"uid": new_governance_uid(),
+                                   "code_hash": hashlib.sha256(exchange.encode()).hexdigest(),
+                                   "session_uid": credentials.session_uid, "redirect_uri": post_login_uri,
+                                   "expires_at": datetime.now(UTC) + timedelta(minutes=2)}, commit=False)
+        db.session.commit()
+    except IdentityPolicyError:
+        db.session.rollback()
+        raise
+    except Exception as exc:
+        db.session.rollback()
+        raise IdentityPolicyError("OIDC callback persistence failed") from exc
+    _audit("oidc_login", "success", provider_uid=config.provider_uid, resource_uid=user_uid,
+           detail={"mapping_version": mapped.mapping_version, "claims_digest": mapped.claims_digest})
+    response = redirect(f"{flow['post_login_uri']}?exchange_code={exchange}", code=302)
+    response.set_cookie("dataops_refresh", credentials.refresh_token, httponly=True, secure=_cookie_secure(), samesite="Strict",
+                        max_age=8 * 3600, path="/api/system/identity")
+    response.delete_cookie("dataops_oidc_flow", path="/api/system/identity/callback")
+    return response
+
+
+@bp.route("/identity/exchange", methods=["POST"])
+@_identity_input_guard
+def identity_exchange():
+    data = _body()
+    exchange_code = _required_str(data, "exchange_code", maximum=1024)
+    redirect_uri = _required_str(data, "redirect_uri", maximum=2048)
+    digest = hashlib.sha256(exchange_code.encode()).hexdigest()
+    row = db.session.execute(text("""
+        UPDATE public.identity_exchange_codes SET status='consumed',consumed_at=CURRENT_TIMESTAMP
+        WHERE code_hash=:digest AND status='pending' AND expires_at>CURRENT_TIMESTAMP
+          AND redirect_uri=:redirect RETURNING session_uid::text,redirect_uri
+    """), {"digest": digest, "redirect": redirect_uri}).mappings().one_or_none()
+    if not row or request.headers.get("Origin") != _origin(row["redirect_uri"]):
+        db.session.rollback()
+        raise IdentityPolicyError("exchange code or browser origin is invalid")
+    db.session.commit()
+    session = _repo().get_session(row["session_uid"])
+    if not session or session["status"] != "active":
+        raise IdentityPolicyError("identity session is unavailable")
+    token = issue_access_token(user_id=session["user_uid"], roles=list(session["roles"]), secret=current_app.config["SECRET_KEY"],
+                               session_uid=session["uid"], token_version=session["token_version"], identity_source=session["identity_source"])
+    return jsonify(success({"token": token, "identity_source": session["identity_source"]}))
+
+
+@bp.route("/identity/refresh", methods=["POST"])
+def identity_refresh():
+    origin = request.headers.get("Origin", "")
+    if not _browser_origin_allowed(origin):
+        raise IdentityPolicyError("browser origin is not allowlisted")
+    credentials = SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).refresh(request.cookies.get("dataops_refresh", ""))
+    session = _repo().get_session(credentials.session_uid)
+    _audit("identity_refresh", "success", actor_uid=session["user_uid"] if session else None,
+           provider_uid=session.get("provider_uid") if session else None,
+           resource_uid=credentials.session_uid,
+           detail={"rotated_from_uid": session.get("rotated_from_uid") if session else None})
+    response = jsonify(success({"token": credentials.access_token}))
+    response.set_cookie("dataops_refresh", credentials.refresh_token, httponly=True, secure=_cookie_secure(), samesite="Strict",
+                        max_age=8 * 3600, path="/api/system/identity")
+    return response
+
+
+@bp.route("/identity/logout", methods=["POST"])
+@require_permissions(IDENTITY_READ)
+def identity_logout():
+    session_uid = g.current_user.get("sid")
+    session = _repo().get_session(session_uid) if session_uid else None
+    if session_uid:
+        SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).revoke(session_uid)
+    _audit("identity_logout", "success", actor_uid=g.current_user.get("id"),
+           provider_uid=session.get("provider_uid") if session else None, resource_uid=session_uid)
+    response = jsonify(success({"revoked": True}))
+    response.delete_cookie("dataops_refresh", path="/api/system/identity")
+    return response
+
+
+@bp.route("/identity/idp-versions", methods=["GET"])
+@require_permissions(IDENTITY_MANAGE)
+def identity_idp_versions():
+    rows = db.session.execute(text("SELECT provider_uid::text,version,status,issuer,client_id,secret_ref,redirect_uris,algorithms,mapping_version,created_at FROM public.identity_idp_config_versions ORDER BY provider_uid,version DESC")).mappings().all()
+    return jsonify(success([{**dict(row), "secret_ref": "env:DATAOPS_OIDC_***"} for row in rows]))
+
+
+@bp.route("/identity/idp-versions", methods=["POST"])
+@require_permissions(IDENTITY_MANAGE)
+@_identity_input_guard
+def identity_idp_version_create():
+    data = _body()
+    provider_uid = _uuid(data["provider_uid"], "provider_uid") if data.get("provider_uid") else new_governance_uid()
+    mapping_version = _required_str(data, "mapping_version", maximum=120)
+    group_rules = data.get("group_rules")
+    _validate_mapping(mapping_version, group_rules)
+    config = IdpConfig(provider_uid=provider_uid, version=_positive_int(data.get("version"), "version"),
+        issuer=_required_str(data, "issuer", maximum=2048), client_id=_required_str(data, "client_id", maximum=300),
+        secret_ref=_required_str(data, "secret_ref", maximum=160),
+        authorization_endpoint=_required_str(data, "authorization_endpoint", maximum=2048),
+        token_endpoint=_required_str(data, "token_endpoint", maximum=2048),
+        jwks_uri=_required_str(data, "jwks_uri", maximum=2048), redirect_uris=_string_array(data, "redirect_uris"),
+        post_login_redirect_uris=_string_array(data, "post_login_redirect_uris"),
+        algorithms=_string_array(data, "algorithms", default=["RS256"]), mapping_version=mapping_version).validate()
+    db.session.execute(text("""
+        INSERT INTO public.identity_idp_config_versions(uid,provider_uid,version,status,issuer,client_id,secret_ref,authorization_endpoint,
+        token_endpoint,jwks_uri,redirect_uris,algorithms,mapping_version,config,created_by)
+        VALUES(CAST(:uid AS uuid),CAST(:provider AS uuid),:version,'draft',:issuer,:client,:secret,:auth,:token,:jwks,
+        CAST(:redirects AS jsonb),CAST(:algorithms AS jsonb),:mapping,CAST(:config AS jsonb),CAST(:actor AS uuid))
+    """), {"uid": new_governance_uid(), "provider": config.provider_uid, "version": config.version, "issuer": config.issuer,
+             "client": config.client_id, "secret": config.secret_ref, "auth": config.authorization_endpoint,
+             "token": config.token_endpoint, "jwks": config.jwks_uri, "redirects": json.dumps(config.redirect_uris),
+             "algorithms": json.dumps(config.algorithms), "mapping": config.mapping_version,
+             "config": json.dumps({"display_name": data.get("display_name"), "group_rules": group_rules,
+                                   "post_login_redirect_uris": data.get("post_login_redirect_uris", [])}), "actor": g.current_user["id"]})
+    db.session.commit()
+    _audit("idp_version_created", "success", actor_uid=g.current_user["id"], provider_uid=config.provider_uid)
+    return jsonify(success(config.public_dict())), 201
+
+
+@bp.route("/identity/idp-versions/<provider_uid>/<int:version>/<action>", methods=["POST"])
+@require_permissions(IDENTITY_MANAGE)
+@_identity_input_guard
+def identity_idp_action(provider_uid: str, version: int, action: str):
+    provider_uid = _uuid(provider_uid, "provider_uid")
+    version = _positive_int(version, "version")
+    row = _load_config(provider_uid, version=version, active=False)
+    if not row:
+        raise IdentityPolicyError("IdP configuration version not found")
+    config = _config(row)
+    config.validate()
+    _validate_mapping(config.mapping_version, dict(row["config"]).get("group_rules"))
+    if action == "dry-run":
+        result = {"valid": True, "secret_available": bool(config.resolve_secret())}
+        _audit("idp_dry_run", "success", actor_uid=g.current_user["id"], provider_uid=provider_uid,
+               resource_uid=f"{provider_uid}:{version}", detail={"version": version})
+        return jsonify(success(result))
+    if action in {"activate", "rotate"}:
+        config.resolve_secret()
+        db.session.execute(text("UPDATE public.identity_idp_config_versions SET status='superseded' WHERE provider_uid=CAST(:p AS uuid) AND status='active'"), {"p": provider_uid})
+        db.session.execute(text("UPDATE public.identity_idp_config_versions SET status='active',activated_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:p AS uuid) AND version=:v"), {"p": provider_uid, "v": version})
+        db.session.execute(text("UPDATE public.enterprise_identity_links SET token_version=token_version+1,updated_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:p AS uuid)"), {"p": provider_uid})
+        db.session.execute(text("""
+            UPDATE public.identity_sessions SET status='revoked',revoke_reason='provider_configuration_changed',revoked_at=CURRENT_TIMESTAMP
+            WHERE status='active' AND identity_link_uid IN
+              (SELECT uid FROM public.enterprise_identity_links WHERE provider_uid=CAST(:p AS uuid))
+        """), {"p": provider_uid})
+    elif action == "disable":
+        disabled = db.session.execute(text("UPDATE public.identity_idp_config_versions SET status='disabled',disabled_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:p AS uuid) AND version=:v AND status='active' RETURNING provider_uid"), {"p": provider_uid, "v": version}).scalar_one_or_none()
+        if not disabled:
+            raise IdentityPolicyError("only the selected active IdP version can be disabled")
+        db.session.execute(text("UPDATE public.enterprise_identity_links SET token_version=token_version+1,updated_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:p AS uuid)"), {"p": provider_uid})
+        db.session.execute(text("""
+            UPDATE public.identity_sessions SET status='revoked',revoke_reason='provider_disabled',revoked_at=CURRENT_TIMESTAMP
+            WHERE status='active' AND identity_link_uid IN
+              (SELECT uid FROM public.enterprise_identity_links WHERE provider_uid=CAST(:p AS uuid))
+        """), {"p": provider_uid})
+    elif action == "connectivity":
+        payload = OidcTransport().fetch_jwks(config)
+        result = {"reachable": True, "key_count": len(payload.get("keys", []))}
+        _audit("idp_connectivity", "success", actor_uid=g.current_user["id"], provider_uid=provider_uid,
+               resource_uid=f"{provider_uid}:{version}", detail={"version": version, **result})
+        return jsonify(success(result))
+    else:
+        raise IdentityPolicyError("unsupported IdP version action")
+    db.session.commit()
+    _audit(f"idp_{action}", "success", actor_uid=g.current_user["id"], provider_uid=provider_uid)
+    return jsonify(success({"provider_uid": provider_uid, "version": version, "action": action}))
+
+
+@bp.route("/identity/claims/dry-run", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_claims_dry_run():
+    data = _body()
+    mapping_version = _required_str(data, "mapping_version", maximum=120)
+    claims = data.get("claims")
+    if not isinstance(claims, dict):
+        raise IdentityPolicyError("claims must be an object")
+    mapped = _validate_mapping(mapping_version, data.get("group_rules")).map(claims)
+    result = {"roles": mapped.roles, "business_domain_uids": mapped.business_domain_uids,
+              "object_types": mapped.object_types, "environments": mapped.environments,
+              "data_scopes": mapped.data_scopes, "mapping_version": mapped.mapping_version,
+              "claims_digest": mapped.claims_digest}
+    _audit("claims_dry_run", "success", actor_uid=g.current_user["id"],
+           enterprise_subject=mapped.subject,
+           detail={"mapping_version": mapped.mapping_version, "claims_digest": mapped.claims_digest,
+                   "role_count": len(mapped.roles), "scope_count": sum(len(result[key]) for key in
+                   ("business_domain_uids", "object_types", "environments", "data_scopes"))})
+    return jsonify(success(result))
+
+
+@bp.route("/identity/directory/delta", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_directory_delta():
+    data = _body()
+    event_type = _required_str(data, "event_type", maximum=20).upper()
+    provider_uid = _uuid(data.get("provider_uid"), "provider_uid")
+    params = {
+        "provider_uid": provider_uid,
+        "source": _required_str(data, "source", maximum=80),
+        "source_event_id": _required_str(data, "source_event_id", maximum=300),
+        "cursor": _required_str(data, "cursor", maximum=500),
+        "cursor_sequence": _positive_int(data.get("cursor_sequence"), "cursor_sequence"),
+        "event_type": event_type,
+    }
+    if event_type in {"JOINER", "MOVER", "RESTORE"}:
+        row = _load_config(provider_uid)
+        if not row:
+            raise IdentityPolicyError("active identity provider not found")
+        config = _config(row)
+        mapped = ClaimMapper(
+            version=config.mapping_version,
+            group_rules=dict(row["config"]).get("group_rules", {}),
+        ).map(data.get("claims", {}))
+        params["subject"] = mapped.subject
+        params["attributes"] = {
+            "provider_uid": config.provider_uid,
+            "username": mapped.username,
+            "display_name": mapped.display_name,
+            "department": mapped.department,
+            "groups": mapped.groups,
+            "roles": mapped.roles,
+            "mapping_version": mapped.mapping_version,
+            "claims_digest": mapped.claims_digest,
+            "authorization_scope": {
+                "business_domain_uids": mapped.business_domain_uids,
+                "object_types": mapped.object_types,
+                "environments": mapped.environments,
+                "data_scopes": mapped.data_scopes,
+            },
+        }
+    elif event_type in {"LEAVER", "DISABLE"}:
+        params["subject"] = _required_str(data, "subject", maximum=500)
+        params["attributes"] = {"username": data.get("username"), "roles": []}
+    elif event_type in {"DEPARTMENT", "GROUP"}:
+        if not isinstance(data.get("attributes"), dict):
+            raise IdentityPolicyError("organization attributes must be an object")
+        params["attributes"] = data["attributes"]
+    else:
+        raise IdentityPolicyError("unsupported directory event type")
+    result = DirectorySynchronizer(_repo(), SessionManager(_repo(), secret=current_app.config["SECRET_KEY"])).apply(**params)
+    _audit("directory_delta", "success", actor_uid=g.current_user["id"],
+           provider_uid=provider_uid, resource_uid=params["source_event_id"],
+           enterprise_subject=str(params["subject"]) if params.get("subject") else None,
+           detail={"event_type": event_type, "result": result})
+    return jsonify(success(result))
+
+
+@bp.route("/identity/sessions", methods=["GET"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_sessions():
+    subject = request.args.get("subject")
+    provider_uid = request.args.get("provider_uid")
+    if subject and not provider_uid:
+        raise IdentityPolicyError("provider_uid is required with subject")
+    if provider_uid:
+        provider_uid = _uuid(provider_uid, "provider_uid")
+    if subject and len(subject) > 500:
+        raise IdentityPolicyError("subject is invalid")
+    rows = _repo().list_sessions(provider_uid, subject)
+    visible = ("uid", "user_uid", "identity_source", "status", "created_at", "last_seen_at", "expires_at")
+    return jsonify(success([{key: row.get(key) for key in visible} for row in rows]))
+
+
+@bp.route("/identity/sessions/<session_uid>/revoke", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_session_revoke(session_uid: str):
+    session_uid = _uuid(session_uid, "session_uid")
+    data = _body()
+    session = _repo().get_session(session_uid)
+    reason = _optional_str(data, "reason", "operator_revoke", maximum=120)
+    SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).revoke(session_uid, reason=reason)
+    _audit("identity_session_revoke", "success", actor_uid=g.current_user["id"],
+           provider_uid=session.get("provider_uid") if session else None, resource_uid=session_uid,
+           detail={"reason_supplied": bool(data.get("reason"))})
+    return jsonify(success({"session_uid": session_uid, "revoked": True}))
+
+
+@bp.route("/identity/sessions/<session_uid>/risk-terminate", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_session_risk(session_uid: str):
+    session_uid = _uuid(session_uid, "session_uid")
+    data = _body()
+    session = _repo().get_session(session_uid)
+    reason = _optional_str(data, "reason", "risk_signal", maximum=300)
+    SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).risk_revoke(session_uid, reason=reason)
+    _audit("identity_session_risk_terminate", "success", actor_uid=g.current_user["id"],
+           provider_uid=session.get("provider_uid") if session else None, resource_uid=session_uid,
+           detail={"reason_supplied": bool(data.get("reason"))})
+    return jsonify(success({"session_uid": session_uid, "revoked": True, "risk": True}))
+
+
+@bp.route("/identity/emergency/requests", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_emergency_request():
+    data = _body()
+    account_uid = _uuid(data.get("account_uid"), "account_uid")
+    reason = _required_str(data, "reason", maximum=2000)
+    expires_at = _timestamp(data.get("expires_at"), "expires_at")
+    eligible = db.session.execute(text("""
+        SELECT EXISTS(
+          SELECT 1 FROM public.users u JOIN public.user_roles ur ON ur.user_id=u.id
+          JOIN public.roles r ON r.id=ur.role_id
+          WHERE u.id=CAST(:account AS uuid) AND u.status='active' AND r.name='admin'
+            AND NOT EXISTS(SELECT 1 FROM public.enterprise_identity_links l WHERE l.user_uid=u.id)
+        )
+    """), {"account": account_uid}).scalar()
+    record = EmergencyAccess(_repo()).request(requester_uid=g.current_user["id"], account_uid=account_uid,
+        reason=reason, expires_at=expires_at,
+        account_is_local_active_admin=bool(eligible))
+    _audit("emergency_request", "success", actor_uid=g.current_user["id"], resource_uid=record["uid"],
+           detail={"account_uid": record["account_uid"], "expires_at": record["expires_at"].isoformat()})
+    return jsonify(success(record)), 201
+
+
+@bp.route("/identity/emergency/<request_uid>/<action>", methods=["POST"])
+@require_permissions(IDENTITY_MANAGE)
+@_identity_input_guard
+def identity_emergency_action(request_uid: str, action: str):
+    request_uid = _uuid(request_uid, "request_uid")
+    service = EmergencyAccess(_repo())
+    data = _body()
+    if action == "approve":
+        result = service.approve(request_uid, approver_uid=g.current_user["id"])
+    elif action == "activate":
+        result = service.activate(request_uid)
+    elif action == "review":
+        result = service.review(request_uid, reviewer_uid=g.current_user["id"],
+                                outcome=_required_str(data, "outcome", maximum=2000))
+    elif action == "close":
+        result = service.close(request_uid, actor_uid=g.current_user["id"])
+    else:
+        raise IdentityPolicyError("unsupported emergency action")
+    _audit(f"emergency_{action}", "warning" if action == "activate" else "success", actor_uid=g.current_user["id"], resource_uid=request_uid)
+    return jsonify(success(result))
+
+
+@bp.route("/identity/sessions/revoke-all", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_session_revoke_all():
+    data = _body()
+    provider_uid = _uuid(data.get("provider_uid"), "provider_uid")
+    subject = _required_str(data, "subject", maximum=500)
+    reason = _optional_str(data, "reason", "operator_revoke_all", maximum=120)
+    SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).revoke_subject(
+        provider_uid, subject, reason=reason
+    )
+    _audit("identity_session_revoke_all", "success", actor_uid=g.current_user["id"],
+           provider_uid=provider_uid, enterprise_subject=subject,
+           detail={"reason_supplied": bool(data.get("reason"))})
+    return jsonify(success({"revoked": True}))
+
+
+@bp.route("/identity/audit", methods=["GET"])
+@require_permissions(IDENTITY_MANAGE)
+@_identity_input_guard
+def identity_audit():
+    rows = db.session.execute(text("SELECT uid::text,event_type,outcome,actor_uid::text,provider_uid::text,resource_type,resource_uid,safe_detail,created_at FROM public.identity_audit_events ORDER BY created_at DESC LIMIT :limit"),
+                              {"limit": _positive_int(request.args.get("limit", 100), "limit", maximum=500)}).mappings().all()
+    return jsonify(success([dict(row) for row in rows]))

+ 34 - 1
deployment/app/api/system/users.py

@@ -4,7 +4,7 @@ import json
 
 from flask import g, jsonify, request
 from sqlalchemy import text
-from sqlalchemy.exc import IntegrityError
+from sqlalchemy.exc import IntegrityError, ProgrammingError
 
 from app import db
 from app.api.system import bp
@@ -33,6 +33,35 @@ def _audit_access_control(action, resource_uid, status, safe_detail):
             "detail": json.dumps(safe_detail, ensure_ascii=False),
         },
     )
+    try:
+        with db.session.begin_nested():
+            db.session.execute(
+                text(
+                    "INSERT INTO public.identity_audit_events "
+                    "(uid,event_type,outcome,actor_uid,resource_type,resource_uid,safe_detail) VALUES "
+                    "(CAST(:uid AS uuid),:action,:outcome,CAST(:actor AS uuid),'user',:resource_uid,CAST(:detail AS jsonb))"
+                ),
+                {"uid": new_governance_uid(), "action": action, "outcome": "success" if status == "success" else "failure",
+                 "actor": g.current_user["id"], "resource_uid": resource_uid,
+                 "detail": json.dumps(safe_detail, ensure_ascii=False)},
+            )
+    except ProgrammingError:
+        pass
+
+
+def _revoke_identity_sessions(user_id: str, reason: str) -> None:
+    try:
+        with db.session.begin_nested():
+            db.session.execute(
+                text("UPDATE public.enterprise_identity_links SET token_version=token_version+1,updated_at=CURRENT_TIMESTAMP WHERE user_uid=CAST(:id AS uuid)"),
+                {"id": user_id},
+            )
+            db.session.execute(
+                text("UPDATE public.identity_sessions SET status='revoked',revoke_reason=:reason,revoked_at=CURRENT_TIMESTAMP WHERE user_uid=CAST(:id AS uuid) AND status='active'"),
+                {"id": user_id, "reason": reason},
+            )
+    except ProgrammingError:
+        pass
 
 
 def _active_admin_count(session) -> int:
@@ -186,6 +215,8 @@ def update_user(user_id: str):
             "password_value_retained": False,
         },
     )
+    if status == "disabled":
+        _revoke_identity_sessions(user_id, "account_disabled")
     db.session.commit()
     return jsonify(success(message="用户更新成功"))
 
@@ -233,5 +264,7 @@ def update_user_roles(user_id: str):
         "success",
         {"before_roles": previous_roles, "after_roles": sorted(roles)},
     )
+    if sorted(previous_roles) != sorted(roles):
+        _revoke_identity_sessions(user_id, "roles_changed")
     db.session.commit()
     return jsonify(success(message="角色更新成功"))

+ 131 - 6
deployment/app/core/system/auth.py

@@ -2,6 +2,8 @@
 
 from __future__ import annotations
 
+import hashlib
+import json
 import logging
 import threading
 import time
@@ -12,11 +14,12 @@ from argon2 import PasswordHasher
 from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
 from flask import current_app
 from sqlalchemy import text
+from sqlalchemy.exc import ProgrammingError, SQLAlchemyError
 
 from app import db
+from app.core.common.identifiers import new_governance_uid
 from app.core.system.tokens import TokenError, decode_access_token, issue_access_token
 
-
 logger = logging.getLogger(__name__)
 _password_hasher = PasswordHasher()
 _failure_lock = threading.Lock()
@@ -87,6 +90,30 @@ def _audit(
             "detail": (detail or "")[:500] or None,
         },
     )
+    try:
+        with db.session.begin_nested():
+            db.session.execute(
+                text(
+                    "INSERT INTO public.identity_audit_events "
+                    "(uid,event_type,outcome,actor_uid,enterprise_subject_digest,resource_type,resource_uid,safe_detail) "
+                    "VALUES (CAST(:uid AS uuid),:event_type,:outcome,CAST(:actor AS uuid),:subject_digest,"
+                    "'authentication',:resource_uid,CAST(:detail AS jsonb))"
+                ),
+                {
+                    "uid": new_governance_uid(),
+                    "event_type": event_type,
+                    "outcome": "success" if success else "failure",
+                    "actor": user_id,
+                    "subject_digest": hashlib.sha256(username.strip().lower().encode()).hexdigest(),
+                    "resource_uid": user_id,
+                    "detail": json.dumps({"ip_address": (ip_address or "")[:64] or None,
+                                          "user_agent": (user_agent or "")[:300] or None,
+                                          "reason": (detail or "")[:160] or None}),
+                },
+            )
+    except ProgrammingError:
+        # Rolling deployment before migration 470 retains the legacy audit row.
+        pass
 
 
 def authenticate_user(
@@ -127,6 +154,36 @@ def authenticate_user(
         db.session.commit()
         return None
 
+    # Once an enterprise IdP is active, local authentication is a controlled
+    # break-glass path only. Missing identity tables mean pre-migration/no-IdP
+    # compatibility; an active IdP always fails closed.
+    try:
+        active_provider = db.session.execute(
+            text("SELECT EXISTS (SELECT 1 FROM public.identity_idp_config_versions WHERE status='active')")
+        ).scalar()
+    except SQLAlchemyError:
+        db.session.rollback()
+        logger.exception("enterprise identity state unavailable; local login denied")
+        return None
+    if active_provider:
+        emergency_request = db.session.execute(
+            text(
+                "SELECT uid::text,expires_at FROM public.identity_emergency_requests "
+                "WHERE emergency_account_uid=CAST(:id AS uuid) AND status='active' "
+                "AND starts_at<=CURRENT_TIMESTAMP AND expires_at>CURRENT_TIMESTAMP "
+                "AND EXISTS (SELECT 1 FROM public.user_roles ur JOIN public.roles r ON r.id=ur.role_id "
+                "WHERE ur.user_id=CAST(:id AS uuid) AND r.name='admin') "
+                "AND NOT EXISTS (SELECT 1 FROM public.enterprise_identity_links l WHERE l.user_uid=CAST(:id AS uuid)) "
+                "ORDER BY expires_at ASC LIMIT 1"
+            ),
+            {"id": row[0]},
+        ).one_or_none()
+        if not emergency_request:
+            _audit(user_id=row[0], username=row[1], event_type="local_login_blocked_by_idp", success=False,
+                   ip_address=ip_address, user_agent=user_agent, detail="active enterprise IdP requires approved emergency access")
+            db.session.commit()
+            return None
+
     db.session.execute(
         text("UPDATE public.users SET last_login_at = CURRENT_TIMESTAMP WHERE id = CAST(:id AS uuid)"),
         {"id": row[0]},
@@ -142,14 +199,40 @@ def authenticate_user(
     db.session.commit()
     _clear_failures(key)
     roles = list(row[5])
+    token = issue_access_token(
+        user_id=row[0], roles=roles, secret=current_app.config["SECRET_KEY"]
+    )
+    if active_provider:
+        from app.core.system.identity_repository import PostgresIdentityRepository
+        from app.core.system.identity_sessions import SessionManager
+
+        credentials = SessionManager(
+            PostgresIdentityRepository(db.session), secret=current_app.config["SECRET_KEY"]
+        ).create(
+            subject=row[0],
+            user_uid=row[0],
+            roles=roles,
+            identity_source="emergency",
+            emergency_request_uid=emergency_request[0],
+            emergency_expires_at=emergency_request[1],
+        )
+        token = credentials.access_token
+        PostgresIdentityRepository(db.session).audit(
+            {
+                "event_type": "emergency_local_login",
+                "outcome": "warning",
+                "actor_uid": row[0],
+                "resource_type": "emergency_access",
+                "resource_uid": row[0],
+                "safe_detail": {"alert_required": True},
+            }
+        )
     return {
         "id": row[0],
         "username": row[1],
         "display_name": row[2],
         "roles": roles,
-        "token": issue_access_token(
-            user_id=row[0], roles=roles, secret=current_app.config["SECRET_KEY"]
-        ),
+        "token": token,
     }
 
 
@@ -176,7 +259,49 @@ def load_identity_from_token(token: str, *, secret: str) -> dict[str, Any] | Non
         claims = decode_access_token(token, secret=secret)
     except TokenError:
         return None
-    return load_identity(claims["sub"])
+    try:
+        active_provider = bool(db.session.execute(text(
+            "SELECT EXISTS (SELECT 1 FROM public.identity_idp_config_versions WHERE status='active')"
+        )).scalar())
+    except SQLAlchemyError:
+        db.session.rollback()
+        logger.exception("enterprise identity state unavailable; bearer token denied")
+        return None
+    if active_provider and not claims.get("sid"):
+        return None
+    if claims.get("sid"):
+        try:
+            session = db.session.execute(
+                text("""
+                SELECT s.status,s.token_version,s.user_uid::text,s.identity_source,
+                  CASE WHEN s.identity_source='emergency' THEN
+                    (e.status='active' AND e.starts_at<=CURRENT_TIMESTAMP AND e.expires_at>CURRENT_TIMESTAMP
+                     AND u.status='active'
+                     AND EXISTS (SELECT 1 FROM public.user_roles ur JOIN public.roles r ON r.id=ur.role_id
+                                 WHERE ur.user_id=u.id AND r.name='admin')
+                     AND NOT EXISTS (SELECT 1 FROM public.enterprise_identity_links l WHERE l.user_uid=u.id))
+                  ELSE TRUE END emergency_valid
+                FROM public.identity_sessions s LEFT JOIN public.identity_emergency_requests e
+                  ON e.uid=s.emergency_request_uid LEFT JOIN public.users u ON u.id=s.user_uid
+                WHERE s.uid=CAST(:sid AS uuid)
+                """),
+                {"sid": claims["sid"]},
+            ).one_or_none()
+        except SQLAlchemyError:
+            db.session.rollback()
+            return None
+        if session and session[3] == "emergency" and not session[4]:
+            db.session.execute(text("UPDATE public.identity_sessions SET status='revoked',revoke_reason='emergency_window_closed',revoked_at=CURRENT_TIMESTAMP WHERE uid=CAST(:sid AS uuid)"), {"sid": claims["sid"]})
+            db.session.commit()
+            return None
+        if (not session or session[0] != "active" or session[2] != claims["sub"]
+                or int(session[1]) != int(claims.get("token_version", -1))):
+            return None
+    identity = load_identity(claims["sub"])
+    if identity and claims.get("sid"):
+        identity.update(sid=claims["sid"], token_version=claims["token_version"],
+                        identity_source=claims.get("identity_source", "oidc"))
+    return identity
 
 
 # Compatibility aliases retained only for imports during the route migration.
@@ -199,6 +324,6 @@ def init_db() -> bool:
 
 
 def require_auth(view):
-    from app.core.system.permissions import require_permissions, READ_GOVERNANCE
+    from app.core.system.permissions import READ_GOVERNANCE, require_permissions
 
     return require_permissions(READ_GOVERNANCE)(view)

+ 345 - 0
deployment/app/core/system/enterprise_identity.py

@@ -0,0 +1,345 @@
+"""Fail-closed enterprise identity policy, mapping, lifecycle and emergency access."""
+
+from __future__ import annotations
+
+import hashlib
+import ipaddress
+import json
+import os
+import re
+from collections.abc import Callable, Mapping
+from dataclasses import dataclass, field
+from datetime import UTC, datetime, timedelta
+from typing import Any
+from urllib.parse import urlsplit
+
+from app.core.common.identifiers import new_governance_uid
+
+
+class IdentityPolicyError(ValueError):
+    pass
+
+
+class IdentityUpstreamError(IdentityPolicyError):
+    """Safe boundary error for unavailable or malformed enterprise IdP responses."""
+
+    pass
+
+
+_SENSITIVE_KEYS = {"code", "token", "id_token", "access_token", "refresh_token", "verifier", "code_verifier",
+                   "secret", "client_secret", "claims", "cookie", "password", "assertion", "authorization_url"}
+
+
+def _utc(value: datetime) -> datetime:
+    if value.tzinfo is None or value.utcoffset() is None:
+        raise IdentityPolicyError("timestamp must be timezone-aware")
+    return value.astimezone(UTC)
+
+
+def _safe_https_url(value: str, *, redirect: bool = False) -> None:
+    parsed = urlsplit(value)
+    if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
+        raise IdentityPolicyError("identity endpoints require absolute HTTPS URLs")
+    if parsed.fragment or (redirect and parsed.query):
+        raise IdentityPolicyError("redirect and identity URLs may not contain query/fragment")
+    host = parsed.hostname.lower()
+    if host == "localhost" or host.endswith(".localhost"):
+        raise IdentityPolicyError("localhost identity endpoints are forbidden")
+    try:
+        address = ipaddress.ip_address(host)
+    except ValueError:
+        return
+    if not address.is_global:
+        raise IdentityPolicyError("private or reserved identity endpoint is forbidden")
+
+
+@dataclass(frozen=True)
+class IdpConfig:
+    provider_uid: str
+    version: int
+    issuer: str
+    client_id: str
+    secret_ref: str = field(repr=False)
+    authorization_endpoint: str
+    token_endpoint: str
+    jwks_uri: str
+    redirect_uris: tuple[str, ...]
+    post_login_redirect_uris: tuple[str, ...]
+    algorithms: tuple[str, ...]
+    mapping_version: str
+    status: str = "draft"
+
+    def validate(self) -> IdpConfig:
+        if self.version < 1 or not self.provider_uid or not self.client_id or not self.mapping_version:
+            raise IdentityPolicyError("incomplete IdP configuration")
+        for endpoint in (self.issuer, self.authorization_endpoint, self.token_endpoint, self.jwks_uri):
+            _safe_https_url(endpoint)
+        if not self.redirect_uris:
+            raise IdentityPolicyError("redirect allowlist is required")
+        for uri in self.redirect_uris:
+            _safe_https_url(uri, redirect=True)
+        if not self.post_login_redirect_uris:
+            raise IdentityPolicyError("post-login redirect allowlist is required")
+        for uri in self.post_login_redirect_uris:
+            _safe_https_url(uri, redirect=True)
+        if not re.fullmatch(r"env:DATAOPS_OIDC_[A-Z0-9_]+", self.secret_ref):
+            raise IdentityPolicyError("client secret must use a dedicated DATAOPS_OIDC environment reference")
+        if not self.algorithms or any(alg not in {"RS256", "RS384", "RS512", "ES256", "ES384"} for alg in self.algorithms):
+            raise IdentityPolicyError("only explicitly configured asymmetric algorithms are allowed")
+        return self
+
+    def resolve_secret(self) -> str:
+        self.validate()
+        value = os.environ.get(self.secret_ref[4:])
+        if not value:
+            raise IdentityPolicyError("configured secret reference is unavailable")
+        return value
+
+    def public_dict(self) -> dict[str, Any]:
+        return {"provider_uid": self.provider_uid, "version": self.version, "issuer": self.issuer,
+                "client_id": self.client_id, "authorization_endpoint": self.authorization_endpoint,
+                "redirect_uris": list(self.redirect_uris), "algorithms": list(self.algorithms),
+                "post_login_redirect_uris": list(self.post_login_redirect_uris),
+                "mapping_version": self.mapping_version, "status": self.status,
+                "secret_ref": "env:DATAOPS_OIDC_***"}
+
+
+def sanitize_audit_detail(value: Any) -> Any:
+    if isinstance(value, Mapping):
+        safe: dict[str, Any] = {}
+        for key, item in value.items():
+            lowered = str(key).lower()
+            if lowered in _SENSITIVE_KEYS or any(part in lowered for part in ("secret", "token", "verifier")):
+                continue
+            if lowered.endswith("claims"):
+                continue
+            safe[str(key)] = sanitize_audit_detail(item)
+        return safe
+    if isinstance(value, (list, tuple)):
+        return [sanitize_audit_detail(item) for item in value]
+    return value
+
+
+@dataclass(frozen=True)
+class MappedIdentity:
+    subject: str
+    username: str
+    display_name: str
+    department: str
+    groups: tuple[str, ...]
+    roles: tuple[str, ...]
+    business_domain_uids: tuple[str, ...]
+    object_types: tuple[str, ...]
+    environments: tuple[str, ...]
+    data_scopes: tuple[str, ...]
+    mapping_version: str
+    claims_digest: str
+    evidence: Mapping[str, Any] = field(repr=False)
+
+
+class ClaimMapper:
+    REQUIRED = ("sub", "preferred_username", "name", "department", "groups")
+    RULE_FIELDS = {"roles", "business_domain_uids", "object_types", "environments", "data_scopes"}
+
+    def __init__(self, *, version: str, group_rules: Mapping[str, Mapping[str, Any]]) -> None:
+        if not isinstance(version, str) or not version.strip():
+            raise IdentityPolicyError("mapping version is required")
+        if not isinstance(group_rules, Mapping) or not group_rules:
+            raise IdentityPolicyError("at least one claims group rule is required")
+        normalized: dict[str, dict[str, tuple[str, ...]]] = {}
+        for group, raw_rule in group_rules.items():
+            if not isinstance(group, str) or not group.strip() or not isinstance(raw_rule, Mapping):
+                raise IdentityPolicyError("claims group rules must be named objects")
+            if set(raw_rule) - self.RULE_FIELDS:
+                raise IdentityPolicyError("claims group rule contains unsupported fields")
+            rule: dict[str, tuple[str, ...]] = {}
+            for field_name in self.RULE_FIELDS:
+                values = raw_rule.get(field_name, ())
+                if not isinstance(values, (list, tuple)) or isinstance(values, (str, bytes)):
+                    raise IdentityPolicyError("claims mapping scope fields must be string arrays")
+                if any(not isinstance(value, str) or not value.strip() for value in values):
+                    raise IdentityPolicyError("claims mapping scope values must be non-empty strings")
+                rule[field_name] = tuple(sorted(set(values)))
+            if not rule["roles"] or not set(rule["roles"]).issubset({"admin", "editor", "viewer"}):
+                raise IdentityPolicyError("mapping contains an unsupported or empty platform role")
+            normalized[group.strip()] = rule
+        self.version = version.strip()
+        self.group_rules = normalized
+
+    def map(self, claims: Mapping[str, Any]) -> MappedIdentity:
+        if not isinstance(claims, Mapping):
+            raise IdentityPolicyError("identity claims must be an object")
+        limits = {"sub": 500, "preferred_username": 64, "name": 300, "department": 300}
+        if any(not isinstance(claims.get(key), str) or not claims[key].strip()
+               or len(claims[key].strip()) > maximum for key, maximum in limits.items()):
+            raise IdentityPolicyError("required identity claim is missing")
+        raw_groups = claims.get("groups")
+        if (not isinstance(raw_groups, (list, tuple)) or isinstance(raw_groups, (str, bytes)) or not raw_groups
+                or any(not isinstance(item, str) or not item.strip() or len(item.strip()) > 300 for item in raw_groups)):
+            raise IdentityPolicyError("identity groups claim must be a non-empty string array")
+        groups = tuple(sorted({item.strip() for item in raw_groups}))
+        matched = [self.group_rules[group] for group in groups if group in self.group_rules]
+        if not matched:
+            raise IdentityPolicyError("claims do not match an approved mapping rule")
+        def collect(key: str) -> tuple[str, ...]:
+            return tuple(sorted({str(value) for rule in matched for value in rule.get(key, ())}))
+        roles = collect("roles")
+        if not roles or not set(roles).issubset({"admin", "editor", "viewer"}):
+            raise IdentityPolicyError("mapping resolves to no approved platform role")
+        canonical = json.dumps(claims, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
+        digest = hashlib.sha256(canonical.encode()).hexdigest()
+        evidence = {"mapping_version": self.version, "claims_digest": digest,
+                    "matched_group_digests": [hashlib.sha256(group.encode()).hexdigest() for group in groups if group in self.group_rules]}
+        return MappedIdentity(claims["sub"].strip(), claims["preferred_username"].strip(), claims["name"].strip(),
+                              claims["department"].strip(), groups, roles, collect("business_domain_uids"),
+                              collect("object_types"), collect("environments"), collect("data_scopes"),
+                              self.version, digest, evidence)
+
+
+class DirectorySynchronizer:
+    EVENTS = {"JOINER", "MOVER", "LEAVER", "DISABLE", "RESTORE"}
+    ORGANIZATION_EVENTS = {"DEPARTMENT", "GROUP"}
+    ORGANIZATION_ACTIONS = {"UPSERT", "DISABLE", "RESTORE"}
+
+    def __init__(self, repository: Any, sessions: Any, *, clock: Callable[[], datetime] | None = None) -> None:
+        self.repository = repository
+        self.sessions = sessions
+        self.clock = clock or (lambda: datetime.now(UTC))
+
+    def apply(self, *, provider_uid: str, source: str, source_event_id: str, cursor: str,
+              cursor_sequence: int, event_type: str, attributes: Mapping[str, Any],
+              subject: str | None = None) -> dict[str, Any]:
+        event_type = event_type.upper()
+        if event_type not in self.EVENTS | self.ORGANIZATION_EVENTS or not all((provider_uid, source, source_event_id, cursor)):
+            raise IdentityPolicyError("invalid directory delta event")
+        if event_type in self.EVENTS and not subject:
+            raise IdentityPolicyError("person lifecycle event requires enterprise subject")
+        if not isinstance(cursor_sequence, int) or cursor_sequence < 1:
+            raise IdentityPolicyError("directory cursor sequence must be a positive integer")
+        digest = hashlib.sha256(json.dumps({"cursor": cursor, "event_type": event_type, "subject": subject,
+                                             "attributes": attributes}, sort_keys=True, default=str).encode()).hexdigest()
+        event = {"provider_uid": provider_uid, "source": source, "source_event_id": source_event_id,
+                 "cursor": cursor, "cursor_sequence": cursor_sequence, "event_type": event_type,
+                 "subject": subject, "payload_digest": digest, "processed_at": _utc(self.clock()).isoformat()}
+
+        def mutate_organization() -> dict[str, Any]:
+            node_type = event_type.lower()
+            external_id = str(attributes.get("external_id") or "").strip()
+            action = str(attributes.get("lifecycle_action") or attributes.get("action") or "UPSERT").upper()
+            if not external_id or action not in self.ORGANIZATION_ACTIONS:
+                raise IdentityPolicyError("invalid organization node delta")
+            existing = self.repository.get_organization_node(provider_uid, node_type, external_id, for_update=True)
+            if action in {"DISABLE", "RESTORE"} and not existing:
+                raise IdentityPolicyError("organization node lifecycle target does not exist")
+            display_name = str(attributes.get("display_name") or (existing or {}).get("display_name") or "").strip()
+            if not display_name:
+                raise IdentityPolicyError("organization node display name is required")
+            node = {
+                "uid": (existing or {}).get("uid") or new_governance_uid(),
+                "provider_uid": provider_uid,
+                "external_id": external_id,
+                "node_type": node_type,
+                "parent_external_id": attributes.get("parent_external_id"),
+                "display_name": display_name,
+                "status": "disabled" if action == "DISABLE" else "active",
+                "attributes": dict(attributes.get("attributes", {})),
+                "updated_at": _utc(self.clock()),
+            }
+            self.repository.put_organization_node(node, commit=False)
+            return {"node_uid": node["uid"], "node_type": node_type, "external_id": external_id,
+                    "status": node["status"], "lifecycle_action": action, "idempotent": False}
+
+        def mutate_identity() -> dict[str, Any]:
+            identity = self.repository.get_identity(provider_uid, str(subject), for_update=True)
+            if not identity and event_type != "JOINER":
+                raise IdentityPolicyError("person lifecycle target does not exist")
+            identity = identity or {"user_uid": new_governance_uid(), "token_version": 0}
+            old_roles = tuple(identity.get("roles", ()))
+            roles = sorted(set(attributes.get("roles", ())))
+            if not set(roles).issubset({"admin", "editor", "viewer"}):
+                raise IdentityPolicyError("directory event contains an unsupported platform role")
+            username = attributes.get("username") or identity.get("username")
+            if not username:
+                raise IdentityPolicyError("person lifecycle username is required")
+            identity.update(subject=subject, username=username,
+                            department=attributes.get("department", identity.get("department", "")),
+                            display_name=attributes.get("display_name") or identity.get("display_name") or username,
+                            provider_uid=provider_uid, groups=sorted(attributes.get("groups", identity.get("groups", ()))),
+                            roles=roles, mapping_version=attributes.get("mapping_version") or identity.get("mapping_version"),
+                            claims_digest=attributes.get("claims_digest") or identity.get("claims_digest"),
+                            authorization_scope=dict(attributes.get("authorization_scope", identity.get("authorization_scope", {}))),
+                            status="disabled" if event_type in {"LEAVER", "DISABLE"} else "active",
+                            token_version=int(identity.get("token_version", 0)) + 1,
+                            updated_at=_utc(self.clock()).isoformat())
+            # RESTORE deliberately recalculates from current input and never restores old roles.
+            self.repository.put_identity(provider_uid, str(subject), identity, commit=False)
+            if event_type in {"MOVER", "LEAVER", "DISABLE", "RESTORE"} or old_roles != tuple(identity["roles"]):
+                self.repository.revoke_subject_sessions(provider_uid, str(subject),
+                                                        reason=f"directory_{event_type.lower()}", commit=False)
+            return {"user_uid": identity["user_uid"], "status": identity["status"],
+                    "token_version": identity["token_version"], "idempotent": False}
+
+        mutation = mutate_organization if event_type in self.ORGANIZATION_EVENTS else mutate_identity
+        return self.repository.apply_directory_event_atomic(event, mutation)
+
+
+class EmergencyAccess:
+    MAX_DURATION = timedelta(hours=2)
+    def __init__(self, repository: Any, *, clock: Callable[[], datetime] | None = None) -> None:
+        self.repository = repository
+        self.clock = clock or (lambda: datetime.now(UTC))
+
+    def request(self, *, requester_uid: str, account_uid: str, reason: str, expires_at: datetime,
+                account_is_local_active_admin: bool) -> dict[str, Any]:
+        now = _utc(self.clock())
+        expiry = _utc(expires_at)
+        if (requester_uid == account_uid or not reason.strip() or not now < expiry
+                or expiry - now > self.MAX_DURATION or not account_is_local_active_admin):
+            raise IdentityPolicyError("invalid emergency access request")
+        record = {"uid": new_governance_uid(), "requester_uid": requester_uid, "account_uid": account_uid,
+                  "reason": reason.strip(), "status": "pending", "approver_uids": [],
+                  "requested_at": now, "expires_at": expiry, "reviewed_at": None}
+        self.repository.put_emergency(record)
+        return record
+
+    def approve(self, request_uid: str, *, approver_uid: str) -> dict[str, Any]:
+        record = self.repository.get_emergency(request_uid)
+        if not record or record["status"] != "pending" or _utc(self.clock()) >= record["expires_at"]:
+            raise IdentityPolicyError("emergency request is not approvable")
+        forbidden = {record["requester_uid"], record["account_uid"], *record["approver_uids"]}
+        if approver_uid in forbidden:
+            raise IdentityPolicyError("emergency access requires two distinct non-self approvers")
+        record["approver_uids"].append(approver_uid)
+        self.repository.put_emergency(record)
+        return record
+
+    def activate(self, request_uid: str) -> dict[str, Any]:
+        record = self.repository.get_emergency(request_uid)
+        if not record or record["status"] != "pending" or len(set(record["approver_uids"])) != 2 or _utc(self.clock()) >= record["expires_at"]:
+            raise IdentityPolicyError("two approvals in the active time window are required")
+        record.update(status="active", activated_at=_utc(self.clock()), alert_required=True)
+        self.repository.put_emergency(record)
+        return record
+
+    def review(self, request_uid: str, *, reviewer_uid: str, outcome: str) -> dict[str, Any]:
+        record = self.repository.get_emergency(request_uid)
+        if record and record["status"] == "active" and _utc(self.clock()) >= record["expires_at"]:
+            record["status"] = "expired"
+            self.repository.put_emergency(record)
+        if not record or record["status"] not in {"closed", "expired"} or reviewer_uid == record["account_uid"]:
+            raise IdentityPolicyError("invalid emergency review")
+        record.update(status="reviewed", reviewed_at=_utc(self.clock()), reviewer_uid=reviewer_uid, review_outcome=outcome)
+        self.repository.put_emergency(record)
+        return record
+
+    def close(self, request_uid: str, *, actor_uid: str) -> dict[str, Any]:
+        record = self.repository.get_emergency(request_uid)
+        if record and record["status"] == "active" and _utc(self.clock()) >= record["expires_at"]:
+            record["status"] = "expired"
+            self.repository.put_emergency(record)
+            raise IdentityPolicyError("emergency access already expired")
+        if not record or record["status"] != "active" or actor_uid == record["account_uid"]:
+            raise IdentityPolicyError("invalid emergency access closure")
+        record.update(status="closed", closed_at=_utc(self.clock()), closed_by_uid=actor_uid)
+        self.repository.put_emergency(record)
+        return record

+ 570 - 0
deployment/app/core/system/identity_repository.py

@@ -0,0 +1,570 @@
+"""Repository contracts and a deterministic test adapter for enterprise identity."""
+
+from __future__ import annotations
+
+import copy
+import json
+import secrets
+import threading
+from collections import defaultdict
+from collections.abc import Callable
+from typing import Any
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.system.enterprise_identity import IdentityPolicyError
+
+
+class MemoryIdentityRepository:
+    """Small adapter used by protocol tests; production persistence is PostgreSQL."""
+
+    def __init__(self) -> None:
+        self.flows: dict[str, dict[str, Any]] = {}
+        self.sessions: dict[str, dict[str, Any]] = {}
+        self.directory_events: dict[tuple[str, str, str], dict[str, Any]] = {}
+        self.directory_checkpoints: dict[tuple[str, str], dict[str, Any]] = {}
+        self.directory_conflicts: list[dict[str, Any]] = []
+        self.identities: dict[tuple[str, str], dict[str, Any]] = {}
+        self.organization_nodes: dict[tuple[str, str, str], dict[str, Any]] = {}
+        self.emergency_requests: dict[str, dict[str, Any]] = {}
+        self.audits: list[dict[str, Any]] = []
+        self.refresh_index: dict[str, str] = {}
+        self.family_members: dict[str, set[str]] = defaultdict(set)
+        self._session_lock = threading.RLock()
+        self._directory_lock = threading.RLock()
+
+    def put_flow(self, flow: dict[str, Any]) -> None:
+        self.flows[flow["state_hash"]] = copy.deepcopy(flow)
+
+    def consume_flow(self, state_hash: str, *, commit: bool = True) -> dict[str, Any] | None:
+        del commit
+        flow = self.flows.get(state_hash)
+        if not flow or flow.get("consumed_at"):
+            return None
+        flow["consumed_at"] = True
+        return copy.deepcopy(flow)
+
+    def put_session(self, session: dict[str, Any]) -> None:
+        self.sessions[session["uid"]] = copy.deepcopy(session)
+        self.refresh_index[session["refresh_hash"]] = session["uid"]
+        self.family_members[session["family_uid"]].add(session["uid"])
+
+    def create_session(self, session: dict[str, Any], *, max_sessions: int, commit: bool = True) -> None:
+        del commit
+        with self._session_lock:
+            self.put_session(session)
+            active = sorted((x for x in self.sessions.values()
+                             if x["user_uid"] == session["user_uid"] and x["status"] == "active"),
+                            key=lambda x: (x["created_at"], x["uid"]), reverse=True)
+            for old in active[max_sessions:]:
+                self.update_session(old["uid"], status="revoked", revoke_reason="concurrency_limit")
+
+    def rotate_refresh(self, old_hash: str, new_session: dict[str, Any]) -> str:
+        old = self.get_session_by_refresh(old_hash)
+        if not old:
+            return "unknown"
+        if old["status"] != "active":
+            self.revoke_family(old["family_uid"], reason="refresh_reuse")
+            return "reuse"
+        self.update_session(old["uid"], status="rotated", rotated_at=new_session["created_at"])
+        self.put_session(new_session)
+        return "rotated"
+
+    def get_session(self, uid: str) -> dict[str, Any] | None:
+        value = self.sessions.get(uid)
+        return copy.deepcopy(value) if value else None
+
+    def get_session_by_refresh(self, refresh_hash: str) -> dict[str, Any] | None:
+        uid = self.refresh_index.get(refresh_hash)
+        return self.get_session(uid) if uid else None
+
+    def update_session(self, uid: str, **changes: Any) -> dict[str, Any]:
+        self.sessions[uid].update(copy.deepcopy(changes))
+        return self.get_session(uid) or {}
+
+    def list_sessions(self, provider_uid: str | None = None, subject: str | None = None) -> list[dict[str, Any]]:
+        rows = self.sessions.values()
+        if provider_uid is not None:
+            rows = (item for item in rows if item.get("provider_uid") == provider_uid)
+        if subject is not None:
+            rows = (item for item in rows if item["subject"] == subject)
+        return [copy.deepcopy(item) for item in rows]
+
+    def revoke_family(self, family_uid: str, *, reason: str) -> None:
+        for uid in self.family_members.get(family_uid, ()):
+            self.sessions[uid].update(status="revoked", revoke_reason=reason)
+
+    def revoke_subject_sessions(self, provider_uid: str | None, subject: str, *, reason: str,
+                                commit: bool = True) -> None:
+        del commit
+        for record in self.list_sessions(provider_uid, subject):
+            if record["status"] == "active":
+                self.update_session(record["uid"], status="revoked", revoke_reason=reason)
+
+    def get_directory_event(self, provider_uid: str, source: str, source_event_id: str) -> dict[str, Any] | None:
+        value = self.directory_events.get((provider_uid, source, source_event_id))
+        return copy.deepcopy(value) if value else None
+
+    def put_directory_event(self, event: dict[str, Any]) -> None:
+        self.directory_events[(event["provider_uid"], event["source"], event["source_event_id"])] = copy.deepcopy(event)
+        self.directory_checkpoints[(event["provider_uid"], event["source"])] = {
+            "cursor": event["cursor"], "cursor_sequence": event["cursor_sequence"]}
+
+    def get_directory_checkpoint(self, provider_uid: str, source: str) -> dict[str, Any] | None:
+        value = self.directory_checkpoints.get((provider_uid, source))
+        return copy.deepcopy(value) if value else None
+
+    def record_directory_conflict(self, **record: Any) -> None:
+        self.directory_conflicts.append(copy.deepcopy(record))
+
+    def put_identity(self, provider_uid: str, subject: str, identity: dict[str, Any], *, commit: bool = True) -> None:
+        del commit
+        self.identities[(provider_uid, subject)] = copy.deepcopy(identity)
+
+    def get_identity(self, provider_uid: str, subject: str, *, for_update: bool = False) -> dict[str, Any] | None:
+        del for_update
+        value = self.identities.get((provider_uid, subject))
+        return copy.deepcopy(value) if value else None
+
+    def get_organization_node(self, provider_uid: str, node_type: str, external_id: str,
+                              *, for_update: bool = False) -> dict[str, Any] | None:
+        del for_update
+        value = self.organization_nodes.get((provider_uid, node_type, external_id))
+        return copy.deepcopy(value) if value else None
+
+    def put_organization_node(self, node: dict[str, Any], *, commit: bool = True) -> None:
+        del commit
+        key = (node["provider_uid"], node["node_type"], node["external_id"])
+        self.organization_nodes[key] = copy.deepcopy(node)
+
+    def apply_directory_event_atomic(self, event: dict[str, Any],
+                                     mutation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
+        """Apply the lifecycle mutation and checkpoint as one in-memory unit of work."""
+        with self._directory_lock:
+            previous = self.get_directory_event(event["provider_uid"], event["source"], event["source_event_id"])
+            if previous:
+                if previous["payload_digest"] != event["payload_digest"]:
+                    self.record_directory_conflict(**event, reason="source_event_id_payload_mismatch")
+                    raise IdentityPolicyError("directory event idempotency conflict")
+                return {**previous["result"], "idempotent": True}
+            checkpoint = self.get_directory_checkpoint(event["provider_uid"], event["source"])
+            if checkpoint and event["cursor_sequence"] <= checkpoint["cursor_sequence"]:
+                self.record_directory_conflict(**event, reason="cursor_not_monotonic")
+                raise IdentityPolicyError("directory cursor is not monotonic")
+            snapshot = copy.deepcopy((self.identities, self.organization_nodes, self.sessions,
+                                      self.refresh_index, self.family_members,
+                                      self.directory_events, self.directory_checkpoints))
+            try:
+                result = mutation()
+                self.put_directory_event({**event, "result": result})
+                return result
+            except Exception:
+                (self.identities, self.organization_nodes, self.sessions, self.refresh_index,
+                 self.family_members, self.directory_events, self.directory_checkpoints) = snapshot
+                raise
+
+    def put_emergency(self, request: dict[str, Any]) -> None:
+        self.emergency_requests[request["uid"]] = copy.deepcopy(request)
+        if request["status"] in {"closed", "expired", "reviewed"}:
+            for session in self.sessions.values():
+                if session.get("emergency_request_uid") == request["uid"] and session["status"] == "active":
+                    session.update(status="revoked", revoke_reason=f"emergency_{request['status']}")
+
+    def get_emergency(self, uid: str) -> dict[str, Any] | None:
+        value = self.emergency_requests.get(uid)
+        return copy.deepcopy(value) if value else None
+
+    def audit(self, event: dict[str, Any]) -> None:
+        self.audits.append(copy.deepcopy(event))
+
+    def emergency_session_active(self, request_uid: str, account_uid: str, now: Any) -> bool:
+        request = self.emergency_requests.get(request_uid)
+        return bool(request and request["account_uid"] == account_uid and request["status"] == "active"
+                    and request["requested_at"] <= now < request["expires_at"])
+
+
+class PostgresIdentityRepository:
+    """PostgreSQL adapter for OIDC flows and server-side sessions."""
+
+    def __init__(self, session: Any) -> None:
+        self.session = session
+
+    def put_flow(self, flow: dict[str, Any]) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_oidc_flows
+            (uid,state_hash,provider_uid,provider_version,nonce_hash,pkce_verifier_hash,redirect_uri,status,expires_at)
+            VALUES (CAST(:uid AS uuid),:state_hash,CAST(:provider_uid AS uuid),:provider_version,:nonce_hash,
+                    :verifier_hash,:redirect_uri,'pending',:expires_at)
+        """), {**flow, "uid": new_governance_uid(), "verifier_hash": flow["verifier_hash"]})
+        self.session.commit()
+
+    def consume_flow(self, state_hash: str, *, commit: bool = True) -> dict[str, Any] | None:
+        row = self.session.execute(text("""
+            UPDATE public.identity_oidc_flows SET status='consumed', consumed_at=CURRENT_TIMESTAMP
+            WHERE state_hash=:state_hash AND status='pending' AND expires_at>CURRENT_TIMESTAMP
+            RETURNING provider_uid::text,provider_version,nonce_hash,pkce_verifier_hash,redirect_uri,expires_at
+        """), {"state_hash": state_hash}).mappings().one_or_none()
+        if commit:
+            self.session.commit()
+        return ({"provider_uid": row["provider_uid"], "provider_version": row["provider_version"],
+                 "nonce_hash": row["nonce_hash"], "verifier_hash": row["pkce_verifier_hash"],
+                 "redirect_uri": row["redirect_uri"], "expires_at": row["expires_at"]} if row else None)
+
+    def _insert_session(self, record: dict[str, Any]) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_sessions
+            (uid,family_uid,identity_link_uid,user_uid,identity_source,token_version,refresh_hash,status,
+             rotated_from_uid,emergency_request_uid,created_at,last_seen_at,expires_at)
+            VALUES (CAST(:uid AS uuid),CAST(:family_uid AS uuid),
+                    COALESCE(CAST(:identity_link_uid AS uuid),
+                    (SELECT uid FROM public.enterprise_identity_links WHERE provider_uid=CAST(:provider_uid AS uuid)
+                     AND enterprise_subject=:subject ORDER BY updated_at DESC LIMIT 1)),
+                    CAST(:user_uid AS uuid),:identity_source,:token_version,
+                    :refresh_hash,:status,CAST(:rotated_from_uid AS uuid),CAST(:emergency_request_uid AS uuid),
+                    :created_at,:last_seen_at,:expires_at)
+        """), {**record, "identity_link_uid": record.get("identity_link_uid"),
+                 "emergency_request_uid": record.get("emergency_request_uid")})
+
+    def put_session(self, record: dict[str, Any]) -> None:
+        self._insert_session(record)
+        self.session.commit()
+
+    def create_session(self, record: dict[str, Any], *, max_sessions: int, commit: bool = True) -> None:
+        # Serialize all session creations for one account. Without this lock two
+        # concurrent transactions can both observe the same pre-insert count.
+        locked = self.session.execute(
+            text("SELECT id FROM public.users WHERE id=CAST(:user AS uuid) FOR UPDATE"),
+            {"user": record["user_uid"]},
+        ).scalar_one_or_none()
+        if not locked:
+            raise IdentityPolicyError("identity session user does not exist")
+        self._insert_session(record)
+        self.session.execute(text("""
+            WITH excess AS (
+              SELECT uid FROM public.identity_sessions WHERE user_uid=CAST(:user AS uuid) AND status='active'
+              ORDER BY created_at DESC,uid DESC OFFSET :maximum
+            ) UPDATE public.identity_sessions s SET status='revoked',revoke_reason='concurrency_limit',
+              revoked_at=CURRENT_TIMESTAMP FROM excess WHERE s.uid=excess.uid
+        """), {"user": record["user_uid"], "maximum": max_sessions})
+        if commit:
+            self.session.commit()
+
+    def insert_exchange_code(self, record: dict[str, Any], *, commit: bool = True) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_exchange_codes(uid,code_hash,session_uid,redirect_uri,status,expires_at)
+            VALUES(CAST(:uid AS uuid),:code_hash,CAST(:session_uid AS uuid),:redirect_uri,'pending',:expires_at)
+        """), record)
+        if commit:
+            self.session.commit()
+
+    def rotate_refresh(self, old_hash: str, new_session: dict[str, Any]) -> str:
+        row = self.session.execute(text("""
+            UPDATE public.identity_sessions SET status='rotated',rotated_at=:rotated_at
+            WHERE refresh_hash=:refresh_hash AND status='active' AND expires_at>:rotated_at
+            RETURNING family_uid::text
+        """), {"refresh_hash": old_hash, "rotated_at": new_session["created_at"]}).mappings().one_or_none()
+        if row:
+            self._insert_session(new_session)
+            self.session.commit()
+            return "rotated"
+        old = self.session.execute(text("SELECT family_uid::text,status FROM public.identity_sessions WHERE refresh_hash=:hash FOR UPDATE"),
+                                   {"hash": old_hash}).mappings().one_or_none()
+        if not old:
+            self.session.rollback()
+            return "unknown"
+        self.session.execute(text("UPDATE public.identity_sessions SET status='revoked',revoke_reason='refresh_reuse',revoked_at=CURRENT_TIMESTAMP WHERE family_uid=CAST(:family AS uuid)"),
+                             {"family": old["family_uid"]})
+        self.session.commit()
+        return "reuse"
+
+    @staticmethod
+    def _session(row: Any) -> dict[str, Any] | None:
+        if not row:
+            return None
+        result = dict(row)
+        for key in ("uid", "family_uid", "identity_link_uid", "user_uid", "rotated_from_uid", "emergency_request_uid"):
+            result[key] = str(result[key]) if result.get(key) else None
+        result.setdefault("roles", [])
+        result.setdefault("subject", result["user_uid"])
+        return result
+
+    def get_session(self, uid: str) -> dict[str, Any] | None:
+        row = self.session.execute(text("""
+            SELECT s.*,l.provider_uid::text provider_uid,COALESCE(l.enterprise_subject,s.user_uid::text) subject,
+              COALESCE(array_agg(r.name) FILTER (WHERE r.name IS NOT NULL),ARRAY[]::varchar[]) roles
+            FROM public.identity_sessions s LEFT JOIN public.user_roles ur ON ur.user_id=s.user_uid
+            LEFT JOIN public.roles r ON r.id=ur.role_id
+            LEFT JOIN public.enterprise_identity_links l ON l.uid=s.identity_link_uid
+            WHERE s.uid=CAST(:uid AS uuid) GROUP BY s.uid,l.provider_uid,l.enterprise_subject
+        """), {"uid": uid}).mappings().one_or_none()
+        return self._session(row)
+
+    def get_session_by_refresh(self, refresh_hash: str) -> dict[str, Any] | None:
+        uid = self.session.execute(text("SELECT uid::text FROM public.identity_sessions WHERE refresh_hash=:value"),
+                                   {"value": refresh_hash}).scalar_one_or_none()
+        return self.get_session(uid) if uid else None
+
+    def update_session(self, uid: str, *, commit: bool = True, **changes: Any) -> dict[str, Any]:
+        allowed = {"status", "revoke_reason", "risk_reason", "last_seen_at", "rotated_at", "revoked_at"}
+        safe = {key: value for key, value in changes.items() if key in allowed}
+        if safe:
+            assignments = ",".join(f"{key}=:{key}" for key in safe)
+            self.session.execute(text(f"UPDATE public.identity_sessions SET {assignments} WHERE uid=CAST(:uid AS uuid)"), {**safe, "uid": uid})
+            if commit:
+                self.session.commit()
+        return self.get_session(uid) or {}
+
+    def list_sessions(self, provider_uid: str | None = None, subject: str | None = None) -> list[dict[str, Any]]:
+        sql = "SELECT s.uid::text FROM public.identity_sessions s LEFT JOIN public.enterprise_identity_links l ON l.uid=s.identity_link_uid"
+        params: dict[str, Any] = {}
+        clauses: list[str] = []
+        if provider_uid:
+            clauses.append("l.provider_uid=CAST(:provider AS uuid)")
+            params["provider"] = provider_uid
+        if subject:
+            clauses.append("l.enterprise_subject=:subject")
+            params["subject"] = subject
+        if clauses:
+            sql += " WHERE " + " AND ".join(clauses)
+        uids = self.session.execute(text(sql), params).scalars().all()
+        return [record for uid in uids if (record := self.get_session(uid))]
+
+    def revoke_family(self, family_uid: str, *, reason: str) -> None:
+        self.session.execute(text("UPDATE public.identity_sessions SET status='revoked',revoke_reason=:reason,revoked_at=CURRENT_TIMESTAMP WHERE family_uid=CAST(:family AS uuid)"), {"family": family_uid, "reason": reason})
+        self.session.commit()
+
+    def revoke_subject_sessions(self, provider_uid: str | None, subject: str, *, reason: str,
+                                commit: bool = True) -> None:
+        if not provider_uid:
+            return
+        self.session.execute(text("""
+            UPDATE public.identity_sessions SET status='revoked',revoke_reason=:reason,revoked_at=CURRENT_TIMESTAMP
+            WHERE status='active' AND identity_link_uid IN (
+              SELECT uid FROM public.enterprise_identity_links
+              WHERE provider_uid=CAST(:provider AS uuid) AND enterprise_subject=:subject
+            )
+        """), {"provider": provider_uid, "subject": subject, "reason": reason})
+        if commit:
+            self.session.commit()
+
+    def get_directory_event(self, provider_uid: str, source: str, source_event_id: str) -> dict[str, Any] | None:
+        row = self.session.execute(text("SELECT payload_digest,safe_result FROM public.identity_directory_events WHERE provider_uid=CAST(:provider AS uuid) AND source=:source AND source_event_id=:event"),
+                                   {"provider": provider_uid, "source": source, "event": source_event_id}).mappings().one_or_none()
+        return {"payload_digest": row["payload_digest"], "result": dict(row["safe_result"])} if row else None
+
+    def put_directory_event(self, event: dict[str, Any]) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_directory_events
+            (uid,provider_uid,source,source_event_id,cursor_value,cursor_sequence,event_type,enterprise_subject,payload_digest,status,safe_result,processed_at)
+            VALUES (CAST(:uid AS uuid),CAST(:provider_uid AS uuid),:source,:source_event_id,:cursor,:cursor_sequence,
+                    :event_type,:subject,:payload_digest,'applied',CAST(:result AS jsonb),:processed_at)
+        """), {**event, "uid": new_governance_uid(), "event_type": event.get("event_type", "MOVER"),
+                 "subject": event.get("subject"), "result": json.dumps(event["result"])})
+        self.session.execute(text("""
+            INSERT INTO public.identity_directory_checkpoints(provider_uid,source,cursor_value,cursor_sequence,updated_at)
+            VALUES(CAST(:provider_uid AS uuid),:source,:cursor,:cursor_sequence,CURRENT_TIMESTAMP)
+            ON CONFLICT(provider_uid,source) DO UPDATE SET cursor_value=EXCLUDED.cursor_value,
+              cursor_sequence=EXCLUDED.cursor_sequence,updated_at=CURRENT_TIMESTAMP
+        """), event)
+        self.session.commit()
+
+    def get_directory_checkpoint(self, provider_uid: str, source: str) -> dict[str, Any] | None:
+        row = self.session.execute(text("SELECT cursor_value cursor,cursor_sequence FROM public.identity_directory_checkpoints WHERE provider_uid=CAST(:provider AS uuid) AND source=:source"),
+                                   {"provider": provider_uid, "source": source}).mappings().one_or_none()
+        return dict(row) if row else None
+
+    def record_directory_conflict(self, *, commit: bool = True, **record: Any) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_directory_conflicts
+            (uid,provider_uid,source,source_event_id,cursor_value,cursor_sequence,payload_digest,reason)
+            VALUES(CAST(:uid AS uuid),CAST(:provider_uid AS uuid),:source,:source_event_id,:cursor,:cursor_sequence,:payload_digest,:reason)
+        """), {**record, "uid": new_governance_uid()})
+        if commit:
+            self.session.commit()
+
+    def get_identity(self, provider_uid: str, subject: str, *, for_update: bool = False) -> dict[str, Any] | None:
+        lock = " FOR UPDATE" if for_update else ""
+        row = self.session.execute(text("""
+            SELECT user_uid::text,username,display_name,department_external_id department,groups,
+                   role_names roles,authorization_scope,mapping_version,claims_digest,status,token_version
+            FROM public.enterprise_identity_links WHERE provider_uid=CAST(:provider AS uuid) AND enterprise_subject=:subject
+        """ + lock), {"provider": provider_uid, "subject": subject}).mappings().one_or_none()
+        return dict(row) if row else None
+
+    def put_identity(self, provider_uid: str, subject: str, identity: dict[str, Any], *, commit: bool = True) -> None:
+        existing = self.get_identity(provider_uid, subject, for_update=not commit)
+        if not existing:
+            if not all(identity.get(key) for key in ("provider_uid", "username", "display_name", "mapping_version", "claims_digest")):
+                raise IdentityPolicyError("JOINER requires an approved claims mapping result")
+            from app.core.system.auth import hash_password
+
+            self.session.execute(text("INSERT INTO public.users(id,username,display_name,password_hash,status) VALUES (CAST(:uid AS uuid),:username,:display,:password,'active')"),
+                                 {"uid": identity["user_uid"], "username": identity["username"],
+                                  "display": identity["display_name"], "password": hash_password(secrets.token_urlsafe(48))})
+            self.session.execute(text("""
+                INSERT INTO public.enterprise_identity_links
+                (uid,provider_uid,enterprise_subject,user_uid,username,display_name,department_external_id,groups,role_names,
+                 authorization_scope,mapping_version,claims_digest,status,token_version)
+                VALUES (CAST(:uid AS uuid),CAST(:provider AS uuid),:subject,CAST(:user AS uuid),:username,:display,
+                 COALESCE(:department,''),CAST(:groups AS jsonb),CAST(:roles AS jsonb),CAST(:scope AS jsonb),:mapping,
+                 :digest,:status,:token_version)
+            """), {"uid": new_governance_uid(), "provider": identity["provider_uid"], "subject": subject,
+                    "user": identity["user_uid"], "username": identity["username"], "display": identity["display_name"],
+                    "department": identity.get("department"), "groups": json.dumps(identity.get("groups", [])),
+                    "roles": json.dumps(identity.get("roles", [])), "scope": json.dumps(identity.get("authorization_scope", {})),
+                    "mapping": identity["mapping_version"], "digest": identity["claims_digest"],
+                    "status": identity["status"], "token_version": identity["token_version"]})
+        else:
+            self.session.execute(text("""
+            UPDATE public.enterprise_identity_links SET username=:username,department_external_id=COALESCE(:department,''),
+                display_name=COALESCE(:display_name,display_name),groups=CAST(:groups AS jsonb),role_names=CAST(:roles AS jsonb),
+                authorization_scope=CAST(:scope AS jsonb),mapping_version=COALESCE(:mapping_version,mapping_version),
+                claims_digest=COALESCE(:claims_digest,claims_digest),status=:status,token_version=:token_version,
+                updated_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:provider AS uuid) AND enterprise_subject=:subject
+            """), {"provider": provider_uid, "subject": subject, "username": identity["username"], "department": identity.get("department"),
+                     "display_name": identity.get("display_name"), "groups": json.dumps(identity.get("groups", [])),
+                     "roles": json.dumps(identity.get("roles", [])), "scope": json.dumps(identity.get("authorization_scope", {})),
+                     "mapping_version": identity.get("mapping_version"), "claims_digest": identity.get("claims_digest"),
+                     "status": identity["status"], "token_version": identity["token_version"]})
+        self.session.execute(text("DELETE FROM public.user_roles WHERE user_id=CAST(:user AS uuid)"), {"user": identity["user_uid"]})
+        self.session.execute(text("INSERT INTO public.user_roles(user_id,role_id) SELECT CAST(:user AS uuid),id FROM public.roles WHERE name=ANY(:roles)"),
+                             {"user": identity["user_uid"], "roles": identity.get("roles", [])})
+        self.session.execute(
+            text("UPDATE public.users SET status=:status,updated_at=CURRENT_TIMESTAMP WHERE id=CAST(:user AS uuid)"),
+            {"user": identity["user_uid"], "status": "active" if identity["status"] == "active" else "disabled"},
+        )
+        if commit:
+            self.session.commit()
+
+    def get_organization_node(self, provider_uid: str, node_type: str, external_id: str,
+                              *, for_update: bool = False) -> dict[str, Any] | None:
+        lock = " FOR UPDATE" if for_update else ""
+        row = self.session.execute(text("""
+            SELECT uid::text,provider_uid::text,external_id,node_type,parent_external_id,display_name,status,attributes,updated_at
+            FROM public.identity_organization_nodes
+            WHERE provider_uid=CAST(:provider AS uuid) AND node_type=:node_type AND external_id=:external_id
+        """ + lock), {"provider": provider_uid, "node_type": node_type,
+                       "external_id": external_id}).mappings().one_or_none()
+        return dict(row) if row else None
+
+    def put_organization_node(self, node: dict[str, Any], *, commit: bool = True) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_organization_nodes
+            (uid,provider_uid,external_id,node_type,parent_external_id,display_name,status,attributes,updated_at)
+            VALUES(CAST(:uid AS uuid),CAST(:provider_uid AS uuid),:external_id,:node_type,:parent_external_id,
+                   :display_name,:status,CAST(:attributes AS jsonb),:updated_at)
+            ON CONFLICT(provider_uid,node_type,external_id) DO UPDATE SET
+              parent_external_id=EXCLUDED.parent_external_id,display_name=EXCLUDED.display_name,
+              status=EXCLUDED.status,attributes=EXCLUDED.attributes,updated_at=EXCLUDED.updated_at
+        """), {**node, "attributes": json.dumps(node.get("attributes", {}))})
+        if commit:
+            self.session.commit()
+
+    def _insert_directory_event(self, event: dict[str, Any], result: dict[str, Any]) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_directory_events
+            (uid,provider_uid,source,source_event_id,cursor_value,cursor_sequence,event_type,enterprise_subject,payload_digest,status,safe_result,processed_at)
+            VALUES (CAST(:uid AS uuid),CAST(:provider_uid AS uuid),:source,:source_event_id,:cursor,:cursor_sequence,
+                    :event_type,:subject,:payload_digest,'applied',CAST(:result AS jsonb),:processed_at)
+        """), {**event, "uid": new_governance_uid(), "subject": event.get("subject"),
+                 "result": json.dumps(result)})
+        self.session.execute(text("""
+            INSERT INTO public.identity_directory_checkpoints(provider_uid,source,cursor_value,cursor_sequence,updated_at)
+            VALUES(CAST(:provider_uid AS uuid),:source,:cursor,:cursor_sequence,CURRENT_TIMESTAMP)
+            ON CONFLICT(provider_uid,source) DO UPDATE SET cursor_value=EXCLUDED.cursor_value,
+              cursor_sequence=EXCLUDED.cursor_sequence,updated_at=CURRENT_TIMESTAMP
+        """), event)
+
+    def apply_directory_event_atomic(self, event: dict[str, Any],
+                                     mutation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
+        """Serialize one provider/source feed and commit its complete effect once."""
+        try:
+            scope = f"{event['provider_uid']}:{event['source']}"
+            self.session.execute(text("SELECT pg_advisory_xact_lock(hashtextextended(:scope,0))"), {"scope": scope})
+            previous = self.get_directory_event(event["provider_uid"], event["source"], event["source_event_id"])
+            if previous:
+                if previous["payload_digest"] != event["payload_digest"]:
+                    self.record_directory_conflict(commit=False, **event, reason="source_event_id_payload_mismatch")
+                    self.session.commit()
+                    raise IdentityPolicyError("directory event idempotency conflict")
+                self.session.commit()
+                return {**previous["result"], "idempotent": True}
+            checkpoint = self.get_directory_checkpoint(event["provider_uid"], event["source"])
+            if checkpoint and event["cursor_sequence"] <= checkpoint["cursor_sequence"]:
+                self.record_directory_conflict(commit=False, **event, reason="cursor_not_monotonic")
+                self.session.commit()
+                raise IdentityPolicyError("directory cursor is not monotonic")
+            result = mutation()
+            self._insert_directory_event(event, result)
+            self.session.commit()
+            return result
+        except IdentityPolicyError:
+            # Policy conflicts are committed above so operators retain evidence;
+            # mutation failures are rolled back with every partial lifecycle write.
+            if self.session.in_transaction():
+                self.session.rollback()
+            raise
+        except Exception:
+            self.session.rollback()
+            raise
+
+    def put_emergency(self, record: dict[str, Any]) -> None:
+        existing = self.get_emergency(record["uid"])
+        if not existing:
+            self.session.execute(text("""
+                INSERT INTO public.identity_emergency_requests
+                (uid,requester_uid,emergency_account_uid,reason,status,starts_at,expires_at)
+                VALUES (CAST(:uid AS uuid),CAST(:requester_uid AS uuid),CAST(:account_uid AS uuid),:reason,:status,:starts_at,:expires_at)
+            """), {**record, "starts_at": record["requested_at"]})
+        else:
+            self.session.execute(text("UPDATE public.identity_emergency_requests SET status=:status,activated_at=:activated_at,closed_at=:closed_at,reviewer_uid=CAST(:reviewer_uid AS uuid),review_outcome=:review_outcome WHERE uid=CAST(:uid AS uuid)"),
+                                 {"uid": record["uid"], "status": record["status"], "activated_at": record.get("activated_at"),
+                                  "closed_at": record.get("closed_at"), "reviewer_uid": record.get("reviewer_uid"),
+                                  "review_outcome": record.get("review_outcome")})
+            for approver in set(record.get("approver_uids", [])) - set(existing.get("approver_uids", [])):
+                self.session.execute(text("INSERT INTO public.identity_emergency_approvals(request_uid,approver_uid) VALUES (CAST(:request AS uuid),CAST(:approver AS uuid)) ON CONFLICT DO NOTHING"), {"request": record["uid"], "approver": approver})
+            if record["status"] in {"closed", "expired", "reviewed"}:
+                self.session.execute(text("""
+                    UPDATE public.identity_sessions SET status='revoked',revoke_reason=:reason,revoked_at=CURRENT_TIMESTAMP
+                    WHERE emergency_request_uid=CAST(:request AS uuid) AND status='active'
+                """), {"request": record["uid"], "reason": f"emergency_{record['status']}"})
+        self.session.commit()
+
+    def get_emergency(self, uid: str) -> dict[str, Any] | None:
+        row = self.session.execute(text("""
+            SELECT r.uid::text,r.requester_uid::text,r.emergency_account_uid::text account_uid,r.reason,r.status,
+                   r.starts_at requested_at,r.expires_at,r.activated_at,r.reviewer_uid::text,r.review_outcome,
+                   COALESCE(array_agg(a.approver_uid::text) FILTER (WHERE a.approver_uid IS NOT NULL),ARRAY[]::text[]) approver_uids
+            FROM public.identity_emergency_requests r LEFT JOIN public.identity_emergency_approvals a ON a.request_uid=r.uid
+            WHERE r.uid=CAST(:uid AS uuid) GROUP BY r.uid
+        """), {"uid": uid}).mappings().one_or_none()
+        return dict(row) if row else None
+
+    def audit(self, event: dict[str, Any]) -> None:
+        self.session.execute(text("""
+            INSERT INTO public.identity_audit_events
+            (uid,event_type,outcome,actor_uid,provider_uid,enterprise_subject_digest,resource_type,resource_uid,correlation_uid,safe_detail)
+            VALUES (CAST(:uid AS uuid),:event_type,:outcome,CAST(:actor_uid AS uuid),CAST(:provider_uid AS uuid),:subject_digest,
+                    :resource_type,:resource_uid,CAST(:correlation_uid AS uuid),CAST(:safe_detail AS jsonb))
+        """), {"uid": new_governance_uid(), "event_type": event["event_type"], "outcome": event["outcome"],
+                 "actor_uid": event.get("actor_uid"), "provider_uid": event.get("provider_uid"),
+                 "subject_digest": event.get("subject_digest"), "resource_type": event.get("resource_type", "identity"),
+                 "resource_uid": event.get("resource_uid"), "correlation_uid": event.get("correlation_uid"),
+                 "safe_detail": json.dumps(event.get("safe_detail", {}))})
+        self.session.commit()
+
+    def emergency_session_active(self, request_uid: str, account_uid: str, now: Any) -> bool:
+        return bool(self.session.execute(text("""
+            SELECT 1 FROM public.identity_emergency_requests e
+            JOIN public.users u ON u.id=e.emergency_account_uid
+            WHERE e.uid=CAST(:request AS uuid) AND e.emergency_account_uid=CAST(:account AS uuid)
+              AND e.status='active' AND e.starts_at<=:now AND e.expires_at>:now AND u.status='active'
+              AND EXISTS (
+                SELECT 1 FROM public.user_roles ur JOIN public.roles r ON r.id=ur.role_id
+                WHERE ur.user_id=u.id AND r.name='admin'
+              )
+              AND NOT EXISTS (
+                SELECT 1 FROM public.enterprise_identity_links l WHERE l.user_uid=u.id
+              )
+        """), {"request": request_uid, "account": account_uid, "now": now}).scalar())

+ 103 - 0
deployment/app/core/system/identity_sessions.py

@@ -0,0 +1,103 @@
+"""Server-side session ledger with opaque rotating refresh credentials."""
+
+from __future__ import annotations
+
+import hashlib
+import secrets
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.system.enterprise_identity import IdentityPolicyError
+from app.core.system.tokens import issue_access_token
+
+
+@dataclass(frozen=True)
+class SessionCredentials:
+    session_uid: str
+    access_token: str
+    refresh_token: str
+
+
+class SessionManager:
+    def __init__(self, repository: Any, *, secret: str, clock: Callable[[], datetime] | None = None,
+                 max_sessions: int = 5, refresh_lifetime: timedelta = timedelta(hours=8)) -> None:
+        self.repository = repository
+        self.secret = secret
+        self.clock = clock or (lambda: datetime.now(UTC))
+        self.max_sessions = max_sessions
+        self.refresh_lifetime = refresh_lifetime
+
+    @staticmethod
+    def _digest(token: str) -> str:
+        return hashlib.sha256(token.encode()).hexdigest()
+
+    def _access(self, record: dict[str, Any]) -> str:
+        return issue_access_token(user_id=record["user_uid"], roles=list(record["roles"]), secret=self.secret,
+                                  now=self.clock(), session_uid=record["uid"], token_version=record["token_version"],
+                                  identity_source=record["identity_source"])
+
+    def create(self, *, subject: str, user_uid: str, roles: list[str], identity_source: str,
+               token_version: int = 1, provider_uid: str | None = None,
+               emergency_request_uid: str | None = None,
+               emergency_expires_at: datetime | None = None, commit: bool = True) -> SessionCredentials:
+        if identity_source not in {"oidc", "local", "emergency"}:
+            raise IdentityPolicyError("unsupported identity source")
+        if identity_source == "oidc" and not provider_uid:
+            raise IdentityPolicyError("OIDC session requires provider binding")
+        if identity_source == "emergency" and (not emergency_request_uid or not emergency_expires_at):
+            raise IdentityPolicyError("emergency session requires request and expiry binding")
+        now = self.clock()
+        if identity_source == "emergency" and not self.repository.emergency_session_active(
+                str(emergency_request_uid), user_uid, now):
+            raise IdentityPolicyError("emergency account is no longer an active local administrator")
+        refresh = secrets.token_urlsafe(48)
+        record = {"uid": new_governance_uid(), "family_uid": new_governance_uid(), "subject": subject,
+                  "user_uid": user_uid, "roles": sorted(set(roles)), "identity_source": identity_source,
+                  "provider_uid": provider_uid, "emergency_request_uid": emergency_request_uid,
+                  "token_version": token_version, "refresh_hash": self._digest(refresh), "status": "active",
+                  "created_at": now, "last_seen_at": now,
+                  "expires_at": min(now + self.refresh_lifetime, emergency_expires_at) if emergency_expires_at else now + self.refresh_lifetime,
+                  "rotated_from_uid": None, "risk_reason": None}
+        self.repository.create_session(record, max_sessions=self.max_sessions, commit=commit)
+        return SessionCredentials(record["uid"], self._access(record), refresh)
+
+    def refresh(self, refresh_token: str) -> SessionCredentials:
+        record = self.repository.get_session_by_refresh(self._digest(refresh_token))
+        if not record:
+            raise IdentityPolicyError("unknown refresh credential")
+        if record["identity_source"] == "emergency" and not self.repository.emergency_session_active(
+                record["emergency_request_uid"], record["user_uid"], self.clock()):
+            self.repository.revoke_family(record["family_uid"], reason="emergency_window_closed")
+            raise IdentityPolicyError("emergency access window is not active")
+        if record["status"] != "active":
+            self.repository.revoke_family(record["family_uid"], reason="refresh_reuse")
+            raise IdentityPolicyError("refresh reuse detected")
+        if self.clock() >= record["expires_at"]:
+            self.repository.update_session(record["uid"], status="revoked", revoke_reason="expired")
+            raise IdentityPolicyError("refresh credential expired")
+        refresh = secrets.token_urlsafe(48)
+        next_expiry = self.clock() + self.refresh_lifetime
+        if record["identity_source"] == "emergency":
+            next_expiry = min(record["expires_at"], next_expiry)
+        next_record = {**record, "uid": new_governance_uid(), "refresh_hash": self._digest(refresh),
+                       "status": "active", "created_at": self.clock(), "last_seen_at": self.clock(),
+                       "expires_at": next_expiry, "rotated_from_uid": record["uid"]}
+        outcome = self.repository.rotate_refresh(self._digest(refresh_token), next_record)
+        if outcome == "unknown":
+            raise IdentityPolicyError("unknown refresh credential")
+        if outcome == "reuse":
+            raise IdentityPolicyError("refresh reuse detected")
+        return SessionCredentials(next_record["uid"], self._access(next_record), refresh)
+
+    def revoke(self, session_uid: str, *, reason: str = "logout") -> None:
+        if self.repository.get_session(session_uid):
+            self.repository.update_session(session_uid, status="revoked", revoke_reason=reason)
+
+    def risk_revoke(self, session_uid: str, *, reason: str) -> None:
+        self.repository.update_session(session_uid, status="revoked", revoke_reason="risk", risk_reason=reason)
+
+    def revoke_subject(self, provider_uid: str | None, subject: str, *, reason: str) -> None:
+        self.repository.revoke_subject_sessions(provider_uid, subject, reason=reason)

+ 261 - 0
deployment/app/core/system/oidc.py

@@ -0,0 +1,261 @@
+"""OIDC Authorization Code + PKCE protocol helpers with strict token validation."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import ipaddress
+import json
+import secrets
+import socket
+from collections.abc import Callable, Mapping
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from typing import Any
+from urllib.parse import urlencode, urlsplit
+
+import jwt
+import requests
+import urllib3
+
+from app.core.system.enterprise_identity import (
+    IdentityPolicyError,
+    IdentityUpstreamError,
+    IdpConfig,
+)
+
+
+def _digest(value: str) -> str:
+    return hashlib.sha256(value.encode()).hexdigest()
+
+
+@dataclass(frozen=True)
+class AuthorizationStart:
+    url: str
+    state: str
+    nonce: str
+    code_verifier: str
+    code_challenge: str
+    code_challenge_method: str = "S256"
+
+
+class OidcClient:
+    def __init__(self, repository: Any, *, clock: Callable[[], datetime] | None = None,
+                 flow_lifetime: timedelta = timedelta(minutes=5)) -> None:
+        self.repository = repository
+        self.clock = clock or (lambda: datetime.now(UTC))
+        self.flow_lifetime = flow_lifetime
+
+    def begin(self, config: IdpConfig, redirect_uri: str) -> AuthorizationStart:
+        config.validate()
+        if redirect_uri not in config.redirect_uris:
+            raise IdentityPolicyError("redirect URI is not allowlisted")
+        state, nonce, verifier = (secrets.token_urlsafe(32) for _ in range(3))
+        challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
+        self.repository.put_flow({"state_hash": _digest(state), "provider_uid": config.provider_uid,
+                                  "provider_version": config.version, "nonce_hash": _digest(nonce),
+                                  "verifier_hash": _digest(verifier), "redirect_uri": redirect_uri,
+                                  "expires_at": self.clock() + self.flow_lifetime, "consumed_at": None})
+        params = {"response_type": "code", "client_id": config.client_id, "redirect_uri": redirect_uri,
+                  "scope": "openid profile", "state": state, "nonce": nonce,
+                  "code_challenge": challenge, "code_challenge_method": "S256"}
+        return AuthorizationStart(f"{config.authorization_endpoint}?{urlencode(params)}", state, nonce, verifier, challenge)
+
+    def verify_callback(self, config: IdpConfig, *, state: str, redirect_uri: str, code_verifier: str,
+                        id_token: str, jwks: Mapping[str, Any], commit: bool = True) -> dict[str, Any]:
+        flow = self.repository.consume_flow(_digest(state), commit=commit)
+        if not flow:
+            raise IdentityPolicyError("state is invalid or already consumed")
+        if self.clock() >= flow["expires_at"] or flow["provider_uid"] != config.provider_uid or flow["provider_version"] != config.version:
+            raise IdentityPolicyError("state is expired or bound to another provider")
+        if redirect_uri != flow["redirect_uri"] or redirect_uri not in config.redirect_uris or _digest(code_verifier) != flow["verifier_hash"]:
+            raise IdentityPolicyError("callback binding or PKCE verifier is invalid")
+        try:
+            header = jwt.get_unverified_header(id_token)
+            algorithm = header.get("alg")
+            if algorithm not in config.algorithms or algorithm == "none":
+                raise IdentityPolicyError("ID token algorithm is not allowed")
+            candidates = [key for key in jwks.get("keys", ()) if key.get("kid") == header.get("kid") and key.get("alg", algorithm) == algorithm]
+            if len(candidates) != 1:
+                raise IdentityPolicyError("ID token signing key is ambiguous or missing")
+            public_key = jwt.algorithms.get_default_algorithms()[algorithm].from_jwk(candidates[0])
+            claims = jwt.decode(id_token, public_key, algorithms=[algorithm], audience=config.client_id,
+                                issuer=config.issuer, options={"verify_exp": False,
+                                "require": ["iss", "aud", "sub", "nonce", "iat", "exp"]})
+        except IdentityPolicyError:
+            raise
+        except jwt.PyJWTError as exc:
+            raise IdentityPolicyError("ID token validation failed") from exc
+        if _digest(str(claims["nonce"])) != flow["nonce_hash"]:
+            raise IdentityPolicyError("ID token nonce mismatch")
+        now = int(self.clock().timestamp())
+        if int(claims["exp"]) <= now:
+            raise IdentityPolicyError("ID token expired")
+        if int(claims["iat"]) > now + 30:
+            raise IdentityPolicyError("ID token issued in the future")
+        return dict(claims)
+
+
+class OidcTransport:
+    """Bounded HTTPS transport whose production connection is pinned to validated IPs."""
+
+    def __init__(self, http: Any | None = None, *, connect_timeout: float = 3.0,
+                 read_timeout: float = 7.0, max_response_bytes: int = 1024 * 1024,
+                 resolver: Callable[..., Any] = socket.getaddrinfo,
+                 pool_factory: Callable[..., Any] = urllib3.HTTPSConnectionPool,
+                 allowed_hosts: set[str] | None = None) -> None:
+        self.http = http
+        self.connect_timeout = connect_timeout
+        self.read_timeout = read_timeout
+        self.timeout = (connect_timeout, read_timeout)
+        self.max_response_bytes = max_response_bytes
+        self.resolver = resolver
+        self.pool_factory = pool_factory
+        self.allowed_hosts = {host.lower() for host in allowed_hosts} if allowed_hosts else None
+
+    def _validate_destination(self, url: str) -> tuple[Any, tuple[str, ...]]:
+        parsed = urlsplit(url)
+        if parsed.scheme != "https" or not parsed.hostname:
+            raise IdentityPolicyError("OIDC destination must be an absolute HTTPS URL")
+        hostname = parsed.hostname.lower()
+        if self.allowed_hosts is not None and hostname not in self.allowed_hosts:
+            raise IdentityPolicyError("enterprise identity provider host is not server-allowlisted")
+        try:
+            addresses = {
+                result[4][0]
+                for result in self.resolver(hostname, parsed.port or 443, type=socket.SOCK_STREAM)
+            }
+        except (OSError, socket.gaierror) as exc:
+            raise IdentityUpstreamError("enterprise identity provider DNS resolution failed") from exc
+        if not addresses:
+            raise IdentityUpstreamError("enterprise identity provider DNS returned no addresses")
+        try:
+            parsed_addresses = [ipaddress.ip_address(address) for address in addresses]
+        except ValueError as exc:
+            raise IdentityUpstreamError("enterprise identity provider DNS response is invalid") from exc
+        if any(not address.is_global for address in parsed_addresses):
+            raise IdentityPolicyError("enterprise identity provider resolved to a non-public address")
+        return parsed, tuple(sorted(str(address) for address in parsed_addresses))
+
+    def _decode_json_response(self, *, status: int, headers: Mapping[str, Any], chunks: Any) -> Mapping[str, Any]:
+        if 300 <= status < 400:
+            raise IdentityUpstreamError("enterprise identity provider redirect is forbidden")
+        if status < 200 or status >= 300:
+            raise IdentityUpstreamError("enterprise identity provider returned an HTTP error")
+        content_type = str(headers.get("Content-Type", "")).lower()
+        if "application/json" not in content_type:
+            raise IdentityUpstreamError("enterprise identity provider response must be JSON")
+        content_length = headers.get("Content-Length")
+        if content_length:
+            try:
+                parsed_length = int(content_length)
+            except (TypeError, ValueError) as exc:
+                raise IdentityUpstreamError("enterprise identity provider response length is invalid") from exc
+            if parsed_length < 0:
+                raise IdentityUpstreamError("enterprise identity provider response length is invalid")
+            if parsed_length > self.max_response_bytes:
+                raise IdentityUpstreamError("enterprise identity provider response is too large")
+        body = bytearray()
+        try:
+            for chunk in chunks:
+                if chunk:
+                    body.extend(chunk)
+                    if len(body) > self.max_response_bytes:
+                        raise IdentityUpstreamError("enterprise identity provider response is too large")
+        except (requests.RequestException, urllib3.exceptions.HTTPError, OSError) as exc:
+            raise IdentityUpstreamError("enterprise identity provider response read failed") from exc
+        try:
+            payload = json.loads(body.decode("utf-8"))
+        except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+            raise IdentityUpstreamError("enterprise identity provider returned invalid JSON") from exc
+        if not isinstance(payload, Mapping):
+            raise IdentityUpstreamError("enterprise identity provider JSON object is required")
+        return payload
+
+    def _pinned_request_json(self, method: str, parsed: Any, addresses: tuple[str, ...],
+                             data: Mapping[str, Any] | None) -> Mapping[str, Any]:
+        hostname = parsed.hostname.lower()
+        port = parsed.port or 443
+        request_target = parsed.path or "/"
+        if parsed.query:
+            request_target += f"?{parsed.query}"
+        host_header = hostname if port == 443 else f"{hostname}:{port}"
+        body = urlencode(data).encode("utf-8") if data is not None else None
+        headers = {"Host": host_header, "Accept": "application/json"}
+        if body is not None:
+            headers["Content-Type"] = "application/x-www-form-urlencoded"
+        last_error: Exception | None = None
+        for address in addresses:
+            pool = self.pool_factory(
+                host=address,
+                port=port,
+                server_hostname=hostname,
+                assert_hostname=hostname,
+                cert_reqs="CERT_REQUIRED",
+                ca_certs=requests.certs.where(),
+                timeout=urllib3.Timeout(connect=self.connect_timeout, read=self.read_timeout),
+                retries=False,
+                maxsize=1,
+                block=True,
+            )
+            try:
+                response = pool.urlopen(
+                    method.upper(), request_target, body=body, headers=headers,
+                    redirect=False, retries=False, assert_same_host=False,
+                    preload_content=False, decode_content=True,
+                    timeout=urllib3.Timeout(connect=self.connect_timeout, read=self.read_timeout),
+                )
+            except (urllib3.exceptions.HTTPError, OSError) as exc:
+                last_error = exc
+                pool.close()
+                continue
+            try:
+                return self._decode_json_response(
+                    status=int(response.status), headers=response.headers,
+                    chunks=response.stream(amt=64 * 1024, decode_content=True),
+                )
+            finally:
+                response.release_conn()
+                pool.close()
+        raise IdentityUpstreamError("enterprise identity provider connection failed") from last_error
+
+    def _request_json(self, method: str, url: str, **kwargs: Any) -> Mapping[str, Any]:
+        parsed, addresses = self._validate_destination(url)
+        if self.http is None:
+            return self._pinned_request_json(method, parsed, addresses, kwargs.get("data"))
+        try:
+            response = getattr(self.http, method)(url, timeout=self.timeout, allow_redirects=False,
+                                                  stream=True, **kwargs)
+        except requests.RequestException as exc:
+            raise IdentityUpstreamError("enterprise identity provider request failed") from exc
+        try:
+            try:
+                status = int(getattr(response, "status_code", 0))
+            except (TypeError, ValueError) as exc:
+                raise IdentityUpstreamError("enterprise identity provider HTTP status is invalid") from exc
+            return self._decode_json_response(
+                status=status, headers=getattr(response, "headers", {}),
+                chunks=response.iter_content(chunk_size=64 * 1024),
+            )
+        finally:
+            close = getattr(response, "close", None)
+            if callable(close):
+                close()
+
+    def exchange_code(self, config: IdpConfig, *, code: str, redirect_uri: str, verifier: str) -> str:
+        payload = self._request_json(
+            "post", config.token_endpoint,
+            data={"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri,
+                  "client_id": config.client_id, "client_secret": config.resolve_secret(),
+                  "code_verifier": verifier},
+        )
+        id_token = payload.get("id_token")
+        if not isinstance(id_token, str) or not id_token:
+            raise IdentityPolicyError("token endpoint omitted ID token")
+        return id_token
+
+    def fetch_jwks(self, config: IdpConfig) -> Mapping[str, Any]:
+        payload = self._request_json("get", config.jwks_uri)
+        if not isinstance(payload, Mapping) or not isinstance(payload.get("keys"), list):
+            raise IdentityPolicyError("JWKS response is invalid")
+        return payload

+ 31 - 1
deployment/app/core/system/permissions.py

@@ -70,6 +70,9 @@ AGENTS_MANAGE = "agents:manage"
 SECURITY_GOVERNANCE_READ = "security-governance:read"
 SECURITY_GOVERNANCE_OPERATE = "security-governance:operate"
 SECURITY_GOVERNANCE_MANAGE = "security-governance:manage"
+IDENTITY_READ = "identity:read"
+IDENTITY_MANAGE = "identity:manage"
+IDENTITY_OPERATE = "identity:operate"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -84,6 +87,7 @@ ROLE_PERMISSIONS = {
             DATA_PRODUCTS_READ,
             AGENTS_READ,
             SECURITY_GOVERNANCE_READ,
+            IDENTITY_READ,
         }
     ),
     "editor": frozenset(
@@ -121,6 +125,8 @@ ROLE_PERMISSIONS = {
             AGENTS_OPERATE,
             SECURITY_GOVERNANCE_READ,
             SECURITY_GOVERNANCE_OPERATE,
+            IDENTITY_READ,
+            IDENTITY_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -188,6 +194,9 @@ ROLE_PERMISSIONS = {
             SECURITY_GOVERNANCE_READ,
             SECURITY_GOVERNANCE_OPERATE,
             SECURITY_GOVERNANCE_MANAGE,
+            IDENTITY_READ,
+            IDENTITY_OPERATE,
+            IDENTITY_MANAGE,
         }
     ),
 }
@@ -198,8 +207,29 @@ PUBLIC = "public"
 def permission_for_request(path: str, method: str) -> tuple[str, ...]:
     """Classify every API request in one auditable, deny-by-default policy."""
     method = method.upper()
-    if path in {"/api/system/health", "/api/system/auth/login"}:
+    public_methods = {
+        ("/api/system/health", "GET"),
+        ("/api/system/auth/login", "POST"),
+        ("/api/system/identity/providers", "GET"),
+        ("/api/system/identity/authorize", "POST"),
+        ("/api/system/identity/callback", "GET"),
+        ("/api/system/identity/exchange", "POST"),
+        ("/api/system/identity/refresh", "POST"),
+    }
+    if (path, method) in public_methods:
         return (PUBLIC,)
+    if path.startswith("/api/system/identity"):
+        if path == "/api/system/identity/logout" and method == "POST":
+            return (IDENTITY_READ,)
+        if path in {"/api/system/identity/idp-versions", "/api/system/identity/audit"} and method == "GET":
+            return (IDENTITY_MANAGE,)
+        if path == "/api/system/identity/sessions" and method == "GET":
+            return (IDENTITY_OPERATE,)
+        if method == "GET":
+            return (IDENTITY_READ,)
+        if any(marker in path for marker in ("/idp-versions", "/emergency/")):
+            return (IDENTITY_MANAGE,)
+        return (IDENTITY_OPERATE,)
     if path.startswith("/api/system/responsibilities/"):
         if method == "GET":
             return (RESPONSIBILITIES_READ,)

+ 14 - 4
deployment/app/core/system/tokens.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
 
 import jwt
 
@@ -18,8 +18,11 @@ def issue_access_token(
     secret: str,
     now: datetime | None = None,
     lifetime: timedelta = timedelta(minutes=30),
+    session_uid: str | None = None,
+    token_version: int | None = None,
+    identity_source: str | None = None,
 ) -> str:
-    now = now or datetime.now(timezone.utc)
+    now = now or datetime.now(UTC)
     claims = {
         "sub": str(user_id),
         "roles": sorted(set(roles)),
@@ -27,6 +30,12 @@ def issue_access_token(
         "exp": int((now + lifetime).timestamp()),
         "jti": new_governance_uid(),
     }
+    if session_uid is not None:
+        claims["sid"] = str(session_uid)
+    if token_version is not None:
+        claims["token_version"] = int(token_version)
+    if identity_source is not None:
+        claims["identity_source"] = identity_source
     return jwt.encode(claims, secret, algorithm="HS256")
 
 
@@ -46,11 +55,12 @@ def decode_access_token(
     except jwt.PyJWTError as exc:
         raise TokenError("invalid access token") from exc
 
-    current = int((now or datetime.now(timezone.utc)).timestamp())
+    current = int((now or datetime.now(UTC)).timestamp())
     if int(claims["exp"]) <= current:
         raise TokenError("access token expired")
     if int(claims["iat"]) > current + 30:
         raise TokenError("access token issued in the future")
     if not isinstance(claims.get("roles"), list):
         raise TokenError("invalid role claims")
-    return {key: claims[key] for key in ("sub", "roles", "iat", "exp", "jti")}
+    allowed = ("sub", "roles", "iat", "exp", "jti", "sid", "token_version", "identity_source")
+    return {key: claims[key] for key in allowed if key in claims}

+ 140 - 0
deployment/migrations/versions/20260802_470_enterprise_identity.py

@@ -0,0 +1,140 @@
+"""Add enterprise identity, OIDC, lifecycle, session and emergency ledgers."""
+
+from alembic import op
+
+revision = "20260802_470"
+down_revision = "20260802_460"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.identity_idp_config_versions (
+            uid UUID PRIMARY KEY, provider_uid UUID NOT NULL, version INTEGER NOT NULL CHECK (version > 0),
+            status VARCHAR(20) NOT NULL CHECK (status IN ('draft','active','disabled','superseded')),
+            issuer TEXT NOT NULL, client_id VARCHAR(300) NOT NULL,
+            secret_ref VARCHAR(160) NOT NULL CHECK (secret_ref ~ '^env:[A-Z][A-Z0-9_]*$'),
+            authorization_endpoint TEXT NOT NULL, token_endpoint TEXT NOT NULL, jwks_uri TEXT NOT NULL,
+            redirect_uris JSONB NOT NULL CHECK (jsonb_typeof(redirect_uris) = 'array'),
+            algorithms JSONB NOT NULL CHECK (jsonb_typeof(algorithms) = 'array'),
+            mapping_version VARCHAR(120) NOT NULL, config JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_by UUID NOT NULL REFERENCES public.users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            activated_at TIMESTAMPTZ, disabled_at TIMESTAMPTZ,
+            UNIQUE (provider_uid, version), CHECK (jsonb_typeof(config) = 'object')
+        );
+        CREATE UNIQUE INDEX uq_identity_active_provider ON public.identity_idp_config_versions(provider_uid) WHERE status = 'active';
+        CREATE INDEX idx_identity_idp_status_time ON public.identity_idp_config_versions(status, created_at DESC);
+
+        CREATE TABLE public.identity_oidc_flows (
+            uid UUID PRIMARY KEY, state_hash CHAR(64) NOT NULL UNIQUE, provider_uid UUID NOT NULL,
+            provider_version INTEGER NOT NULL, nonce_hash CHAR(64) NOT NULL, pkce_verifier_hash CHAR(64) NOT NULL,
+            redirect_uri TEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','consumed','expired','failed')),
+            expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (expires_at > created_at)
+        );
+        CREATE INDEX idx_identity_oidc_flow_expiry ON public.identity_oidc_flows(status, expires_at);
+
+        CREATE TABLE public.enterprise_identity_links (
+            uid UUID PRIMARY KEY, provider_uid UUID NOT NULL, enterprise_subject VARCHAR(500) NOT NULL,
+            user_uid UUID NOT NULL REFERENCES public.users(id), username VARCHAR(64) NOT NULL,
+            display_name VARCHAR(300) NOT NULL, department_external_id VARCHAR(300) NOT NULL,
+            groups JSONB NOT NULL DEFAULT '[]'::jsonb, role_names JSONB NOT NULL DEFAULT '[]'::jsonb,
+            authorization_scope JSONB NOT NULL DEFAULT '{}'::jsonb, mapping_version VARCHAR(120) NOT NULL,
+            claims_digest CHAR(64) NOT NULL, status VARCHAR(20) NOT NULL CHECK (status IN ('active','disabled','left')),
+            token_version INTEGER NOT NULL DEFAULT 1 CHECK (token_version > 0),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (provider_uid, enterprise_subject), UNIQUE (provider_uid, user_uid),
+            CHECK (jsonb_typeof(groups) = 'array'), CHECK (jsonb_typeof(role_names) = 'array'),
+            CHECK (jsonb_typeof(authorization_scope) = 'object')
+        );
+        CREATE INDEX idx_enterprise_identity_status ON public.enterprise_identity_links(status, updated_at DESC);
+
+        CREATE TABLE public.identity_organization_nodes (
+            uid UUID PRIMARY KEY, provider_uid UUID NOT NULL, external_id VARCHAR(300) NOT NULL,
+            node_type VARCHAR(20) NOT NULL CHECK (node_type IN ('department','group')),
+            parent_external_id VARCHAR(300), display_name VARCHAR(300) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('active','disabled')),
+            attributes JSONB NOT NULL DEFAULT '{}'::jsonb, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (provider_uid, node_type, external_id), CHECK (jsonb_typeof(attributes) = 'object')
+        );
+        CREATE INDEX idx_identity_org_parent ON public.identity_organization_nodes(provider_uid, parent_external_id);
+
+        CREATE TABLE public.identity_directory_events (
+            uid UUID PRIMARY KEY, provider_uid UUID NOT NULL, source VARCHAR(80) NOT NULL, source_event_id VARCHAR(300) NOT NULL,
+            cursor_value VARCHAR(500) NOT NULL, cursor_sequence BIGINT NOT NULL CHECK(cursor_sequence > 0),
+            event_type VARCHAR(20) NOT NULL CHECK (event_type IN ('JOINER','MOVER','LEAVER','DISABLE','RESTORE','DEPARTMENT','GROUP')),
+            enterprise_subject VARCHAR(500), payload_digest CHAR(64) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('applied','rejected','conflict')),
+            safe_result JSONB NOT NULL DEFAULT '{}'::jsonb, processed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (provider_uid, source, source_event_id), UNIQUE(provider_uid,source,cursor_sequence),
+            CHECK (jsonb_typeof(safe_result) = 'object')
+        );
+        CREATE INDEX idx_identity_directory_cursor ON public.identity_directory_events(provider_uid,source,cursor_sequence DESC);
+        CREATE TABLE public.identity_directory_checkpoints (
+            provider_uid UUID NOT NULL,source VARCHAR(80) NOT NULL,cursor_value VARCHAR(500) NOT NULL,
+            cursor_sequence BIGINT NOT NULL CHECK(cursor_sequence > 0),updated_at TIMESTAMPTZ NOT NULL,
+            PRIMARY KEY(provider_uid,source)
+        );
+        CREATE TABLE public.identity_directory_conflicts (
+            uid UUID PRIMARY KEY,provider_uid UUID NOT NULL,source VARCHAR(80) NOT NULL,source_event_id VARCHAR(300) NOT NULL,
+            cursor_value VARCHAR(500) NOT NULL,cursor_sequence BIGINT NOT NULL,payload_digest CHAR(64) NOT NULL,
+            reason VARCHAR(120) NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+
+        CREATE TABLE public.identity_sessions (
+            uid UUID PRIMARY KEY, family_uid UUID NOT NULL, identity_link_uid UUID REFERENCES public.enterprise_identity_links(uid),
+            user_uid UUID NOT NULL REFERENCES public.users(id), identity_source VARCHAR(20) NOT NULL CHECK (identity_source IN ('oidc','local','emergency')),
+            token_version INTEGER NOT NULL CHECK (token_version > 0), refresh_hash CHAR(64) NOT NULL UNIQUE,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('active','rotated','revoked','expired')),
+            rotated_from_uid UUID REFERENCES public.identity_sessions(uid), revoke_reason VARCHAR(120), risk_reason VARCHAR(300),
+            emergency_request_uid UUID,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, last_seen_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            expires_at TIMESTAMPTZ NOT NULL, rotated_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, CHECK (expires_at > created_at),
+            CHECK ((identity_source='emergency') = (emergency_request_uid IS NOT NULL))
+        );
+        CREATE INDEX idx_identity_session_user_status ON public.identity_sessions(user_uid, status, created_at DESC);
+        CREATE INDEX idx_identity_session_family ON public.identity_sessions(family_uid, created_at);
+
+        CREATE TABLE public.identity_exchange_codes (
+            uid UUID PRIMARY KEY, code_hash CHAR(64) NOT NULL UNIQUE, session_uid UUID NOT NULL REFERENCES public.identity_sessions(uid),
+            redirect_uri TEXT NOT NULL, status VARCHAR(20) NOT NULL CHECK (status IN ('pending','consumed','expired')),
+            expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (expires_at > created_at)
+        );
+        CREATE INDEX idx_identity_exchange_expiry ON public.identity_exchange_codes(status, expires_at);
+
+        CREATE TABLE public.identity_emergency_requests (
+            uid UUID PRIMARY KEY, requester_uid UUID NOT NULL REFERENCES public.users(id),
+            emergency_account_uid UUID NOT NULL REFERENCES public.users(id), reason TEXT NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('pending','active','closed','expired','reviewed','rejected')),
+            starts_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
+            activated_at TIMESTAMPTZ, closed_at TIMESTAMPTZ, reviewer_uid UUID REFERENCES public.users(id),
+            review_outcome TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (requester_uid <> emergency_account_uid), CHECK (expires_at > starts_at)
+        );
+        CREATE INDEX idx_identity_emergency_status_expiry ON public.identity_emergency_requests(status, expires_at);
+        CREATE TABLE public.identity_emergency_approvals (
+            request_uid UUID NOT NULL REFERENCES public.identity_emergency_requests(uid),
+            approver_uid UUID NOT NULL REFERENCES public.users(id), approved_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            PRIMARY KEY (request_uid, approver_uid)
+        );
+        ALTER TABLE public.identity_sessions ADD CONSTRAINT fk_identity_session_emergency
+          FOREIGN KEY(emergency_request_uid) REFERENCES public.identity_emergency_requests(uid);
+
+        CREATE TABLE public.identity_audit_events (
+            uid UUID PRIMARY KEY, event_type VARCHAR(80) NOT NULL, outcome VARCHAR(20) NOT NULL CHECK (outcome IN ('success','failure','denied','warning')),
+            actor_uid UUID REFERENCES public.users(id), provider_uid UUID, enterprise_subject_digest CHAR(64),
+            resource_type VARCHAR(80) NOT NULL, resource_uid VARCHAR(300), correlation_uid UUID,
+            safe_detail JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(safe_detail) = 'object')
+        );
+        CREATE INDEX idx_identity_audit_time ON public.identity_audit_events(created_at DESC, uid DESC);
+        CREATE INDEX idx_identity_audit_actor ON public.identity_audit_events(actor_uid, created_at DESC);
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError("Forward-only migration: enterprise identity and authentication evidence cannot be removed")

+ 24 - 0
deployment/migrations/versions/20260802_471_enterprise_identity_hardening.py

@@ -0,0 +1,24 @@
+"""Harden enterprise identity secret references."""
+
+from alembic import op
+
+revision = "20260802_471"
+down_revision = "20260802_470"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        ALTER TABLE public.identity_idp_config_versions
+          DROP CONSTRAINT IF EXISTS identity_idp_config_versions_secret_ref_check;
+        ALTER TABLE public.identity_idp_config_versions
+          ADD CONSTRAINT ck_identity_idp_secret_ref_dataops_oidc
+          CHECK (secret_ref ~ '^env:DATAOPS_OIDC_[A-Z0-9_]+$');
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError("Forward-only migration: identity secret boundary cannot be weakened")

+ 13 - 9
docs/DATAOPS_PHASE3_6_MONTH_DEVELOPMENT_PLAN_20260802.md

@@ -196,7 +196,7 @@ P3-WP01 的企业验收和 P3-WP07 的交付工程是贯穿性工作。第 1~2
 |---|---|---:|---|---|---|---|
 | P3-WP00 | 范围、架构与企业依赖基线 | P0 | 全部第三阶段范围 | 第 1~2 周 | 工程基线完成待企业输入确认 | 企业输入和实名责任人待确认;未实现后续功能 |
 | P3-WP01 | 第二业务域企业正式验收 | P0 | P2-WP13 外部门禁 | 第 1~4 周 | 工程完成待企业 UAT | 真实用户、来源、配置、预生产环境 |
-| P3-WP02 | 企业统一身份与 SSO | P0 | IAM-05~12、IAM-17 | 第 5~8 周 | 外部条件待满足 | IdP 测试租户和映射规则 |
+| P3-WP02 | 企业统一身份与 SSO | P0 | IAM-05~12、IAM-17 | 第 5~8 周 | 工程完成待企业 UAT | IdP 测试租户和映射规则仍待提供 |
 | P3-WP03 | 企业连接器与扩展契约 | P0 | CON-06~15、CON-21~22、IAM-13 | 第 5~8 周 | 外部条件待满足 | 两个目标来源和只读账号 |
 | P3-WP04 | 边缘网关与数据不出域 | P0 | CON-17~20、SEC-05~07 | 第 9~12 周 | 外部条件待满足 | 网络区、出站和脱敏策略 |
 | P3-WP05 | 生产可观测、值班与协同 | P0 | OBS-03~06、WFC-11~14、PLT-11~12、DFY-22 | 第 9~12 周 | 外部条件待满足 | 监控、SMTP/协作、ITSM 和排班接口 |
@@ -297,14 +297,18 @@ AI、多租户、BI/AI、成本或插件输入。P0 企业、安全、恢复和
 
 **主要工作:**
 
-- [ ] 建设 OIDC Authorization Code + PKCE 登录、回调、JWKS 校验、state/nonce 和失败关闭。
-- [ ] 建设 IdP 配置版本、密钥引用、回调白名单、启停、轮换、连通性和 dry-run。
-- [ ] 建设 Claims 到用户、部门、用户组、平台角色、业务域和数据范围的确定性映射。
-- [ ] 建设 SCIM 或企业通讯录增量同步、幂等、冲突、停用、恢复和审计。
-- [ ] 建设入职、转岗、离职的授权重算和会话/短期凭证撤销。
-- [ ] 收口 Token 刷新、注销、并发会话、风险会话终止和浏览器安全边界。
-- [ ] 建设双人授权的应急本地管理员、限时启用、使用告警和事后复核。
-- [ ] 将登录、IdP、Claims、组织同步、角色和应急行为接入统一认证审计。
+- [x] 建设 OIDC Authorization Code + PKCE 登录、回调、JWKS 校验、state/nonce 和失败关闭。
+- [x] 建设 IdP 配置版本、密钥引用、回调白名单、启停、轮换、连通性和 dry-run。
+- [x] 建设 Claims 到用户、部门、用户组、平台角色、业务域和数据范围的确定性映射。
+- [x] 建设受控目录增量同步、幂等、冲突、停用、恢复和审计。
+- [x] 建设入职、转岗、离职的授权重算和会话/短期凭证撤销。
+- [x] 收口 Token 刷新、注销、并发会话、风险会话终止和浏览器安全边界。
+- [x] 建设双人授权的应急本地管理员、限时启用、使用告警和事后复核。
+- [x] 将登录、IdP、Claims、组织同步、角色和应急行为接入统一认证审计。
+- [ ] 使用目标企业 IdP、实名账号、批准 Claims 和生命周期规则完成联调及企业 UAT。
+
+**工程状态:** `ENGINEERING_COMPLETE_ENTERPRISE_IDP_UAT_BLOCKED`。上述勾选仅代表工程实现;
+`identity` 和 `pilot_goal` 仍为 `TBD_EXTERNAL`,不得据此声明目标企业 IdP 已验收。
 
 **主要文件区域:** `app/core/system/`、`app/api/system/`、`app/models/`、
 `migrations/versions/`、`frontend/src/views/systemManage/`、`deployment/app/`、

+ 472 - 1
docs/architecture/OPENAPI.yaml

@@ -3,7 +3,7 @@ info:
   title: "DataOps Platform API(当前代码基线)"
   version: "2026-07-16"
   description: "由 scripts/generate_openapi.py 从 app/api/*/routes.py 生成。请求体与响应细节仍以现有专项 API 文档和代码为准。"
-x-route-count: 395
+x-route-count: 413
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -8462,6 +8462,477 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/audit":
+    get:
+      tags: [system]
+      operationId: system_identity_audit_get
+      summary: "identity audit"
+      x-source: "app/api/system/enterprise_identity.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/authorize":
+    post:
+      tags: [system]
+      operationId: system_identity_authorize_post
+      summary: "identity authorize"
+      x-source: "app/api/system/enterprise_identity.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/callback":
+    get:
+      tags: [system]
+      operationId: system_identity_callback_get
+      summary: "identity callback"
+      x-source: "app/api/system/enterprise_identity.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/claims/dry-run":
+    post:
+      tags: [system]
+      operationId: system_identity_claims_dry_run_post
+      summary: "identity claims dry run"
+      x-source: "app/api/system/enterprise_identity.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/directory/delta":
+    post:
+      tags: [system]
+      operationId: system_identity_directory_delta_post
+      summary: "identity directory delta"
+      x-source: "app/api/system/enterprise_identity.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/emergency/requests":
+    post:
+      tags: [system]
+      operationId: system_identity_emergency_request_post
+      summary: "identity emergency request"
+      x-source: "app/api/system/enterprise_identity.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/emergency/{request_uid}/{action}":
+    post:
+      tags: [system]
+      operationId: system_identity_emergency_action_post
+      summary: "identity emergency action"
+      x-source: "app/api/system/enterprise_identity.py"
+      parameters:
+        - name: request_uid
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: action
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/exchange":
+    post:
+      tags: [system]
+      operationId: system_identity_exchange_post
+      summary: "identity exchange"
+      x-source: "app/api/system/enterprise_identity.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/idp-versions":
+    get:
+      tags: [system]
+      operationId: system_identity_idp_versions_get
+      summary: "identity idp versions"
+      x-source: "app/api/system/enterprise_identity.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [system]
+      operationId: system_identity_idp_version_create_post
+      summary: "identity idp version create"
+      x-source: "app/api/system/enterprise_identity.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/idp-versions/{provider_uid}/{version}/{action}":
+    post:
+      tags: [system]
+      operationId: system_identity_idp_action_post
+      summary: "identity idp action"
+      x-source: "app/api/system/enterprise_identity.py"
+      parameters:
+        - name: provider_uid
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: version
+          in: path
+          required: true
+          schema:
+            type: integer
+        - name: action
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/logout":
+    post:
+      tags: [system]
+      operationId: system_identity_logout_post
+      summary: "identity logout"
+      x-source: "app/api/system/enterprise_identity.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/providers":
+    get:
+      tags: [system]
+      operationId: system_identity_providers_get
+      summary: "identity providers"
+      x-source: "app/api/system/enterprise_identity.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/refresh":
+    post:
+      tags: [system]
+      operationId: system_identity_refresh_post
+      summary: "identity refresh"
+      x-source: "app/api/system/enterprise_identity.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/sessions":
+    get:
+      tags: [system]
+      operationId: system_identity_sessions_get
+      summary: "identity sessions"
+      x-source: "app/api/system/enterprise_identity.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/sessions/revoke-all":
+    post:
+      tags: [system]
+      operationId: system_identity_session_revoke_all_post
+      summary: "identity session revoke all"
+      x-source: "app/api/system/enterprise_identity.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/sessions/{session_uid}/revoke":
+    post:
+      tags: [system]
+      operationId: system_identity_session_revoke_post
+      summary: "identity session revoke"
+      x-source: "app/api/system/enterprise_identity.py"
+      parameters:
+        - name: session_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/identity/sessions/{session_uid}/risk-terminate":
+    post:
+      tags: [system]
+      operationId: system_identity_session_risk_post
+      summary: "identity session risk"
+      x-source: "app/api/system/enterprise_identity.py"
+      parameters:
+        - name: session_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/system/info":
     get:
       tags: [system]

+ 1 - 1
docs/phase3/P3_WP00_REQUIREMENTS.json

@@ -503,7 +503,7 @@
         "identity",
         "pilot_goal"
       ],
-      "engineering_completion_status": "NOT_STARTED",
+      "engineering_completion_status": "ENGINEERING_COMPLETE_ENTERPRISE_IDP_UAT_BLOCKED",
       "enterprise_confirmation_status": "TBD_EXTERNAL",
       "dynamic_required_inputs_by_started_wp": {}
     },

+ 66 - 0
docs/phase3/P3_WP02_ENTERPRISE_IDENTITY.md

@@ -0,0 +1,66 @@
+# P3-WP02 企业统一身份与 SSO 工程交付说明
+
+## 1. 状态与边界
+
+- 工程状态:`ENGINEERING_COMPLETE_ENTERPRISE_IDP_UAT_BLOCKED`
+- 企业身份输入:`identity = TBD_EXTERNAL`
+- 企业试点目标:`pilot_goal = TBD_EXTERNAL`
+- 本工作包完成了协议、持久化、接口、浏览器和安全门禁的工程闭环,但没有目标企业
+  IdP、企业实名账号、批准的 Claims 矩阵和生命周期事件,不能声明企业联调或 UAT 通过。
+- IAM-05~12、IAM-17 的最终功能台账成熟度仍由 P3-WP14 按真实证据统一收口。
+
+## 2. 已实现能力
+
+1. OIDC Authorization Code + PKCE S256,state、nonce、verifier、provider 版本和回调地址
+   一次性绑定;严格校验 JWKS、非对称算法、Issuer、Audience、有效期和重放。
+2. IdP 配置追加版本,secret 仅接受 `env:DATAOPS_OIDC_[A-Z0-9_]+` 专用命名空间引用;
+   管理接口仅返回掩码;支持 dry-run、激活、停用、轮换、
+   JWKS 连通性检查和精确回调/登录后跳转白名单。所有身份 URL 必须为公网 HTTPS,拒绝
+   localhost、私网/保留地址字面量、userinfo 和 fragment。OIDC 出站会解析并检查全部 DNS
+   地址,生产连接固定到已验证的公网 IP,同时以原 hostname 执行 TLS SNI、证书主机名校验和
+   Host 头校验,底层不再对 hostname 二次解析;禁止重定向,限制连接/读取超时和响应大小,
+   并严格校验 HTTP、Content-Type 与 JSON。
+3. Claims 映射必须包含类型和长度合规的 subject、用户名、显示名、部门和非空字符串组数组;
+   group rule 必须是严格 Schema 的字符串数组且至少映射一个批准角色;规则版本化、确定性输出
+   admin/editor/viewer、业务域、对象类型、环境和数据范围;无匹配规则默认拒绝。
+4. 受控目录增量入口使用 source event id 与 cursor,重复载荷幂等、同 id 不同载荷失败;
+   身份键固定为 provider + subject,cursor sequence 单调推进并保留 checkpoint/冲突记录;
+   JOINER/MOVER/LEAVER/DISABLE/RESTORE 触发 token version 递增和会话回收,恢复按当前映射
+   重算,不恢复历史高权角色。身份/角色变更、会话撤销、event 和 checkpoint 在同一数据库
+   事务提交,按 provider + source 串行,任一步失败均整体回滚。
+5. DEPARTMENT/GROUP 组织节点支持 provider 隔离的 UPSERT、DISABLE、RESTORE、父节点和
+   扩展属性;复用同一 cursor、幂等、冲突和审计协议,不允许停用或恢复不存在的节点。
+6. access token 在企业会话中增加 sid、token_version 和 identity_source,服务端验证会话;
+   opaque refresh 只保存 SHA-256,旋转后旧 token 重放撤销整个 family,另有并发上限、
+   注销、指定撤销和风险终止。并发创建会话先锁定用户行,再计算和回收超限会话;IdP 激活或
+   轮换递增该 provider 的 token version 并撤销旧会话。active IdP 存在时拒绝所有无 sid 的
+   历史本地 token;身份状态查询失败时本地登录和 token 校验均失败关闭。
+7. active IdP 存在时,普通本地登录默认关闭;应急账号须有请求、两个不同实名管理员审批、
+   最长两小时有效窗口和事后复核,请求人、应急账号不得自批;应急账号必须是未链接企业
+   身份的有效本地管理员,实际登录和刷新均再次校验该资格;refresh 后的到期时间不超过原
+   审批窗口;会话绑定请求与到期时间,关闭、到期和复核立即拒绝并撤销。
+8. 配置、OIDC、映射、目录、refresh/logout、会话撤销、风险终止、应急申请和后续动作进入
+   统一身份审计;safe detail 递归移除 code、token、verifier、secret、cookie、password、
+   assertion 和完整 claims。
+9. OIDC callback 将用户/link、角色、旧会话撤销、新会话和一次性交换码放在同一事务,任一
+   持久化失败整体回滚。管理 API 对必填字段、UUID、整数、日期和网络/JSON 错误返回受控
+   4xx/502,不回显底层异常;IdP 版本和审计仅 identity:manage 可读,会话仅 identity:operate
+   可读,列表不返回 subject、family、refresh 或应急绑定字段。
+10. 登录页展示 active provider,回调页只交换一次性 code;access token 使用 sessionStorage,
+   自动清理历史 localStorage,401 最多进行一次共享 refresh,退出调用服务端撤销。
+
+## 3. 生产配置约束
+
+- 先依次执行前向迁移 `20260802_470` 与安全加固迁移 `20260802_471`,不得降级删除身份和
+  认证证据或放宽 secret ref 边界。
+- 每个 secret ref 必须由受控运行环境注入;接口和审计只显示引用,不显示秘密。
+- `IDENTITY_COOKIE_SECURE` 生产必须保持 true;反向代理必须保留 HTTPS Origin 和安全 Cookie。
+- IdP 配置激活前必须完成 dry-run、JWKS 连通性检查和回调地址注册;生产不允许临时宽泛
+  callback,也不允许把 access/refresh token 放入 URL。
+
+## 4. 企业 UAT 剩余门禁
+
+以下证据未提供,状态保持阻断:目标 IdP 租户/Issuer/Client/证书;批准的 group/Claims 与
+权限矩阵;入职、转岗、离职、冻结、恢复事件;两名实名应急审批人;企业安全负责人;
+预生产域名、Origin、Cookie 和网络策略。UAT 必须验证正常登录、错误算法/Issuer/Audience、
+角色和数据范围、转岗离职回收、refresh 重放、并发/风险撤销、IdP 故障应急及全链审计。

+ 52 - 0
docs/validation/P3_WP02_ENTERPRISE_IDENTITY_EVIDENCE.md

@@ -0,0 +1,52 @@
+# P3-WP02 企业统一身份定向验证证据
+
+## 结论
+
+状态:`ENGINEERING_COMPLETE_ENTERPRISE_IDP_UAT_BLOCKED`。
+
+工程实现和本地定向门禁可以复现;`identity`、`pilot_goal`、企业 IdP 租户、实名用户、
+企业生命周期事件和安全签署均为 `TBD_EXTERNAL`。本文件不是企业 UAT 或生产就绪证明。
+
+## 验证范围
+
+- 核心安全:专用 secret ref 命名空间、DNS 全地址公网检查、已验证 IP 连接固定、原域名
+  TLS SNI/证书/Host 校验、禁止重定向、出站超时/响应上限、HTTP/Content-Type/JSON 错误边界、
+  PKCE/state/nonce、RS256/JWKS、算法混淆、Issuer/Audience、
+  过期和重放、Claims 严格 Schema、默认拒绝与确定性映射。
+- 生命周期与会话:目录幂等和冲突、入转离冻结恢复、DEPARTMENT/GROUP 节点生命周期、
+  单事务回滚、token version、refresh rotation/reuse、family revoke、并发上限和风险终止。
+- 应急与审计:两名不同非自身审批人、有效窗口、事后复核和递归脱敏。
+- 接口与前端:精确 PUBLIC 白名单、最小化 identity:read/manage/operate、管理输入安全 4xx、
+  上游安全 502、一次性交换、callback 单事务、最小会话输出、HttpOnly Cookie、sessionStorage、
+  单次共享 refresh、回调 URL 清理和企业身份管理入口。
+- 交付:迁移 head、OpenAPI、源码/发布副本一致性和前端生产构建。
+
+## 企业门禁
+
+必须在目标环境补齐:真实 IdP discovery/JWKS 和 client secret 引用;企业 Claims/组织矩阵;
+真实入转调离;IdP 故障与双人应急演练;浏览器 Cookie/Origin/反向代理验证;企业身份、
+安全和业务负责人签署。在这些证据完成前不得把状态改为完成。
+
+## 本地执行结果(2026-08-02)
+
+- P3-WP02 核心、契约及直接受影响的认证/权限定向测试:`29 passed`;本轮无跳过项。
+- P3-WP02 专用 PostgreSQL 集成测试:`5 passed`;使用本地隔离 Docker PostgreSQL 和
+  `TEST_DATABASE_URL`,测试数据按专属 provider/user/request 精确清理,未修改既有数据。
+- 迁移静态/契约与 OpenAPI 架构定向测试:`24 passed, 4 skipped`;4 个跳过项仅为未配置
+  `TEST_POSTGRES_ADMIN_URL` 的临时数据库创建/删除迁移用例,现有隔离库的真实 471 升级和
+  P3-WP02 PostgreSQL 用例均已执行,不属于同一缺口。
+- 新增失败边界覆盖:跨 IdP 同 subject 隔离、LEAVER 精确撤销、cursor 倒退和冲突记录、
+  目录写入失败整体回滚、部门/用户组停用恢复与 provider 隔离、并发 refresh 至多一次成功且
+  重放后 family 全撤销、callback exchange 写入失败整体回滚、并发会话上限、应急审批到期
+  上限与账号实际登录资格复核、关闭撤销、DNS 私网解析/30x/超大响应/错误 JSON 拒绝、
+  公网解析 IP 传入连接池且原域名 SNI/证书/Host 保持不变、
+  post-login URI 协议与内网拒绝、PUBLIC 方法精确匹配、最小权限和成功审计脱敏。
+- 相关 Python 静态检查:通过。
+- 前端生产构建:通过;仅保留仓库既有 console、bundle size 和 CSS order 警告,无新增错误。
+- OpenAPI:可重复生成,`413 operations`,与 Flask 源路由计数一致。
+- `git diff --check`:通过;P3-WP02 源码与 deployment 发布副本逐文件一致。
+- 本地隔离 PostgreSQL 已从 `20260802_460` 实际升级到 `20260802_470`,本轮继续前向升级到
+  `20260802_471`;`alembic current` 返回 `20260802_471 (head)`,集成测试同时核验
+  `alembic_version`、真实表、secret ref/唯一/检查约束和 repository 持久化语义。
+- 已执行发布副本同步并核对 `app/` 与 `deployment/app/`、`migrations/` 与
+  `deployment/migrations/`;未执行全量回归、企业 IdP 联网、部署或企业 UAT。

+ 12 - 0
frontend/src/api/enterpriseIdentity.js

@@ -0,0 +1,12 @@
+import http from '@/utils/request'
+
+export const listIdpVersions = () => http.get('/system/identity/idp-versions')
+export const createIdpVersion = data => http.post('/system/identity/idp-versions', data)
+export const runIdpAction = (provider, version, action) => http.post(`/system/identity/idp-versions/${provider}/${version}/${action}`, {})
+export const claimsDryRun = data => http.post('/system/identity/claims/dry-run', data)
+export const syncDirectoryDelta = data => http.post('/system/identity/directory/delta', data)
+export const listIdentitySessions = params => http.get('/system/identity/sessions', params)
+export const revokeIdentitySession = (uid, reason) => http.post(`/system/identity/sessions/${uid}/revoke`, { reason })
+export const listIdentityAudit = params => http.get('/system/identity/audit', params)
+export const requestEmergencyAccess = data => http.post('/system/identity/emergency/requests', data)
+export const runEmergencyAction = (uid, action, data = {}) => http.post(`/system/identity/emergency/${uid}/${action}`, data)

+ 6 - 0
frontend/src/api/user.js

@@ -8,3 +8,9 @@ export function login (data) {
 export function getCurrentUser () {
   return http.get('/system/auth/me')
 }
+
+export function listIdentityProviders () { return http.get('/system/identity/providers') }
+export function beginEnterpriseLogin (data) { return http.post('/system/identity/authorize', data) }
+export function exchangeIdentityCode (data) { return http.post('/system/identity/exchange', data) }
+export function refreshIdentitySession () { return http.post('/system/identity/refresh', {}) }
+export function logoutIdentitySession () { return http.post('/system/identity/logout', {}) }

+ 6 - 0
frontend/src/router/defaultRoute.js

@@ -26,6 +26,12 @@ const base = [
     meta: { title: '登录' },
     component: () => import('@/views/login')
   },
+  {
+    path: '/login/callback',
+    name: 'enterpriseLoginCallback',
+    meta: { title: '企业登录回调', noLogin: true },
+    component: () => import('@/views/login/callback')
+  },
   {
     path: '/signAgain',
     name: 'signAgain',

+ 18 - 1
frontend/src/router/routes.js

@@ -179,6 +179,23 @@ export default {
       path: '/data-governance',
       urls: '',
       children: [
+        {
+          hidden: 0,
+          type: 1,
+          title: '企业身份',
+          path: '/systemManage/enterprise-identity',
+          children: [],
+          label: '企业身份',
+          sort: 1,
+          component: 'systemManage/enterpriseIdentity',
+          meta: {
+            title: '企业身份',
+            icon: 'mdi-account-key-outline',
+            permissions: ['identity:manage']
+          },
+          name: 'systemEnterpriseIdentity',
+          alwaysShow: 0
+        },
         {
           hidden: 0,
           icon: 'mdi-database-cog-outline',
@@ -1953,7 +1970,7 @@ export default {
       meta: {
         title: '系统管理',
         icon: 'mdi-account-cog-outline',
-        permissions: ['users:manage', 'agents:read']
+        permissions: ['users:manage', 'agents:read', 'identity:manage']
       },
       name: 'systemManage',
       alwaysShow: 0

+ 31 - 5
frontend/src/store/modules/user.js

@@ -1,4 +1,4 @@
-import { getCurrentUser, login } from '@/api/user'
+import { beginEnterpriseLogin, exchangeIdentityCode, getCurrentUser, login, logoutIdentitySession } from '@/api/user'
 import router, { resetRouter } from '@/router'
 import { setToken, deleteToken } from '@/utils/auth'
 // import Base from '@/utils/base64ToPassword'
@@ -6,11 +6,16 @@ import { setToken, deleteToken } from '@/utils/auth'
 
 // const defaultSingleSignOn = localStorage.getItem('SET_SINGLE_SIGN_ON') !== '0' // 默认单点登录
 
-const userInfo = localStorage.getItem('userInfo')
+const storedUserInfo = localStorage.getItem('userInfo')
+const userInfo = storedUserInfo && storedUserInfo !== 'undefined' ? JSON.parse(storedUserInfo) : {}
+if (userInfo && userInfo.token) {
+  delete userInfo.token
+  localStorage.setItem('userInfo', JSON.stringify(userInfo))
+}
 
 const state = {
   singleSignOn: localStorage.getItem('SET_SINGLE_SIGN_ON') !== '0', // 单点登录
-  userInfo: userInfo && userInfo !== 'undefined' ? JSON.parse(userInfo) : {},
+  userInfo,
   loaded: false
 }
 
@@ -44,10 +49,12 @@ const actions = {
       login(newParams).then(res => {
         // 登录成功返回对应的信息 包括token 用户信息 权限等等
         const { data } = res
+        const profile = { ...data }
+        delete profile.token
         commit('SET_SINGLE_SIGN_ON', 0)
         setToken(data.token)
         // const { username, name, id, employeeCode } = data.user
-        commit('SET_USERINFO', data)
+        commit('SET_USERINFO', profile)
         resolve()
       }).catch(err => { reject(err) })
     })
@@ -58,13 +65,32 @@ const actions = {
       return res.data
     })
   },
+  beginEnterpriseLogin (context, providerUid) {
+    const callback = `${window.location.origin}/api/system/identity/callback`
+    const postLogin = `${window.location.origin}/login/callback`
+    return beginEnterpriseLogin({ provider_uid: providerUid, redirect_uri: callback, post_login_uri: postLogin }).then(res => {
+      window.location.assign(res.data.authorization_url)
+    })
+  },
+  exchangeIdentityCode ({ commit }, exchangeCode) {
+    const redirectUri = `${window.location.origin}/login/callback`
+    return exchangeIdentityCode({ exchange_code: exchangeCode, redirect_uri: redirectUri }).then(res => {
+      setToken(res.data.token)
+      return getCurrentUser().then(me => {
+        commit('SET_SINGLE_SIGN_ON', 1)
+        commit('SET_USERINFO', me.data)
+        return me.data
+      })
+    })
+  },
   // 退出登录
   /**
    *
    * @param {*} param
    * @param { Boolean } isLogin 是否调用回到登录页方法
    */
-  userLogout ({ commit }) {
+  async userLogout ({ commit }) {
+    try { await logoutIdentitySession() } catch (error) { /* local cleanup remains mandatory */ }
     // 清空权限菜单
     // deleteRoles()
     // 清空token

+ 11 - 2
frontend/src/utils/auth.js

@@ -1,16 +1,25 @@
 
 // 获取token
 export function getToken () {
-  return localStorage.getItem('access_token')
+  const current = sessionStorage.getItem('access_token')
+  if (current) return current
+  const legacy = localStorage.getItem('access_token')
+  if (legacy) {
+    sessionStorage.setItem('access_token', legacy)
+    localStorage.removeItem('access_token')
+  }
+  return legacy
 }
 
 // 设置token
 export function setToken (token) {
-  return localStorage.setItem('access_token', token)
+  localStorage.removeItem('access_token')
+  return sessionStorage.setItem('access_token', token)
 }
 
 // 清除token
 export function deleteToken () {
+  sessionStorage.removeItem('access_token')
   return localStorage.removeItem('access_token')
 }
 

+ 29 - 1
frontend/src/utils/request.js

@@ -7,6 +7,22 @@ const service = axios.create({
   baseURL: process.env.VUE_APP_BASE_API,
   timeout: 120000 // request timeout
 })
+let refreshPromise = null
+
+function refreshAccessToken () {
+  if (!refreshPromise) {
+    refreshPromise = axios.post(`${process.env.VUE_APP_BASE_API}/system/identity/refresh`, {}, {
+      withCredentials: true,
+      headers: { 'Content-Type': 'application/json' }
+    }).then(response => {
+      const token = response.data && response.data.data && response.data.data.token
+      if (!token) throw new Error('身份会话刷新失败')
+      sessionStorage.setItem('access_token', token)
+      return token
+    }).finally(() => { refreshPromise = null })
+  }
+  return refreshPromise
+}
 
 // request interceptor
 // 发送请求拦截器
@@ -14,6 +30,7 @@ service.interceptors.request.use(
   config => {
     const token = getToken()
     if (token) config.headers.Authorization = `Bearer ${token}`
+    config.withCredentials = true
     return config
   },
   error => {
@@ -64,8 +81,19 @@ service.interceptors.response.use(
     }
     return res
   },
-  error => {
+  async error => {
     if (error.response && error.response.status === 401) {
+      const original = error.config || {}
+      const refreshPath = '/system/identity/refresh'
+      if (!original.__identityRetry && !String(original.url || '').includes(refreshPath)) {
+        original.__identityRetry = true
+        try {
+          const token = await refreshAccessToken()
+          original.headers = original.headers || {}
+          original.headers.Authorization = `Bearer ${token}`
+          return service(original)
+        } catch (refreshError) { /* use common logout path below */ }
+      }
       deleteToken()
       const redirect = router.currentRoute.fullPath
       if (router.currentRoute.path !== '/login-local') {

+ 34 - 0
frontend/src/views/login/callback.vue

@@ -0,0 +1,34 @@
+<template>
+  <div class="identity-callback">
+    <v-card width="440" class="pa-8 text-center">
+      <v-progress-circular v-if="working" indeterminate color="primary" />
+      <h2 class="mt-4">{{ working ? '正在完成企业登录' : '企业登录未完成' }}</h2>
+      <p class="mt-3">{{ message }}</p>
+      <v-btn v-if="!working" color="primary" @click="$router.replace('/login-local')">返回登录</v-btn>
+    </v-card>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'EnterpriseLoginCallback',
+  data: () => ({ working: true, message: '正在验证一次性交换码,请稍候。' }),
+  async created () {
+    const code = this.$route.query.exchange_code
+    history.replaceState({}, document.title, '/login/callback')
+    if (!code) { this.working = false; this.message = '缺少一次性交换码,请重新发起企业登录。'; return }
+    try {
+      await this.$store.dispatch('user/exchangeIdentityCode', code)
+      await this.$store.dispatch('menu/getMenu2')
+      this.$router.replace('/')
+    } catch (error) {
+      this.working = false
+      this.message = typeof error === 'string' ? error : '交换码无效或已过期,请重新登录。'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.identity-callback { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f4f7fb; }
+</style>

+ 17 - 0
frontend/src/views/login/components/login.vue

@@ -15,12 +15,21 @@
                 </v-btn>
             </template>
         </form-data>
+        <v-divider class="mb-5" />
+        <v-btn v-for="provider in providers" :key="provider.provider_uid" outlined color="primary"
+          class="mb-3" style="width: 100%;" @click="enterpriseLogin(provider.provider_uid)">
+          {{ provider.display_name }}
+        </v-btn>
+        <p class="login-boundary">
+          未启用企业身份时可使用本地账号;启用后,本地入口仅供已完成双人审批且仍在有效期内的应急管理员。
+        </p>
     </div>
 </template>
 
 <script>
 
 import FormData from '@/components/Form'
+import { listIdentityProviders } from '@/api/user'
 
 export default {
   name: 'login-page',
@@ -28,6 +37,7 @@ export default {
   data () {
     return {
       loading: false,
+      providers: [],
       config: {
         options: [
           {
@@ -86,7 +96,13 @@ export default {
       query: '1'
     }
   },
+  created () {
+    listIdentityProviders().then(res => { this.providers = res.data || [] }).catch(() => { this.providers = [] })
+  },
   methods: {
+    enterpriseLogin (providerUid) {
+      return this.$store.dispatch('user/beginEnterpriseLogin', providerUid)
+    },
     handleChange (v) {
       Object.assign(this.queryForm, v)
     },
@@ -137,4 +153,5 @@ export default {
   top: 8px;
   right: 10px;
 }
+.login-boundary { color: #607080; font-size: 12px; line-height: 1.6; }
 </style>

+ 71 - 0
frontend/src/views/systemManage/enterpriseIdentity/index.vue

@@ -0,0 +1,71 @@
+<template>
+  <div class="pa-6">
+    <div class="d-flex align-center mb-5">
+      <div><h2>企业身份与 SSO</h2><p class="grey--text mb-0">工程能力已就绪;目标企业 IdP 联调与 UAT 尚未完成。</p></div>
+      <v-spacer /><v-chip color="warning" dark>待企业 IdP UAT</v-chip>
+    </div>
+    <v-tabs v-model="tab"><v-tab>IdP 版本</v-tab><v-tab>会话</v-tab><v-tab>统一审计</v-tab></v-tabs>
+    <v-tabs-items v-model="tab">
+      <v-tab-item>
+        <v-textarea v-if="canManage" v-model="idpDraft" label="IdP 版本 JSON" outlined rows="5" class="mt-4" />
+        <v-btn v-if="canManage" color="primary" class="mb-4" @click="createIdp">创建版本</v-btn>
+        <v-data-table :headers="idpHeaders" :items="versions" :loading="loading">
+          <template v-slot:[`item.actions`]="{ item }">
+            <v-btn v-for="action in ['dry-run', 'connectivity', 'activate', 'disable']" :key="action"
+              small text :disabled="!canManage" @click="idpAction(item, action)">{{ action }}</v-btn>
+          </template>
+        </v-data-table>
+        <v-textarea v-if="canOperate" v-model="claimsDraft" label="Claims dry-run JSON" outlined rows="4" />
+        <v-btn v-if="canOperate" text color="primary" @click="claimsPreview">验证 Claims</v-btn>
+        <v-textarea v-if="canOperate" v-model="directoryDraft" label="目录增量 JSON" outlined rows="4" />
+        <v-btn v-if="canOperate" text color="primary" @click="directorySync">提交目录增量</v-btn>
+        <v-textarea v-if="canOperate" v-model="emergencyDraft" label="应急请求/动作 JSON" outlined rows="3" />
+        <v-btn v-if="canOperate" text color="warning" @click="emergencyRequest">申请应急访问</v-btn>
+        <v-btn v-for="action in ['approve', 'activate', 'close', 'review']" :key="`emergency-${action}`"
+          text small :disabled="!canManage" @click="emergencyAction(action)">{{ action }}</v-btn>
+      </v-tab-item>
+      <v-tab-item><v-data-table :headers="sessionHeaders" :items="sessions" :loading="loading">
+        <template v-slot:[`item.actions`]="{ item }"><v-btn small text color="error" :disabled="!canOperate || item.status !== 'active'" @click="revoke(item)">撤销</v-btn></template>
+      </v-data-table></v-tab-item>
+      <v-tab-item><v-data-table :headers="auditHeaders" :items="audits" :loading="loading" /></v-tab-item>
+    </v-tabs-items>
+  </div>
+</template>
+
+<script>
+import { claimsDryRun, createIdpVersion, listIdpVersions, listIdentityAudit, listIdentitySessions, requestEmergencyAccess, revokeIdentitySession, runEmergencyAction, runIdpAction, syncDirectoryDelta } from '@/api/enterpriseIdentity'
+export default {
+  name: 'EnterpriseIdentity',
+  data: () => ({
+    tab: 0,
+    loading: false,
+    versions: [],
+    sessions: [],
+    audits: [],
+    idpDraft: '{}',
+    claimsDraft: '{}',
+    directoryDraft: '{}',
+    emergencyDraft: '{}',
+    idpHeaders: [{ text: 'Provider', value: 'provider_uid' }, { text: '版本', value: 'version' }, { text: '状态', value: 'status' }, { text: 'Issuer', value: 'issuer' }, { text: '映射规则', value: 'mapping_version' }, { text: '动作', value: 'actions', sortable: false }],
+    sessionHeaders: [{ text: '会话', value: 'uid' }, { text: '用户', value: 'user_uid' }, { text: '来源', value: 'identity_source' }, { text: '状态', value: 'status' }, { text: '到期', value: 'expires_at' }, { text: '', value: 'actions', sortable: false }],
+    auditHeaders: [{ text: '时间', value: 'created_at' }, { text: '事件', value: 'event_type' }, { text: '结果', value: 'outcome' }, { text: '资源', value: 'resource_uid' }]
+  }),
+  computed: {
+    permissions () { return ((this.$store.state.user.userInfo || {}).permissions || []) },
+    canOperate () { return this.permissions.includes('identity:operate') },
+    canManage () { return this.permissions.includes('identity:manage') }
+  },
+  created () { this.load() },
+  methods: {
+    async load () { this.loading = true; try { const [a, b, c] = await Promise.all([listIdpVersions(), listIdentitySessions(), listIdentityAudit({ limit: 100 })]); this.versions = a.data || []; this.sessions = b.data || []; this.audits = c.data || [] } finally { this.loading = false } },
+    parse (value) { return JSON.parse(value) },
+    async createIdp () { await createIdpVersion(this.parse(this.idpDraft)); await this.load() },
+    async idpAction (item, action) { await runIdpAction(item.provider_uid, item.version, action); await this.load() },
+    async claimsPreview () { await claimsDryRun(this.parse(this.claimsDraft)); this.$snackbar.success('Claims 映射验证通过') },
+    async directorySync () { await syncDirectoryDelta(this.parse(this.directoryDraft)); this.$snackbar.success('目录增量已处理') },
+    async emergencyRequest () { await requestEmergencyAccess(this.parse(this.emergencyDraft)); this.$snackbar.success('应急请求已创建') },
+    async emergencyAction (action) { const data = this.parse(this.emergencyDraft); await runEmergencyAction(data.request_uid, action, data); await this.load() },
+    async revoke (item) { await revokeIdentitySession(item.uid, 'operator_revoke'); await this.load() }
+  }
+}
+</script>

+ 140 - 0
migrations/versions/20260802_470_enterprise_identity.py

@@ -0,0 +1,140 @@
+"""Add enterprise identity, OIDC, lifecycle, session and emergency ledgers."""
+
+from alembic import op
+
+revision = "20260802_470"
+down_revision = "20260802_460"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.identity_idp_config_versions (
+            uid UUID PRIMARY KEY, provider_uid UUID NOT NULL, version INTEGER NOT NULL CHECK (version > 0),
+            status VARCHAR(20) NOT NULL CHECK (status IN ('draft','active','disabled','superseded')),
+            issuer TEXT NOT NULL, client_id VARCHAR(300) NOT NULL,
+            secret_ref VARCHAR(160) NOT NULL CHECK (secret_ref ~ '^env:[A-Z][A-Z0-9_]*$'),
+            authorization_endpoint TEXT NOT NULL, token_endpoint TEXT NOT NULL, jwks_uri TEXT NOT NULL,
+            redirect_uris JSONB NOT NULL CHECK (jsonb_typeof(redirect_uris) = 'array'),
+            algorithms JSONB NOT NULL CHECK (jsonb_typeof(algorithms) = 'array'),
+            mapping_version VARCHAR(120) NOT NULL, config JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_by UUID NOT NULL REFERENCES public.users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            activated_at TIMESTAMPTZ, disabled_at TIMESTAMPTZ,
+            UNIQUE (provider_uid, version), CHECK (jsonb_typeof(config) = 'object')
+        );
+        CREATE UNIQUE INDEX uq_identity_active_provider ON public.identity_idp_config_versions(provider_uid) WHERE status = 'active';
+        CREATE INDEX idx_identity_idp_status_time ON public.identity_idp_config_versions(status, created_at DESC);
+
+        CREATE TABLE public.identity_oidc_flows (
+            uid UUID PRIMARY KEY, state_hash CHAR(64) NOT NULL UNIQUE, provider_uid UUID NOT NULL,
+            provider_version INTEGER NOT NULL, nonce_hash CHAR(64) NOT NULL, pkce_verifier_hash CHAR(64) NOT NULL,
+            redirect_uri TEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','consumed','expired','failed')),
+            expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (expires_at > created_at)
+        );
+        CREATE INDEX idx_identity_oidc_flow_expiry ON public.identity_oidc_flows(status, expires_at);
+
+        CREATE TABLE public.enterprise_identity_links (
+            uid UUID PRIMARY KEY, provider_uid UUID NOT NULL, enterprise_subject VARCHAR(500) NOT NULL,
+            user_uid UUID NOT NULL REFERENCES public.users(id), username VARCHAR(64) NOT NULL,
+            display_name VARCHAR(300) NOT NULL, department_external_id VARCHAR(300) NOT NULL,
+            groups JSONB NOT NULL DEFAULT '[]'::jsonb, role_names JSONB NOT NULL DEFAULT '[]'::jsonb,
+            authorization_scope JSONB NOT NULL DEFAULT '{}'::jsonb, mapping_version VARCHAR(120) NOT NULL,
+            claims_digest CHAR(64) NOT NULL, status VARCHAR(20) NOT NULL CHECK (status IN ('active','disabled','left')),
+            token_version INTEGER NOT NULL DEFAULT 1 CHECK (token_version > 0),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (provider_uid, enterprise_subject), UNIQUE (provider_uid, user_uid),
+            CHECK (jsonb_typeof(groups) = 'array'), CHECK (jsonb_typeof(role_names) = 'array'),
+            CHECK (jsonb_typeof(authorization_scope) = 'object')
+        );
+        CREATE INDEX idx_enterprise_identity_status ON public.enterprise_identity_links(status, updated_at DESC);
+
+        CREATE TABLE public.identity_organization_nodes (
+            uid UUID PRIMARY KEY, provider_uid UUID NOT NULL, external_id VARCHAR(300) NOT NULL,
+            node_type VARCHAR(20) NOT NULL CHECK (node_type IN ('department','group')),
+            parent_external_id VARCHAR(300), display_name VARCHAR(300) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('active','disabled')),
+            attributes JSONB NOT NULL DEFAULT '{}'::jsonb, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (provider_uid, node_type, external_id), CHECK (jsonb_typeof(attributes) = 'object')
+        );
+        CREATE INDEX idx_identity_org_parent ON public.identity_organization_nodes(provider_uid, parent_external_id);
+
+        CREATE TABLE public.identity_directory_events (
+            uid UUID PRIMARY KEY, provider_uid UUID NOT NULL, source VARCHAR(80) NOT NULL, source_event_id VARCHAR(300) NOT NULL,
+            cursor_value VARCHAR(500) NOT NULL, cursor_sequence BIGINT NOT NULL CHECK(cursor_sequence > 0),
+            event_type VARCHAR(20) NOT NULL CHECK (event_type IN ('JOINER','MOVER','LEAVER','DISABLE','RESTORE','DEPARTMENT','GROUP')),
+            enterprise_subject VARCHAR(500), payload_digest CHAR(64) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('applied','rejected','conflict')),
+            safe_result JSONB NOT NULL DEFAULT '{}'::jsonb, processed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (provider_uid, source, source_event_id), UNIQUE(provider_uid,source,cursor_sequence),
+            CHECK (jsonb_typeof(safe_result) = 'object')
+        );
+        CREATE INDEX idx_identity_directory_cursor ON public.identity_directory_events(provider_uid,source,cursor_sequence DESC);
+        CREATE TABLE public.identity_directory_checkpoints (
+            provider_uid UUID NOT NULL,source VARCHAR(80) NOT NULL,cursor_value VARCHAR(500) NOT NULL,
+            cursor_sequence BIGINT NOT NULL CHECK(cursor_sequence > 0),updated_at TIMESTAMPTZ NOT NULL,
+            PRIMARY KEY(provider_uid,source)
+        );
+        CREATE TABLE public.identity_directory_conflicts (
+            uid UUID PRIMARY KEY,provider_uid UUID NOT NULL,source VARCHAR(80) NOT NULL,source_event_id VARCHAR(300) NOT NULL,
+            cursor_value VARCHAR(500) NOT NULL,cursor_sequence BIGINT NOT NULL,payload_digest CHAR(64) NOT NULL,
+            reason VARCHAR(120) NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+
+        CREATE TABLE public.identity_sessions (
+            uid UUID PRIMARY KEY, family_uid UUID NOT NULL, identity_link_uid UUID REFERENCES public.enterprise_identity_links(uid),
+            user_uid UUID NOT NULL REFERENCES public.users(id), identity_source VARCHAR(20) NOT NULL CHECK (identity_source IN ('oidc','local','emergency')),
+            token_version INTEGER NOT NULL CHECK (token_version > 0), refresh_hash CHAR(64) NOT NULL UNIQUE,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('active','rotated','revoked','expired')),
+            rotated_from_uid UUID REFERENCES public.identity_sessions(uid), revoke_reason VARCHAR(120), risk_reason VARCHAR(300),
+            emergency_request_uid UUID,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, last_seen_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            expires_at TIMESTAMPTZ NOT NULL, rotated_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, CHECK (expires_at > created_at),
+            CHECK ((identity_source='emergency') = (emergency_request_uid IS NOT NULL))
+        );
+        CREATE INDEX idx_identity_session_user_status ON public.identity_sessions(user_uid, status, created_at DESC);
+        CREATE INDEX idx_identity_session_family ON public.identity_sessions(family_uid, created_at);
+
+        CREATE TABLE public.identity_exchange_codes (
+            uid UUID PRIMARY KEY, code_hash CHAR(64) NOT NULL UNIQUE, session_uid UUID NOT NULL REFERENCES public.identity_sessions(uid),
+            redirect_uri TEXT NOT NULL, status VARCHAR(20) NOT NULL CHECK (status IN ('pending','consumed','expired')),
+            expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (expires_at > created_at)
+        );
+        CREATE INDEX idx_identity_exchange_expiry ON public.identity_exchange_codes(status, expires_at);
+
+        CREATE TABLE public.identity_emergency_requests (
+            uid UUID PRIMARY KEY, requester_uid UUID NOT NULL REFERENCES public.users(id),
+            emergency_account_uid UUID NOT NULL REFERENCES public.users(id), reason TEXT NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('pending','active','closed','expired','reviewed','rejected')),
+            starts_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
+            activated_at TIMESTAMPTZ, closed_at TIMESTAMPTZ, reviewer_uid UUID REFERENCES public.users(id),
+            review_outcome TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (requester_uid <> emergency_account_uid), CHECK (expires_at > starts_at)
+        );
+        CREATE INDEX idx_identity_emergency_status_expiry ON public.identity_emergency_requests(status, expires_at);
+        CREATE TABLE public.identity_emergency_approvals (
+            request_uid UUID NOT NULL REFERENCES public.identity_emergency_requests(uid),
+            approver_uid UUID NOT NULL REFERENCES public.users(id), approved_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            PRIMARY KEY (request_uid, approver_uid)
+        );
+        ALTER TABLE public.identity_sessions ADD CONSTRAINT fk_identity_session_emergency
+          FOREIGN KEY(emergency_request_uid) REFERENCES public.identity_emergency_requests(uid);
+
+        CREATE TABLE public.identity_audit_events (
+            uid UUID PRIMARY KEY, event_type VARCHAR(80) NOT NULL, outcome VARCHAR(20) NOT NULL CHECK (outcome IN ('success','failure','denied','warning')),
+            actor_uid UUID REFERENCES public.users(id), provider_uid UUID, enterprise_subject_digest CHAR(64),
+            resource_type VARCHAR(80) NOT NULL, resource_uid VARCHAR(300), correlation_uid UUID,
+            safe_detail JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(safe_detail) = 'object')
+        );
+        CREATE INDEX idx_identity_audit_time ON public.identity_audit_events(created_at DESC, uid DESC);
+        CREATE INDEX idx_identity_audit_actor ON public.identity_audit_events(actor_uid, created_at DESC);
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError("Forward-only migration: enterprise identity and authentication evidence cannot be removed")

+ 24 - 0
migrations/versions/20260802_471_enterprise_identity_hardening.py

@@ -0,0 +1,24 @@
+"""Harden enterprise identity secret references."""
+
+from alembic import op
+
+revision = "20260802_471"
+down_revision = "20260802_470"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        ALTER TABLE public.identity_idp_config_versions
+          DROP CONSTRAINT IF EXISTS identity_idp_config_versions_secret_ref_check;
+        ALTER TABLE public.identity_idp_config_versions
+          ADD CONSTRAINT ck_identity_idp_secret_ref_dataops_oidc
+          CHECK (secret_ref ~ '^env:DATAOPS_OIDC_[A-Z0-9_]+$');
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError("Forward-only migration: identity secret boundary cannot be weakened")

+ 353 - 0
tests/integration/test_phase3_wp02_enterprise_identity_postgres.py

@@ -0,0 +1,353 @@
+from __future__ import annotations
+
+import os
+import threading
+import uuid
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from sqlalchemy import create_engine, text
+from sqlalchemy.orm import Session
+
+from app.core.system.enterprise_identity import (
+    DirectorySynchronizer,
+    EmergencyAccess,
+    IdentityPolicyError,
+    sanitize_audit_detail,
+)
+from app.core.system.identity_repository import PostgresIdentityRepository
+from app.core.system.identity_sessions import SessionManager
+
+pytestmark = pytest.mark.integration
+
+
+def _uid() -> str:
+    return str(uuid.uuid4())
+
+
+def _identity_attributes(username: str, roles: list[str] | None = None) -> dict:
+    return {
+        "provider_uid": "filled-by-synchronizer",
+        "username": username,
+        "display_name": username.title(),
+        "department": "engineering",
+        "groups": ["data-stewards"],
+        "roles": roles or ["viewer"],
+        "mapping_version": "integration-v1",
+        "claims_digest": "a" * 64,
+        "authorization_scope": {"environments": ["test"]},
+    }
+
+
+@pytest.fixture
+def identity_database():
+    database_url = os.environ.get("TEST_DATABASE_URL")
+    if not database_url:
+        pytest.skip("TEST_DATABASE_URL is required")
+    engine = create_engine(database_url, pool_pre_ping=True)
+    marker = uuid.uuid4().hex[:10]
+    users = {name: _uid() for name in ("requester", "breakglass", "approver_a", "approver_b")}
+    providers: list[str] = []
+    with engine.begin() as connection:
+        for name, user_uid in users.items():
+            connection.execute(text("""
+                INSERT INTO public.users(id,username,display_name,password_hash,status)
+                VALUES(CAST(:uid AS uuid),:username,:display,'integration-only','active')
+            """), {"uid": user_uid, "username": f"p3wp02-{name}-{marker}", "display": name})
+            connection.execute(text("""
+                INSERT INTO public.user_roles(user_id,role_id)
+                SELECT CAST(:uid AS uuid),id FROM public.roles WHERE name='admin'
+            """), {"uid": user_uid})
+    state = {"engine": engine, "users": users, "providers": providers, "marker": marker}
+    try:
+        yield state
+    finally:
+        with engine.begin() as connection:
+            linked_users: set[str] = set()
+            for provider in providers:
+                linked_users.update(str(value) for value in connection.execute(text(
+                    "SELECT user_uid FROM public.enterprise_identity_links WHERE provider_uid=CAST(:provider AS uuid)"
+                ), {"provider": provider}).scalars())
+                connection.execute(text("DELETE FROM public.identity_audit_events WHERE provider_uid=CAST(:provider AS uuid)"),
+                                   {"provider": provider})
+                connection.execute(text("DELETE FROM public.identity_directory_conflicts WHERE provider_uid=CAST(:provider AS uuid)"),
+                                   {"provider": provider})
+                connection.execute(text("DELETE FROM public.identity_directory_events WHERE provider_uid=CAST(:provider AS uuid)"),
+                                   {"provider": provider})
+                connection.execute(text("DELETE FROM public.identity_directory_checkpoints WHERE provider_uid=CAST(:provider AS uuid)"),
+                                   {"provider": provider})
+                connection.execute(text("DELETE FROM public.identity_organization_nodes WHERE provider_uid=CAST(:provider AS uuid)"),
+                                   {"provider": provider})
+            all_users = set(users.values()) | linked_users
+            for user_uid in all_users:
+                connection.execute(text("DELETE FROM public.identity_exchange_codes WHERE session_uid IN (SELECT uid FROM public.identity_sessions WHERE user_uid=CAST(:uid AS uuid))"),
+                                   {"uid": user_uid})
+                connection.execute(text("DELETE FROM public.identity_sessions WHERE user_uid=CAST(:uid AS uuid)"), {"uid": user_uid})
+            for request_uid in connection.execute(text("SELECT uid::text FROM public.identity_emergency_requests WHERE requester_uid=CAST(:uid AS uuid)"),
+                                                  {"uid": users["requester"]}).scalars().all():
+                connection.execute(text("DELETE FROM public.identity_emergency_approvals WHERE request_uid=CAST(:uid AS uuid)"), {"uid": request_uid})
+                connection.execute(text("DELETE FROM public.identity_emergency_requests WHERE uid=CAST(:uid AS uuid)"), {"uid": request_uid})
+            for provider in providers:
+                connection.execute(text("DELETE FROM public.enterprise_identity_links WHERE provider_uid=CAST(:provider AS uuid)"),
+                                   {"provider": provider})
+            for user_uid in all_users:
+                connection.execute(text("DELETE FROM public.identity_audit_events WHERE actor_uid=CAST(:uid AS uuid)"), {"uid": user_uid})
+                connection.execute(text("DELETE FROM public.user_roles WHERE user_id=CAST(:uid AS uuid)"), {"uid": user_uid})
+                connection.execute(text("DELETE FROM public.users WHERE id=CAST(:uid AS uuid)"), {"uid": user_uid})
+            assert connection.execute(
+                text("SELECT count(*) FROM public.users WHERE username LIKE :pattern"),
+                {"pattern": f"%-{marker}"},
+            ).scalar_one() == 0
+        engine.dispose()
+
+
+def _provider(state) -> str:
+    provider = _uid()
+    state["providers"].append(provider)
+    return provider
+
+
+def test_real_470_schema_constraints_and_provider_scoped_directory_unit_of_work(identity_database, monkeypatch):
+    engine = identity_database["engine"]
+    provider_a, provider_b = _provider(identity_database), _provider(identity_database)
+    with Session(engine) as session:
+        assert session.execute(text("SELECT version_num FROM public.alembic_version")).scalar_one() == "20260802_471"
+        tables = set(session.execute(text("""
+            SELECT table_name FROM information_schema.tables WHERE table_schema='public'
+              AND table_name LIKE 'identity_%'
+        """)).scalars())
+        assert {"identity_directory_events", "identity_directory_checkpoints", "identity_directory_conflicts",
+                "identity_organization_nodes", "identity_sessions", "identity_emergency_requests"} <= tables
+        constraints = " ".join(session.execute(text("""
+            SELECT pg_get_constraintdef(oid) FROM pg_constraint
+            WHERE conrelid IN ('public.identity_sessions'::regclass,
+                                'public.identity_directory_events'::regclass,
+                                'public.identity_emergency_requests'::regclass,
+                                'public.identity_idp_config_versions'::regclass)
+        """)).scalars())
+        assert "UNIQUE (refresh_hash)" in constraints
+        assert "UNIQUE (provider_uid, source, source_event_id)" in constraints
+        assert "expires_at > starts_at" in constraints
+        assert "DATAOPS_OIDC_" in constraints
+
+        repo = PostgresIdentityRepository(session)
+        sync = DirectorySynchronizer(repo, SessionManager(repo, secret="integration-secret"))
+        first = sync.apply(provider_uid=provider_a, source="scim", source_event_id="join-a", cursor="1",
+                           cursor_sequence=1, event_type="JOINER", subject="shared-subject",
+                           attributes=_identity_attributes(f"alice-a-{identity_database['marker']}"))
+        second = sync.apply(provider_uid=provider_b, source="scim", source_event_id="join-b", cursor="1",
+                            cursor_sequence=1, event_type="JOINER", subject="shared-subject",
+                            attributes=_identity_attributes(f"alice-b-{identity_database['marker']}"))
+        assert first["user_uid"] != second["user_uid"]
+        assert repo.get_identity(provider_a, "shared-subject")["user_uid"] == first["user_uid"]
+        assert repo.get_identity(provider_b, "shared-subject")["user_uid"] == second["user_uid"]
+
+        session_a = SessionManager(repo, secret="integration-secret").create(
+            provider_uid=provider_a, subject="shared-subject", user_uid=first["user_uid"],
+            roles=["viewer"], identity_source="oidc")
+        session_b = SessionManager(repo, secret="integration-secret").create(
+            provider_uid=provider_b, subject="shared-subject", user_uid=second["user_uid"],
+            roles=["viewer"], identity_source="oidc")
+        assert {row["uid"] for row in repo.list_sessions(provider_a, "shared-subject")} == {session_a.session_uid}
+        assert {row["uid"] for row in repo.list_sessions(provider_b, "shared-subject")} == {session_b.session_uid}
+
+        sync.apply(provider_uid=provider_a, source="scim", source_event_id="move-a", cursor="2",
+                   cursor_sequence=2, event_type="MOVER", subject="shared-subject",
+                   attributes=_identity_attributes(f"alice-a-{identity_database['marker']}", ["editor"]))
+        assert repo.get_session(session_a.session_uid)["status"] == "revoked"
+        assert repo.get_session(session_b.session_uid)["status"] == "active"
+        assert repo.get_directory_checkpoint(provider_a, "scim") == {"cursor": "2", "cursor_sequence": 2}
+        with pytest.raises(IdentityPolicyError, match="idempotency conflict"):
+            sync.apply(provider_uid=provider_a, source="scim", source_event_id="move-a", cursor="2",
+                       cursor_sequence=2, event_type="MOVER", subject="shared-subject",
+                       attributes=_identity_attributes(f"changed-{identity_database['marker']}", ["editor"]))
+        assert session.execute(text("SELECT count(*) FROM public.identity_directory_conflicts WHERE provider_uid=CAST(:p AS uuid)"),
+                               {"p": provider_a}).scalar_one() == 1
+
+        rollback_subject = "rollback-subject"
+        original_insert = repo._insert_directory_event
+
+        def fail_after_mutation(event, result):
+            raise RuntimeError("injected event persistence failure")
+
+        monkeypatch.setattr(repo, "_insert_directory_event", fail_after_mutation)
+        with pytest.raises(RuntimeError, match="injected"):
+            sync.apply(provider_uid=provider_a, source="rollback", source_event_id="rollback-1", cursor="1",
+                       cursor_sequence=1, event_type="JOINER", subject=rollback_subject,
+                       attributes=_identity_attributes(f"rollback-{identity_database['marker']}"))
+        monkeypatch.setattr(repo, "_insert_directory_event", original_insert)
+        assert repo.get_identity(provider_a, rollback_subject) is None
+        assert repo.get_directory_checkpoint(provider_a, "rollback") is None
+
+
+def test_organization_node_incremental_lifecycle_is_provider_isolated_and_auditable(identity_database):
+    engine = identity_database["engine"]
+    provider_a, provider_b = _provider(identity_database), _provider(identity_database)
+    with Session(engine) as session:
+        repo = PostgresIdentityRepository(session)
+        sync = DirectorySynchronizer(repo, SessionManager(repo, secret="integration-secret"))
+        department = {"external_id": "dept-ops", "display_name": "Operations", "action": "UPSERT",
+                      "attributes": {"cost_center": "CC-1"}}
+        created = sync.apply(provider_uid=provider_a, source="directory", source_event_id="dept-1", cursor="1",
+                             cursor_sequence=1, event_type="DEPARTMENT", attributes=department)
+        assert created["status"] == "active"
+        disabled = sync.apply(provider_uid=provider_a, source="directory", source_event_id="dept-2", cursor="2",
+                              cursor_sequence=2, event_type="DEPARTMENT",
+                              attributes={"external_id": "dept-ops", "action": "DISABLE"})
+        assert disabled["status"] == "disabled"
+        restored = sync.apply(provider_uid=provider_a, source="directory", source_event_id="dept-3", cursor="3",
+                              cursor_sequence=3, event_type="DEPARTMENT",
+                              attributes={**department, "action": "RESTORE", "display_name": "Operations Restored"})
+        assert restored["status"] == "active"
+        assert sync.apply(provider_uid=provider_a, source="directory", source_event_id="dept-3", cursor="3",
+                          cursor_sequence=3, event_type="DEPARTMENT",
+                          attributes={**department, "action": "RESTORE", "display_name": "Operations Restored"})["idempotent"] is True
+        sync.apply(provider_uid=provider_a, source="directory", source_event_id="group-1", cursor="4",
+                   cursor_sequence=4, event_type="GROUP",
+                   attributes={"external_id": "group-stewards", "display_name": "Stewards", "action": "UPSERT"})
+        sync.apply(provider_uid=provider_b, source="directory", source_event_id="dept-other", cursor="1",
+                   cursor_sequence=1, event_type="DEPARTMENT", attributes=department)
+        node_a = repo.get_organization_node(provider_a, "department", "dept-ops")
+        node_b = repo.get_organization_node(provider_b, "department", "dept-ops")
+        assert node_a["display_name"] == "Operations Restored"
+        assert node_b["display_name"] == "Operations"
+        repo.audit({"event_type": "directory_delta", "outcome": "success", "provider_uid": provider_a,
+                    "resource_type": "organization_node", "resource_uid": created["node_uid"],
+                    "safe_detail": sanitize_audit_detail({"event_type": "DEPARTMENT", "token": "must-not-persist"})})
+        audit = session.execute(text("SELECT safe_detail FROM public.identity_audit_events WHERE provider_uid=CAST(:p AS uuid)"),
+                                {"p": provider_a}).scalar_one()
+        assert audit == {"event_type": "DEPARTMENT"}
+
+
+def test_refresh_rotation_and_session_limit_are_atomic_under_concurrency(identity_database):
+    engine = identity_database["engine"]
+    provider = _provider(identity_database)
+    with Session(engine) as session:
+        repo = PostgresIdentityRepository(session)
+        sync = DirectorySynchronizer(repo, SessionManager(repo, secret="integration-secret"))
+        identity = sync.apply(provider_uid=provider, source="scim", source_event_id="join", cursor="1",
+                              cursor_sequence=1, event_type="JOINER", subject="concurrent-subject",
+                              attributes=_identity_attributes(f"concurrent-{identity_database['marker']}"))
+        original = SessionManager(repo, secret="integration-secret").create(
+            provider_uid=provider, subject="concurrent-subject", user_uid=identity["user_uid"],
+            roles=["viewer"], identity_source="oidc")
+        family_uid = repo.get_session(original.session_uid)["family_uid"]
+
+    barrier = threading.Barrier(2)
+    outcomes: list[str] = []
+    outcome_lock = threading.Lock()
+
+    def rotate_once():
+        with Session(engine) as thread_session:
+            manager = SessionManager(PostgresIdentityRepository(thread_session), secret="integration-secret")
+            barrier.wait()
+            try:
+                manager.refresh(original.refresh_token)
+                outcome = "rotated"
+            except IdentityPolicyError as exc:
+                outcome = str(exc)
+            with outcome_lock:
+                outcomes.append(outcome)
+
+    threads = [threading.Thread(target=rotate_once) for _ in range(2)]
+    for thread in threads:
+        thread.start()
+    for thread in threads:
+        thread.join(timeout=15)
+    assert all(not thread.is_alive() for thread in threads)
+    assert outcomes.count("rotated") <= 1
+    assert "refresh reuse detected" in outcomes
+    with Session(engine) as session:
+        statuses = session.execute(text("SELECT status FROM public.identity_sessions WHERE family_uid=CAST(:family AS uuid)"),
+                                   {"family": family_uid}).scalars().all()
+        assert statuses and set(statuses) == {"revoked"}
+
+    create_barrier = threading.Barrier(6)
+    create_errors: list[Exception] = []
+
+    def create_once(index: int):
+        with Session(engine) as thread_session:
+            manager = SessionManager(PostgresIdentityRepository(thread_session), secret="integration-secret", max_sessions=2)
+            create_barrier.wait()
+            try:
+                manager.create(provider_uid=provider, subject="concurrent-subject", user_uid=identity["user_uid"],
+                               roles=["viewer"], identity_source="oidc")
+            except Exception as exc:  # captured and asserted below
+                create_errors.append(exc)
+
+    creators = [threading.Thread(target=create_once, args=(index,)) for index in range(6)]
+    for thread in creators:
+        thread.start()
+    for thread in creators:
+        thread.join(timeout=15)
+    assert not create_errors
+    with Session(engine) as session:
+        active_count = session.execute(text("SELECT count(*) FROM public.identity_sessions WHERE user_uid=CAST(:u AS uuid) AND status='active'"),
+                                       {"u": identity["user_uid"]}).scalar_one()
+        assert active_count <= 2
+
+
+def test_emergency_session_rechecks_local_admin_and_close_revokes(identity_database):
+    engine = identity_database["engine"]
+    users = identity_database["users"]
+    now = datetime.now(UTC)
+    with Session(engine) as session:
+        repo = PostgresIdentityRepository(session)
+        service = EmergencyAccess(repo, clock=lambda: now)
+        request = service.request(requester_uid=users["requester"], account_uid=users["breakglass"],
+                                  reason="integration outage", expires_at=now + timedelta(minutes=30),
+                                  account_is_local_active_admin=True)
+        service.approve(request["uid"], approver_uid=users["approver_a"])
+        service.approve(request["uid"], approver_uid=users["approver_b"])
+        active = service.activate(request["uid"])
+        manager = SessionManager(repo, secret="integration-secret", clock=lambda: now)
+        credentials = manager.create(subject=users["breakglass"], user_uid=users["breakglass"], roles=["admin"],
+                                     identity_source="emergency", emergency_request_uid=request["uid"],
+                                     emergency_expires_at=active["expires_at"])
+        session.execute(text("DELETE FROM public.user_roles WHERE user_id=CAST(:u AS uuid)"), {"u": users["breakglass"]})
+        session.commit()
+        with pytest.raises(IdentityPolicyError, match="active local administrator"):
+            manager.create(subject=users["breakglass"], user_uid=users["breakglass"], roles=["admin"],
+                           identity_source="emergency", emergency_request_uid=request["uid"],
+                           emergency_expires_at=active["expires_at"])
+        session.execute(text("INSERT INTO public.user_roles(user_id,role_id) SELECT CAST(:u AS uuid),id FROM public.roles WHERE name='admin'"),
+                        {"u": users["breakglass"]})
+        session.commit()
+        service.close(request["uid"], actor_uid=users["approver_a"])
+        assert repo.get_session(credentials.session_uid)["status"] == "revoked"
+        assert repo.get_session(credentials.session_uid)["emergency_request_uid"] == request["uid"]
+
+
+def test_oidc_login_persistence_rolls_back_identity_session_and_exchange_together(identity_database, monkeypatch):
+    engine = identity_database["engine"]
+    provider = _provider(identity_database)
+    user_uid = _uid()
+    subject = "atomic-callback-subject"
+    with Session(engine) as session:
+        repo = PostgresIdentityRepository(session)
+        repo.put_identity(provider, subject, {
+            "provider_uid": provider, "user_uid": user_uid,
+            "username": f"atomic-{identity_database['marker']}", "display_name": "Atomic Callback",
+            "department": "security", "groups": ["stewards"], "roles": ["viewer"],
+            "authorization_scope": {"environments": ["test"]}, "mapping_version": "integration-v1",
+            "claims_digest": "b" * 64, "status": "active", "token_version": 1,
+        }, commit=False)
+        credentials = SessionManager(repo, secret="integration-secret").create(
+            provider_uid=provider, subject=subject, user_uid=user_uid, roles=["viewer"],
+            identity_source="oidc", commit=False)
+
+        def fail_exchange(record, *, commit=True):
+            del record, commit
+            raise RuntimeError("injected exchange persistence failure")
+
+        monkeypatch.setattr(repo, "insert_exchange_code", fail_exchange)
+        with pytest.raises(RuntimeError, match="injected"):
+            repo.insert_exchange_code({"uid": _uid(), "code_hash": "c" * 64,
+                                       "session_uid": credentials.session_uid,
+                                       "redirect_uri": "https://dataops.example.com/login/callback",
+                                       "expires_at": datetime.now(UTC) + timedelta(minutes=2)}, commit=False)
+        session.rollback()
+        assert repo.get_identity(provider, subject) is None
+        assert repo.get_session(credentials.session_uid) is None
+        assert session.execute(text("SELECT count(*) FROM public.users WHERE id=CAST(:u AS uuid)"),
+                               {"u": user_uid}).scalar_one() == 0

+ 476 - 0
tests/test_phase3_wp02_enterprise_identity.py

@@ -0,0 +1,476 @@
+from __future__ import annotations
+
+import base64
+import hashlib
+from datetime import UTC, datetime, timedelta
+
+import jwt
+import pytest
+from cryptography.hazmat.primitives.asymmetric import rsa
+
+NOW = datetime(2026, 8, 2, 8, 0, tzinfo=UTC)
+
+
+def _b64int(value: int) -> str:
+    size = (value.bit_length() + 7) // 8
+    return base64.urlsafe_b64encode(value.to_bytes(size, "big")).rstrip(b"=").decode()
+
+
+def _key_material():
+    private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+    numbers = private.public_key().public_numbers()
+    jwk = {"kty": "RSA", "kid": "test-key", "alg": "RS256", "use": "sig", "n": _b64int(numbers.n), "e": _b64int(numbers.e)}
+    return private, jwk
+
+
+def test_idp_config_rejects_unsafe_urls_redirects_and_secret_values(monkeypatch):
+    from app.core.system.enterprise_identity import IdentityPolicyError, IdpConfig
+
+    valid = {
+        "provider_uid": "idp-1", "version": 1, "issuer": "https://login.example.com/tenant",
+        "client_id": "dataops", "secret_ref": "env:DATAOPS_OIDC_CLIENT_SECRET",
+        "authorization_endpoint": "https://login.example.com/oauth2/authorize",
+        "token_endpoint": "https://login.example.com/oauth2/token",
+        "jwks_uri": "https://login.example.com/.well-known/jwks.json",
+        "redirect_uris": ("https://dataops.example.com/api/system/identity/callback",),
+        "post_login_redirect_uris": ("https://dataops.example.com/login/callback",),
+        "algorithms": ("RS256",), "mapping_version": "mapping-v1",
+    }
+    config = IdpConfig(**valid)
+    config.validate()
+    monkeypatch.setenv("DATAOPS_OIDC_CLIENT_SECRET", "not-returned")
+    assert config.resolve_secret() == "not-returned"
+    assert "not-returned" not in repr(config)
+
+    for update in (
+        {"issuer": "http://login.example.com"},
+        {"issuer": "https://127.0.0.1"},
+        {"issuer": "https://localhost"},
+        {"secret_ref": "plain-secret"},
+        {"secret_ref": "env:AWS_SECRET_ACCESS_KEY"},
+        {"secret_ref": "env:OIDC_TEST_SECRET"},
+        {"algorithms": ("none",)},
+        {"redirect_uris": ("https://dataops.example.com/callback#token",)},
+        {"post_login_redirect_uris": ("javascript:alert(1)",)},
+        {"post_login_redirect_uris": ("https://localhost/login/callback",)},
+    ):
+        payload = {**valid, **update}
+        with pytest.raises(IdentityPolicyError):
+            IdpConfig(**payload).validate()
+
+
+def test_oidc_pkce_state_nonce_signature_and_replay_are_fail_closed():
+    from app.core.system.enterprise_identity import IdentityPolicyError, IdpConfig
+    from app.core.system.identity_repository import MemoryIdentityRepository
+    from app.core.system.oidc import OidcClient
+
+    config = IdpConfig(
+        provider_uid="idp-1", version=1, issuer="https://login.example.com/tenant",
+        client_id="dataops", secret_ref="env:DATAOPS_OIDC_TEST_SECRET",
+        authorization_endpoint="https://login.example.com/authorize",
+        token_endpoint="https://login.example.com/token",
+        jwks_uri="https://login.example.com/jwks", redirect_uris=("https://dataops.example.com/callback",),
+        post_login_redirect_uris=("https://dataops.example.com/login/callback",),
+        algorithms=("RS256",), mapping_version="mapping-v1",
+    )
+    private, jwk = _key_material()
+    repo = MemoryIdentityRepository()
+    client = OidcClient(repo, clock=lambda: NOW)
+    started = client.begin(config, "https://dataops.example.com/callback")
+    assert started.code_challenge_method == "S256"
+    assert started.code_challenge == base64.urlsafe_b64encode(
+        hashlib.sha256(started.code_verifier.encode()).digest()
+    ).rstrip(b"=").decode()
+
+    claims = {"iss": config.issuer, "aud": config.client_id, "sub": "employee-1", "nonce": started.nonce,
+              "iat": int(NOW.timestamp()), "exp": int((NOW + timedelta(minutes=5)).timestamp()),
+              "preferred_username": "alice", "name": "Alice", "department": "ops", "groups": ["stewards"]}
+    token = jwt.encode(claims, private, algorithm="RS256", headers={"kid": "test-key"})
+    verified = client.verify_callback(config, state=started.state, redirect_uri="https://dataops.example.com/callback",
+                                      code_verifier=started.code_verifier, id_token=token, jwks={"keys": [jwk]})
+    assert verified["sub"] == "employee-1"
+    with pytest.raises(IdentityPolicyError, match="state"):
+        client.verify_callback(config, state=started.state, redirect_uri="https://dataops.example.com/callback",
+                               code_verifier=started.code_verifier, id_token=token, jwks={"keys": [jwk]})
+
+    forged = jwt.encode({**claims, "nonce": "wrong"}, "symmetric", algorithm="HS256", headers={"kid": "test-key"})
+    second = client.begin(config, "https://dataops.example.com/callback")
+    with pytest.raises(IdentityPolicyError):
+        client.verify_callback(config, state=second.state, redirect_uri="https://dataops.example.com/callback",
+                               code_verifier=second.code_verifier, id_token=forged, jwks={"keys": [jwk]})
+
+
+def test_oidc_transport_rejects_private_dns_redirects_oversize_and_bad_json():
+    from app.core.system.enterprise_identity import (
+        IdentityPolicyError,
+        IdentityUpstreamError,
+        IdpConfig,
+    )
+    from app.core.system.oidc import OidcTransport
+
+    config = IdpConfig(
+        provider_uid="idp-1", version=1, issuer="https://login.example.com/tenant",
+        client_id="dataops", secret_ref="env:DATAOPS_OIDC_TEST_SECRET",
+        authorization_endpoint="https://login.example.com/authorize",
+        token_endpoint="https://login.example.com/token", jwks_uri="https://login.example.com/jwks",
+        redirect_uris=("https://dataops.example.com/callback",),
+        post_login_redirect_uris=("https://dataops.example.com/login/callback",),
+        algorithms=("RS256",), mapping_version="mapping-v1",
+    )
+
+    class Response:
+        def __init__(self, status, body, content_type="application/json"):
+            self.status_code = status
+            self.body = body
+            self.headers = {"Content-Type": content_type, "Content-Length": str(len(body))}
+
+        def iter_content(self, chunk_size):
+            del chunk_size
+            yield self.body
+
+        def close(self):
+            return None
+
+    class Http:
+        def __init__(self, response):
+            self.response = response
+
+        def get(self, url, **kwargs):
+            del url, kwargs
+            return self.response
+
+    def public_dns(*args, **kwargs):
+        del args, kwargs
+        return [(2, 1, 6, "", ("93.184.216.34", 443))]
+
+    def private_dns(*args, **kwargs):
+        del args, kwargs
+        return [(2, 1, 6, "", ("10.0.0.8", 443))]
+    with pytest.raises(IdentityPolicyError, match="non-public"):
+        OidcTransport(http=Http(Response(200, b'{"keys":[]}')), resolver=private_dns).fetch_jwks(config)
+    with pytest.raises(IdentityUpstreamError, match="redirect"):
+        OidcTransport(http=Http(Response(302, b"{}")), resolver=public_dns).fetch_jwks(config)
+    with pytest.raises(IdentityUpstreamError, match="too large"):
+        OidcTransport(http=Http(Response(200, b"x" * 33)), resolver=public_dns,
+                      max_response_bytes=32).fetch_jwks(config)
+    with pytest.raises(IdentityUpstreamError, match="invalid JSON"):
+        OidcTransport(http=Http(Response(200, b"not-json")), resolver=public_dns).fetch_jwks(config)
+    with pytest.raises(IdentityUpstreamError, match="must be JSON"):
+        OidcTransport(http=Http(Response(200, b"{}", "text/plain")), resolver=public_dns).fetch_jwks(config)
+
+
+def test_oidc_production_transport_pins_validated_ip_and_preserves_tls_hostname():
+    from app.core.system.enterprise_identity import IdpConfig
+    from app.core.system.oidc import OidcTransport
+
+    config = IdpConfig(
+        provider_uid="idp-1", version=1, issuer="https://login.example.com/tenant",
+        client_id="dataops", secret_ref="env:DATAOPS_OIDC_TEST_SECRET",
+        authorization_endpoint="https://login.example.com/authorize",
+        token_endpoint="https://login.example.com/token", jwks_uri="https://login.example.com/jwks?tenant=one",
+        redirect_uris=("https://dataops.example.com/callback",),
+        post_login_redirect_uris=("https://dataops.example.com/login/callback",),
+        algorithms=("RS256",), mapping_version="mapping-v1",
+    )
+    observed = {"resolver_calls": 0, "pool": [], "request": []}
+
+    def resolver(host, port, **kwargs):
+        observed["resolver_calls"] += 1
+        assert host == "login.example.com" and port == 443
+        assert kwargs["type"]
+        return [(2, 1, 6, "", ("93.184.216.34", 443))]
+
+    class Response:
+        status = 200
+        headers = {"Content-Type": "application/json", "Content-Length": "11"}
+
+        def stream(self, **kwargs):
+            assert kwargs == {"amt": 64 * 1024, "decode_content": True}
+            yield b'{"keys":[]}'
+
+        def release_conn(self):
+            return None
+
+    class Pool:
+        def __init__(self, **kwargs):
+            observed["pool"].append(kwargs)
+
+        def urlopen(self, method, target, **kwargs):
+            observed["request"].append((method, target, kwargs))
+            return Response()
+
+        def close(self):
+            return None
+
+    payload = OidcTransport(resolver=resolver, pool_factory=Pool).fetch_jwks(config)
+    assert payload == {"keys": []}
+    assert observed["resolver_calls"] == 1
+    pool = observed["pool"][0]
+    assert pool["host"] == "93.184.216.34"
+    assert pool["server_hostname"] == "login.example.com"
+    assert pool["assert_hostname"] == "login.example.com"
+    assert pool["cert_reqs"] == "CERT_REQUIRED" and pool["ca_certs"]
+    method, target, request_options = observed["request"][0]
+    assert method == "GET" and target == "/jwks?tenant=one"
+    assert request_options["headers"]["Host"] == "login.example.com"
+    assert request_options["redirect"] is False and request_options["retries"] is False
+
+
+def test_claim_mapping_is_deterministic_versioned_and_default_deny():
+    from app.core.system.enterprise_identity import ClaimMapper, IdentityPolicyError
+
+    mapper = ClaimMapper(
+        version="mapping-v7",
+        group_rules={"stewards": {"roles": ["editor"], "business_domain_uids": ["domain-b", "domain-a"],
+                                    "object_types": ["dataset"], "environments": ["prod"],
+                                    "data_scopes": ["plant-1"]}},
+    )
+    claims = {"sub": "s-1", "preferred_username": "alice", "name": "Alice", "department": "ops",
+              "groups": ["stewards"]}
+    result = mapper.map(claims)
+    assert result.roles == ("editor",)
+    assert result.business_domain_uids == ("domain-a", "domain-b")
+    assert result.mapping_version == "mapping-v7"
+    assert len(result.claims_digest) == 64
+    assert "Alice" not in repr(result.evidence)
+    with pytest.raises(IdentityPolicyError):
+        mapper.map({**claims, "groups": ["unknown"]})
+    for malformed_claims in (
+        {**claims, "groups": "stewards"},
+        {**claims, "groups": ["stewards", 1]},
+        {**claims, "groups": []},
+        {**claims, "sub": {"value": "s-1"}},
+        {**claims, "preferred_username": ["alice"]},
+        {**claims, "name": ""},
+        {**claims, "department": "x" * 301},
+    ):
+        with pytest.raises(IdentityPolicyError):
+            mapper.map(malformed_claims)
+    with pytest.raises(IdentityPolicyError):
+        ClaimMapper(version="x", group_rules={"bad": {"roles": ["superuser"]}})
+    for version, rules in (
+        ("", {"group": {"roles": ["viewer"]}}),
+        ("v1", {}),
+        ("v1", {"group": "viewer"}),
+        ("v1", {"group": {"roles": "viewer"}}),
+        ("v1", {"group": {"roles": ["viewer"], "unknown_scope": ["x"]}}),
+        ("v1", {"group": {"roles": ["viewer"], "data_scopes": [1]}}),
+    ):
+        with pytest.raises(IdentityPolicyError):
+            ClaimMapper(version=version, group_rules=rules)
+
+
+def test_directory_events_are_idempotent_conflict_safe_and_leaver_revokes():
+    from app.core.system.enterprise_identity import (
+        DirectorySynchronizer,
+        IdentityPolicyError,
+    )
+    from app.core.system.identity_repository import MemoryIdentityRepository
+    from app.core.system.identity_sessions import SessionManager
+
+    repo = MemoryIdentityRepository()
+    sessions = SessionManager(repo, secret="a-long-test-secret", clock=lambda: NOW)
+    sync = DirectorySynchronizer(repo, sessions, clock=lambda: NOW)
+    first = sync.apply(provider_uid="idp-a", source="scim", source_event_id="e1", cursor="10", cursor_sequence=10, event_type="JOINER",
+                       subject="s1", attributes={"username": "alice", "roles": ["viewer"]})
+    sessions.create(provider_uid="idp-a", subject="s1", user_uid=first["user_uid"], roles=["viewer"], identity_source="oidc")
+    sessions.create(provider_uid="idp-b", subject="s1", user_uid="other", roles=["viewer"], identity_source="oidc")
+    assert sync.apply(provider_uid="idp-a", source="scim", source_event_id="e1", cursor="10", cursor_sequence=10, event_type="JOINER",
+                      subject="s1", attributes={"username": "alice", "roles": ["viewer"]})["idempotent"] is True
+    with pytest.raises(IdentityPolicyError):
+        sync.apply(provider_uid="idp-a", source="scim", source_event_id="e1", cursor="10", cursor_sequence=10, event_type="MOVER",
+                   subject="s1", attributes={"username": "alice", "roles": ["editor"]})
+    with pytest.raises(IdentityPolicyError, match="monotonic"):
+        sync.apply(provider_uid="idp-a", source="scim", source_event_id="old", cursor="9", cursor_sequence=9,
+                   event_type="MOVER", subject="s1", attributes={"username": "alice", "roles": ["viewer"]})
+    assert len(repo.directory_conflicts) == 2
+    sync.apply(provider_uid="idp-a", source="scim", source_event_id="e2", cursor="11", cursor_sequence=11, event_type="LEAVER",
+               subject="s1", attributes={"username": "alice", "roles": []})
+    assert all(item["status"] == "revoked" for item in repo.list_sessions("idp-a", "s1"))
+    assert repo.list_sessions("idp-b", "s1")[0]["status"] == "active"
+
+
+def test_directory_organization_nodes_are_provider_scoped_and_atomic(monkeypatch):
+    from app.core.system.enterprise_identity import (
+        DirectorySynchronizer,
+        IdentityPolicyError,
+    )
+    from app.core.system.identity_repository import MemoryIdentityRepository
+    from app.core.system.identity_sessions import SessionManager
+
+    repo = MemoryIdentityRepository()
+    sync = DirectorySynchronizer(repo, SessionManager(repo, secret="a-long-test-secret"), clock=lambda: NOW)
+    first = sync.apply(provider_uid="idp-a", source="directory", source_event_id="d1", cursor="1",
+                       cursor_sequence=1, event_type="DEPARTMENT",
+                       attributes={"external_id": "ops", "display_name": "Operations", "action": "UPSERT"})
+    sync.apply(provider_uid="idp-a", source="directory", source_event_id="d2", cursor="2",
+               cursor_sequence=2, event_type="DEPARTMENT",
+               attributes={"external_id": "ops", "action": "DISABLE"})
+    restored = sync.apply(provider_uid="idp-a", source="directory", source_event_id="d3", cursor="3",
+                          cursor_sequence=3, event_type="DEPARTMENT",
+                          attributes={"external_id": "ops", "display_name": "Operations", "action": "RESTORE"})
+    assert first["node_uid"] == restored["node_uid"]
+    assert repo.get_organization_node("idp-a", "department", "ops")["status"] == "active"
+    sync.apply(provider_uid="idp-b", source="directory", source_event_id="d1", cursor="1",
+               cursor_sequence=1, event_type="DEPARTMENT",
+               attributes={"external_id": "ops", "display_name": "Other Operations", "action": "UPSERT"})
+    assert repo.get_organization_node("idp-b", "department", "ops")["display_name"] == "Other Operations"
+    with pytest.raises(IdentityPolicyError, match="idempotency"):
+        sync.apply(provider_uid="idp-a", source="directory", source_event_id="d3", cursor="3",
+                   cursor_sequence=3, event_type="DEPARTMENT",
+                   attributes={"external_id": "ops", "display_name": "Changed", "action": "RESTORE"})
+
+    original = repo.put_directory_event
+    monkeypatch.setattr(repo, "put_directory_event", lambda event: (_ for _ in ()).throw(RuntimeError("event failure")))
+    with pytest.raises(RuntimeError, match="event failure"):
+        sync.apply(provider_uid="idp-a", source="rollback", source_event_id="g1", cursor="1",
+                   cursor_sequence=1, event_type="GROUP",
+                   attributes={"external_id": "stewards", "display_name": "Stewards", "action": "UPSERT"})
+    monkeypatch.setattr(repo, "put_directory_event", original)
+    assert repo.get_organization_node("idp-a", "group", "stewards") is None
+    assert repo.get_directory_checkpoint("idp-a", "rollback") is None
+
+
+def test_refresh_rotation_reuse_concurrency_risk_and_token_version():
+    from app.core.system.enterprise_identity import IdentityPolicyError
+    from app.core.system.identity_repository import MemoryIdentityRepository
+    from app.core.system.identity_sessions import SessionManager
+
+    repo = MemoryIdentityRepository()
+    manager = SessionManager(repo, secret="a-long-test-secret", clock=lambda: NOW, max_sessions=2)
+    one = manager.create(provider_uid="idp-1", subject="s1", user_uid="u1", roles=["viewer"], identity_source="oidc")
+    rotated = manager.refresh(one.refresh_token)
+    with pytest.raises(IdentityPolicyError, match="reuse"):
+        manager.refresh(one.refresh_token)
+    assert repo.get_session(rotated.session_uid)["status"] == "revoked"
+    manager.create(provider_uid="idp-1", subject="s1", user_uid="u1", roles=["viewer"], identity_source="oidc")
+    latest = manager.create(provider_uid="idp-1", subject="s1", user_uid="u1", roles=["editor"], identity_source="oidc")
+    manager.create(provider_uid="idp-1", subject="s1", user_uid="u1", roles=["viewer"], identity_source="oidc")
+    assert len([x for x in repo.list_sessions("idp-1", "s1") if x["status"] == "active"]) <= 2
+    manager.risk_revoke(latest.session_uid, reason="impossible_travel")
+    assert repo.get_session(latest.session_uid)["risk_reason"] == "impossible_travel"
+
+
+def test_emergency_access_requires_two_distinct_non_self_approvers_and_window():
+    from app.core.system.enterprise_identity import EmergencyAccess, IdentityPolicyError
+    from app.core.system.identity_repository import MemoryIdentityRepository
+    from app.core.system.identity_sessions import SessionManager
+
+    repo = MemoryIdentityRepository()
+    emergency = EmergencyAccess(repo, clock=lambda: NOW)
+    with pytest.raises(IdentityPolicyError):
+        emergency.request(requester_uid="requester", account_uid="breakglass", reason="IdP outage",
+                          expires_at=NOW + timedelta(minutes=30), account_is_local_active_admin=False)
+    req = emergency.request(requester_uid="requester", account_uid="breakglass", reason="IdP outage",
+                            expires_at=NOW + timedelta(minutes=30), account_is_local_active_admin=True)
+    for approver in ("requester", "breakglass"):
+        with pytest.raises(IdentityPolicyError):
+            emergency.approve(req["uid"], approver_uid=approver)
+    emergency.approve(req["uid"], approver_uid="admin-a")
+    with pytest.raises(IdentityPolicyError):
+        emergency.activate(req["uid"])
+    emergency.approve(req["uid"], approver_uid="admin-b")
+    active = emergency.activate(req["uid"])
+    assert active["status"] == "active"
+    manager = SessionManager(repo, secret="a-long-test-secret", clock=lambda: NOW)
+    credentials = manager.create(
+        subject="breakglass", user_uid="breakglass", roles=["admin"], identity_source="emergency",
+        emergency_request_uid=req["uid"], emergency_expires_at=req["expires_at"])
+    rotated = manager.refresh(credentials.refresh_token)
+    assert repo.get_session(rotated.session_uid)["expires_at"] == req["expires_at"]
+    emergency.close(req["uid"], actor_uid="admin-a")
+    with pytest.raises(IdentityPolicyError, match="window"):
+        manager.refresh(rotated.refresh_token)
+    reviewed = emergency.review(req["uid"], reviewer_uid="admin-b", outcome="approved")
+    assert reviewed["status"] == "reviewed"
+
+
+def test_identity_audit_sanitizes_sensitive_fields_recursively():
+    from app.core.system.enterprise_identity import sanitize_audit_detail
+
+    safe = sanitize_audit_detail({"code": "secret", "token": "secret", "claims": {"sub": "s1"},
+                                  "cookie": "secret", "password": "secret", "assertion": "secret",
+                                  "ok": True, "nested": {"verifier": "secret", "event": "login"}})
+    serialized = repr(safe)
+    assert "secret" not in serialized and "s1" not in serialized
+    assert safe["ok"] is True and safe["nested"]["event"] == "login"
+
+
+def test_public_identity_policy_is_method_exact_and_logout_is_viewer_safe():
+    from app.core.system.permissions import (
+        IDENTITY_READ,
+        MANAGE_USERS,
+        PUBLIC,
+        permission_for_request,
+    )
+
+    assert permission_for_request("/api/system/identity/providers", "GET") == (PUBLIC,)
+    assert permission_for_request("/api/system/identity/providers", "POST") != (PUBLIC,)
+    assert permission_for_request("/api/system/identity/callback", "POST") != (PUBLIC,)
+    assert permission_for_request("/api/system/auth/login", "GET") == (MANAGE_USERS,)
+    assert permission_for_request("/api/system/identity/logout", "POST") == (IDENTITY_READ,)
+    assert permission_for_request("/api/system/identity/idp-versions", "GET") == ("identity:manage",)
+    assert permission_for_request("/api/system/identity/sessions", "GET") == ("identity:operate",)
+    assert permission_for_request("/api/system/identity/audit", "GET") == ("identity:manage",)
+
+
+def test_identity_management_input_helpers_reject_malformed_values():
+    from app.api.system.enterprise_identity import _positive_int, _timestamp, _uuid
+    from app.core.system.enterprise_identity import IdentityPolicyError
+
+    for value in (None, "not-a-uuid", 123):
+        with pytest.raises(IdentityPolicyError):
+            _uuid(value, "provider_uid")
+    for value in (None, "bad", 0, -1, True, 501):
+        with pytest.raises(IdentityPolicyError):
+            _positive_int(value, "limit", maximum=500)
+    for value in (None, "2026-08-02", "not-a-date"):
+        with pytest.raises(IdentityPolicyError):
+            _timestamp(value, "expires_at")
+
+
+def test_identity_management_api_returns_safe_4xx_and_upstream_502(monkeypatch):
+    from app import create_app
+    from app.api.system import enterprise_identity as identity_api
+    from app.core.system.enterprise_identity import IdentityUpstreamError
+    from app.core.system.oidc import OidcTransport
+
+    monkeypatch.setattr(
+        "app.core.system.auth.load_identity_from_token",
+        lambda token, secret: {"id": "01900000-0000-7000-8000-000000000001", "username": "admin",
+                               "roles": ["admin"]} if token == "admin" else None,
+    )
+    monkeypatch.setattr(identity_api, "_audit", lambda *args, **kwargs: None)
+    app = create_app()
+    app.config.update(TESTING=True)
+    client = app.test_client()
+    headers = {"Authorization": "Bearer admin"}
+    malformed = (
+        client.post("/api/system/identity/idp-versions", json={"provider_uid": "bad"}, headers=headers),
+        client.get("/api/system/identity/sessions?provider_uid=bad", headers=headers),
+        client.get("/api/system/identity/audit?limit=not-an-int", headers=headers),
+        client.post("/api/system/identity/directory/delta", json={"provider_uid": "bad"}, headers=headers),
+        client.post("/api/system/identity/emergency/requests", json={"account_uid": "bad"}, headers=headers),
+    )
+    assert all(response.status_code == 400 for response in malformed)
+    assert all("Traceback" not in response.get_data(as_text=True) for response in malformed)
+
+    provider_uid = "01900000-0000-7000-8000-000000000099"
+    monkeypatch.setattr(identity_api, "_load_config", lambda *args, **kwargs: {
+        "provider_uid": provider_uid, "version": 1, "status": "draft",
+        "issuer": "https://login.example.com/tenant", "client_id": "dataops",
+        "secret_ref": "env:DATAOPS_OIDC_TEST_SECRET",
+        "authorization_endpoint": "https://login.example.com/authorize",
+        "token_endpoint": "https://login.example.com/token", "jwks_uri": "https://login.example.com/jwks",
+        "redirect_uris": ["https://dataops.example.com/callback"], "algorithms": ["RS256"],
+        "mapping_version": "v1", "config": {
+            "post_login_redirect_uris": ["https://dataops.example.com/login/callback"],
+            "group_rules": {"stewards": {"roles": ["viewer"]}},
+        },
+    })
+    monkeypatch.setattr(OidcTransport, "fetch_jwks", lambda self, config: (_ for _ in ()).throw(
+        IdentityUpstreamError("internal network detail must not leak")))
+    response = client.post(f"/api/system/identity/idp-versions/{provider_uid}/1/connectivity",
+                           json={}, headers=headers)
+    assert response.status_code == 502
+    assert "internal network detail" not in response.get_data(as_text=True)

+ 91 - 0
tests/test_phase3_wp02_enterprise_identity_contract.py

@@ -0,0 +1,91 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_enterprise_identity_migration_is_normalized_forward_only():
+    content = (ROOT / "migrations/versions/20260802_470_enterprise_identity.py").read_text()
+    assert 'revision = "20260802_470"' in content
+    assert 'down_revision = "20260802_460"' in content
+    for table in (
+        "identity_idp_config_versions", "identity_oidc_flows", "enterprise_identity_links",
+        "identity_organization_nodes", "identity_directory_events", "identity_sessions",
+        "identity_exchange_codes", "identity_emergency_requests", "identity_emergency_approvals",
+        "identity_audit_events", "identity_directory_checkpoints", "identity_directory_conflicts",
+    ):
+        assert f"CREATE TABLE public.{table}" in content
+    hardening = (ROOT / "migrations/versions/20260802_471_enterprise_identity_hardening.py").read_text()
+    assert 'revision = "20260802_471"' in hardening
+    assert 'down_revision = "20260802_470"' in hardening
+    assert "^env:DATAOPS_OIDC_[A-Z0-9_]+$" in hardening
+    assert "refresh_hash CHAR(64) NOT NULL UNIQUE" in content
+    assert "emergency_request_uid UUID" in content
+    assert "Forward-only migration" in content
+
+
+def test_identity_api_uses_exact_public_routes_and_scoped_permissions():
+    permissions = (ROOT / "app/core/system/permissions.py").read_text()
+    api = (ROOT / "app/api/system/enterprise_identity.py").read_text()
+    for permission in ("identity:read", "identity:manage", "identity:operate"):
+        assert permission in permissions
+    for path in ("providers", "authorize", "callback", "exchange", "refresh"):
+        assert f'"/api/system/identity/{path}"' in permissions
+    assert 'path.startswith("/api/system/auth/")' not in permissions
+    for endpoint in ("/identity/providers", "/identity/authorize", "/identity/callback", "/identity/exchange",
+                     "/identity/refresh", "/identity/logout", "/identity/idp-versions", "/identity/claims/dry-run",
+                     "/identity/directory/delta", "/identity/sessions", "/identity/emergency/requests", "/identity/audit"):
+        assert endpoint in api
+    assert "OidcTransport" in api
+    assert "client_secret" in (ROOT / "app/core/system/oidc.py").read_text()
+    assert "safe_detail" in api
+    assert "access_token" not in api.split("response = redirect", 1)[1].split("return response", 1)[0]
+    repository = (ROOT / "app/core/system/identity_repository.py").read_text()
+    assert "WHERE refresh_hash=:refresh_hash AND status='active'" in repository
+    assert "RETURNING family_uid::text" in repository
+    assert "provider_uid=CAST(:provider AS uuid) AND enterprise_subject=:subject" in repository
+    assert "provider_disabled" in api
+    for audit_event in ("idp_dry_run", "idp_connectivity", "claims_dry_run", "identity_refresh",
+                        "identity_logout", "identity_session_revoke", "identity_session_risk_terminate",
+                        "identity_session_revoke_all", "emergency_request"):
+        assert f'"{audit_event}"' in api
+    assert "pg_advisory_xact_lock" in repository
+    assert "SELECT id FROM public.users WHERE id=CAST(:user AS uuid) FOR UPDATE" in repository
+    assert "put_organization_node" in repository and "apply_directory_event_atomic" in repository
+    assert 'visible = ("uid", "user_uid", "identity_source", "status", "created_at", "last_seen_at", "expires_at")' in api
+    assert '"family_uid"' not in api.split("def identity_sessions():", 1)[1].split("def identity_session_revoke", 1)[0]
+    auth = (ROOT / "app/core/system/auth.py").read_text()
+    assert 'if active_provider and not claims.get("sid")' in auth
+    assert "enterprise identity state unavailable; local login denied" in auth
+    assert "provider_configuration_changed" in api
+    assert "commit=False" in api and "insert_exchange_code" in api
+
+
+def test_frontend_has_sso_callback_secure_storage_single_refresh_and_management_surface():
+    auth = (ROOT / "frontend/src/utils/auth.js").read_text()
+    request = (ROOT / "frontend/src/utils/request.js").read_text()
+    routes = (ROOT / "frontend/src/router/defaultRoute.js").read_text()
+    menu = (ROOT / "frontend/src/router/routes.js").read_text()
+    callback = (ROOT / "frontend/src/views/login/callback.vue").read_text()
+    management = (ROOT / "frontend/src/views/systemManage/enterpriseIdentity/index.vue").read_text()
+    user_store = (ROOT / "frontend/src/store/modules/user.js").read_text()
+    assert "sessionStorage.getItem('access_token')" in auth
+    assert "localStorage.removeItem('access_token')" in auth
+    assert "delete profile.token" in user_store and "delete userInfo.token" in user_store
+    assert "let refreshPromise = null" in request
+    assert "__identityRetry" in request
+    assert "'/login/callback'" in routes and "noLogin: true" in routes
+    assert "history.replaceState" in callback and "exchangeIdentityCode" in callback
+    assert "/systemManage/enterprise-identity" in menu and "identity:manage" in menu
+    assert "待企业 IdP UAT" in management
+    for operation in ("createIdpVersion", "runIdpAction", "claimsDryRun", "syncDirectoryDelta",
+                      "requestEmergencyAccess", "runEmergencyAction"):
+        assert operation in management
+
+
+def test_phase3_identity_docs_and_status_are_fail_closed():
+    delivery = (ROOT / "docs/phase3/P3_WP02_ENTERPRISE_IDENTITY.md").read_text()
+    evidence = (ROOT / "docs/validation/P3_WP02_ENTERPRISE_IDENTITY_EVIDENCE.md").read_text()
+    required = (ROOT / "docs/phase3/P3_WP00_REQUIREMENTS.json").read_text()
+    status = "ENGINEERING_COMPLETE_ENTERPRISE_IDP_UAT_BLOCKED"
+    assert status in delivery and status in evidence and status in required
+    assert "TBD_EXTERNAL" in delivery and "TBD_EXTERNAL" in evidence