"""Database identity policy shared by login, session and HTTP access control.""" from __future__ import annotations from datetime import datetime import ipaddress import os from typing import Iterable, Mapping POLICY = "ewoooc_database_identity_policy_v1" AUTH_IDENTITY_MODES = frozenset({"auto", "shadow", "hybrid", "database"}) DEFAULT_AUTH_IDENTITY_MODE = "auto" DEFAULT_TRUSTED_PROXY_CIDRS = ( "127.0.0.1/32", "::1/128", "172.19.0.1/32", ) ROLE_ADMIN = "admin" ROLE_MANAGER = "manager" ROLE_USER = "user" KNOWN_ROLES = frozenset({ROLE_ADMIN, ROLE_MANAGER, ROLE_USER}) READ_ROLES = (ROLE_ADMIN, ROLE_MANAGER, ROLE_USER) OPERATE_ROLES = (ROLE_ADMIN, ROLE_MANAGER) ADMIN_ROLES = (ROLE_ADMIN,) MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) # These endpoints mutate only the current identity and remain available to every # authenticated role. Administrative identity mutations already use admin_required. SELF_SERVICE_MUTATION_ENDPOINTS = frozenset({ "user_bp.api_change_password", }) # Operational and identity administration never inherit the generic read role. ADMIN_BLUEPRINTS = frozenset({"cicd", "crawler_management", "user_bp"}) ADMIN_ENDPOINTS = frozenset({ "alert.test_alert", "code_review.api_history", "code_review.api_status", "code_review.dashboard", "openclaw_bot.set_webhook", "openclaw_bot.webhook_info", "system_public.get_logs_api", "system_public.settings", "system_public.show_logs", "system_public.system_settings_page", }) def resolve_auth_identity_mode( value: str | None = None, *, environ: Mapping[str, str] | None = None, ) -> str: """Return a validated auth mode; invalid configuration fails closed.""" source = os.environ if environ is None else environ candidate = (value if value is not None else source.get("MOMO_AUTH_IDENTITY_MODE")) mode = (candidate or DEFAULT_AUTH_IDENTITY_MODE).strip().lower() if mode not in AUTH_IDENTITY_MODES: raise ValueError( "MOMO_AUTH_IDENTITY_MODE must be one of: auto, database, hybrid, shadow" ) return mode def auth_auto_cutover_min_successes( *, environ: Mapping[str, str] | None = None, ) -> int: source = os.environ if environ is None else environ try: value = int(source.get("MOMO_AUTH_AUTO_CUTOVER_MIN_SUCCESSES", "2")) except (TypeError, ValueError): value = 2 return max(2, min(value, 10)) def database_auth_can_authorize(mode: str) -> bool: return resolve_auth_identity_mode(mode) in {"auto", "hybrid", "database"} def database_auth_is_evaluated(mode: str) -> bool: return resolve_auth_identity_mode(mode) in AUTH_IDENTITY_MODES def legacy_auth_can_authorize(mode: str) -> bool: return resolve_auth_identity_mode(mode) in {"auto", "shadow", "hybrid"} def resolve_effective_auth_identity_mode( configured_mode: str, *, auto_cutover_ready: bool = False, ) -> str: mode = resolve_auth_identity_mode(configured_mode) if mode != "auto": return mode return "database" if auto_cutover_ready else "hybrid" def parse_trusted_proxy_networks( value: str | None = None, *, environ: Mapping[str, str] | None = None, ) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]: source = os.environ if environ is None else environ raw = value if value is not None else source.get("MOMO_TRUSTED_PROXY_CIDRS") cidrs = [part.strip() for part in (raw or ",".join(DEFAULT_TRUSTED_PROXY_CIDRS)).split(",")] networks = [] for cidr in cidrs: if not cidr: continue networks.append(ipaddress.ip_network(cidr, strict=True)) if not networks: raise ValueError("MOMO_TRUSTED_PROXY_CIDRS must contain at least one CIDR") return tuple(networks) def resolve_client_ip( remote_addr: str | None, x_forwarded_for: str | None = None, x_real_ip: str | None = None, *, trusted_proxy_cidrs: str | None = None, ) -> str: """Trust forwarding headers only when the direct peer is allowlisted.""" peer_text = (remote_addr or "").strip() try: peer = ipaddress.ip_address(peer_text) except ValueError: return "unknown" networks = parse_trusted_proxy_networks(trusted_proxy_cidrs) if not any(peer in network for network in networks): return str(peer) candidates = [] if x_forwarded_for: candidates.extend(part.strip() for part in x_forwarded_for.split(",")) if x_real_ip: candidates.append(x_real_ip.strip()) for candidate in candidates: try: return str(ipaddress.ip_address(candidate)) except ValueError: continue return str(peer) def identity_version(user) -> str: """Create a stable revocation version from durable identity timestamps.""" values = [ value for value in ( getattr(user, "password_changed_at", None), getattr(user, "updated_at", None), getattr(user, "created_at", None), ) if isinstance(value, datetime) ] if not values: return "unversioned" return max(values).isoformat(timespec="microseconds") def role_is_allowed(role: str | None, allowed_roles: Iterable[str]) -> bool: allowed = frozenset(allowed_roles) return bool(role in KNOWN_ROLES and role in allowed) def required_roles_for_request( method: str, endpoint: str | None, blueprint: str | None, ) -> tuple[str, ...]: """Map protected HTTP requests to least-privilege role tiers.""" normalized_method = (method or "GET").upper() if endpoint in SELF_SERVICE_MUTATION_ENDPOINTS: return READ_ROLES if blueprint in ADMIN_BLUEPRINTS or endpoint in ADMIN_ENDPOINTS: return ADMIN_ROLES if normalized_method in MUTATING_METHODS: return OPERATE_ROLES return READ_ROLES def public_policy_readback(mode: str | None = None) -> dict: resolved_mode = resolve_auth_identity_mode(mode) return { "policy": POLICY, "auth_identity_mode": resolved_mode, "database_auth_evaluated": database_auth_is_evaluated(resolved_mode), "database_auth_authoritative": database_auth_can_authorize(resolved_mode), "legacy_shared_password_authoritative": legacy_auth_can_authorize(resolved_mode), "session_revocation_source": "users.password_changed_at_or_updated_at", "durable_lockout_source": "login_history", "identity_mutation_audit_source": "login_history", "role_matrix": { ROLE_ADMIN: ["read", "operate", "administer"], ROLE_MANAGER: ["read", "operate"], ROLE_USER: ["read"], }, "trusted_proxy_network_count": len(parse_trusted_proxy_networks()), } def build_auth_identity_runtime_readback(db_session, mode: str | None = None) -> dict: """Build a no-secret runtime receipt from the durable identity store.""" from sqlalchemy import func, inspect from database.permission_models import UserPermission from services.user_service import UserService configured_mode = resolve_auth_identity_mode(mode) inspector = inspect(db_session.get_bind()) required_tables = ("users", "login_history", "user_permissions") table_state = {table: inspector.has_table(table) for table in required_tables} user_service = UserService(db_session) inventory = user_service.get_identity_inventory() auto_cutover = user_service.get_auth_auto_cutover_state( min_successes=auth_auto_cutover_min_successes() ) resolved_mode = resolve_effective_auth_identity_mode( configured_mode, auto_cutover_ready=auto_cutover["ready"], ) permission_assignments = ( db_session.query(func.count(UserPermission.id)).scalar() or 0 if table_state["user_permissions"] else 0 ) checks = { "required_identity_tables_present": all(table_state.values()), "active_admin_available": inventory["active_admin_count"] >= 1, "database_auth_evaluated": database_auth_is_evaluated(resolved_mode), "database_auth_authoritative": database_auth_can_authorize(resolved_mode), "durable_lockout_configured": table_state["login_history"], "session_revocation_configured": table_state["users"], "identity_mutation_audit_configured": table_state["login_history"], "least_privilege_role_matrix_configured": True, "trusted_proxy_policy_configured": bool(parse_trusted_proxy_networks()), "legacy_shared_password_retired": not legacy_auth_can_authorize(resolved_mode), } canary_ready = all( value for key, value in checks.items() if key != "legacy_shared_password_retired" ) completed = canary_ready and checks["legacy_shared_password_retired"] return { "receipt_kind": "auth_identity_runtime_readback", "policy": POLICY, "status": "completed" if completed else "canary_ready" if canary_ready else "blocked", "work_item_id": "SEC-P0-002", "configured_auth_identity_mode": configured_mode, "auth_identity_mode": resolved_mode, "inventory": { **inventory, "permission_assignments": permission_assignments, }, "tables": table_state, "auto_cutover": auto_cutover, "checks": checks, "canary_ready": canary_ready, "runtime_closed": completed, "remaining_blocker": ( None if completed else "legacy_shared_password_authority_still_enabled" if canary_ready else "identity_canary_preconditions_failed" ), "next_machine_action": ( "advance_to_SEC-P0-003" if completed else "capture_database_admin_login_receipts_for_automatic_cutover" if configured_mode == "auto" else "capture_database_login_canary_then_switch_to_database_mode" if canary_ready else "repair_identity_schema_or_active_admin_inventory" ), "safety": { "read_only": True, "secret_values_read": False, "password_hashes_returned": False, "raw_sessions_returned": False, }, } __all__ = [ "ADMIN_ROLES", "AUTH_IDENTITY_MODES", "KNOWN_ROLES", "OPERATE_ROLES", "POLICY", "READ_ROLES", "auth_auto_cutover_min_successes", "database_auth_can_authorize", "database_auth_is_evaluated", "build_auth_identity_runtime_readback", "identity_version", "legacy_auth_can_authorize", "parse_trusted_proxy_networks", "public_policy_readback", "required_roles_for_request", "resolve_auth_identity_mode", "resolve_effective_auth_identity_mode", "resolve_client_ip", "role_is_allowed", ]