Files
ewoooc/services/auth_identity_service.py

314 lines
11 KiB
Python

"""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",
]