"""Scope registry for the External API. Independent of the identity-consumer scope vocabulary — this is the External API's own capability catalog. A scope is ``:``. Adding a resource = add constants here + a router that gates on them. """ from __future__ import annotations from dataclasses import dataclass #: Cloud-subscription inventory (``cmdb_cloud_scopes``) read/write. SCOPE_CLOUD_SUBSCRIPTIONS_READ = "cloud.subscriptions:read" SCOPE_CLOUD_SUBSCRIPTIONS_WRITE = "cloud.subscriptions:write" #: Cloud spend read — daily / monthly / aggregated cloud cost for the #: subscriptions this client can see (``cloud_visibility``). Always the #: **business pre-discount (list) basis**, never the invoiced net: the #: two side by side divide out to the company's contract discount rate, #: which is why this scope carries no ``measure`` parameter at all. An #: invoice-basis surface, if it is ever built, is a separate scope with #: its own grant decision — not a flag on this one. SCOPE_CLOUD_BILLING_READ = "cloud.billing:read" #: Software catalog (``asset_software_catalog``) read — the software #: product list only (name / id / category / status). Powers the #: Feishu-approval external-options dropdown #: (``POST /api/v1/open/feishu/software-options``); a plain product #: picker, no seat / cost / owner data. SCOPE_SOFTWARE_CATALOG_READ = "software.catalog:read" #: Software permission catalog (``software_permissions``) read — the #: grantable permissions within a software. Powers the cascading Feishu #: external-options dropdown (``POST /api/v1/open/feishu/permission-options``, #: linked to the software field); permission key + display name only. SCOPE_SOFTWARE_PERMISSIONS_READ = "software.permissions:read" #: Leave-absence availability lookup (``hr_leave_absences``) read — the #: time-off request spans, coarsened. Powers #: ``POST /api/v1/open/hr/absences/lookup`` (e.g. an on-call scheduler #: checking whether a person is out). Returns date spans + coarse category #: (leave) + approval status only — never the leave reason or the specific #: absence type. Lookup-only (must name the people by email); no listing. SCOPE_HR_ABSENCES_READ = "hr.absences:read" #: Company-wide public-holiday calendar (country + date + holiday name). #: Non-personal reference data — no person is named and no per-client #: population filter is needed, unlike the absences scope above. SCOPE_HR_HOLIDAYS_READ = "hr.holidays:read" #: Person ↔ GitHub handle mapping (``identity_persons.github_id``) read. #: Powers ``POST /api/v1/open/people/github/lookup`` — the point-query #: twin of the Directory pull API's ``github_id`` field, for callers that #: need "who is this handle / what is this person's handle" on demand #: instead of syncing the whole roster. #: #: Unlike every other scope here this one can LIST people (ask by #: department), so it is the first External API scope whose blast radius #: is bounded by a per-client *population* setting rather than by the #: caller having to name its subjects: see #: ``api_clients.person_visibility`` (migration 513) and #: :mod:`app.api_clients.person_visibility`. Grant + population are set #: together; the scope alone never implies whole-company reach. SCOPE_PEOPLE_GITHUB_READ = "people.github:read" KNOWN_SCOPES: frozenset[str] = frozenset({ SCOPE_CLOUD_SUBSCRIPTIONS_READ, SCOPE_CLOUD_SUBSCRIPTIONS_WRITE, SCOPE_CLOUD_BILLING_READ, SCOPE_SOFTWARE_CATALOG_READ, SCOPE_SOFTWARE_PERMISSIONS_READ, SCOPE_HR_ABSENCES_READ, SCOPE_HR_HOLIDAYS_READ, SCOPE_PEOPLE_GITHUB_READ, }) #: Scopes that imply another (granting the key requires the base too), #: mirroring how a write add-on implies its read base. _IMPLIES: dict[str, str] = { SCOPE_CLOUD_SUBSCRIPTIONS_WRITE: SCOPE_CLOUD_SUBSCRIPTIONS_READ, } @dataclass(frozen=True) class ScopeSpec: key: str #: i18n key suffix the admin UI renders (``apiClients.scopeDesc.``). desc_id: str #: ``read`` | ``write`` — drives the admin UI tag colour + grouping. kind: str #: The catalog the governance ``/scopes`` endpoint returns; the FE maps #: ``desc_id`` to a localized description. SCOPE_CATALOG: list[ScopeSpec] = [ ScopeSpec(SCOPE_CLOUD_SUBSCRIPTIONS_READ, "cloud_subscriptions_read", "read"), ScopeSpec(SCOPE_CLOUD_SUBSCRIPTIONS_WRITE, "cloud_subscriptions_write", "write"), ScopeSpec(SCOPE_CLOUD_BILLING_READ, "cloud_billing_read", "read"), ScopeSpec(SCOPE_SOFTWARE_CATALOG_READ, "software_catalog_read", "read"), ScopeSpec(SCOPE_SOFTWARE_PERMISSIONS_READ, "software_permissions_read", "read"), ScopeSpec(SCOPE_HR_ABSENCES_READ, "hr_absences_read", "read"), ScopeSpec(SCOPE_HR_HOLIDAYS_READ, "hr_holidays_read", "read"), ScopeSpec(SCOPE_PEOPLE_GITHUB_READ, "people_github_read", "read"), ] def validate_scopes(scopes: list[str]) -> list[str]: """Clean + validate a scope grant. Raises ``ValueError`` on unknown scope, empty grant, or a write scope without its read base. Returns the sorted, de-duplicated list.""" cleaned = sorted({s.strip() for s in scopes if s.strip()}) unknown = [s for s in cleaned if s not in KNOWN_SCOPES] if unknown: raise ValueError(f"unknown scope(s): {', '.join(unknown)}") if not cleaned: raise ValueError("at least one scope is required") for scope, base in _IMPLIES.items(): if scope in cleaned and base not in cleaned: raise ValueError( f"{scope} is an add-on to {base}; grant the base scope too", ) return cleaned def catalog_payload() -> list[dict]: """Scope catalog for the governance ``/scopes`` endpoint + FE editor.""" return [ {"key": s.key, "desc_id": s.desc_id, "kind": s.kind} for s in SCOPE_CATALOG ] __all__ = [ "KNOWN_SCOPES", "SCOPE_CATALOG", "SCOPE_CLOUD_BILLING_READ", "SCOPE_CLOUD_SUBSCRIPTIONS_READ", "SCOPE_CLOUD_SUBSCRIPTIONS_WRITE", "SCOPE_HR_ABSENCES_READ", "SCOPE_HR_HOLIDAYS_READ", "SCOPE_PEOPLE_GITHUB_READ", "SCOPE_SOFTWARE_CATALOG_READ", "SCOPE_SOFTWARE_PERMISSIONS_READ", "ScopeSpec", "catalog_payload", "validate_scopes", ]