"""Directory field catalog — the source of truth for WHICH person attributes a consumer can pull, with a sensitivity tier per field. This turns the old all-or-nothing scopes into Authing-style field-level grants. A consumer is granted a *set of person fields* (least privilege); the three scopes survive as one-click **bundles** that pre-select a field set: * ``directory.org:read`` — org tree + memberships (structural, no field-level catalog — it's a coarse bundle toggle on those endpoints). * ``directory.persons:read`` — the non-PII person fields below. * ``directory.pii:read`` — the PII person fields (mobile_phone, personal_email). An add-on to persons:read. The REST ``/persons`` serializer projects only the granted fields; the governance ``/scopes`` endpoint renders this catalog so an applicant or admin sees exactly what each field exposes (and its sensitivity) before granting it. SCIM is a fixed-schema protocol, so it stays bundle-level except that its ``phoneNumbers`` attribute is gated on the granted ``mobile_phone`` field (not merely the PII scope). Field keys are exactly the top-level keys emitted by ``directory_service.serialize_person`` — keep the two in lockstep. """ from __future__ import annotations from dataclasses import dataclass from enum import StrEnum from app.identity.consumers.models import ( SCOPE_PERSONS_READ, SCOPE_PII_READ, ) class Sensitivity(StrEnum): """Disclosure tier shown in the catalog UI. * ``public`` — already visible org-wide (name, title, department). * ``internal`` — HR/identity attributes (employee_no, cost_center, hire/termination dates, email aliases, manager/HRBP refs). * ``pii`` — personal contact data; reading it plaintext is audited per request (``directory_api.pii_read``). """ PUBLIC = "public" INTERNAL = "internal" PII = "pii" @dataclass(frozen=True, slots=True) class FieldSpec: #: Top-level key in the serialized person object. key: str #: The bundle (scope) this field belongs to — a field can only be #: granted when its bundle scope is also granted. bundle: str sensitivity: Sensitivity #: Always emitted when its bundle is granted and never removable. #: ``id`` + ``updated_at`` are the stable key + the keyset-sync #: watermark — dropping them would break a consumer's incremental #: pull, so they are forced into every persons grant. always: bool = False #: Person fields, in the order they read best in the catalog UI. PERSON_FIELDS: tuple[FieldSpec, ...] = ( FieldSpec("id", SCOPE_PERSONS_READ, Sensitivity.INTERNAL, always=True), FieldSpec("updated_at", SCOPE_PERSONS_READ, Sensitivity.INTERNAL, always=True), FieldSpec("employee_no", SCOPE_PERSONS_READ, Sensitivity.INTERNAL), FieldSpec("work_email", SCOPE_PERSONS_READ, Sensitivity.INTERNAL), FieldSpec("display_name", SCOPE_PERSONS_READ, Sensitivity.PUBLIC), FieldSpec("first_name", SCOPE_PERSONS_READ, Sensitivity.PUBLIC), FieldSpec("last_name", SCOPE_PERSONS_READ, Sensitivity.PUBLIC), FieldSpec("avatar_url", SCOPE_PERSONS_READ, Sensitivity.PUBLIC), FieldSpec("employment_status", SCOPE_PERSONS_READ, Sensitivity.INTERNAL), FieldSpec("job_title", SCOPE_PERSONS_READ, Sensitivity.PUBLIC), FieldSpec("is_china_employee", SCOPE_PERSONS_READ, Sensitivity.INTERNAL), FieldSpec("department", SCOPE_PERSONS_READ, Sensitivity.PUBLIC), FieldSpec("manager", SCOPE_PERSONS_READ, Sensitivity.INTERNAL), FieldSpec("hrbp", SCOPE_PERSONS_READ, Sensitivity.INTERNAL), FieldSpec("work_location", SCOPE_PERSONS_READ, Sensitivity.PUBLIC), FieldSpec("company", SCOPE_PERSONS_READ, Sensitivity.PUBLIC), FieldSpec("cost_center", SCOPE_PERSONS_READ, Sensitivity.INTERNAL), FieldSpec("hire_date", SCOPE_PERSONS_READ, Sensitivity.INTERNAL), FieldSpec("termination_date", SCOPE_PERSONS_READ, Sensitivity.INTERNAL), FieldSpec("preferred_language", SCOPE_PERSONS_READ, Sensitivity.PUBLIC), FieldSpec("email_aliases", SCOPE_PERSONS_READ, Sensitivity.INTERNAL), # GitHub handle, HRIS-collected at onboarding (SF ``cust_Other.cust_id``). # SPARSE by nature — never collected retroactively, so a null here means # "not on file", never "no GitHub account". A consumer reconciling GitHub # org membership must treat null as unknown, not as "not an employee". # Internal (a work-identity handle), not PII: it is a public GitHub # profile name the person chose, not personal contact data. FieldSpec("github_id", SCOPE_PERSONS_READ, Sensitivity.INTERNAL), FieldSpec("source_updated_at", SCOPE_PERSONS_READ, Sensitivity.INTERNAL), # PII tier — each is grant-or-omit (no masked middle ground on a # field-level grant) and gated on the pii bundle. FieldSpec("mobile_phone", SCOPE_PII_READ, Sensitivity.PII), FieldSpec("personal_email", SCOPE_PII_READ, Sensitivity.PII), ) _FIELD_BY_KEY: dict[str, FieldSpec] = {f.key: f for f in PERSON_FIELDS} #: PII person-field keys — reused by the serializer / SCIM gate and to #: decide whether a request read plaintext PII (audit trigger). PII_FIELD_KEYS: frozenset[str] = frozenset( f.key for f in PERSON_FIELDS if f.sensitivity is Sensitivity.PII ) #: Always-on person fields forced into every persons grant. ALWAYS_FIELD_KEYS: frozenset[str] = frozenset( f.key for f in PERSON_FIELDS if f.always ) def person_field_keys() -> frozenset[str]: return frozenset(_FIELD_BY_KEY) def fields_for_bundle(scope: str) -> list[str]: """All catalog field keys belonging to one bundle/scope.""" return [f.key for f in PERSON_FIELDS if f.bundle == scope] def default_fields_for_scopes(scopes: list[str] | frozenset[str]) -> list[str]: """Full field set for a set of scopes — the bundle-preset default. Used when an applicant just ticks bundles (no field-level narrowing) and as the backward-compatible backfill for consumers created before field-level grants existed: their old ``scopes`` expand to every field in those bundles, so behaviour is unchanged. """ scope_set = set(scopes) return sorted( (f.key for f in PERSON_FIELDS if f.bundle in scope_set), key=_catalog_order, ) def _catalog_order(key: str) -> int: for i, f in enumerate(PERSON_FIELDS): if f.key == key: return i return len(PERSON_FIELDS) def validate_granted_fields( granted: list[str] | None, scopes: list[str] | frozenset[str], ) -> list[str]: """Clean + validate a field grant against the scopes that back it. Rules (raise ``ValueError`` on violation): * every field must exist in the catalog; * a field can only be granted when its bundle scope is also granted (you cannot grant ``mobile_phone`` without ``directory.pii:read``); * always-on fields are force-added whenever ``persons:read`` is held; * if ``persons:read`` is held the result must be non-empty. ``granted=None`` means "no field-level narrowing" → expand to the full bundle default. Returns the cleaned, catalog-ordered list. """ scope_set = set(scopes) if granted is None: return default_fields_for_scopes(scope_set) cleaned = {f.strip() for f in granted if f and f.strip()} unknown = [f for f in cleaned if f not in _FIELD_BY_KEY] if unknown: raise ValueError(f"unknown field(s): {', '.join(sorted(unknown))}") out: set[str] = set() for key in cleaned: spec = _FIELD_BY_KEY[key] if spec.bundle not in scope_set: raise ValueError( f"field '{key}' requires scope '{spec.bundle}' to be granted" ) out.add(key) # Force always-on fields in whenever the persons bundle is granted. if SCOPE_PERSONS_READ in scope_set: out |= ALWAYS_FIELD_KEYS if SCOPE_PERSONS_READ in scope_set and not out: raise ValueError("persons:read requires at least one person field") return sorted(out, key=_catalog_order) def reconcile_granted_fields( existing: list[str] | None, scopes: list[str] | frozenset[str], ) -> list[str]: """Re-derive a grant after the scopes changed but no explicit field list was sent: keep the existing narrowing, drop fields whose bundle is no longer granted, and re-add always-on fields. Never raises — orphaned fields are filtered, not rejected (the caller didn't choose them this time).""" scope_set = set(scopes) kept = [ f for f in (existing or []) if f in _FIELD_BY_KEY and _FIELD_BY_KEY[f].bundle in scope_set ] return validate_granted_fields(kept, scope_set) def catalog_payload() -> list[dict]: """Catalog for the governance ``/scopes`` endpoint + the FE editor.""" return [ { "key": f.key, "bundle": f.bundle, "sensitivity": f.sensitivity.value, "always": f.always, } for f in PERSON_FIELDS ] __all__ = [ "ALWAYS_FIELD_KEYS", "PERSON_FIELDS", "PII_FIELD_KEYS", "FieldSpec", "Sensitivity", "catalog_payload", "default_fields_for_scopes", "fields_for_bundle", "person_field_keys", "reconcile_granted_fields", "validate_granted_fields", ]