"""Central cloud-role governance classifier. Every requestable cloud role is sorted into one of four governance tiers that drive visibility, request, and approval gating uniformly across all providers: * ``normal`` — read-only / low-risk. Shown in the request dropdown, self-requestable, approvable by the scope owner. * ``elevated`` — high-privilege but legitimately requestable (Contributor, PowerUser, editor, non-"admin"-named IAM-write). Shown + flagged; self-requestable; **approval requires a cloud admin** (scope owner alone is not enough). * ``restricted`` — owner-equivalent / full-IAM-admin / billing-admin. Hidden from the employee dropdown; not self-requestable; only a cloud admin can grant. Approval still requires a cloud admin. * ``ungrantable`` — cannot be bound at this scope at all (resource-level mismatch like GCP ``roles/billing.viewer`` on a project, or a wrong role-key shape). Never shown, never requestable; the driver fails fast. Why a single module: the tier algorithm must run in two places — the role **sync** layer (to persist ``governance_tier`` on ``cmdb_cloud_roles``) and the request/approve **service** layer (to re-check live, never trusting only the cached row). Drivers contribute provider-specific knowledge by declaring explicit role-key sets (see ``CloudDriver``); this module composes those with a keyword backstop that covers custom roles the explicit sets don't enumerate. This lives under ``app/integrations`` (not a domain module) so both the drivers (``app/integrations/drivers``) and the cmdb domain can import it without violating the domain→integrations layering direction. """ from __future__ import annotations import re from collections.abc import Iterable from dataclasses import dataclass # ── Tier constants ─────────────────────────────────────────────── TIER_NORMAL = "normal" TIER_ELEVATED = "elevated" TIER_RESTRICTED = "restricted" TIER_UNGRANTABLE = "ungrantable" ALL_TIERS: frozenset[str] = frozenset({ TIER_NORMAL, TIER_ELEVATED, TIER_RESTRICTED, TIER_UNGRANTABLE, }) # Ordering for "take the most severe of N signals". Higher = more severe. _SEVERITY: dict[str, int] = { TIER_NORMAL: 0, TIER_ELEVATED: 1, TIER_RESTRICTED: 2, TIER_UNGRANTABLE: 3, } @dataclass(frozen=True, slots=True) class RoleGovernance: """Verdict for a single role on a single scope type.""" tier: str reason: str # ── Keyword backstop (for custom roles the explicit sets don't list) ── # # Restricted: reads as owner-equivalent or full administrative authority. # ``admin``/``管理员`` mirror the legacy ``is_admin_role`` sieve; ``owner`` # catches GCP ``roles/owner`` / Azure "Owner" which have no "admin" token. _RESTRICTED_PATTERNS = [ re.compile(r"admin", re.IGNORECASE), re.compile(r"\bowner\b", re.IGNORECASE), re.compile(r"管理员"), ] # Elevated: broad write / privilege-adjacent but not full admin. High-signal # tokens only — we would rather miss-and-leave-normal a benign custom role # than block every "*Manage*" role behind admin approval. The authoritative # elevated classification is the driver's explicit ``elevated_role_keys``; # this backstop only catches obvious vendor-shaped names. _ELEVATED_PATTERNS = [ re.compile(r"fullaccess", re.IGNORECASE), re.compile(r"poweruser", re.IGNORECASE), re.compile(r"contributor", re.IGNORECASE), re.compile(r"\beditor\b", re.IGNORECASE), re.compile(r"读写"), ] # Billing / cost / finance — used by the cmdb domain's billing-alert # recipient resolution (kept here so the keyword set has one home). _BILLING_PATTERNS = [ re.compile(r"billing", re.IGNORECASE), re.compile(r"\bcost\b", re.IGNORECASE), re.compile(r"finance", re.IGNORECASE), re.compile(r"bss", re.IGNORECASE), re.compile(r"账单"), re.compile(r"计费"), re.compile(r"财务"), ] # Roles named like an admin but intentionally self-serviceable — a bounded # "admin" whose privilege is scoped to one product (mirrors the legacy # ``_ADMIN_NAME_ALLOWLIST``). Exact match (case-insensitive) on key or name. _RESTRICTED_NAME_ALLOWLIST: frozenset[str] = frozenset({ "dbaas-prod-admin-role", }) def _matches(patterns: list[re.Pattern[str]], *texts: str | None) -> bool: for t in texts: if not t: continue for p in patterns: if p.search(t): return True return False def _allowlisted(role_key: str | None, display_name: str | None) -> bool: for t in (role_key, display_name): if t and t.strip().lower() in _RESTRICTED_NAME_ALLOWLIST: return True return False def matches_admin_keyword( role_key: str | None, display_name: str | None = None, ) -> bool: """Backstop predicate for the legacy ``is_admin_role`` semantics — restricted-by-name, minus the bounded-admin allowlist.""" if _allowlisted(role_key, display_name): return False return _matches(_RESTRICTED_PATTERNS, role_key, display_name) def matches_billing_keyword( role_key: str | None, display_name: str | None = None, ) -> bool: """Backstop predicate for the legacy ``is_billing_role`` semantics.""" return _matches(_BILLING_PATTERNS, role_key, display_name) def _in(role_key: str | None, keys: Iterable[str] | None) -> bool: return bool(role_key and keys and role_key in keys) def classify_role( role_key: str | None, display_name: str | None = None, *, owner_role_keys: Iterable[str] | None = None, ungrantable_keys: Iterable[str] | None = None, restricted_keys: Iterable[str] | None = None, elevated_keys: Iterable[str] | None = None, ) -> RoleGovernance: """Classify one role into a governance tier. Explicit per-driver sets are authoritative; the keyword backstop only fires when no explicit set matched, so a driver can always override a heuristic by listing the role. Precedence (most → least severe): ``ungrantable`` > ``restricted`` > ``elevated`` > ``normal``. ``owner_role_keys`` is folded into the restricted set — the provider's owner-equivalent role is restricted by definition. """ key = (role_key or "").strip() # 1. Ungrantable — explicit only. A wrong-scope / wrong-shape role is a # provider fact, never a keyword guess. if _in(key, ungrantable_keys): return RoleGovernance(TIER_UNGRANTABLE, "explicit: not grantable at this scope") # 2. Restricted — explicit set, owner-equivalent, or admin/owner keyword. if _in(key, restricted_keys): return RoleGovernance(TIER_RESTRICTED, "explicit: restricted role") if _in(key, owner_role_keys): return RoleGovernance(TIER_RESTRICTED, "owner-equivalent role") if matches_admin_keyword(key, display_name): return RoleGovernance(TIER_RESTRICTED, "admin/owner keyword") # 3. Elevated — explicit set or high-signal keyword. if _in(key, elevated_keys): return RoleGovernance(TIER_ELEVATED, "explicit: elevated role") if _matches(_ELEVATED_PATTERNS, key, display_name): return RoleGovernance(TIER_ELEVATED, "elevated keyword") return RoleGovernance(TIER_NORMAL, "default") def most_severe(*tiers: str) -> str: """Return the most severe of several tier strings (unknown → normal).""" return max( (t for t in tiers if t in _SEVERITY), key=lambda t: _SEVERITY[t], default=TIER_NORMAL, ) __all__ = [ "TIER_NORMAL", "TIER_ELEVATED", "TIER_RESTRICTED", "TIER_UNGRANTABLE", "ALL_TIERS", "RoleGovernance", "classify_role", "most_severe", "matches_admin_keyword", "matches_billing_keyword", ]