314 lines
11 KiB
Python
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",
|
|
]
|