diff --git a/CHANGELOG.md b/CHANGELOG.md index 16454da3d..39cc99a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes to Orgmetra will be documented in this file. - `employment_record_version.employment_concurrency_code` constrained to `exclusive` or `concurrent`. - ADR 0005 for exclusive employment and staffable seats. - `orgmetra_hris_kernel` 0.3.0 with identity-scoped bitemporal resolution, assignment-employment coverage, allocation-portfolio checks, and a Memorial Hospital RN correction case at 100% statement and branch coverage. -- `employment_record_version` and `position_record_version` so employment and position identity stay stable across retroactive corrections. +- `employment_record_version` and `position_record_version` so corrections no longer mint a new employment or position identifier. - `assignment_record.employment_record_id` bound to the same person as the covering employment. - `orgmetra_keyverse_adapter` that binds an opaque Keyverse subject to a person and rejects passwords, passkeys, and tokens. - Design tokens for the repeating HR actions: approve, review, correct, request evidence, compare, export, and escalate. @@ -59,6 +59,7 @@ All notable changes to Orgmetra will be documented in this file. - Predictive-validity cases fail closed when selection evidence, Job scope, study criterion, converted worker, or system-recorded visibility does not match; the normalized case relation is tenant-qualified, append-only, TRUNCATE-protected, and forced through row-level security. - Purpose-bound PII authorization now fails closed across active tenant, authenticated actor tenant, resource tenant, resource kind, purpose, operation, operation-specific Keyverse scope, and requested-field subset; malformed/wildcard-like attributes, mutable field/scope collections, reserved UUID sentinels, and cross-tenant confused-deputy contexts are rejected before protected values are returned. Authorization requests and allow/deny evidence now also require and preserve one namespaced opaque target-resource reference, so immutable audit correlation identifies the exact HR record without copying its protected values. Authorization evidence otherwise contains governance metadata and field names only, with stable denial reasons and actionable next steps rather than PII. +- Active-PR authorization runtime-integrity hardening at `orgmetra_keyverse_adapter` additionally requires exact built-in UUID, string, and `frozenset` values plus exact policy/request classes before evaluation, so subclass-controlled equality, membership, and validation bypasses fail closed. This is active-PR refinement to ADR 0008 and is not yet protected-`develop` truth. - LLM output constrained to draft evidence. - No direct cross-service application-table access. - Service-owned database schemas and roles inside the initially shared physical PostgreSQL cluster. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 22a4178fe..d90709772 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -21,7 +21,7 @@ | Tenant-safe atomic outbox claiming and crash recovery | Integration Hub dispatcher boundary | `outbox_delivery_record` pending/expired-lease claim indexes plus `claim_outbox_delivery(...)` | PostgreSQL already-expired-new-lease rejection, due-order claim, live-lease exclusion, pre-exhaustion takeover with `lease_expired` evidence, retry-budget claim bound, tenant-context binding, opaque-worker validation, and bounded-lease contract | ADR-0006 | implemented_on_active_pr | | Owner-bound outbox completion, retry, and terminal dead-letter escalation | Integration Hub dispatcher boundary | immutable `outbox_delivery_record.maximum_attempt_count`, `complete_outbox_delivery(...)`, `retry_outbox_delivery(...)`, `dead_letter_outbox_delivery(...)`, `outbox_delivery_escalation_record` | PostgreSQL foreign/stale-owner denial, dispatcher-budget-signature rejection, direct-terminal-DML rejection, stored-budget exhaustion, retry-attempt-N+1 denial, exhausted expired-lease non-reclaimability, recorded-owner terminalization, nonterminal-escalation rejection, terminal non-reclaimability, and append-only escalation evidence | ADR-0006 | implemented_on_active_pr | | Predictive-validity case integrity | Workforce Validation | `validity_study`, normalized `validity_study_case_record`, exact `selection_decision`, sealed `decision_evidence_set`, governed `candidate_worker_conversion_record`, `criterion_observation` | `test_validity_study_case_postgres.sh`: legacy loose-link write rejection; exact evidence-set ID, Job, criterion and worker mismatch rejection; study/observation system-recorded visibility boundaries; governed upstream decision/evidence/conversion lineage from the evidence-sealing and candidate-worker conversion contracts; UPDATE/DELETE/TRUNCATE protection; missing/foreign-tenant RLS denial. Statistical estimation remains subsequent work. | ADR-0001, SIOP Principles 5th ed., 29 C.F.R. Part 1607 | implemented_on_protected_main | -| Purpose-bound PII access | Security architecture / Keyverse adapter boundary | `PurposeBoundAccessPolicy`, `PurposeBoundAccessRequest.resource_reference`, `AuthorizationDecision.resource_reference` | exact tenant/actor/resource binding, exact opaque target correlation for allow/deny audit evidence, resource/purpose/operation matching, operation-specific scope, field-subset minimization, malformed-attribute rejection, reserved-UUID rejection, PII-minimized denial evidence, and exact 100% owned statement/branch coverage | ADR-0008 | implemented_on_protected_main | +| Purpose-bound PII access and authorization decision integrity | Security architecture / Keyverse adapter / trusted service composition | `PurposeBoundAccessPolicy`, `PurposeBoundAccessRequest.resource_reference`, `AuthorizationDecision` | exact tenant/actor/resource binding; exact opaque target correlation; resource/purpose/operation/scope/field minimization; exact built-in runtime types; UUID detachment; evaluation-time policy/request revalidation; verdict/reason/field coherence; consumer-side decision revalidation; regressions proving no mutable module/closure registry is represented as an issuance capability; exact 100% owned statement/branch coverage | ADR-0008 | implemented_on_active_pr | | Least-privilege API capability | Keyverse gateway boundary | operation scope conceptual | structural per-operation scope and confused-deputy contract tests | ADR-0002 | implemented_on_active_pr | | Client-safe failure correlation | API error boundary | `support_reference` conceptual | error disclosure and support-lookup tests | ADR-0002 | implemented_on_active_pr | | Foundation artifact integrity | Repository governance | deterministic `manifest.json` file inventory | SHA-256/byte/line validation plus Python/Node inventory-equivalence regression and explicit dispatcher/validity/criterion/job-analysis migration and execution-contract provenance regression | ADR-0001 | implemented_on_active_pr | @@ -30,7 +30,7 @@ | External contract | Orgmetra owner boundary | Integration style | Required evidence | ADR | Maturity | |---|---|---|---|---|---| -| Keyverse identity and authorization | API Gateway / purpose-bound authorization | Published OIDC/API identity and scope contract plus Orgmetra-owned `orgmetra_keyverse_adapter` policy evaluation | tenant/actor/resource agreement, exact opaque target-resource reference, purpose, operation-specific scope, requested-field minimization, opaque subject, no stored credentials or protected values in authorization evidence | ADR-0002, ADR-0008 | implemented_on_protected_main | +| Keyverse identity and authorization | API Gateway / purpose-bound authorization | Published OIDC/API identity and scope contract plus Orgmetra-owned `orgmetra_keyverse_adapter` policy evaluation | tenant/actor/resource agreement, exact opaque target-resource reference, purpose, operation-specific scope, requested-field minimization, opaque subject, no stored credentials or protected values in authorization evidence; production policy values come from trusted Orgmetra service composition/policy sources rather than request-controlled data; Python decision objects are revalidated PII-minimized data, not unforgeable capabilities against arbitrary same-process code | ADR-0002, ADR-0008 | implemented_on_active_pr | | naruon communication and calendar | Integration Hub | Published API/event adapter | idempotency, delivery audit, no direct table access | ADR-0002 | planned | | Psychometrics Commons @ `cc5850a0d1eacbbf16d03075534fce460a8286e6` | Workforce Validation | Immutable response/result snapshot contract | pinned revision, model/version/provenance snapshot, immutable result linkage, no direct application-table access | ADR-0002 | accepted_architecture | | fast-mlsirm @ `fb67ced09d8ee00542c05d56374537a9a7239751` | Workforce Validation | Published `orgmetra.fast_mlsirm.v1` result contract; direct calls only from approved offline validation worker | pinned revision, contract identifier, backend/result provenance, CPU/GPU parity evidence where material, no duplicated kernel | ADR-0002 | accepted_architecture | diff --git a/manifest.json b/manifest.json index f7b6cf55e..02926e103 100644 --- a/manifest.json +++ b/manifest.json @@ -29,9 +29,9 @@ }, { "path": "CHANGELOG.md", - "sha256": "f2d2e0b488c0440533effa821808f2f17e37d92f8fb586174c2fdb594f760ca5", - "bytes": 17539, - "lines": 77 + "sha256": "8712d0ec7442acb52fcb5776988799da1d8550a90bcced6630273af0be6468ba", + "bytes": 17907, + "lines": 78 }, { "path": "CLAUDE.md", @@ -197,8 +197,8 @@ }, { "path": "docs/TRACEABILITY.md", - "sha256": "dbf6fd91375ea28e05456d2a0c9ba629506cbac6f52f5dfda61ae68db2395f7e", - "bytes": 11462, + "sha256": "d5b57dfa3b4b5c4a408062b6a47f947080aa98eb4b82ffaf130a85ac17c80892", + "bytes": 11841, "lines": 40 }, { diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/__init__.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/__init__.py index fcfbd63ff..410f22620 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/__init__.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/__init__.py @@ -1,10 +1,9 @@ """Keyverse identity binding and purpose-bound authorization for Orgmetra. Orgmetra never stores passwords, passkeys, or raw credentials on a person -record. Use ``bind_identity_subject`` after Keyverse authenticates the actor, -then evaluate the authenticated subject, tenant, purpose, operation, scope, and -requested field set against an Orgmetra-owned purpose-bound policy before -returning protected HR data. +record. Keyverse authenticates identity and scopes; Orgmetra's trusted service +composition supplies HR policy. The exported value objects validate data but do +not pretend to be unforgeable capabilities against arbitrary same-process code. """ from orgmetra_keyverse_adapter.authorization import ( @@ -14,6 +13,7 @@ PurposeBoundAccessRequest, evaluate_purpose_bound_access, require_purpose_bound_access, + validate_authorization_decision, ) from orgmetra_keyverse_adapter.binding import ( CredentialRejectedError, @@ -31,4 +31,5 @@ "bind_identity_subject", "evaluate_purpose_bound_access", "require_purpose_bound_access", + "validate_authorization_decision", ] diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py index b1a5f92c2..44ae26d49 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py @@ -1,17 +1,18 @@ """Fail-closed purpose-bound authorization at the Orgmetra Keyverse boundary. -The adapter consumes only already-authenticated Keyverse identity attributes and -Orgmetra-owned policy data. It never stores credentials and never asks Keyverse -to make an Orgmetra employment-policy decision. Authorization follows the NIST -SP 800-162 ABAC shape: subject/context, object, requested operation, and policy -attributes must all match. Purpose is one policy attribute, never a substitute -for the operation-specific token scope. +The adapter consumes authenticated Keyverse identity/scope attributes and an +Orgmetra-owned policy supplied by the trusted service composition boundary. The +Python value objects below validate and detach authorization data; they are not +unforgeable capabilities against arbitrary code already executing in the same +interpreter. Same-process arbitrary code execution is a service compromise and +belongs to deployment/workload isolation controls, not object-constructor tricks. """ from __future__ import annotations from dataclasses import dataclass import re +from typing import NamedTuple from uuid import UUID _MAX_UUID_INT = (1 << 128) - 1 @@ -21,6 +22,7 @@ _REFERENCE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$") _VERSION_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") +_ALLOW_NEXT_ACTION = "Continue with only the authorized fields." _DENIAL_NEXT_ACTION = { "tenant_scope_mismatch": ( "Re-resolve the actor, request context, resource, and policy in one tenant before retrying." @@ -42,28 +44,37 @@ def _validate_uuid(field_name: str, value: object) -> None: - """Require a real UUID and reject protocol-reserved Nil/Max sentinels.""" - if not isinstance(value, UUID): + """Require an exact UUID and reject protocol-reserved Nil/Max sentinels.""" + if type(value) is not UUID: raise ValueError(f"{field_name} must be a UUID.") if value.int in (0, _MAX_UUID_INT): raise ValueError(f"{field_name} must not use a reserved UUID sentinel.") +def _validated_uuid_int(field_name: str, value: object) -> int: + """Validate and detach an exact UUID into one immutable integer snapshot.""" + _validate_uuid(field_name, value) + value_int = value.int + if type(value_int) is not int or not 0 <= value_int <= _MAX_UUID_INT: + raise ValueError(f"{field_name} must contain a valid UUID integer.") + return value_int + + def _validate_code(field_name: str, value: object) -> None: - """Require an explicit lower snake_case policy or request code.""" - if not isinstance(value, str) or _CODE_PATTERN.fullmatch(value) is None: + """Require an exact built-in lower snake_case policy or request code.""" + if type(value) is not str or _CODE_PATTERN.fullmatch(value) is None: raise ValueError(f"{field_name} must be a lower snake_case code.") def _validate_resource_kind(value: object) -> None: - """Require a descriptive two-or-more-word lower snake_case resource kind.""" - if not isinstance(value, str) or _RESOURCE_KIND_PATTERN.fullmatch(value) is None: + """Require an exact built-in descriptive lower snake_case resource kind.""" + if type(value) is not str or _RESOURCE_KIND_PATTERN.fullmatch(value) is None: raise ValueError("resource_kind must contain two or more lower snake_case words.") def _validate_scope(field_name: str, value: object) -> None: - """Require one explicit Orgmetra operation scope rather than wildcards.""" - if not isinstance(value, str) or _SCOPE_PATTERN.fullmatch(value) is None: + """Require one exact built-in Orgmetra operation scope rather than wildcards.""" + if type(value) is not str or _SCOPE_PATTERN.fullmatch(value) is None: raise ValueError(f"{field_name} must be an explicit orgmetra.. scope.") @@ -73,46 +84,99 @@ def _validate_reference( *, expected_namespace: str | None = None, ) -> None: - """Require an opaque audit reference and optionally bind it to one resource kind.""" - if not isinstance(value, str) or _REFERENCE_PATTERN.fullmatch(value) is None: + """Require an exact built-in opaque reference and optionally bind its namespace.""" + if type(value) is not str or _REFERENCE_PATTERN.fullmatch(value) is None: raise ValueError(f"{field_name} must be a namespaced opaque reference.") if expected_namespace is not None and value.partition(":")[0] != expected_namespace: raise ValueError(f"{field_name} namespace must match resource_kind.") def _validate_version(value: object) -> None: - """Require an immutable, whitespace-free policy version token.""" - if not isinstance(value, str) or _VERSION_PATTERN.fullmatch(value) is None: + """Require an exact built-in immutable, whitespace-free policy version token.""" + if type(value) is not str or _VERSION_PATTERN.fullmatch(value) is None: raise ValueError("policy_version_code must be a whitespace-free version token.") def _validate_field_set(field_name: str, values: object) -> None: - """Require an immutable, non-empty set of explicit lower snake_case fields.""" - if not isinstance(values, frozenset): + """Require an exact immutable non-empty set of exact built-in field codes.""" + if type(values) is not frozenset: raise ValueError(f"{field_name} must be a frozenset.") if not values: raise ValueError(f"{field_name} must not be empty.") - if any(not isinstance(value, str) or _CODE_PATTERN.fullmatch(value) is None for value in values): + if any(type(value) is not str or _CODE_PATTERN.fullmatch(value) is None for value in values): raise ValueError(f"{field_name} must contain only explicit lower snake_case field names.") +def _validate_authorized_field_set(values: object) -> None: + """Require an exact immutable authorized-field set while allowing an empty deny result.""" + if type(values) is not frozenset: + raise ValueError("authorized_fields must be a frozenset.") + if any(type(value) is not str or _CODE_PATTERN.fullmatch(value) is None for value in values): + raise ValueError("authorized_fields must contain only explicit lower snake_case field names.") + + def _validate_scope_set(values: object) -> None: - """Require immutable, non-empty, explicit token scopes from the authenticated principal.""" - if not isinstance(values, frozenset): + """Require an exact immutable non-empty set of authenticated Orgmetra scopes.""" + if type(values) is not frozenset: raise ValueError("granted_scope_codes must be a frozenset.") if not values: raise ValueError("granted_scope_codes must not be empty.") - if any(not isinstance(value, str) or _SCOPE_PATTERN.fullmatch(value) is None for value in values): + if any(type(value) is not str or _SCOPE_PATTERN.fullmatch(value) is None for value in values): raise ValueError("granted_scope_codes must contain only explicit Orgmetra scopes.") +class _PolicySnapshot(NamedTuple): + """Validated detached values for one trusted-source policy at evaluation time.""" + + tenant_record_id_int: int + policy_version_code: str + resource_kind: str + purpose_code: str + operation_code: str + required_scope_code: str + permitted_fields: frozenset[str] + + +class _RequestSnapshot(NamedTuple): + """Validated detached request attributes at evaluation time.""" + + tenant_record_id_int: int + actor_tenant_record_id_int: int + resource_tenant_record_id_int: int + actor_reference: str + resource_reference: str + purpose_code: str + operation_code: str + resource_kind: str + requested_fields: frozenset[str] + granted_scope_codes: frozenset[str] + + +class _DecisionSnapshot(NamedTuple): + """Validated PII-minimized decision values at a consumer boundary.""" + + allowed: bool + tenant_record_id_int: int + actor_reference: str + resource_reference: str + policy_version_code: str + purpose_code: str + operation_code: str + resource_kind: str + requested_fields: frozenset[str] + authorized_fields: frozenset[str] + reason_code: str + next_action: str + + @dataclass(frozen=True, slots=True) class PurposeBoundAccessPolicy: - """One tenant-local field policy for one purpose, resource, and operation. + """One trusted-composition policy value for one tenant/resource/operation. - A policy intentionally has no wildcard form. Separate purposes, operations, - or resources require separate reviewed policy records so a broad token cannot - silently widen access to necessary HR PII. + Object construction is validation, not policy issuance. Production callers + must obtain this value from the Orgmetra-controlled composition/policy source; + request payloads, LLM outputs, plugins, and remote callers are not policy + authorities. """ tenant_record_id: UUID @@ -124,28 +188,20 @@ class PurposeBoundAccessPolicy: permitted_fields: frozenset[str] def __post_init__(self) -> None: - """Reject ambiguous or mutable policy attributes before evaluation.""" - _validate_uuid("tenant_record_id", self.tenant_record_id) + """Validate policy data and detach the caller-owned UUID instance.""" + tenant_int = _validated_uuid_int("tenant_record_id", self.tenant_record_id) _validate_version(self.policy_version_code) _validate_resource_kind(self.resource_kind) _validate_code("purpose_code", self.purpose_code) _validate_code("operation_code", self.operation_code) _validate_scope("required_scope_code", self.required_scope_code) _validate_field_set("permitted_fields", self.permitted_fields) + object.__setattr__(self, "tenant_record_id", UUID(int=tenant_int)) @dataclass(frozen=True, slots=True) class PurposeBoundAccessRequest: - """PII access attributes resolved before any protected field is returned. - - ``actor_tenant_record_id`` comes from the authenticated identity binding, - ``tenant_record_id`` is the active Orgmetra request context, and - ``resource_tenant_record_id`` comes from the target record identity. The - opaque ``resource_reference`` identifies that exact target for audit - correlation without copying its PII. All tenant identifiers must match the - policy tenant. Only field names are carried here; field values remain behind - the authoritative data boundary until access is allowed. - """ + """Authorization attributes resolved from the request and authenticated identity.""" tenant_record_id: UUID actor_tenant_record_id: UUID @@ -159,10 +215,16 @@ class PurposeBoundAccessRequest: granted_scope_codes: frozenset[str] def __post_init__(self) -> None: - """Reject untrusted identity, target, tenant, purpose, field, or scope attributes.""" - _validate_uuid("tenant_record_id", self.tenant_record_id) - _validate_uuid("actor_tenant_record_id", self.actor_tenant_record_id) - _validate_uuid("resource_tenant_record_id", self.resource_tenant_record_id) + """Validate input attributes and detach caller-owned UUID instances.""" + tenant_int = _validated_uuid_int("tenant_record_id", self.tenant_record_id) + actor_tenant_int = _validated_uuid_int( + "actor_tenant_record_id", + self.actor_tenant_record_id, + ) + resource_tenant_int = _validated_uuid_int( + "resource_tenant_record_id", + self.resource_tenant_record_id, + ) _validate_reference("actor_reference", self.actor_reference) _validate_resource_kind(self.resource_kind) _validate_reference( @@ -174,11 +236,20 @@ def __post_init__(self) -> None: _validate_code("operation_code", self.operation_code) _validate_field_set("requested_fields", self.requested_fields) _validate_scope_set(self.granted_scope_codes) + object.__setattr__(self, "tenant_record_id", UUID(int=tenant_int)) + object.__setattr__(self, "actor_tenant_record_id", UUID(int=actor_tenant_int)) + object.__setattr__(self, "resource_tenant_record_id", UUID(int=resource_tenant_int)) @dataclass(frozen=True, slots=True) class AuthorizationDecision: - """PII-minimized authorization evidence safe to bind into an audit event.""" + """PII-minimized authorization decision data produced inside the service TCB. + + The type rejects dynamic subclasses and validates verdict/evidence coherence, + but it is not an unforgeable capability against arbitrary code already running + inside the service process. Persistence boundaries must treat same-process code + as trusted and still revalidate decision semantics before durable use. + """ allowed: bool tenant_record_id: UUID @@ -193,6 +264,29 @@ class AuthorizationDecision: reason_code: str next_action: str + def __post_init__(self) -> None: + """Validate decision coherence and detach the tenant UUID instance.""" + snapshot = _validated_decision_snapshot( + allowed=self.allowed, + tenant_record_id=self.tenant_record_id, + actor_reference=self.actor_reference, + resource_reference=self.resource_reference, + policy_version_code=self.policy_version_code, + purpose_code=self.purpose_code, + operation_code=self.operation_code, + resource_kind=self.resource_kind, + requested_fields=self.requested_fields, + authorized_fields=self.authorized_fields, + reason_code=self.reason_code, + next_action=self.next_action, + ) + object.__setattr__(self, "tenant_record_id", UUID(int=snapshot.tenant_record_id_int)) + + def __init_subclass__(cls, **kwargs: object) -> None: + """Prevent caller-defined decision subclasses from overriding field behavior.""" + del kwargs + raise TypeError("AuthorizationDecision must not be subclassed") + class AuthorizationDeniedError(PermissionError): """A purpose-bound policy denied access and tells the caller how to recover safely.""" @@ -205,23 +299,159 @@ def __init__(self, decision: AuthorizationDecision) -> None: self.decision = decision +def _validated_policy_snapshot(policy: PurposeBoundAccessPolicy) -> _PolicySnapshot: + """Revalidate and detach one policy immediately before evaluation.""" + tenant_int = _validated_uuid_int("tenant_record_id", policy.tenant_record_id) + _validate_version(policy.policy_version_code) + _validate_resource_kind(policy.resource_kind) + _validate_code("purpose_code", policy.purpose_code) + _validate_code("operation_code", policy.operation_code) + _validate_scope("required_scope_code", policy.required_scope_code) + _validate_field_set("permitted_fields", policy.permitted_fields) + return _PolicySnapshot( + tenant_int, + policy.policy_version_code, + policy.resource_kind, + policy.purpose_code, + policy.operation_code, + policy.required_scope_code, + policy.permitted_fields, + ) + + +def _validated_request_snapshot(request: PurposeBoundAccessRequest) -> _RequestSnapshot: + """Revalidate and detach request data immediately before evaluation.""" + tenant_int = _validated_uuid_int("tenant_record_id", request.tenant_record_id) + actor_tenant_int = _validated_uuid_int( + "actor_tenant_record_id", + request.actor_tenant_record_id, + ) + resource_tenant_int = _validated_uuid_int( + "resource_tenant_record_id", + request.resource_tenant_record_id, + ) + _validate_reference("actor_reference", request.actor_reference) + _validate_resource_kind(request.resource_kind) + _validate_reference( + "resource_reference", + request.resource_reference, + expected_namespace=request.resource_kind, + ) + _validate_code("purpose_code", request.purpose_code) + _validate_code("operation_code", request.operation_code) + _validate_field_set("requested_fields", request.requested_fields) + _validate_scope_set(request.granted_scope_codes) + return _RequestSnapshot( + tenant_int, + actor_tenant_int, + resource_tenant_int, + request.actor_reference, + request.resource_reference, + request.purpose_code, + request.operation_code, + request.resource_kind, + request.requested_fields, + request.granted_scope_codes, + ) + + +def _validated_decision_snapshot( + *, + allowed: object, + tenant_record_id: object, + actor_reference: object, + resource_reference: object, + policy_version_code: object, + purpose_code: object, + operation_code: object, + resource_kind: object, + requested_fields: object, + authorized_fields: object, + reason_code: object, + next_action: object, +) -> _DecisionSnapshot: + """Validate PII-minimized decision values without conferring policy authority.""" + if type(allowed) is not bool: + raise ValueError("allowed must be a boolean.") + tenant_int = _validated_uuid_int("tenant_record_id", tenant_record_id) + _validate_reference("actor_reference", actor_reference) + _validate_resource_kind(resource_kind) + _validate_reference( + "resource_reference", + resource_reference, + expected_namespace=resource_kind, + ) + _validate_version(policy_version_code) + _validate_code("purpose_code", purpose_code) + _validate_code("operation_code", operation_code) + _validate_field_set("requested_fields", requested_fields) + _validate_authorized_field_set(authorized_fields) + _validate_code("reason_code", reason_code) + if type(next_action) is not str or not next_action.strip() or len(next_action) > 500: + raise ValueError("next_action must be a non-blank string of at most 500 characters.") + if allowed and authorized_fields != requested_fields: + raise ValueError("allow decision must authorize exactly the requested fields.") + if not allowed and authorized_fields: + raise ValueError("deny decision must not authorize fields.") + if allowed: + if reason_code != "access_permitted": + raise ValueError("allow decision must use access_permitted reason.") + if next_action != _ALLOW_NEXT_ACTION: + raise ValueError("allow decision must use the canonical next action.") + else: + if reason_code not in _DENIAL_NEXT_ACTION: + raise ValueError("deny decision must use a known denial reason.") + if next_action != _DENIAL_NEXT_ACTION[reason_code]: + raise ValueError("deny decision must use the canonical next action.") + return _DecisionSnapshot( + allowed, + tenant_int, + actor_reference, + resource_reference, + policy_version_code, + purpose_code, + operation_code, + resource_kind, + requested_fields, + authorized_fields, + reason_code, + next_action, + ) + + +def validate_authorization_decision(decision: AuthorizationDecision) -> _DecisionSnapshot: + """Revalidate exact decision data before a same-process durable consumer uses it.""" + if type(decision) is not AuthorizationDecision: + raise TypeError("decision must be an AuthorizationDecision") + return _validated_decision_snapshot( + allowed=decision.allowed, + tenant_record_id=decision.tenant_record_id, + actor_reference=decision.actor_reference, + resource_reference=decision.resource_reference, + policy_version_code=decision.policy_version_code, + purpose_code=decision.purpose_code, + operation_code=decision.operation_code, + resource_kind=decision.resource_kind, + requested_fields=decision.requested_fields, + authorized_fields=decision.authorized_fields, + reason_code=decision.reason_code, + next_action=decision.next_action, + ) + + def _decision( *, - request: PurposeBoundAccessRequest, - policy: PurposeBoundAccessPolicy, + request: _RequestSnapshot, + policy: _PolicySnapshot, allowed: bool, reason_code: str, ) -> AuthorizationDecision: - """Build one immutable allow/deny record without copying protected values.""" + """Build one validated decision from the current evaluation snapshots.""" authorized_fields = request.requested_fields if allowed else frozenset() - next_action = ( - "Continue with only the authorized fields." - if allowed - else _DENIAL_NEXT_ACTION[reason_code] - ) + next_action = _ALLOW_NEXT_ACTION if allowed else _DENIAL_NEXT_ACTION[reason_code] return AuthorizationDecision( allowed=allowed, - tenant_record_id=request.tenant_record_id, + tenant_record_id=UUID(int=request.tenant_record_id_int), actor_reference=request.actor_reference, resource_reference=request.resource_reference, policy_version_code=policy.policy_version_code, @@ -240,62 +470,70 @@ def evaluate_purpose_bound_access( request: PurposeBoundAccessRequest, policy: PurposeBoundAccessPolicy, ) -> AuthorizationDecision: - """Evaluate tenant, resource, purpose, operation, scope, and field attributes. + """Evaluate trusted-source policy against authenticated/request attributes. - The order deliberately checks tenant isolation before policy detail and then - requires every narrowing attribute. Possessing a broad identity or a valid - purpose header is insufficient when the operation scope or requested field - set is not explicitly authorized. + ``policy`` must come from the service's trusted Orgmetra policy composition + boundary. The evaluator deliberately does not attempt to prove that arbitrary + Python code in the same interpreter is trustworthy; it revalidates current + values and defends the data boundary exposed to remote/untrusted inputs. """ + if type(request) is not PurposeBoundAccessRequest: + raise TypeError("request must be a PurposeBoundAccessRequest") + if type(policy) is not PurposeBoundAccessPolicy: + raise TypeError("policy must be a PurposeBoundAccessPolicy") + + request_snapshot = _validated_request_snapshot(request) + policy_snapshot = _validated_policy_snapshot(policy) + if ( - request.tenant_record_id != policy.tenant_record_id - or request.actor_tenant_record_id != policy.tenant_record_id - or request.resource_tenant_record_id != policy.tenant_record_id + request_snapshot.tenant_record_id_int != policy_snapshot.tenant_record_id_int + or request_snapshot.actor_tenant_record_id_int != policy_snapshot.tenant_record_id_int + or request_snapshot.resource_tenant_record_id_int != policy_snapshot.tenant_record_id_int ): return _decision( - request=request, - policy=policy, + request=request_snapshot, + policy=policy_snapshot, allowed=False, reason_code="tenant_scope_mismatch", ) - if request.resource_kind != policy.resource_kind: + if request_snapshot.resource_kind != policy_snapshot.resource_kind: return _decision( - request=request, - policy=policy, + request=request_snapshot, + policy=policy_snapshot, allowed=False, reason_code="resource_not_allowed", ) - if request.purpose_code != policy.purpose_code: + if request_snapshot.purpose_code != policy_snapshot.purpose_code: return _decision( - request=request, - policy=policy, + request=request_snapshot, + policy=policy_snapshot, allowed=False, reason_code="purpose_not_allowed", ) - if request.operation_code != policy.operation_code: + if request_snapshot.operation_code != policy_snapshot.operation_code: return _decision( - request=request, - policy=policy, + request=request_snapshot, + policy=policy_snapshot, allowed=False, reason_code="operation_not_allowed", ) - if policy.required_scope_code not in request.granted_scope_codes: + if policy_snapshot.required_scope_code not in request_snapshot.granted_scope_codes: return _decision( - request=request, - policy=policy, + request=request_snapshot, + policy=policy_snapshot, allowed=False, reason_code="required_scope_missing", ) - if not request.requested_fields.issubset(policy.permitted_fields): + if not request_snapshot.requested_fields.issubset(policy_snapshot.permitted_fields): return _decision( - request=request, - policy=policy, + request=request_snapshot, + policy=policy_snapshot, allowed=False, reason_code="field_not_allowed", ) return _decision( - request=request, - policy=policy, + request=request_snapshot, + policy=policy_snapshot, allowed=True, reason_code="access_permitted", ) diff --git a/packages/keyverse-adapter/tests/test_authorization_decision_consumer_validation.py b/packages/keyverse-adapter/tests/test_authorization_decision_consumer_validation.py new file mode 100644 index 000000000..a2e9246f3 --- /dev/null +++ b/packages/keyverse-adapter/tests/test_authorization_decision_consumer_validation.py @@ -0,0 +1,90 @@ +"""Consumer-boundary regressions for authorization decision revalidation.""" + +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import AuthorizationDecision, validate_authorization_decision + +TENANT = UUID("10000000-0000-7000-8000-000000000501") +FIELDS = frozenset({"assignment_category_code"}) + + +def _decision() -> AuthorizationDecision: + """Build one coherent internal decision-data value.""" + return AuthorizationDecision( + allowed=True, + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-17", + resource_reference="assignment_record:0198a412800070008000000000000070", + policy_version_code="assignment-correction-v1", + purpose_code="workforce_admin", + operation_code="correct_record", + resource_kind="assignment_record", + requested_fields=FIELDS, + authorized_fields=FIELDS, + reason_code="access_permitted", + next_action="Continue with only the authorized fields.", + ) + + +def test_consumer_validator_returns_detached_coherent_snapshot() -> None: + """A durable consumer can revalidate exact current decision semantics.""" + snapshot = validate_authorization_decision(_decision()) + + assert snapshot.allowed is True + assert snapshot.tenant_record_id_int == TENANT.int + assert snapshot.authorized_fields == FIELDS + + +def test_consumer_validator_rejects_non_decision_runtime_type() -> None: + """Caller-defined unrelated objects cannot enter the durable evidence boundary.""" + with pytest.raises(TypeError, match="decision must be an AuthorizationDecision"): + validate_authorization_decision(object()) # type: ignore[arg-type] + + +def test_consumer_validator_rejects_noncanonical_allow_next_action() -> None: + """Post-construction mutation cannot change the canonical allow recovery contract.""" + decision = _decision() + object.__setattr__(decision, "next_action", "Continue with any fields.") + + with pytest.raises(ValueError, match="allow decision must use the canonical next action"): + validate_authorization_decision(decision) + + +def test_decision_rejects_unknown_denial_reason() -> None: + """Denial evidence must use a governed reason rather than an arbitrary valid code.""" + with pytest.raises(ValueError, match="deny decision must use a known denial reason"): + AuthorizationDecision( + allowed=False, + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-17", + resource_reference="assignment_record:0198a412800070008000000000000070", + policy_version_code="assignment-correction-v1", + purpose_code="workforce_admin", + operation_code="correct_record", + resource_kind="assignment_record", + requested_fields=FIELDS, + authorized_fields=frozenset(), + reason_code="policy_denied", + next_action="Request another policy decision.", + ) + + +def test_decision_binds_denial_next_action_to_reason() -> None: + """Known denial reasons cannot carry caller-selected recovery instructions.""" + with pytest.raises(ValueError, match="deny decision must use the canonical next action"): + AuthorizationDecision( + allowed=False, + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-17", + resource_reference="assignment_record:0198a412800070008000000000000070", + policy_version_code="assignment-correction-v1", + purpose_code="workforce_admin", + operation_code="correct_record", + resource_kind="assignment_record", + requested_fields=FIELDS, + authorized_fields=frozenset(), + reason_code="purpose_not_allowed", + next_action="Request another policy decision.", + ) diff --git a/packages/keyverse-adapter/tests/test_authorization_decision_issuance_provenance.py b/packages/keyverse-adapter/tests/test_authorization_decision_issuance_provenance.py new file mode 100644 index 000000000..7fc648626 --- /dev/null +++ b/packages/keyverse-adapter/tests/test_authorization_decision_issuance_provenance.py @@ -0,0 +1,91 @@ +"""Trust-boundary regressions for purpose-bound authorization decisions.""" + +from __future__ import annotations + +from uuid import UUID + +import orgmetra_keyverse_adapter.authorization as authorization_module +from orgmetra_keyverse_adapter import ( + AuthorizationDecision, + PurposeBoundAccessPolicy, + PurposeBoundAccessRequest, + evaluate_purpose_bound_access, +) + +TENANT = UUID("10000000-0000-7000-8000-000000000501") +RESOURCE_REFERENCE = "assignment_record:0198a412800070008000000000000070" +REQUESTED_FIELDS = frozenset({"assignment_category_code"}) + + +def _decision_values() -> dict[str, object]: + """Return one semantically coherent PII-minimized allow decision payload.""" + return { + "allowed": True, + "tenant_record_id": TENANT, + "actor_reference": "keyverse_subject:operator-17", + "resource_reference": RESOURCE_REFERENCE, + "policy_version_code": "assignment-correction-v1", + "purpose_code": "workforce_admin", + "operation_code": "correct_record", + "resource_kind": "assignment_record", + "requested_fields": REQUESTED_FIELDS, + "authorized_fields": REQUESTED_FIELDS, + "reason_code": "access_permitted", + "next_action": "Continue with only the authorized fields.", + } + + +def test_direct_decision_construction_validates_data_but_does_not_claim_provenance() -> None: + """A Python decision object is validated evidence data, not an unforgeable capability.""" + decision = AuthorizationDecision(**_decision_values()) # type: ignore[arg-type] + + assert decision.allowed is True + assert decision.tenant_record_id == TENANT + assert decision.authorized_fields == REQUESTED_FIELDS + + +def test_authorization_module_exposes_no_mutable_issuance_registry() -> None: + """No Python mapping or id-set may be represented as authorization issuance authority.""" + for attribute_name in ( + "_DECISION_SNAPSHOT_REGISTRY", + "_DECISION_ISSUANCE_IDS", + "_POLICY_SNAPSHOT_REGISTRY", + "_REQUEST_SNAPSHOT_REGISTRY", + "_POLICY_CONSTRUCTION_IDS", + "_REQUEST_CONSTRUCTION_IDS", + ): + assert not hasattr(authorization_module, attribute_name) + assert evaluate_purpose_bound_access.__closure__ is None + + +def test_governed_evaluator_builds_decision_from_current_trusted_policy_and_request() -> None: + """The normal service path still evaluates every narrowing attribute before allow.""" + policy = PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="assignment-correction-v1", + resource_kind="assignment_record", + purpose_code="workforce_admin", + operation_code="correct_record", + required_scope_code="orgmetra.people.write", + permitted_fields=REQUESTED_FIELDS, + ) + request = PurposeBoundAccessRequest( + tenant_record_id=TENANT, + actor_tenant_record_id=TENANT, + resource_tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-17", + resource_reference=RESOURCE_REFERENCE, + purpose_code="workforce_admin", + operation_code="correct_record", + resource_kind="assignment_record", + requested_fields=REQUESTED_FIELDS, + granted_scope_codes=frozenset({"orgmetra.people.write"}), + ) + + decision = evaluate_purpose_bound_access(request=request, policy=policy) + + assert type(decision) is AuthorizationDecision + assert decision.allowed is True + assert decision.tenant_record_id == TENANT + assert decision.resource_reference == RESOURCE_REFERENCE + assert decision.authorized_fields == REQUESTED_FIELDS diff --git a/packages/keyverse-adapter/tests/test_authorization_decision_runtime_integrity.py b/packages/keyverse-adapter/tests/test_authorization_decision_runtime_integrity.py new file mode 100644 index 000000000..5bdde6459 --- /dev/null +++ b/packages/keyverse-adapter/tests/test_authorization_decision_runtime_integrity.py @@ -0,0 +1,262 @@ +"""Runtime-integrity regressions for purpose-bound authorization decision data.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +import orgmetra_keyverse_adapter.authorization as authorization_module +from orgmetra_keyverse_adapter import ( + AuthorizationDecision, + PurposeBoundAccessPolicy, + PurposeBoundAccessRequest, + evaluate_purpose_bound_access, +) + +TENANT = UUID("10000000-0000-7000-8000-000000000501") +RESOURCE_REFERENCE = "assignment_record:0198a412800070008000000000000070" +REQUESTED_FIELDS = frozenset({"assignment_category_code"}) + + +class _ForgedUUID(UUID): + """Carry caller-defined UUID behavior inside authorization evidence input.""" + + +class _ForgedText(str): + """Carry caller-defined text behavior inside authorization evidence input.""" + + +class _ForgedFieldSet(frozenset[str]): + """Carry caller-defined set behavior inside authorization evidence input.""" + + +def _policy(**overrides: object) -> PurposeBoundAccessPolicy: + """Build one deterministic trusted policy for decision-integrity tests.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "policy_version_code": "assignment-correction-v1", + "resource_kind": "assignment_record", + "purpose_code": "workforce_admin", + "operation_code": "correct_record", + "required_scope_code": "orgmetra.people.write", + "permitted_fields": REQUESTED_FIELDS, + } + values.update(overrides) + return PurposeBoundAccessPolicy(**values) # type: ignore[arg-type] + + +def _request(**overrides: object) -> PurposeBoundAccessRequest: + """Build one deterministic request value for decision-integrity tests.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "actor_tenant_record_id": TENANT, + "resource_tenant_record_id": TENANT, + "actor_reference": "keyverse_subject:operator-17", + "resource_reference": RESOURCE_REFERENCE, + "purpose_code": "workforce_admin", + "operation_code": "correct_record", + "resource_kind": "assignment_record", + "requested_fields": REQUESTED_FIELDS, + "granted_scope_codes": frozenset({"orgmetra.people.write"}), + } + values.update(overrides) + return PurposeBoundAccessRequest(**values) # type: ignore[arg-type] + + +def _decision() -> AuthorizationDecision: + """Return one allow decision from the normal evaluator path.""" + return evaluate_purpose_bound_access(request=_request(), policy=_policy()) + + +def _validate_decision(**overrides: object) -> tuple[object, ...]: + """Exercise the pure evidence validator without asserting object provenance.""" + values: dict[str, object] = { + "allowed": True, + "tenant_record_id": TENANT, + "actor_reference": "keyverse_subject:operator-17", + "resource_reference": RESOURCE_REFERENCE, + "policy_version_code": "assignment-correction-v1", + "purpose_code": "workforce_admin", + "operation_code": "correct_record", + "resource_kind": "assignment_record", + "requested_fields": REQUESTED_FIELDS, + "authorized_fields": REQUESTED_FIELDS, + "reason_code": "access_permitted", + "next_action": "Continue with only the authorized fields.", + } + values.update(overrides) + return authorization_module._validated_decision_snapshot(**values) + + +def test_decision_cannot_be_subclassed_to_override_runtime_behavior() -> None: + """Caller-defined decision classes cannot override validated field semantics.""" + with pytest.raises(TypeError, match="AuthorizationDecision must not be subclassed"): + type("_ForgedDecision", (AuthorizationDecision,), {}) + + +def test_consumer_revalidation_detects_low_level_decision_mutation() -> None: + """A durable consumer can fail closed if trusted-process code corrupts decision data.""" + decision = _decision() + object.__setattr__(decision, "allowed", False) + + with pytest.raises(ValueError, match="deny decision must not authorize fields"): + authorization_module.validate_authorization_decision(decision) + + +def test_decision_detaches_caller_owned_exact_uuid() -> None: + """Later low-level UUID mutation cannot rewrite constructed decision data.""" + tenant = UUID(str(TENANT)) + decision = AuthorizationDecision( + allowed=True, + tenant_record_id=tenant, + actor_reference="keyverse_subject:operator-17", + resource_reference=RESOURCE_REFERENCE, + policy_version_code="assignment-correction-v1", + purpose_code="workforce_admin", + operation_code="correct_record", + resource_kind="assignment_record", + requested_fields=REQUESTED_FIELDS, + authorized_fields=REQUESTED_FIELDS, + reason_code="access_permitted", + next_action="Continue with only the authorized fields.", + ) + object.__setattr__(tenant, "int", 0) + + assert decision.tenant_record_id == TENANT + + +@pytest.mark.parametrize("forged_int", [-1, 1 << 128, "invalid"]) +def test_decision_validator_rejects_low_level_corrupted_exact_uuid(forged_int: object) -> None: + """The snapshot validator rejects an exact UUID with corrupted integer state.""" + tenant = UUID(str(TENANT)) + object.__setattr__(tenant, "int", forged_int) + with pytest.raises(ValueError, match="tenant_record_id must contain a valid UUID integer"): + _validate_decision(tenant_record_id=tenant) + + +def test_decision_preserves_value_semantics_and_deterministic_repr() -> None: + """Validation preserves equality, hashing, and diagnostic representation.""" + left = _decision() + right = _decision() + assert left == right + assert not (left == object()) + assert hash(left) == hash(right) + assert repr(left).startswith("AuthorizationDecision(allowed=True") + assert "assignment_category_code" in repr(left) + + +def test_decision_validator_rejects_non_boolean_allowed_flag() -> None: + """Truthy integers cannot masquerade as an authorization verdict.""" + with pytest.raises(ValueError, match="allowed must be a boolean"): + _validate_decision(allowed=1) + + +def test_decision_validator_rejects_uuid_subclass() -> None: + """Decision data cannot retain caller-defined UUID runtime behavior.""" + forged = _ForgedUUID(str(TENANT)) + with pytest.raises(ValueError, match="tenant_record_id must be a UUID"): + _validate_decision(tenant_record_id=forged) + + +@pytest.mark.parametrize( + ("field_name", "forged_value"), + [ + ("actor_reference", _ForgedText("keyverse_subject:operator-17")), + ("resource_reference", _ForgedText(RESOURCE_REFERENCE)), + ("policy_version_code", _ForgedText("assignment-correction-v1")), + ("purpose_code", _ForgedText("workforce_admin")), + ("operation_code", _ForgedText("correct_record")), + ("resource_kind", _ForgedText("assignment_record")), + ("reason_code", _ForgedText("access_permitted")), + ("next_action", _ForgedText("Continue with only the authorized fields.")), + ], +) +def test_decision_validator_rejects_string_subclasses(field_name: str, forged_value: str) -> None: + """Decision data cannot retain caller-defined text runtime behavior.""" + with pytest.raises(ValueError): + _validate_decision(**{field_name: forged_value}) + + +@pytest.mark.parametrize("field_name", ["requested_fields", "authorized_fields"]) +def test_decision_validator_rejects_frozenset_subclasses(field_name: str) -> None: + """Field evidence cannot override containment or equality behavior.""" + forged = _ForgedFieldSet({"assignment_category_code"}) + with pytest.raises(ValueError, match=f"{field_name} must be a frozenset"): + _validate_decision(**{field_name: forged}) + + +@pytest.mark.parametrize("field_name", ["requested_fields", "authorized_fields"]) +def test_decision_validator_rejects_string_subclasses_inside_field_sets(field_name: str) -> None: + """Each field identifier must be an exact built-in string.""" + forged = frozenset({_ForgedText("assignment_category_code")}) + with pytest.raises(ValueError, match=f"{field_name} must contain only"): + _validate_decision(**{field_name: forged}) + + +def test_allow_decision_requires_exact_requested_authorized_field_equality() -> None: + """An allow verdict cannot silently authorize fewer or different fields.""" + with pytest.raises(ValueError, match="allow decision must authorize exactly the requested fields"): + _validate_decision(authorized_fields=frozenset({"legal_name"})) + + +def test_deny_decision_cannot_carry_authorized_fields() -> None: + """A deny verdict cannot retain a non-empty authorized field set.""" + with pytest.raises(ValueError, match="deny decision must not authorize fields"): + _validate_decision( + allowed=False, + authorized_fields=REQUESTED_FIELDS, + reason_code="field_not_allowed", + next_action=( + "Request only fields allowed for this purpose or obtain a separately reviewed field policy." + ), + ) + + +def test_allow_decision_rejects_denial_reason() -> None: + """An allow verdict cannot carry a denial reason into downstream evidence.""" + with pytest.raises(ValueError, match="allow decision must use access_permitted reason"): + _validate_decision(reason_code="field_not_allowed") + + +def test_deny_decision_rejects_success_reason() -> None: + """A deny verdict cannot masquerade as successful authorization evidence.""" + with pytest.raises(ValueError, match="deny decision must use a known denial reason"): + _validate_decision( + allowed=False, + authorized_fields=frozenset(), + reason_code="access_permitted", + ) + + +def test_decision_validator_accepts_governed_denial_reason_and_action() -> None: + """A denial snapshot preserves the evaluator's exact reason-to-recovery contract.""" + next_action = ( + "Use an approved purpose for this policy or obtain a separately governed policy decision." + ) + snapshot = _validate_decision( + allowed=False, + authorized_fields=frozenset(), + reason_code="purpose_not_allowed", + next_action=next_action, + ) + assert snapshot[10] == "purpose_not_allowed" + assert snapshot[11] == next_action + + +def test_decision_validator_rejects_noncanonical_allow_action() -> None: + """Allow evidence cannot replace the evaluator's governed recovery instruction.""" + with pytest.raises(ValueError, match="allow decision must use the canonical next action"): + _validate_decision(next_action="Continue after logging the reviewed evidence.") + + +def test_decision_validator_rejects_resource_reference_namespace_mismatch() -> None: + """Downstream evidence must correlate its target to the declared resource kind.""" + with pytest.raises(ValueError, match="resource_reference namespace must match resource_kind"): + _validate_decision(resource_reference="employment_record:0198a412800070008000000000000070") + + +def test_decision_validator_rejects_blank_next_action() -> None: + """Authorization evidence must preserve a bounded recovery instruction.""" + with pytest.raises(ValueError, match="next_action must be a non-blank string"): + _validate_decision(next_action=" ") diff --git a/packages/keyverse-adapter/tests/test_authorization_input_issuance_capability_privacy.py b/packages/keyverse-adapter/tests/test_authorization_input_issuance_capability_privacy.py new file mode 100644 index 000000000..3694dc9f2 --- /dev/null +++ b/packages/keyverse-adapter/tests/test_authorization_input_issuance_capability_privacy.py @@ -0,0 +1,114 @@ +"""Regressions for the service-process authorization trust boundary.""" + +from __future__ import annotations + +from collections.abc import Callable +from uuid import UUID + +import pytest + +import orgmetra_keyverse_adapter.authorization as authorization +from orgmetra_keyverse_adapter.authorization import ( + PurposeBoundAccessPolicy, + PurposeBoundAccessRequest, + evaluate_purpose_bound_access, +) + +TENANT = UUID("10000000-0000-7000-8000-000000000501") + + +def _policy() -> PurposeBoundAccessPolicy: + """Build one trusted-composition policy value.""" + return PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="people_pii_v1", + resource_kind="person_record", + purpose_code="hr_operations", + operation_code="read_person_pii", + required_scope_code="orgmetra.people.read", + permitted_fields=frozenset({"work_email"}), + ) + + +def _request() -> PurposeBoundAccessRequest: + """Build one authenticated/request-derived authorization value.""" + return PurposeBoundAccessRequest( + tenant_record_id=TENANT, + actor_tenant_record_id=TENANT, + resource_tenant_record_id=TENANT, + actor_reference="keyverse_subject:sub_jordan_hale", + resource_reference="person_record:per_01J5EXACTTARGET", + purpose_code="hr_operations", + operation_code="read_person_pii", + resource_kind="person_record", + requested_fields=frozenset({"work_email"}), + granted_scope_codes=frozenset({"orgmetra.people.read"}), + ) + + +def _closure_bindings(function: Callable[..., object]) -> dict[str, object]: + """Expose closure state exactly as same-process Python code can inspect it.""" + cells = function.__closure__ + if cells is None: + return {} + return { + name: cell.cell_contents + for name, cell in zip(function.__code__.co_freevars, cells, strict=True) + } + + +@pytest.mark.parametrize( + "attribute_name", + ( + "_POLICY_SNAPSHOT_REGISTRY", + "_REQUEST_SNAPSHOT_REGISTRY", + "_POLICY_CONSTRUCTION_IDS", + "_REQUEST_CONSTRUCTION_IDS", + "_DECISION_SNAPSHOT_REGISTRY", + "_DECISION_ISSUANCE_IDS", + ), +) +def test_module_has_no_runtime_authority_registry(attribute_name: str) -> None: + """No mutable Python registry may be described as an issuance security boundary.""" + assert not hasattr(authorization, attribute_name) + + +@pytest.mark.parametrize( + "function", + ( + PurposeBoundAccessPolicy.__post_init__, + PurposeBoundAccessRequest.__post_init__, + evaluate_purpose_bound_access, + ), +) +def test_authorization_boundary_does_not_hide_mutable_authority_in_closure_cells( + function: Callable[..., object], +) -> None: + """Inspectable closure cells must not carry a claimed authorization capability.""" + assert _closure_bindings(function) == {} + + +def test_policy_authority_is_not_inferred_from_python_constructor_provenance() -> None: + """Evaluation validates data; trusted composition, not object provenance, owns policy authority.""" + policy = object.__new__(PurposeBoundAccessPolicy) + object.__setattr__(policy, "tenant_record_id", TENANT) + object.__setattr__(policy, "policy_version_code", "people_pii_v1") + object.__setattr__(policy, "resource_kind", "person_record") + object.__setattr__(policy, "purpose_code", "hr_operations") + object.__setattr__(policy, "operation_code", "read_person_pii") + object.__setattr__(policy, "required_scope_code", "orgmetra.people.read") + object.__setattr__(policy, "permitted_fields", frozenset({"work_email"})) + + decision = evaluate_purpose_bound_access(request=_request(), policy=policy) + + assert decision.allowed is True + assert decision.authorized_fields == frozenset({"work_email"}) + + +def test_current_values_are_revalidated_even_inside_the_trusted_process() -> None: + """Low-level corruption still fails closed instead of relying on creation-time bookkeeping.""" + policy = _policy() + object.__setattr__(policy, "permitted_fields", {"work_email"}) + + with pytest.raises(ValueError, match="permitted_fields must be a frozenset"): + evaluate_purpose_bound_access(request=_request(), policy=policy) diff --git a/packages/keyverse-adapter/tests/test_authorization_issued_input_integrity.py b/packages/keyverse-adapter/tests/test_authorization_issued_input_integrity.py new file mode 100644 index 000000000..62323f0c9 --- /dev/null +++ b/packages/keyverse-adapter/tests/test_authorization_issued_input_integrity.py @@ -0,0 +1,131 @@ +"""Live-value integrity regressions for purpose-bound authorization inputs.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter.authorization import ( + PurposeBoundAccessPolicy, + PurposeBoundAccessRequest, + evaluate_purpose_bound_access, +) + +TENANT = UUID("10000000-0000-7000-8000-000000000501") + + +def _policy(*, field: str = "work_email", tenant: UUID = TENANT) -> PurposeBoundAccessPolicy: + """Build one trusted-composition policy value.""" + return PurposeBoundAccessPolicy( + tenant_record_id=tenant, + policy_version_code="people_pii_v1", + resource_kind="person_record", + purpose_code="hr_operations", + operation_code="read_person_pii", + required_scope_code="orgmetra.people.read", + permitted_fields=frozenset({field}), + ) + + +def _request( + *, + field: str = "work_email", + tenant: UUID = TENANT, +) -> PurposeBoundAccessRequest: + """Build one request-derived authorization value.""" + return PurposeBoundAccessRequest( + tenant_record_id=tenant, + actor_tenant_record_id=tenant, + resource_tenant_record_id=tenant, + actor_reference="keyverse_subject:sub_jordan_hale", + resource_reference="person_record:per_01J5EXACTTARGET", + purpose_code="hr_operations", + operation_code="read_person_pii", + resource_kind="person_record", + requested_fields=frozenset({field}), + granted_scope_codes=frozenset({"orgmetra.people.read"}), + ) + + +def test_policy_constructor_detaches_caller_owned_uuid() -> None: + """Later low-level caller UUID mutation cannot rewrite a policy value.""" + tenant = UUID(str(TENANT)) + policy = _policy(tenant=tenant) + + object.__setattr__(tenant, "int", 0) + + assert policy.tenant_record_id == TENANT + + +def test_request_constructor_detaches_caller_owned_uuid_instances() -> None: + """Request tenant identities do not retain caller-owned UUID objects.""" + tenant = UUID(str(TENANT)) + request = _request(tenant=tenant) + + object.__setattr__(tenant, "int", 0) + + assert request.tenant_record_id == TENANT + assert request.actor_tenant_record_id == TENANT + assert request.resource_tenant_record_id == TENANT + + +def test_evaluator_revalidates_post_construction_policy_runtime_type() -> None: + """Low-level policy corruption fails closed at the evaluation boundary.""" + policy = _policy() + object.__setattr__(policy, "permitted_fields", {"work_email"}) + + with pytest.raises(ValueError, match="permitted_fields must be a frozenset"): + evaluate_purpose_bound_access(request=_request(), policy=policy) + + +def test_evaluator_revalidates_post_construction_request_scope_runtime_type() -> None: + """Low-level scope corruption cannot reach membership evaluation.""" + request = _request() + object.__setattr__(request, "granted_scope_codes", {"orgmetra.people.read"}) + + with pytest.raises(ValueError, match="granted_scope_codes must be a frozenset"): + evaluate_purpose_bound_access(request=request, policy=_policy()) + + +def test_evaluator_revalidates_post_construction_request_field_runtime_type() -> None: + """Low-level field-set corruption cannot reach subset evaluation.""" + request = _request() + object.__setattr__(request, "requested_fields", {"work_email"}) + + with pytest.raises(ValueError, match="requested_fields must be a frozenset"): + evaluate_purpose_bound_access(request=request, policy=_policy()) + + +def test_same_process_policy_value_change_is_not_misrepresented_as_provenance_security() -> None: + """Inside the TCB, valid current policy data—not a Python object history—drives evaluation.""" + policy = _policy() + object.__setattr__(policy, "permitted_fields", frozenset({"compensation_amount"})) + + decision = evaluate_purpose_bound_access( + request=_request(field="compensation_amount"), + policy=policy, + ) + + assert decision.allowed is True + assert decision.authorized_fields == frozenset({"compensation_amount"}) + + +def test_policy_retains_deterministic_value_semantics() -> None: + """Validation hardening preserves stable equality, hashing, and diagnostics.""" + left = _policy() + right = _policy() + + assert left == right + assert hash(left) == hash(right) + assert repr(left).startswith("PurposeBoundAccessPolicy(") + + +def test_request_retains_deterministic_value_semantics() -> None: + """Request validation preserves stable equality, hashing, and diagnostics.""" + left = _request() + right = _request() + + assert left == right + assert hash(left) == hash(right) + assert repr(left).startswith("PurposeBoundAccessRequest(") diff --git a/packages/keyverse-adapter/tests/test_authorization_runtime_integrity.py b/packages/keyverse-adapter/tests/test_authorization_runtime_integrity.py new file mode 100644 index 000000000..8c2dacdd4 --- /dev/null +++ b/packages/keyverse-adapter/tests/test_authorization_runtime_integrity.py @@ -0,0 +1,208 @@ +"""Runtime-type integrity regressions for purpose-bound authorization.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter.authorization import ( + PurposeBoundAccessPolicy, + PurposeBoundAccessRequest, + evaluate_purpose_bound_access, +) + +TENANT = UUID("10000000-0000-7000-8000-000000000501") + + +class _ForgedUUID(UUID): + """Attempt to render a tenant identity different from its underlying UUID.""" + + def __str__(self) -> str: + """Return caller-controlled identity text.""" + return "10000000-0000-7000-8000-ffffffffffff" + + +class _ForgedText(str): + """Attempt to carry caller-controlled runtime behavior through text validation.""" + + +class _ForgedFieldSet(frozenset[str]): + """Attempt to bypass field containment with a validation-passing set subclass.""" + + def issubset(self, other: object) -> bool: + """Claim every requested field set is permitted.""" + return True + + +class _ForgedScopeSet(frozenset[str]): + """Attempt to bypass required-scope membership with a set subclass.""" + + def __contains__(self, item: object) -> bool: + """Claim every required scope is present.""" + return True + + +class _UnvalidatedPolicy(PurposeBoundAccessPolicy): + """Attempt to bypass immutable policy validation through subclass dispatch.""" + + def __post_init__(self) -> None: + """Intentionally skip the governed base validation.""" + + +class _UnvalidatedRequest(PurposeBoundAccessRequest): + """Attempt to bypass immutable request validation through subclass dispatch.""" + + def __post_init__(self) -> None: + """Intentionally skip the governed base validation.""" + + +def _policy(**overrides: object) -> PurposeBoundAccessPolicy: + """Build one exact governed People PII access policy.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "policy_version_code": "people_pii_v1", + "resource_kind": "person_record", + "purpose_code": "hr_operations", + "operation_code": "read_person_pii", + "required_scope_code": "orgmetra.people.read", + "permitted_fields": frozenset({"legal_name", "work_email"}), + } + values.update(overrides) + return PurposeBoundAccessPolicy(**values) # type: ignore[arg-type] + + +def _request(**overrides: object) -> PurposeBoundAccessRequest: + """Build one exact governed People PII access request.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "actor_tenant_record_id": TENANT, + "resource_tenant_record_id": TENANT, + "actor_reference": "keyverse_subject:sub_jordan_hale", + "resource_reference": "person_record:per_01J5EXACTTARGET", + "purpose_code": "hr_operations", + "operation_code": "read_person_pii", + "resource_kind": "person_record", + "requested_fields": frozenset({"work_email"}), + "granted_scope_codes": frozenset({"orgmetra.people.read"}), + } + values.update(overrides) + return PurposeBoundAccessRequest(**values) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "field_name", + ["tenant_record_id", "actor_tenant_record_id", "resource_tenant_record_id"], +) +def test_access_request_rejects_uuid_subclasses(field_name: str) -> None: + """Tenant isolation cannot depend on a UUID object with caller-controlled rendering.""" + forged = _ForgedUUID("10000000-0000-7000-8000-000000000501") + with pytest.raises(ValueError, match=f"{field_name} must be a UUID"): + _request(**{field_name: forged}) + + +def test_access_policy_rejects_uuid_subclasses() -> None: + """Persisted policy identity must use the exact built-in UUID contract.""" + forged = _ForgedUUID("10000000-0000-7000-8000-000000000501") + with pytest.raises(ValueError, match="tenant_record_id must be a UUID"): + _policy(tenant_record_id=forged) + + +@pytest.mark.parametrize( + ("field_name", "forged_value"), + [ + ("policy_version_code", _ForgedText("people_pii_v1")), + ("resource_kind", _ForgedText("person_record")), + ("purpose_code", _ForgedText("hr_operations")), + ("operation_code", _ForgedText("read_person_pii")), + ("required_scope_code", _ForgedText("orgmetra.people.read")), + ], +) +def test_access_policy_rejects_string_subclasses(field_name: str, forged_value: str) -> None: + """Policy semantics cannot depend on caller-defined string runtime behavior.""" + with pytest.raises(ValueError): + _policy(**{field_name: forged_value}) + + +@pytest.mark.parametrize( + ("field_name", "forged_value"), + [ + ("actor_reference", _ForgedText("keyverse_subject:sub_jordan_hale")), + ("resource_reference", _ForgedText("person_record:per_01J5EXACTTARGET")), + ("purpose_code", _ForgedText("hr_operations")), + ("operation_code", _ForgedText("read_person_pii")), + ("resource_kind", _ForgedText("person_record")), + ], +) +def test_access_request_rejects_string_subclasses(field_name: str, forged_value: str) -> None: + """Request authorization cannot depend on caller-defined string runtime behavior.""" + with pytest.raises(ValueError): + _request(**{field_name: forged_value}) + + +def test_access_policy_rejects_frozenset_subclass_for_permitted_fields() -> None: + """A field-set subclass cannot control later policy containment semantics.""" + forged = _ForgedFieldSet({"legal_name", "work_email"}) + with pytest.raises(ValueError, match="permitted_fields must be a frozenset"): + _policy(permitted_fields=forged) + + +def test_access_request_rejects_frozenset_subclass_that_can_widen_fields() -> None: + """A requested-field set cannot override ``issubset`` and authorize a forbidden field.""" + forged = _ForgedFieldSet({"compensation_amount"}) + with pytest.raises(ValueError, match="requested_fields must be a frozenset"): + _request(requested_fields=forged) + + +def test_access_request_rejects_frozenset_subclass_that_can_forge_scope_membership() -> None: + """A scope set cannot override membership and fabricate the required operation scope.""" + forged = _ForgedScopeSet({"orgmetra.people.other"}) + with pytest.raises(ValueError, match="granted_scope_codes must be a frozenset"): + _request(granted_scope_codes=forged) + + +def test_access_policy_rejects_string_subclass_inside_field_set() -> None: + """Field identifiers themselves must be exact immutable built-in strings.""" + forged = frozenset({_ForgedText("work_email")}) + with pytest.raises(ValueError, match="permitted_fields must contain only"): + _policy(permitted_fields=forged) + + +def test_access_request_rejects_string_subclass_inside_scope_set() -> None: + """Scope identifiers themselves must be exact immutable built-in strings.""" + forged = frozenset({_ForgedText("orgmetra.people.read")}) + with pytest.raises(ValueError, match="granted_scope_codes must contain only"): + _request(granted_scope_codes=forged) + + +def test_evaluator_rejects_policy_subclass_that_skipped_validation() -> None: + """A subclass cannot widen immutable policy attributes by skipping post-init checks.""" + forged = _UnvalidatedPolicy( + tenant_record_id=TENANT, + policy_version_code="people_pii_v1", + resource_kind="person_record", + purpose_code="hr_operations", + operation_code="read_person_pii", + required_scope_code="orgmetra.people.read", + permitted_fields={"work_email"}, # type: ignore[arg-type] + ) + with pytest.raises(TypeError, match="policy must be a PurposeBoundAccessPolicy"): + evaluate_purpose_bound_access(request=_request(), policy=forged) + + +def test_evaluator_rejects_request_subclass_that_skipped_validation() -> None: + """A subclass cannot present mutable token scopes as validated authorization input.""" + forged = _UnvalidatedRequest( + tenant_record_id=TENANT, + actor_tenant_record_id=TENANT, + resource_tenant_record_id=TENANT, + actor_reference="keyverse_subject:sub_jordan_hale", + resource_reference="person_record:per_01J5EXACTTARGET", + purpose_code="hr_operations", + operation_code="read_person_pii", + resource_kind="person_record", + requested_fields=frozenset({"work_email"}), + granted_scope_codes={"orgmetra.people.read"}, # type: ignore[arg-type] + ) + with pytest.raises(TypeError, match="request must be a PurposeBoundAccessRequest"): + evaluate_purpose_bound_access(request=forged, policy=_policy()) diff --git a/services/job-analysis-api/src/orgmetra_job_analysis_api/auth.py b/services/job-analysis-api/src/orgmetra_job_analysis_api/auth.py index dc3ef9274..74ce2d70e 100644 --- a/services/job-analysis-api/src/orgmetra_job_analysis_api/auth.py +++ b/services/job-analysis-api/src/orgmetra_job_analysis_api/auth.py @@ -7,45 +7,209 @@ from __future__ import annotations -from dataclasses import dataclass import re +import sys +from collections.abc import Iterator from typing import Protocol, runtime_checkable from uuid import UUID _MAX_UUID_INT = (1 << 128) - 1 _REFERENCE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$") _SCOPE_PATTERN = re.compile(r"^orgmetra(?:\.[a-z][a-z0-9_]*){2,}$") +_MAX_BEARER_TOKEN_LENGTH = 8192 +_MAX_AUTHORIZATION_HEADER_LENGTH = 8199 + + +def _validated_principal_storage( + value: tuple[int, str, frozenset[str]], +) -> tuple[int, str, frozenset[str]]: + """Return exact tuple-backed identity evidence or reject malformed storage. + + ``tuple.__new__`` can instantiate a tuple subclass without invoking that + subclass's public constructor. Public principal behavior therefore cannot + assume that tuple storage was validated merely because the runtime class is + exact. Revalidating the raw built-in tuple slots keeps request-edge consumers + fail-closed without treating Python construction history as policy authority. + """ + if tuple.__len__(value) != 3: + raise ValueError("stored authentication evidence is malformed.") + tenant_record_id_int = tuple.__getitem__(value, 0) + actor_reference = tuple.__getitem__(value, 1) + granted_scope_codes = tuple.__getitem__(value, 2) + if ( + type(tenant_record_id_int) is not int + or not 0 <= tenant_record_id_int <= _MAX_UUID_INT + or tenant_record_id_int in (0, _MAX_UUID_INT) + ): + raise ValueError("stored authentication evidence is malformed.") + if type(actor_reference) is not str or _REFERENCE_PATTERN.fullmatch(actor_reference) is None: + raise ValueError("stored authentication evidence is malformed.") + if type(granted_scope_codes) is not frozenset or not granted_scope_codes: + raise ValueError("stored authentication evidence is malformed.") + if any( + type(scope) is not str or _SCOPE_PATTERN.fullmatch(scope) is None + for scope in granted_scope_codes + ): + raise ValueError("stored authentication evidence is malformed.") + return tenant_record_id_int, actor_reference, granted_scope_codes class AuthenticationFailed(RuntimeError): """Indicate that bearer authentication evidence is absent or malformed.""" -@dataclass(frozen=True, slots=True) -class AuthenticatedPrincipal: - """Identity attributes that may be trusted only after token authentication. +class AuthenticatedPrincipal(tuple[int, str, frozenset[str]]): + """Structurally immutable identity evidence returned by token authentication. ``tenant_record_id`` binds the authenticated actor to one Orgmetra tenant. ``actor_reference`` is opaque audit correlation. ``granted_scope_codes`` carries explicit operation capabilities and never an HR purpose decision. - """ - tenant_record_id: UUID - actor_reference: str - granted_scope_codes: frozenset[str] + Tuple-backed storage deliberately leaves no writable instance slots. The + tenant UUID is stored as its validated integer and reconstructed on access, + so neither the caller's UUID nor a returned UUID aliases stored authority. + Public access also revalidates all raw tuple slots because callers inside the + service TCB can invoke ``tuple.__new__`` without this class's constructor. + """ - def __post_init__(self) -> None: - """Reject sentinel identities, mutable grants, wildcards, and bad references.""" - if not isinstance(self.tenant_record_id, UUID): + __slots__ = () + __match_args__ = ("tenant_record_id", "actor_reference", "granted_scope_codes") + + def __new__( + cls, + tenant_record_id: UUID, + actor_reference: str, + granted_scope_codes: frozenset[str], + ) -> AuthenticatedPrincipal: + """Validate, detach, and store exact authentication evidence once.""" + if type(tenant_record_id) is not UUID: raise ValueError("tenant_record_id must be a UUID.") - if self.tenant_record_id.int in (0, _MAX_UUID_INT): + tenant_record_id_int = tenant_record_id.int + if type(tenant_record_id_int) is not int or not 0 <= tenant_record_id_int <= _MAX_UUID_INT: + raise ValueError("tenant_record_id must contain a valid UUID integer.") + if tenant_record_id_int in (0, _MAX_UUID_INT): raise ValueError("tenant_record_id must not use a reserved UUID sentinel.") - if not isinstance(self.actor_reference, str) or _REFERENCE_PATTERN.fullmatch(self.actor_reference) is None: + if type(actor_reference) is not str or _REFERENCE_PATTERN.fullmatch(actor_reference) is None: raise ValueError("actor_reference must be a namespaced opaque reference.") - if not isinstance(self.granted_scope_codes, frozenset) or not self.granted_scope_codes: + if type(granted_scope_codes) is not frozenset or not granted_scope_codes: raise ValueError("granted_scope_codes must be a non-empty frozenset.") - if any(not isinstance(scope, str) or _SCOPE_PATTERN.fullmatch(scope) is None for scope in self.granted_scope_codes): + if any(type(scope) is not str or _SCOPE_PATTERN.fullmatch(scope) is None for scope in granted_scope_codes): raise ValueError("granted_scope_codes must contain explicit Orgmetra scopes.") + return tuple.__new__(cls, (tenant_record_id_int, actor_reference, granted_scope_codes)) + + def __len__(self) -> int: + """Report sequence length only after stored authentication evidence is valid.""" + _validated_principal_storage(self) + return 3 + + def __getitem__( + self, + key: int | slice, + ) -> int | str | frozenset[str] | tuple[int, str, frozenset[str]]: + """Expose sequence items only after all stored authentication evidence is valid.""" + return _validated_principal_storage(self)[key] + + def __iter__(self) -> Iterator[int | str | frozenset[str]]: + """Iterate only after all stored authentication evidence is revalidated.""" + return iter(_validated_principal_storage(self)) + + def __contains__(self, value: object) -> bool: + """Search only revalidated authentication evidence.""" + return value in _validated_principal_storage(self) + + def count(self, value: object) -> int: + """Count matches only in revalidated authentication evidence.""" + return _validated_principal_storage(self).count(value) + + def index(self, value: object, start: int = 0, stop: int = sys.maxsize) -> int: + """Locate a value only in revalidated authentication evidence.""" + return _validated_principal_storage(self).index(value, start, stop) + + def __add__(self, other: tuple[object, ...]) -> tuple[object, ...]: + """Concatenate only after this principal's stored evidence is revalidated.""" + return _validated_principal_storage(self) + other + + def __radd__(self, other: tuple[object, ...]) -> tuple[object, ...]: + """Right-concatenate only after this principal's stored evidence is revalidated.""" + return other + _validated_principal_storage(self) + + def __mul__(self, count: int) -> tuple[object, ...]: + """Repeat only revalidated authentication evidence.""" + return _validated_principal_storage(self) * count + + def __rmul__(self, count: int) -> tuple[object, ...]: + """Right-repeat only revalidated authentication evidence.""" + return count * _validated_principal_storage(self) + + def __lt__(self, other: tuple[object, ...]) -> bool: + """Order only after this principal's stored evidence is revalidated.""" + return _validated_principal_storage(self) < other + + def __le__(self, other: tuple[object, ...]) -> bool: + """Order only after this principal's stored evidence is revalidated.""" + return _validated_principal_storage(self) <= other + + def __gt__(self, other: tuple[object, ...]) -> bool: + """Order only after this principal's stored evidence is revalidated.""" + return _validated_principal_storage(self) > other + + def __ge__(self, other: tuple[object, ...]) -> bool: + """Order only after this principal's stored evidence is revalidated.""" + return _validated_principal_storage(self) >= other + + @property + def tenant_record_id(self) -> UUID: + """Return a detached authenticated tenant identifier value.""" + tenant_record_id_int, _, _ = _validated_principal_storage(self) + return UUID(int=tenant_record_id_int) + + @property + def actor_reference(self) -> str: + """Return the opaque authenticated actor correlation reference.""" + _, actor_reference, _ = _validated_principal_storage(self) + return actor_reference + + @property + def granted_scope_codes(self) -> frozenset[str]: + """Return the exact operation scopes issued at authentication.""" + _, _, granted_scope_codes = _validated_principal_storage(self) + return granted_scope_codes + + def __repr__(self) -> str: + """Render the same field-oriented diagnostic shape as the prior value object.""" + tenant_record_id_int, actor_reference, granted_scope_codes = _validated_principal_storage(self) + return ( + "AuthenticatedPrincipal(" + f"tenant_record_id={UUID(int=tenant_record_id_int)!r}, " + f"actor_reference={actor_reference!r}, " + f"granted_scope_codes={granted_scope_codes!r})" + ) + + def __eq__(self, other: object) -> bool: + """Compare only another exact authenticated-principal value.""" + if type(other) is not AuthenticatedPrincipal: + return False + return _validated_principal_storage(self) == _validated_principal_storage(other) + + def __ne__(self, other: object) -> bool: + """Keep inequality consistent with strict principal-only equality.""" + if type(other) is not AuthenticatedPrincipal: + return True + return _validated_principal_storage(self) != _validated_principal_storage(other) + + def __hash__(self) -> int: + """Hash only revalidated immutable authentication evidence.""" + return hash(_validated_principal_storage(self)) + + def __getnewargs__(self) -> tuple[UUID, str, frozenset[str]]: + """Preserve validated constructor arguments for standard value reconstruction.""" + tenant_record_id_int, actor_reference, granted_scope_codes = _validated_principal_storage(self) + return (UUID(int=tenant_record_id_int), actor_reference, granted_scope_codes) + + def __init_subclass__(cls, **kwargs: object) -> None: + """Prevent executable principal subclasses from overriding authenticated evidence.""" + del kwargs + raise TypeError("AuthenticatedPrincipal must not be subclassed") @runtime_checkable @@ -60,11 +224,15 @@ def extract_bearer_token(authorization_header: str | None) -> str: """Return one bounded printable bearer token without logging its value.""" if authorization_header is None: raise AuthenticationFailed("bearer authentication is required") + if type(authorization_header) is not str: + raise AuthenticationFailed("authorization header must be plain text") + if len(authorization_header) > _MAX_AUTHORIZATION_HEADER_LENGTH: + raise AuthenticationFailed("authorization header length is invalid") parts = authorization_header.split(" ", 1) if len(parts) != 2 or parts[0].casefold() != "bearer": raise AuthenticationFailed("authorization must use the Bearer scheme") token = parts[1] - if not token or len(token) > 8192: + if not token or len(token) > _MAX_BEARER_TOKEN_LENGTH: raise AuthenticationFailed("bearer token length is invalid") if any(ord(character) < 0x21 or ord(character) > 0x7E for character in token): raise AuthenticationFailed("bearer token contains invalid characters") diff --git a/services/job-analysis-api/src/orgmetra_job_analysis_api/postgres.py b/services/job-analysis-api/src/orgmetra_job_analysis_api/postgres.py index 48fa57ce6..cf32a6871 100644 --- a/services/job-analysis-api/src/orgmetra_job_analysis_api/postgres.py +++ b/services/job-analysis-api/src/orgmetra_job_analysis_api/postgres.py @@ -11,9 +11,14 @@ from contextlib import AbstractContextManager from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import date, datetime, timezone +from hashlib import sha256 +from inspect import getattr_static +import json +import re from typing import Any, Callable from uuid import UUID +from zoneinfo import ZoneInfo from orgmetra_hris_kernel import ( AuditOutboxEvent, @@ -29,11 +34,19 @@ JobAnalysisIdempotencyConflict, JobAnalysisIntegrityError, JobAnalysisScopeMissing, + _validate_idempotency_key, + command_digest, + snapshot_from_document, validate_operational_uuid, ) PostgresConnectionFactory = Callable[[], AbstractContextManager[Any]] +_REQUEST_DIGEST_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_EXPECTED_AUDIT_SOURCE_SERVICE = "job_analysis_api" +_EXPECTED_AUDIT_EVENT_TYPE = "orgmetra.job_architecture.snapshot_recorded" +_EXPECTED_AUDIT_REASON_CODE = "snapshot_persisted" +_EXPECTED_AUDIT_RESULT_CODE = "recorded" _TENANT_CONTEXT_SQL = "SELECT pg_catalog.set_config('orgmetra.tenant_record_id', %s, true)" _READ_ONLY_SQL = "SET TRANSACTION READ ONLY" _IDEMPOTENCY_LOOKUP_SQL = """ @@ -189,24 +202,256 @@ def _source_params(source: EvidenceSource) -> tuple[object, ...]: ) -def _is_unique_violation(error: Exception) -> bool: - """Return whether a PostgreSQL DB-API error reports SQLSTATE 23505.""" - return getattr(error, "sqlstate", getattr(error, "pgcode", None)) == "23505" +def _validate_durable_command_scalars( + *, + idempotency_key: object, + request_digest: object, + actor_reference: object, + purpose_code: object, +) -> None: + """Reject malformed durable command text before acquiring PostgreSQL resources.""" + if type(idempotency_key) is not str: + raise ValueError("idempotency_key must be exact built-in text.") + _validate_idempotency_key(idempotency_key) + if type(request_digest) is not str or _REQUEST_DIGEST_PATTERN.fullmatch(request_digest) is None: + raise ValueError("request_digest must be an exact lowercase SHA-256 digest.") + if type(actor_reference) is not str: + raise ValueError("actor_reference must be exact built-in text.") + if type(purpose_code) is not str: + raise ValueError("purpose_code must be exact built-in text.") + + +def _validate_projection_uuid( + field_name: str, + value: object, + *, + row_label: str, +) -> UUID: + """Normalize malformed durable projection identity to an integrity failure.""" + try: + return validate_operational_uuid(field_name, value) + except ValueError as error: + raise JobAnalysisIntegrityError(f"{row_label} has invalid identity") from error + + +def _validate_projection_scalar(field_name: str, value: object, expected_type: type[object]) -> object: + """Require one inert exact built-in scalar before kernel reconstruction.""" + if type(value) is not expected_type: + raise JobAnalysisIntegrityError(f"{field_name} row has invalid scalar evidence") + return value + + +def _validate_projection_text(field_name: str, value: object) -> str: + """Require inert exact text before durable evidence reaches kernel validators.""" + resolved = _validate_projection_scalar(field_name, value, str) + assert type(resolved) is str + return resolved + + +def _validate_projection_integer(field_name: str, value: object) -> int: + """Require an exact integer before ordinal or worker-function comparisons.""" + resolved = _validate_projection_scalar(field_name, value, int) + assert type(resolved) is int + return resolved + + +def _validate_projection_boolean(field_name: str, value: object) -> bool: + """Require an exact boolean before link evidence enters the kernel.""" + resolved = _validate_projection_scalar(field_name, value, bool) + assert type(resolved) is bool + return resolved + + +def _validate_projection_date(field_name: str, value: object) -> date: + """Require an exact business date before kernel serialization.""" + resolved = _validate_projection_scalar(field_name, value, date) + assert type(resolved) is date + return resolved + + +def _validate_projection_datetime(field_name: str, value: object) -> datetime: + """Require an exact standard-library instant before offset-aware kernel operations.""" + resolved = _validate_projection_scalar(field_name, value, datetime) + assert type(resolved) is datetime + if type(resolved.tzinfo) not in (timezone, ZoneInfo): + raise JobAnalysisIntegrityError(f"{field_name} row has invalid scalar evidence") + return resolved + + +def _validate_projection_optional_text(field_name: str, value: object) -> str | None: + """Require optional durable text to be absent or an inert exact string.""" + return None if value is None else _validate_projection_text(field_name, value) + + +def _validate_projection_optional_datetime(field_name: str, value: object) -> datetime | None: + """Require optional durable time to be absent or an inert standard-library datetime.""" + return None if value is None else _validate_projection_datetime(field_name, value) + + +def _validate_projection_digest(field_name: str, value: object) -> str: + """Require inert lowercase SHA-256 text before durable-evidence comparison.""" + if type(value) is not str or _REQUEST_DIGEST_PATTERN.fullmatch(value) is None: + raise JobAnalysisIntegrityError(f"{field_name} row has invalid scalar evidence") + return value + + +def _unpack_fixed_projection( + row_label: str, + row: Any, + expected_columns: int, +) -> tuple[object, ...]: + """Reject executable or shape-invalid rows from fixed SQL projections.""" + if type(row) not in (tuple, list): + raise JobAnalysisIntegrityError(f"{row_label} row has invalid shape") + values = tuple(row) + if len(values) != expected_columns: + raise JobAnalysisIntegrityError(f"{row_label} row has invalid shape") + return values + + +def _unpack_projection_rows( + row_label: str, + rows: Any, +) -> tuple[object, ...]: + """Reject executable row collections before fixed projection processing.""" + if type(rows) not in (tuple, list): + raise JobAnalysisIntegrityError(f"{row_label} row collection has invalid shape") + return tuple(rows) -def _constraint_name(error: Exception) -> str | None: - """Return a driver-provided PostgreSQL constraint name when available.""" - diagnostic = getattr(error, "diag", None) - constraint_name = getattr(diagnostic, "constraint_name", None) - return constraint_name if isinstance(constraint_name, str) else None +def _detach_durable_snapshot(snapshot: JobAnalysisSnapshot) -> JobAnalysisSnapshot: + """Rebuild exact snapshot evidence before any executable database boundary runs.""" + tenant_record_id = validate_operational_uuid( + "snapshot.tenant_record_id", + snapshot.tenant_record_id, + ) + canonical_json = snapshot.canonical_json() + document = json.loads(canonical_json) + detached = snapshot_from_document(document, tenant_record_id=tenant_record_id) + if detached.canonical_json() != canonical_json: + raise JobAnalysisIntegrityError("detached snapshot does not match canonical evidence") + return detached + + +@dataclass(frozen=True, slots=True) +class _DurableAuditEvidence: + """Detached Job Analysis audit evidence frozen before PostgreSQL acquisition.""" + + event_id: UUID + tenant_record_id: UUID + source_service: str + event_type: str + resource_reference: str + actor_reference: str + purpose_code: str + reason_code: str + evidence_version_code: str + result_code: str + high_impact: bool + confirmation_reference: str | None + canonical_json: str + content_digest: str + + +def _snapshot_durable_audit_authority( + audit_event: AuditOutboxEvent, +) -> _DurableAuditEvidence: + """Freeze exact audit authority, semantics, and canonical bytes before DB acquisition.""" + event_id = validate_operational_uuid("audit_event.event_id", audit_event.event_id) + tenant_record_id = validate_operational_uuid( + "audit_event.tenant_record_id", + audit_event.tenant_record_id, + ) + audit_text: dict[str, str] = {} + for field_name in ( + "source_service", + "event_type", + "resource_reference", + "actor_reference", + "purpose_code", + "reason_code", + "evidence_version_code", + "result_code", + ): + value = getattr(audit_event, field_name) + if type(value) is not str: + raise ValueError(f"audit_event.{field_name} must be exact built-in text.") + audit_text[field_name] = value + high_impact = audit_event.high_impact + confirmation_reference = audit_event.confirmation_reference + canonical_json = audit_event.canonical_json() + canonical_event = json.loads(canonical_json) + if ( + canonical_event.get("id") != str(event_id) + or canonical_event.get("orgmetratenant") != str(tenant_record_id) + or canonical_event.get("subject") != audit_text["resource_reference"] + or canonical_event.get("orgmetraactor") != audit_text["actor_reference"] + or canonical_event.get("orgmetrapurpose") != audit_text["purpose_code"] + ): + raise JobAnalysisIntegrityError( + "canonical audit evidence does not match validated authority" + ) + if ( + canonical_event.get("source") != f"urn:orgmetra:{audit_text['source_service']}" + or canonical_event.get("type") != audit_text["event_type"] + or canonical_event.get("orgmetrareason") != audit_text["reason_code"] + or canonical_event.get("orgmetraevidence") != audit_text["evidence_version_code"] + or canonical_event.get("data") + != { + "result_code": audit_text["result_code"], + "high_impact": high_impact, + } + or canonical_event.get("orgmetraconfirmation") != confirmation_reference + ): + raise JobAnalysisIntegrityError( + "canonical audit evidence does not match validated semantics" + ) + content_digest = sha256(canonical_json.encode("utf-8")).hexdigest() + return _DurableAuditEvidence( + event_id=event_id, + tenant_record_id=tenant_record_id, + source_service=audit_text["source_service"], + event_type=audit_text["event_type"], + resource_reference=audit_text["resource_reference"], + actor_reference=audit_text["actor_reference"], + purpose_code=audit_text["purpose_code"], + reason_code=audit_text["reason_code"], + evidence_version_code=audit_text["evidence_version_code"], + result_code=audit_text["result_code"], + high_impact=high_impact, + confirmation_reference=confirmation_reference, + canonical_json=canonical_json, + content_digest=content_digest, + ) + + +def _static_builtin_text_attribute(owner: object, attribute_name: str) -> str | None: + """Read stored driver metadata without invoking dynamic attribute behavior.""" + try: + value = getattr_static(owner, attribute_name) + except AttributeError: + return None + return value if type(value) is str else None + + +def _is_unique_violation(error: Exception) -> bool: + """Return whether inert PostgreSQL DB-API metadata reports SQLSTATE 23505.""" + sqlstate = _static_builtin_text_attribute(error, "sqlstate") + if sqlstate is None: + sqlstate = _static_builtin_text_attribute(error, "pgcode") + return sqlstate == "23505" @dataclass(frozen=True, slots=True) class PostgresJobAnalysisPort: """Persist and reconstruct snapshots through parameterized PostgreSQL SQL. - ``connection_factory`` must return a DB-API-compatible connection context - manager. Deployment code owns pooling, credentials, TLS, and role selection. + ``connection_factory`` must return a DB-API-style connection context manager + whose cursor fetch methods return exact built-in ``tuple`` or ``list`` row + collections containing exact built-in ``tuple`` or ``list`` rows. Custom row + and collection factories must normalize to those inert types before crossing + this durable-evidence boundary. Deployment code owns pooling, credentials, + TLS, role selection, and that row-factory configuration. """ connection_factory: PostgresConnectionFactory @@ -237,18 +482,61 @@ def persist_snapshot( ``record_audit_outbox_event`` runs only for a new write, inside the same transaction. """ - if not isinstance(snapshot, JobAnalysisSnapshot): - raise TypeError("snapshot must be a JobAnalysisSnapshot") - if not isinstance(audit_event, AuditOutboxEvent): - raise TypeError("audit_event must be an AuditOutboxEvent") - if not isinstance(idempotency_key, str): - raise ValueError("idempotency_key must reach the write port as a string.") - validate_operational_uuid("write_command_id", write_command_id) - validate_operational_uuid("outbox_delivery_record_id", outbox_delivery_record_id) + if type(snapshot) is not JobAnalysisSnapshot: + raise TypeError("snapshot must be an exact JobAnalysisSnapshot") + if type(audit_event) is not AuditOutboxEvent: + raise TypeError("audit_event must be an exact AuditOutboxEvent") + _validate_durable_command_scalars( + idempotency_key=idempotency_key, + request_digest=request_digest, + actor_reference=actor_reference, + purpose_code=purpose_code, + ) + snapshot = _detach_durable_snapshot(snapshot) + audit_evidence = _snapshot_durable_audit_authority(audit_event) + write_command_id = validate_operational_uuid("write_command_id", write_command_id) + outbox_delivery_record_id = validate_operational_uuid( + "outbox_delivery_record_id", + outbox_delivery_record_id, + ) if position_record_id is not None: - validate_operational_uuid("position_record_id", position_record_id) + position_record_id = validate_operational_uuid("position_record_id", position_record_id) if criterion_blueprint_id is not None: - validate_operational_uuid("criterion_blueprint_id", criterion_blueprint_id) + criterion_blueprint_id = validate_operational_uuid( + "criterion_blueprint_id", + criterion_blueprint_id, + ) + expected_request_digest = command_digest( + snapshot=snapshot, + position_record_id=position_record_id, + criterion_blueprint_id=criterion_blueprint_id, + ) + if request_digest != expected_request_digest: + raise JobAnalysisIntegrityError( + "request_digest does not match detached snapshot command" + ) + expected_resource_reference = f"job_analysis_snapshot:{snapshot.analysis_record_id.hex}" + if ( + audit_evidence.tenant_record_id != snapshot.tenant_record_id + or audit_evidence.resource_reference != expected_resource_reference + or audit_evidence.actor_reference != actor_reference + or audit_evidence.purpose_code != purpose_code + ): + raise JobAnalysisIntegrityError( + "audit event does not match the job-analysis write authority" + ) + if ( + audit_evidence.source_service != _EXPECTED_AUDIT_SOURCE_SERVICE + or audit_evidence.event_type != _EXPECTED_AUDIT_EVENT_TYPE + or audit_evidence.reason_code != _EXPECTED_AUDIT_REASON_CODE + or audit_evidence.evidence_version_code != snapshot.analysis_version_code + or audit_evidence.result_code != _EXPECTED_AUDIT_RESULT_CODE + or audit_evidence.high_impact is not False + or audit_evidence.confirmation_reference is not None + ): + raise JobAnalysisIntegrityError( + "audit event does not match the job-analysis snapshot semantics" + ) with self.connection_factory() as connection: with connection.cursor() as cursor: @@ -263,14 +551,62 @@ def persist_snapshot( ), ) existing = cursor.fetchone() - if existing is not None and existing[0] is not None: - stored_digest, stored_analysis_id, *stored_authority = existing - if stored_digest != request_digest: - raise JobAnalysisIdempotencyConflict( - "idempotency key is bound to a different snapshot digest" - ) - if stored_authority: - stored_actor_reference, stored_purpose_code = stored_authority + if existing is None: + raise JobAnalysisIntegrityError( + "idempotent durable command lookup returned no projection" + ) + if existing is not None: + ( + stored_digest, + stored_analysis_id, + stored_actor_reference, + stored_purpose_code, + ) = _unpack_fixed_projection( + "idempotent durable command", + existing, + 4, + ) + if stored_digest is None: + if any( + value is not None + for value in ( + stored_analysis_id, + stored_actor_reference, + stored_purpose_code, + ) + ): + raise JobAnalysisIntegrityError( + "idempotent durable command row is partial-null" + ) + else: + try: + _validate_durable_command_scalars( + idempotency_key=idempotency_key, + request_digest=stored_digest, + actor_reference=stored_actor_reference, + purpose_code=stored_purpose_code, + ) + except ValueError as error: + raise JobAnalysisIntegrityError( + "idempotent durable command has invalid scalar evidence" + ) from error + if stored_digest != request_digest: + raise JobAnalysisIdempotencyConflict( + "idempotency key is bound to a different snapshot digest" + ) + try: + stored_analysis_id = validate_operational_uuid( + "stored analysis_record_id", + stored_analysis_id, + ) + except ValueError as error: + raise JobAnalysisIntegrityError( + "idempotent command has invalid analysis_record_id" + ) from error + if stored_analysis_id != snapshot.analysis_record_id: + raise JobAnalysisIntegrityError( + "idempotent command analysis_record_id does not match detached snapshot" + ) if stored_actor_reference != actor_reference: raise JobAnalysisIdempotencyConflict( "idempotency key is bound to a different actor" @@ -279,40 +615,108 @@ def persist_snapshot( raise JobAnalysisIdempotencyConflict( "idempotency key is bound to a different purpose" ) - replayed = self._load_snapshot( - cursor, - tenant_record_id=snapshot.tenant_record_id, - analysis_record_id=stored_analysis_id, - ) - if replayed is None: - raise JobAnalysisIntegrityError("idempotent command lost its snapshot") - return replayed + replayed = self._load_snapshot( + cursor, + tenant_record_id=snapshot.tenant_record_id, + analysis_record_id=stored_analysis_id, + ) + if replayed is None: + raise JobAnalysisIntegrityError("idempotent command lost its snapshot") + replayed_digest = command_digest( + snapshot=replayed, + position_record_id=position_record_id, + criterion_blueprint_id=criterion_blueprint_id, + ) + if replayed_digest != request_digest: + raise JobAnalysisIntegrityError( + "idempotent replay snapshot does not match recorded command digest" + ) + return replayed try: cursor.execute( _JOB_SCOPE_SQL, (snapshot.tenant_record_id, snapshot.job_record_id), ) - if cursor.fetchone() is None: + job_row = cursor.fetchone() + if job_row is None: raise JobAnalysisScopeMissing("job_profile does not exist in the tenant") + (job_projection_value,) = _unpack_fixed_projection( + "job_profile scope", + job_row, + 1, + ) + job_projection_id = _validate_projection_uuid( + "job_profile", + job_projection_value, + row_label="job_profile scope row", + ) + if job_projection_id != snapshot.job_record_id: + raise JobAnalysisIntegrityError( + "job_profile scope row escaped requested target" + ) if position_record_id is not None: cursor.execute( _POSITION_SCOPE_SQL, (snapshot.tenant_record_id, position_record_id), ) position_row = cursor.fetchone() - if position_row is None or position_row[1] != snapshot.job_record_id: + if position_row is None: + raise JobAnalysisScopeMissing("position_record is missing or not bound to the job") + position_projection_value, position_job_value = _unpack_fixed_projection( + "position_record scope", + position_row, + 2, + ) + position_job_id = _validate_projection_uuid( + "position_record.job_profile_id", + position_job_value, + row_label="position_record.job_profile_id scope row", + ) + if position_job_id != snapshot.job_record_id: raise JobAnalysisScopeMissing("position_record is missing or not bound to the job") + position_projection_id = _validate_projection_uuid( + "position_record", + position_projection_value, + row_label="position_record scope row", + ) + if position_projection_id != position_record_id: + raise JobAnalysisIntegrityError( + "position_record scope row escaped requested target" + ) if criterion_blueprint_id is not None: cursor.execute( _CRITERION_SCOPE_SQL, (snapshot.tenant_record_id, criterion_blueprint_id), ) criterion_row = cursor.fetchone() - if criterion_row is None or criterion_row[1] != snapshot.job_record_id: + if criterion_row is None: raise JobAnalysisScopeMissing( "criterion_blueprint is missing or not bound to the job" ) + criterion_projection_value, criterion_job_value = _unpack_fixed_projection( + "criterion_blueprint scope", + criterion_row, + 2, + ) + criterion_job_id = _validate_projection_uuid( + "criterion_blueprint.job_profile_id", + criterion_job_value, + row_label="criterion_blueprint.job_profile_id scope row", + ) + if criterion_job_id != snapshot.job_record_id: + raise JobAnalysisScopeMissing( + "criterion_blueprint is missing or not bound to the job" + ) + criterion_projection_id = _validate_projection_uuid( + "criterion_blueprint", + criterion_projection_value, + row_label="criterion_blueprint scope row", + ) + if criterion_projection_id != criterion_blueprint_id: + raise JobAnalysisIntegrityError( + "criterion_blueprint scope row escaped requested target" + ) cursor.execute( _INSERT_SNAPSHOT_SQL, @@ -338,9 +742,8 @@ def persist_snapshot( except Exception as error: # noqa: BLE001 - DB-API errors are normalized below. if not _is_unique_violation(error): raise - constraint_name = _constraint_name(error) raise JobAnalysisIntegrityError( - f"job-analysis snapshot identity or version already exists ({constraint_name!r})" + "job-analysis snapshot identity or version already exists" ) from error for task in snapshot.tasks: @@ -398,18 +801,17 @@ def persist_snapshot( except Exception as error: # noqa: BLE001 - DB-API errors are normalized below. if not _is_unique_violation(error): raise - constraint_name = _constraint_name(error) raise JobAnalysisIdempotencyConflict( - f"idempotency or command identity was recorded concurrently ({constraint_name!r})" + "idempotency or command identity was recorded concurrently" ) from error cursor.execute( _AUDIT_OUTBOX_SQL, ( snapshot.tenant_record_id, - audit_event.event_id, + audit_evidence.event_id, outbox_delivery_record_id, - audit_event.canonical_json(), - audit_event.content_digest(), + audit_evidence.canonical_json, + audit_evidence.content_digest, "integration_hub", ), ) @@ -422,8 +824,8 @@ def read_snapshot( analysis_record_id: UUID, ) -> JobAnalysisSnapshot | None: """Read one snapshot under forced tenant RLS and reconstruct the kernel document.""" - validate_operational_uuid("tenant_record_id", tenant_record_id) - validate_operational_uuid("analysis_record_id", analysis_record_id) + tenant_record_id = validate_operational_uuid("tenant_record_id", tenant_record_id) + analysis_record_id = validate_operational_uuid("analysis_record_id", analysis_record_id) with self.connection_factory() as connection: with connection.cursor() as cursor: cursor.execute(_READ_ONLY_SQL) @@ -441,91 +843,222 @@ def _load_snapshot( tenant_record_id: UUID, analysis_record_id: UUID, ) -> JobAnalysisSnapshot | None: - """Assemble one kernel snapshot from normalized rows or return None.""" + """Assemble one kernel snapshot from exact-validated durable rows or return None.""" cursor.execute(_READ_SNAPSHOT_SQL, (tenant_record_id, analysis_record_id)) - headers = cursor.fetchmany(2) + headers = _unpack_projection_rows( + "job_analysis_snapshot", + cursor.fetchmany(2), + ) if not headers: return None if len(headers) != 1: raise JobAnalysisIntegrityError("multiple snapshot headers match the requested target") - header = headers[0] - if header[0] != tenant_record_id or header[1] != analysis_record_id: + header = _unpack_fixed_projection("job_analysis_snapshot", headers[0], 19) + header_tenant_id = _validate_projection_uuid( + "job_analysis_snapshot.tenant_record_id", + header[0], + row_label="job_analysis_snapshot.tenant_record_id row", + ) + header_analysis_id = _validate_projection_uuid( + "job_analysis_snapshot.analysis_record_id", + header[1], + row_label="job_analysis_snapshot.analysis_record_id row", + ) + if header_tenant_id != tenant_record_id or header_analysis_id != analysis_record_id: raise JobAnalysisIntegrityError("database row escaped requested target") + header_job_id = _validate_projection_uuid( + "job_analysis_snapshot.job_profile_id", + header[2], + row_label="job_analysis_snapshot.job_profile_id row", + ) + header_analysis_version_code = _validate_projection_text( + "job_analysis_snapshot.analysis_version_code", + header[3], + ) + header_status_code = _validate_projection_text( + "job_analysis_snapshot.status_code", + header[4], + ) + header_effective_from = _validate_projection_date( + "job_analysis_snapshot.effective_from", + header[5], + ) + header_recorded_at = _validate_projection_datetime( + "job_analysis_snapshot.recorded_at", + header[6], + ) + header_reviewed_by_reference = _validate_projection_optional_text( + "job_analysis_snapshot.reviewed_by_reference", + header[7], + ) + header_reviewed_at = _validate_projection_optional_datetime( + "job_analysis_snapshot.reviewed_at", + header[8], + ) + header_content_digest = _validate_projection_digest( + "job_analysis_snapshot.content_digest_sha256", + header[9], + ) + fja_data_function_code = _validate_projection_integer( + "job_analysis_snapshot.data_function_code", + header[10], + ) + fja_people_function_code = _validate_projection_integer( + "job_analysis_snapshot.people_function_code", + header[11], + ) + fja_things_function_code = _validate_projection_integer( + "job_analysis_snapshot.things_function_code", + header[12], + ) + fja_source = _source_from_row( + header[13:19], + row_label="job_analysis_snapshot.fja_source", + ) + cursor.execute(_READ_TASKS_SQL, (tenant_record_id, analysis_record_id)) - task_rows = cursor.fetchall() + task_rows = tuple( + _unpack_fixed_projection("job_analysis_task_item", row, 10) + for row in _unpack_projection_rows( + "job_analysis_task_item", + cursor.fetchall(), + ) + ) cursor.execute(_READ_KSAOS_SQL, (tenant_record_id, analysis_record_id)) - ksao_rows = cursor.fetchall() + ksao_rows = tuple( + _unpack_fixed_projection("job_analysis_ksao_item", row, 11) + for row in _unpack_projection_rows( + "job_analysis_ksao_item", + cursor.fetchall(), + ) + ) cursor.execute(_READ_LINKS_SQL, (tenant_record_id, analysis_record_id)) - link_rows = cursor.fetchall() + link_rows = tuple( + _unpack_fixed_projection("job_analysis_task_ksao_link", row, 4) + for row in _unpack_projection_rows( + "job_analysis_task_ksao_link", + cursor.fetchall(), + ) + ) snapshot = JobAnalysisSnapshot( - analysis_record_id=header[1], - tenant_record_id=header[0], - job_record_id=header[2], - analysis_version_code=header[3], - status_code=header[4], - effective_from=header[5], - recorded_at=header[6], - tasks=tuple(_task_from_row(tenant_record_id, header[2], row) for row in task_rows), - ksao_requirements=tuple(_ksao_from_row(tenant_record_id, header[2], row) for row in ksao_rows), - task_ksao_links=tuple( - TaskKSAOLink( - task_record_id=row[0], - ksao_record_id=row[1], - relationship_strength=row[2], - essential_for_task=row[3], - ) - for row in link_rows + analysis_record_id=header_analysis_id, + tenant_record_id=header_tenant_id, + job_record_id=header_job_id, + analysis_version_code=header_analysis_version_code, + status_code=header_status_code, + effective_from=header_effective_from, + recorded_at=header_recorded_at, + tasks=tuple(_task_from_row(tenant_record_id, header_job_id, row) for row in task_rows), + ksao_requirements=tuple( + _ksao_from_row(tenant_record_id, header_job_id, row) for row in ksao_rows ), + task_ksao_links=tuple(_link_from_row(row) for row in link_rows), fja_profile=FunctionalJobAnalysisProfile( tenant_record_id=tenant_record_id, - job_record_id=header[2], - data_function_code=header[10], - people_function_code=header[11], - things_function_code=header[12], - source=_source_from_row(header[13:19]), + job_record_id=header_job_id, + data_function_code=fja_data_function_code, + people_function_code=fja_people_function_code, + things_function_code=fja_things_function_code, + source=fja_source, ), - reviewed_by_reference=header[7], - reviewed_at=header[8], + reviewed_by_reference=header_reviewed_by_reference, + reviewed_at=header_reviewed_at, ) - if snapshot.content_digest() != header[9]: + if snapshot.content_digest() != header_content_digest: raise JobAnalysisIntegrityError("stored snapshot digest does not match reconstructed evidence") return snapshot -def _source_from_row(values: tuple[object, ...]) -> EvidenceSource: - """Rebuild one evidence source from six persisted provenance columns.""" +def _source_from_row(values: tuple[object, ...], *, row_label: str) -> EvidenceSource: + """Rebuild one evidence source only from exact-validated persisted scalars.""" return EvidenceSource( - source_uri=values[0], - source_title=values[1], - source_version_code=values[2], - retrieved_at=values[3], - content_digest_sha256=values[4], - origin_code=values[5], + source_uri=_validate_projection_text(f"{row_label}.source_uri", values[0]), + source_title=_validate_projection_text(f"{row_label}.source_title", values[1]), + source_version_code=_validate_projection_text( + f"{row_label}.source_version_code", + values[2], + ), + retrieved_at=_validate_projection_datetime(f"{row_label}.retrieved_at", values[3]), + content_digest_sha256=_validate_projection_digest( + f"{row_label}.content_digest_sha256", + values[4], + ), + origin_code=_validate_projection_text(f"{row_label}.origin_code", values[5]), ) def _task_from_row(tenant_record_id: UUID, job_record_id: UUID, row: tuple[object, ...]) -> TaskEvidence: - """Rebuild one task item from its persisted 3NF row.""" + """Rebuild one task item only after exact-validating its persisted scalars.""" return TaskEvidence( tenant_record_id=tenant_record_id, job_record_id=job_record_id, - task_record_id=row[0], - task_statement=row[1], - importance_level=row[2], - difficulty_level=row[3], - source=_source_from_row(row[4:10]), + task_record_id=_validate_projection_uuid( + "job_analysis_task_item.task_record_id", + row[0], + row_label="job_analysis_task_item.task_record_id row", + ), + task_statement=_validate_projection_text( + "job_analysis_task_item.task_statement", + row[1], + ), + importance_level=_validate_projection_integer( + "job_analysis_task_item.importance_level", + row[2], + ), + difficulty_level=_validate_projection_integer( + "job_analysis_task_item.difficulty_level", + row[3], + ), + source=_source_from_row(row[4:10], row_label="job_analysis_task_item.source"), ) def _ksao_from_row(tenant_record_id: UUID, job_record_id: UUID, row: tuple[object, ...]) -> KSAORequirement: - """Rebuild one KSAO item from its persisted 3NF row.""" + """Rebuild one KSAO item only after exact-validating its persisted scalars.""" return KSAORequirement( tenant_record_id=tenant_record_id, job_record_id=job_record_id, - ksao_record_id=row[0], - category_code=row[1], - requirement_statement=row[2], - importance_level=row[3], - proficiency_level=row[4], - source=_source_from_row(row[5:11]), + ksao_record_id=_validate_projection_uuid( + "job_analysis_ksao_item.ksao_record_id", + row[0], + row_label="job_analysis_ksao_item.ksao_record_id row", + ), + category_code=_validate_projection_text("job_analysis_ksao_item.category_code", row[1]), + requirement_statement=_validate_projection_text( + "job_analysis_ksao_item.requirement_statement", + row[2], + ), + importance_level=_validate_projection_integer( + "job_analysis_ksao_item.importance_level", + row[3], + ), + proficiency_level=_validate_projection_integer( + "job_analysis_ksao_item.proficiency_level", + row[4], + ), + source=_source_from_row(row[5:11], row_label="job_analysis_ksao_item.source"), + ) + + +def _link_from_row(row: tuple[object, ...]) -> TaskKSAOLink: + """Rebuild one task-KSAO link only from exact-validated persisted scalars.""" + return TaskKSAOLink( + task_record_id=_validate_projection_uuid( + "job_analysis_task_ksao_link.task_record_id", + row[0], + row_label="job_analysis_task_ksao_link.task_record_id row", + ), + ksao_record_id=_validate_projection_uuid( + "job_analysis_task_ksao_link.ksao_record_id", + row[1], + row_label="job_analysis_task_ksao_link.ksao_record_id row", + ), + relationship_strength=_validate_projection_integer( + "job_analysis_task_ksao_link.relationship_strength", + row[2], + ), + essential_for_task=_validate_projection_boolean( + "job_analysis_task_ksao_link.essential_for_task", + row[3], + ), ) diff --git a/services/job-analysis-api/src/orgmetra_job_analysis_api/snapshot.py b/services/job-analysis-api/src/orgmetra_job_analysis_api/snapshot.py index 28fcbe62a..bcb602577 100644 --- a/services/job-analysis-api/src/orgmetra_job_analysis_api/snapshot.py +++ b/services/job-analysis-api/src/orgmetra_job_analysis_api/snapshot.py @@ -14,6 +14,7 @@ import json from typing import Protocol, runtime_checkable from uuid import UUID, uuid4 +from zoneinfo import ZoneInfo from orgmetra_hris_kernel import ( AuditOutboxEvent, @@ -122,23 +123,49 @@ def _reject_unknown_fields( allowed_fields: frozenset[str], ) -> None: """Reject object members that the published evidence contract does not own.""" - unknown_fields = sorted(str(key) for key in value if key not in allowed_fields) + if any(type(key) is not str for key in value): + raise ValueError(f"{boundary_name} field names must be exact built-in text.") + unknown_fields = sorted(key for key in value if key not in allowed_fields) if unknown_fields: raise ValueError( f"{boundary_name} contains unsupported fields: {', '.join(unknown_fields)}." ) +def _exact_text(field_name: str, value: object) -> str: + """Return inert caller text before domain validators may normalize or match it.""" + if type(value) is not str: + raise ValueError(f"{field_name} must be exact built-in text.") + return value + + +def _exact_integer(field_name: str, value: object) -> int: + """Return an inert integer before domain validators perform ordinal comparisons.""" + if type(value) is not int: + raise ValueError(f"{field_name} must be an exact built-in integer.") + return value + + +def _exact_boolean(field_name: str, value: object) -> bool: + """Return an exact boolean for a trust-bearing posted flag.""" + if type(value) is not bool: + raise ValueError(f"{field_name} must be an exact built-in boolean.") + return value + + def validate_operational_uuid(field_name: str, value: object) -> UUID: - """Require a UUID that is not one of Orgmetra's reserved protocol sentinels.""" - if not isinstance(value, UUID) or value.int in (0, _MAX_UUID_INT): + """Return a detached exact UUID after validating operational identity evidence.""" + if type(value) is not UUID: raise ValueError(f"{field_name} must be an operational UUID.") - return value + value_int = value.int + if type(value_int) is not int or not 0 < value_int < _MAX_UUID_INT: + raise ValueError(f"{field_name} must be an operational UUID.") + return UUID(int=value_int) def _validate_idempotency_key(value: object) -> str: """Require the exact caller Idempotency-Key that must reach the write port.""" - if not isinstance(value, str) or not (_IDEMPOTENCY_MIN <= len(value) <= _IDEMPOTENCY_MAX): + if type(value) is not str or not (_IDEMPOTENCY_MIN <= len(value) <= _IDEMPOTENCY_MAX): raise ValueError("idempotency_key must be 16 to 200 characters.") if any(ord(character) < 0x21 or ord(character) > 0x7E for character in value): raise ValueError("idempotency_key must be printable ASCII.") @@ -147,9 +174,9 @@ def _validate_idempotency_key(value: object) -> str: def _parse_uuid(field_name: str, value: object) -> UUID: """Parse one posted UUID string or reject a non-operational identity.""" - if isinstance(value, UUID): + if type(value) is UUID: return validate_operational_uuid(field_name, value) - if not isinstance(value, str): + if type(value) is not str: raise ValueError(f"{field_name} must be a UUID string.") try: parsed = UUID(value) @@ -160,11 +187,15 @@ def _parse_uuid(field_name: str, value: object) -> UUID: def _parse_aware_datetime(field_name: str, value: object) -> datetime: """Parse one posted UTC instant used as evidence time.""" - if isinstance(value, datetime): - if value.tzinfo is None or value.utcoffset() is None: + if type(value) is datetime: + if value.tzinfo is None: + raise ValueError(f"{field_name} must be timezone-aware.") + if type(value.tzinfo) is not timezone: + raise ValueError(f"{field_name} must use a fixed UTC offset.") + if value.utcoffset() is None: raise ValueError(f"{field_name} must be timezone-aware.") return value - if not isinstance(value, str): + if type(value) is not str: raise ValueError(f"{field_name} must be an ISO-8601 datetime.") normalized = value.replace("Z", "+00:00") try: @@ -178,11 +209,11 @@ def _parse_aware_datetime(field_name: str, value: object) -> datetime: def _parse_business_date(field_name: str, value: object) -> date: """Parse one posted business date without accepting a datetime.""" - if isinstance(value, datetime): + if type(value) is datetime: raise ValueError(f"{field_name} must be a date.") - if isinstance(value, date): + if type(value) is date: return value - if not isinstance(value, str): + if type(value) is not str: raise ValueError(f"{field_name} must be an ISO business date.") try: return date.fromisoformat(value) @@ -192,16 +223,22 @@ def _parse_business_date(field_name: str, value: object) -> date: def _parse_source(value: object) -> EvidenceSource: """Rebuild one evidence source from posted provenance fields.""" - if not isinstance(value, dict): + if type(value) is not dict: raise ValueError("source must be an object.") _reject_unknown_fields("source", value, _SOURCE_FIELDS) return EvidenceSource( - source_uri=value.get("source_uri"), - source_title=value.get("source_title"), - source_version_code=value.get("source_version_code"), + source_uri=_exact_text("source_uri", value.get("source_uri")), + source_title=_exact_text("source_title", value.get("source_title")), + source_version_code=_exact_text( + "source_version_code", + value.get("source_version_code"), + ), retrieved_at=_parse_aware_datetime("retrieved_at", value.get("retrieved_at")), - content_digest_sha256=value.get("content_digest_sha256"), - origin_code=value.get("origin_code"), + content_digest_sha256=_exact_text( + "content_digest_sha256", + value.get("content_digest_sha256"), + ), + origin_code=_exact_text("origin_code", value.get("origin_code")), ) @@ -215,7 +252,7 @@ def snapshot_from_document( The posted tenant must match the authorized route tenant. Kernel constructors then enforce linkage completeness, provenance, and review governance. """ - if not isinstance(document, dict): + if type(document) is not dict: raise ValueError("snapshot document must be an object.") _reject_unknown_fields("snapshot document", document, _SNAPSHOT_FIELDS) posted_tenant = _parse_uuid("tenant_record_id", document.get("tenant_record_id")) @@ -226,26 +263,26 @@ def snapshot_from_document( raw_ksaos = document.get("ksao_requirements") raw_links = document.get("task_ksao_links") raw_fja = document.get("fja_profile") - if not isinstance(raw_tasks, list) or not raw_tasks: + if type(raw_tasks) is not list or not raw_tasks: raise ValueError("tasks must be a non-empty list.") if len(raw_tasks) > _MAX_TASKS: raise ValueError(f"tasks must contain at most {_MAX_TASKS} items.") - if not isinstance(raw_ksaos, list) or not raw_ksaos: + if type(raw_ksaos) is not list or not raw_ksaos: raise ValueError("ksao_requirements must be a non-empty list.") if len(raw_ksaos) > _MAX_KSAOS: raise ValueError(f"ksao_requirements must contain at most {_MAX_KSAOS} items.") - if not isinstance(raw_links, list) or not raw_links: + if type(raw_links) is not list or not raw_links: raise ValueError("task_ksao_links must be a non-empty list.") if len(raw_links) > _MAX_TASK_KSAO_LINKS: raise ValueError( f"task_ksao_links must contain at most {_MAX_TASK_KSAO_LINKS} items." ) - if not isinstance(raw_fja, dict): + if type(raw_fja) is not dict: raise ValueError("fja_profile must be an object.") _reject_unknown_fields("fja_profile", raw_fja, _FJA_FIELDS) tasks = [] for item in raw_tasks: - if not isinstance(item, dict): + if type(item) is not dict: raise ValueError("tasks must contain objects.") _reject_unknown_fields("task", item, _TASK_FIELDS) tasks.append( @@ -253,15 +290,21 @@ def snapshot_from_document( tenant_record_id=tenant_record_id, job_record_id=job_record_id, task_record_id=_parse_uuid("task_record_id", item.get("task_record_id")), - task_statement=item.get("task_statement"), - importance_level=item.get("importance_level"), - difficulty_level=item.get("difficulty_level"), + task_statement=_exact_text("task_statement", item.get("task_statement")), + importance_level=_exact_integer( + "importance_level", + item.get("importance_level"), + ), + difficulty_level=_exact_integer( + "difficulty_level", + item.get("difficulty_level"), + ), source=_parse_source(item.get("source")), ) ) ksaos = [] for item in raw_ksaos: - if not isinstance(item, dict): + if type(item) is not dict: raise ValueError("ksao_requirements must contain objects.") _reject_unknown_fields("ksao_requirement", item, _KSAO_FIELDS) ksaos.append( @@ -269,24 +312,39 @@ def snapshot_from_document( tenant_record_id=tenant_record_id, job_record_id=job_record_id, ksao_record_id=_parse_uuid("ksao_record_id", item.get("ksao_record_id")), - category_code=item.get("category_code"), - requirement_statement=item.get("requirement_statement"), - importance_level=item.get("importance_level"), - proficiency_level=item.get("proficiency_level"), + category_code=_exact_text("category_code", item.get("category_code")), + requirement_statement=_exact_text( + "requirement_statement", + item.get("requirement_statement"), + ), + importance_level=_exact_integer( + "importance_level", + item.get("importance_level"), + ), + proficiency_level=_exact_integer( + "proficiency_level", + item.get("proficiency_level"), + ), source=_parse_source(item.get("source")), ) ) links = [] for item in raw_links: - if not isinstance(item, dict): + if type(item) is not dict: raise ValueError("task_ksao_links must contain objects.") _reject_unknown_fields("task_ksao_link", item, _TASK_KSAO_LINK_FIELDS) links.append( TaskKSAOLink( task_record_id=_parse_uuid("task_record_id", item.get("task_record_id")), ksao_record_id=_parse_uuid("ksao_record_id", item.get("ksao_record_id")), - relationship_strength=item.get("relationship_strength"), - essential_for_task=item.get("essential_for_task"), + relationship_strength=_exact_integer( + "relationship_strength", + item.get("relationship_strength"), + ), + essential_for_task=_exact_boolean( + "essential_for_task", + item.get("essential_for_task"), + ), ) ) reviewed_by_reference = document.get("reviewed_by_reference") @@ -295,8 +353,11 @@ def snapshot_from_document( analysis_record_id=_parse_uuid("analysis_record_id", document.get("analysis_record_id")), tenant_record_id=tenant_record_id, job_record_id=job_record_id, - analysis_version_code=document.get("analysis_version_code"), - status_code=document.get("status_code"), + analysis_version_code=_exact_text( + "analysis_version_code", + document.get("analysis_version_code"), + ), + status_code=_exact_text("status_code", document.get("status_code")), effective_from=_parse_business_date("effective_from", document.get("effective_from")), recorded_at=_parse_aware_datetime("recorded_at", document.get("recorded_at")), tasks=tuple(tasks), @@ -305,12 +366,25 @@ def snapshot_from_document( fja_profile=FunctionalJobAnalysisProfile( tenant_record_id=tenant_record_id, job_record_id=job_record_id, - data_function_code=raw_fja.get("data_function_code"), - people_function_code=raw_fja.get("people_function_code"), - things_function_code=raw_fja.get("things_function_code"), + data_function_code=_exact_integer( + "data_function_code", + raw_fja.get("data_function_code"), + ), + people_function_code=_exact_integer( + "people_function_code", + raw_fja.get("people_function_code"), + ), + things_function_code=_exact_integer( + "things_function_code", + raw_fja.get("things_function_code"), + ), source=_parse_source(raw_fja.get("source")), ), - reviewed_by_reference=reviewed_by_reference, + reviewed_by_reference=( + None + if reviewed_by_reference is None + else _exact_text("reviewed_by_reference", reviewed_by_reference) + ), reviewed_at=None if reviewed_at is None else _parse_aware_datetime("reviewed_at", reviewed_at), ) @@ -322,6 +396,8 @@ def command_digest( criterion_blueprint_id: UUID | None, ) -> str: """Return SHA-256 over the exact snapshot bytes plus optional scope identities.""" + if type(snapshot) is not JobAnalysisSnapshot: + raise TypeError("snapshot must be an exact JobAnalysisSnapshot") payload = { "criterion_blueprint_id": None if criterion_blueprint_id is None else str(criterion_blueprint_id), "position_record_id": None if position_record_id is None else str(position_record_id), @@ -338,6 +414,334 @@ def _optional_scope_id(field_name: str, value: object) -> UUID | None: return _parse_uuid(field_name, value) +def _resolved_exact(field_name: str, value: object, expected_type: type[object]) -> object: + """Require one inert exact runtime value returned by a snapshot read adapter.""" + if type(value) is not expected_type: + raise JobAnalysisIntegrityError( + f"resolved snapshot graph has invalid runtime evidence at {field_name}" + ) + return value + + +def _resolved_uuid(field_name: str, value: object) -> UUID: + """Require one operational exact UUID before snapshot export can stringify it.""" + try: + return validate_operational_uuid(field_name, value) + except ValueError as error: + raise JobAnalysisIntegrityError( + f"resolved snapshot graph has invalid runtime evidence at {field_name}" + ) from error + + +def _resolved_datetime(field_name: str, value: object) -> datetime: + """Require an exact standard-library datetime before canonicalization can use it.""" + resolved = _resolved_exact(field_name, value, datetime) + assert type(resolved) is datetime + if type(resolved.tzinfo) not in (timezone, ZoneInfo): + raise JobAnalysisIntegrityError( + f"resolved snapshot graph has invalid runtime evidence at {field_name}" + ) + return resolved + + +def _resolved_datetime_text(field_name: str, value: object) -> str: + """Capture one inert returned instant as the kernel's canonical UTC text.""" + resolved = _resolved_datetime(field_name, value) + return resolved.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _validate_resolved_source_runtime( + field_name: str, + value: object, +) -> dict[str, object]: + """Capture one returned provenance object into validated inert evidence.""" + source = _resolved_exact(field_name, value, EvidenceSource) + assert type(source) is EvidenceSource + source_uri = _resolved_exact(f"{field_name}.source_uri", source.source_uri, str) + source_title = _resolved_exact(f"{field_name}.source_title", source.source_title, str) + source_version_code = _resolved_exact( + f"{field_name}.source_version_code", + source.source_version_code, + str, + ) + retrieved_at = _resolved_datetime_text( + f"{field_name}.retrieved_at", + source.retrieved_at, + ) + content_digest_sha256 = _resolved_exact( + f"{field_name}.content_digest_sha256", + source.content_digest_sha256, + str, + ) + origin_code = _resolved_exact(f"{field_name}.origin_code", source.origin_code, str) + return { + "source_uri": source_uri, + "source_title": source_title, + "source_version_code": source_version_code, + "retrieved_at": retrieved_at, + "content_digest_sha256": content_digest_sha256, + "origin_code": origin_code, + } + + +def _validate_resolved_owner_runtime( + field_name: str, + *, + tenant_record_id: object, + job_record_id: object, + expected_tenant_record_id: UUID, + expected_job_record_id: UUID, +) -> None: + """Require returned nested evidence to retain the detached root ownership pair.""" + resolved_tenant_record_id = _resolved_uuid( + f"{field_name}.tenant_record_id", + tenant_record_id, + ) + resolved_job_record_id = _resolved_uuid( + f"{field_name}.job_record_id", + job_record_id, + ) + if ( + resolved_tenant_record_id != expected_tenant_record_id + or resolved_job_record_id != expected_job_record_id + ): + raise JobAnalysisIntegrityError( + f"resolved snapshot graph ownership mismatch at {field_name}" + ) + + +def _validate_resolved_snapshot_graph_runtime( + snapshot: JobAnalysisSnapshot, +) -> tuple[UUID, UUID, dict[str, object]]: + """Capture target/root ownership and validated evidence into the document to emit.""" + tenant_record_id = _resolved_uuid("tenant_record_id", snapshot.tenant_record_id) + analysis_record_id = _resolved_uuid("analysis_record_id", snapshot.analysis_record_id) + job_record_id = _resolved_uuid("job_record_id", snapshot.job_record_id) + analysis_version_code = _resolved_exact( + "analysis_version_code", + snapshot.analysis_version_code, + str, + ) + status_code = _resolved_exact("status_code", snapshot.status_code, str) + effective_from = _resolved_exact("effective_from", snapshot.effective_from, date) + assert type(effective_from) is date + recorded_at = _resolved_datetime_text("recorded_at", snapshot.recorded_at) + + tasks = _resolved_exact("tasks", snapshot.tasks, tuple) + ksaos = _resolved_exact("ksao_requirements", snapshot.ksao_requirements, tuple) + links = _resolved_exact("task_ksao_links", snapshot.task_ksao_links, tuple) + assert type(tasks) is tuple + assert type(ksaos) is tuple + assert type(links) is tuple + + task_entries: list[tuple[str, dict[str, object]]] = [] + for index, task_value in enumerate(tasks): + task = _resolved_exact(f"tasks[{index}]", task_value, TaskEvidence) + assert type(task) is TaskEvidence + _validate_resolved_owner_runtime( + f"tasks[{index}]", + tenant_record_id=task.tenant_record_id, + job_record_id=task.job_record_id, + expected_tenant_record_id=tenant_record_id, + expected_job_record_id=job_record_id, + ) + task_record_id = _resolved_uuid( + f"tasks[{index}].task_record_id", + task.task_record_id, + ) + task_statement = _resolved_exact( + f"tasks[{index}].task_statement", + task.task_statement, + str, + ) + importance_level = _resolved_exact( + f"tasks[{index}].importance_level", + task.importance_level, + int, + ) + difficulty_level = _resolved_exact( + f"tasks[{index}].difficulty_level", + task.difficulty_level, + int, + ) + task_record_id_text = str(task_record_id) + task_entries.append( + ( + task_record_id_text, + { + "task_record_id": task_record_id_text, + "task_statement": task_statement, + "importance_level": importance_level, + "difficulty_level": difficulty_level, + "source": _validate_resolved_source_runtime( + f"tasks[{index}].source", + task.source, + ), + }, + ) + ) + + ksao_entries: list[tuple[str, dict[str, object]]] = [] + for index, ksao_value in enumerate(ksaos): + ksao = _resolved_exact( + f"ksao_requirements[{index}]", + ksao_value, + KSAORequirement, + ) + assert type(ksao) is KSAORequirement + _validate_resolved_owner_runtime( + f"ksao_requirements[{index}]", + tenant_record_id=ksao.tenant_record_id, + job_record_id=ksao.job_record_id, + expected_tenant_record_id=tenant_record_id, + expected_job_record_id=job_record_id, + ) + ksao_record_id = _resolved_uuid( + f"ksao_requirements[{index}].ksao_record_id", + ksao.ksao_record_id, + ) + category_code = _resolved_exact( + f"ksao_requirements[{index}].category_code", + ksao.category_code, + str, + ) + requirement_statement = _resolved_exact( + f"ksao_requirements[{index}].requirement_statement", + ksao.requirement_statement, + str, + ) + importance_level = _resolved_exact( + f"ksao_requirements[{index}].importance_level", + ksao.importance_level, + int, + ) + proficiency_level = _resolved_exact( + f"ksao_requirements[{index}].proficiency_level", + ksao.proficiency_level, + int, + ) + ksao_record_id_text = str(ksao_record_id) + ksao_entries.append( + ( + ksao_record_id_text, + { + "ksao_record_id": ksao_record_id_text, + "category_code": category_code, + "requirement_statement": requirement_statement, + "importance_level": importance_level, + "proficiency_level": proficiency_level, + "source": _validate_resolved_source_runtime( + f"ksao_requirements[{index}].source", + ksao.source, + ), + }, + ) + ) + + link_entries: list[tuple[tuple[str, str], dict[str, object]]] = [] + for index, link_value in enumerate(links): + link = _resolved_exact( + f"task_ksao_links[{index}]", + link_value, + TaskKSAOLink, + ) + assert type(link) is TaskKSAOLink + task_record_id = _resolved_uuid( + f"task_ksao_links[{index}].task_record_id", + link.task_record_id, + ) + ksao_record_id = _resolved_uuid( + f"task_ksao_links[{index}].ksao_record_id", + link.ksao_record_id, + ) + relationship_strength = _resolved_exact( + f"task_ksao_links[{index}].relationship_strength", + link.relationship_strength, + int, + ) + essential_for_task = _resolved_exact( + f"task_ksao_links[{index}].essential_for_task", + link.essential_for_task, + bool, + ) + task_record_id_text = str(task_record_id) + ksao_record_id_text = str(ksao_record_id) + link_entries.append( + ( + (task_record_id_text, ksao_record_id_text), + { + "task_record_id": task_record_id_text, + "ksao_record_id": ksao_record_id_text, + "relationship_strength": relationship_strength, + "essential_for_task": essential_for_task, + }, + ) + ) + + fja = _resolved_exact("fja_profile", snapshot.fja_profile, FunctionalJobAnalysisProfile) + assert type(fja) is FunctionalJobAnalysisProfile + _validate_resolved_owner_runtime( + "fja_profile", + tenant_record_id=fja.tenant_record_id, + job_record_id=fja.job_record_id, + expected_tenant_record_id=tenant_record_id, + expected_job_record_id=job_record_id, + ) + data_function_code = _resolved_exact( + "fja_profile.data_function_code", + fja.data_function_code, + int, + ) + people_function_code = _resolved_exact( + "fja_profile.people_function_code", + fja.people_function_code, + int, + ) + things_function_code = _resolved_exact( + "fja_profile.things_function_code", + fja.things_function_code, + int, + ) + fja_source = _validate_resolved_source_runtime("fja_profile.source", fja.source) + + reviewed_by_reference = snapshot.reviewed_by_reference + reviewed_at = snapshot.reviewed_at + document: dict[str, object] = { + "job_record_id": str(job_record_id), + "analysis_version_code": analysis_version_code, + "status_code": status_code, + "effective_from": effective_from.isoformat(), + "recorded_at": recorded_at, + "tasks": [ + item_document + for _, item_document in sorted(task_entries, key=lambda item: item[0]) + ], + "ksao_requirements": [ + item_document + for _, item_document in sorted(ksao_entries, key=lambda item: item[0]) + ], + "task_ksao_links": [ + item_document + for _, item_document in sorted(link_entries, key=lambda item: item[0]) + ], + "fja_profile": { + "data_function_code": data_function_code, + "people_function_code": people_function_code, + "things_function_code": things_function_code, + "source": fja_source, + }, + } + if reviewed_by_reference is not None: + document["reviewed_by_reference"] = _resolved_exact( + "reviewed_by_reference", + reviewed_by_reference, + str, + ) + if reviewed_at is not None: + document["reviewed_at"] = _resolved_datetime_text("reviewed_at", reviewed_at) + return tenant_record_id, analysis_record_id, document + + @runtime_checkable class JobAnalysisWritePort(Protocol): """Persist one authorized snapshot and its Idempotency-Key in one transaction.""" @@ -429,6 +833,11 @@ def persist_job_analysis_snapshot( occurred_at=datetime.now(timezone.utc), high_impact=False, ) + authorized_snapshot = snapshot.to_snapshot() + authorized_analysis_record_id = validate_operational_uuid( + "authorized snapshot analysis_record_id", + snapshot.analysis_record_id, + ) persisted = write_port.persist_snapshot( snapshot=snapshot, idempotency_key=key, @@ -445,11 +854,67 @@ def persist_job_analysis_snapshot( outbox_delivery_record_id=uuid4(), write_command_id=uuid4(), ) - if persisted.to_snapshot() != snapshot.to_snapshot(): + if type(persisted) is not JobAnalysisSnapshot: + raise JobAnalysisIntegrityError("persisted snapshot has an invalid runtime type") + try: + persisted_tenant_record_id = validate_operational_uuid( + "persisted snapshot tenant_record_id", + persisted.tenant_record_id, + ) + persisted_analysis_record_id = validate_operational_uuid( + "persisted snapshot analysis_record_id", + persisted.analysis_record_id, + ) + except ValueError as error: + raise JobAnalysisIntegrityError( + "persisted snapshot graph has invalid runtime evidence" + ) from error + if ( + persisted_tenant_record_id != tenant_record_id + or persisted_analysis_record_id != authorized_analysis_record_id + ): + raise JobAnalysisIntegrityError("persisted snapshot escaped posted payload") + try: + ( + captured_tenant_record_id, + captured_analysis_record_id, + persisted_graph, + ) = _validate_resolved_snapshot_graph_runtime(persisted) + if ( + captured_tenant_record_id != persisted_tenant_record_id + or captured_analysis_record_id != persisted_analysis_record_id + ): + raise JobAnalysisIntegrityError( + "persisted snapshot target changed during graph capture" + ) + except JobAnalysisIntegrityError as error: + raise JobAnalysisIntegrityError( + "persisted snapshot graph has invalid runtime evidence" + ) from error + persisted_document = { + "analysis_record_id": str(persisted_analysis_record_id), + "tenant_record_id": str(persisted_tenant_record_id), + **persisted_graph, + } + try: + governed_persisted = snapshot_from_document( + persisted_document, + tenant_record_id=tenant_record_id, + ) + except (TypeError, ValueError) as error: + raise JobAnalysisIntegrityError( + "persisted snapshot graph violates governed evidence contract" + ) from error + governed_document = governed_persisted.to_snapshot() + if governed_document != persisted_document: + raise JobAnalysisIntegrityError( + "persisted snapshot graph changes under governed reconstruction" + ) + if governed_document != authorized_snapshot: raise JobAnalysisIntegrityError("persisted snapshot escaped posted payload") return PersistedJobAnalysisView( resource_reference=decision.resource_reference, - snapshot=persisted.to_snapshot(), + snapshot=authorized_snapshot, ) @@ -483,12 +948,55 @@ def read_job_analysis_snapshot( ) if snapshot is None: raise JobAnalysisSnapshotNotFound("job-analysis snapshot is unavailable") + if type(snapshot) is not JobAnalysisSnapshot: + raise JobAnalysisIntegrityError("resolved snapshot has an invalid runtime type") + try: + resolved_tenant_record_id = validate_operational_uuid( + "resolved snapshot tenant_record_id", + snapshot.tenant_record_id, + ) + resolved_analysis_record_id = validate_operational_uuid( + "resolved snapshot analysis_record_id", + snapshot.analysis_record_id, + ) + except ValueError as error: + raise JobAnalysisIntegrityError("resolved snapshot target identity is invalid") from error if ( - snapshot.tenant_record_id != tenant_record_id - or snapshot.analysis_record_id != analysis_record_id + resolved_tenant_record_id != tenant_record_id + or resolved_analysis_record_id != analysis_record_id ): raise JobAnalysisIntegrityError("resolved snapshot does not match authorized target") + + ( + captured_tenant_record_id, + captured_analysis_record_id, + resolved_graph, + ) = _validate_resolved_snapshot_graph_runtime(snapshot) + if ( + captured_tenant_record_id != resolved_tenant_record_id + or captured_analysis_record_id != resolved_analysis_record_id + ): + raise JobAnalysisIntegrityError("resolved snapshot target changed during graph capture") + resolved_document = { + "analysis_record_id": str(resolved_analysis_record_id), + "tenant_record_id": str(resolved_tenant_record_id), + **resolved_graph, + } + try: + governed_snapshot = snapshot_from_document( + resolved_document, + tenant_record_id=tenant_record_id, + ) + except (TypeError, ValueError) as error: + raise JobAnalysisIntegrityError( + "resolved snapshot graph violates governed evidence contract" + ) from error + governed_document = governed_snapshot.to_snapshot() + if governed_document != resolved_document: + raise JobAnalysisIntegrityError( + "resolved snapshot graph changes under governed reconstruction" + ) return PersistedJobAnalysisView( resource_reference=decision.resource_reference, - snapshot=snapshot.to_snapshot(), + snapshot=governed_document, ) diff --git a/services/job-analysis-api/tests/test_auth.py b/services/job-analysis-api/tests/test_auth.py index fa5923fec..57eca0714 100644 --- a/services/job-analysis-api/tests/test_auth.py +++ b/services/job-analysis-api/tests/test_auth.py @@ -16,6 +16,13 @@ from fixtures import TENANT, OTHER_TENANT, ANALYSIS, write_policy +class _ExecutableHeader(str): + """Model caller-defined header text that executes during polymorphic parsing.""" + + def split(self, *args: object, **kwargs: object) -> list[str]: + raise AssertionError("executable header split must not run") + + class BearerBoundaryTests(unittest.TestCase): """Prove that malformed token syntax never reaches an injected authenticator.""" @@ -32,6 +39,10 @@ def test_rejects_hidden_control_non_ascii_and_unbounded_tokens(self) -> None: with self.subTest(token_length=len(token)), self.assertRaises(AuthenticationFailed): extract_bearer_token(f"Bearer {token}") + def test_rejects_executable_string_subtype_before_parsing(self) -> None: + with self.assertRaisesRegex(AuthenticationFailed, "authorization header"): + extract_bearer_token(_ExecutableHeader("Bearer forged-token")) + class PrincipalBoundaryTests(unittest.TestCase): """Keep authenticated identity/scope facts narrow and immutable.""" diff --git a/services/job-analysis-api/tests/test_authenticated_principal_runtime_types.py b/services/job-analysis-api/tests/test_authenticated_principal_runtime_types.py new file mode 100644 index 000000000..f3034f2cd --- /dev/null +++ b/services/job-analysis-api/tests/test_authenticated_principal_runtime_types.py @@ -0,0 +1,152 @@ +"""Regression contracts for Job Analysis principal runtime-type integrity.""" + +from __future__ import annotations + +import pickle +import unittest +from uuid import UUID + +from orgmetra_job_analysis_api import AuthenticatedPrincipal + +TENANT = UUID("0198a412-6000-7000-8000-000000000001") +OTHER_TENANT = UUID("0198a412-6000-7000-8000-000000000002") +SCOPE = "orgmetra.job_architecture.write" + + +class _UUIDSubtype(UUID): + """Caller-defined UUID subtype that must not cross the authentication boundary.""" + + +class _TextSubtype(str): + """Caller-defined text subtype that must not carry identity or scope evidence.""" + + +class _ScopeSetSubtype(frozenset[str]): + """Caller-defined immutable-set subtype that must not carry scope evidence.""" + + +class AuthenticatedPrincipalRuntimeTypeTests(unittest.TestCase): + """Require exact canonical authentication evidence at principal construction.""" + + def test_rejects_trust_bearing_runtime_subtypes(self) -> None: + cases = ( + { + "tenant_record_id": _UUIDSubtype(TENANT.hex), + "actor_reference": "keyverse:actor-ja-1", + "granted_scope_codes": frozenset({SCOPE}), + }, + { + "tenant_record_id": TENANT, + "actor_reference": _TextSubtype("keyverse:actor-ja-1"), + "granted_scope_codes": frozenset({SCOPE}), + }, + { + "tenant_record_id": TENANT, + "actor_reference": "keyverse:actor-ja-1", + "granted_scope_codes": _ScopeSetSubtype({SCOPE}), + }, + { + "tenant_record_id": TENANT, + "actor_reference": "keyverse:actor-ja-1", + "granted_scope_codes": frozenset({_TextSubtype(SCOPE)}), + }, + ) + for values in cases: + with self.subTest(values=values), self.assertRaises(ValueError): + AuthenticatedPrincipal(**values) + + def test_principal_runtime_class_cannot_be_subclassed(self) -> None: + """Executable principal subclasses cannot override authenticated evidence access.""" + with self.assertRaisesRegex(TypeError, "AuthenticatedPrincipal must not be subclassed"): + type("_PrincipalSubtype", (AuthenticatedPrincipal,), {}) + + def test_tenant_uuid_is_detached_from_caller_owned_instance(self) -> None: + """Post-construction mutation of the caller UUID cannot retarget the principal.""" + tenant_record_id = UUID(TENANT.hex) + principal = AuthenticatedPrincipal( + tenant_record_id=tenant_record_id, + actor_reference="keyverse:actor-ja-1", + granted_scope_codes=frozenset({SCOPE}), + ) + + object.__setattr__(tenant_record_id, "int", OTHER_TENANT.int) + + self.assertEqual(principal.tenant_record_id, TENANT) + self.assertIsNot(principal.tenant_record_id, tenant_record_id) + + def test_returned_tenant_uuid_cannot_retarget_principal(self) -> None: + """Mutating a returned UUID value cannot rewrite stored tenant evidence.""" + principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse:actor-ja-1", + granted_scope_codes=frozenset({SCOPE}), + ) + returned_tenant_record_id = principal.tenant_record_id + + object.__setattr__(returned_tenant_record_id, "int", OTHER_TENANT.int) + + self.assertEqual(principal.tenant_record_id, TENANT) + self.assertIsNot(principal.tenant_record_id, returned_tenant_record_id) + + def test_principal_evidence_cannot_be_rewritten_after_authentication(self) -> None: + """Low-level writes must not replace authenticated evidence on a live principal.""" + principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse:actor-ja-1", + granted_scope_codes=frozenset({SCOPE}), + ) + cases = ( + ("tenant_record_id", OTHER_TENANT), + ("actor_reference", "keyverse:actor-ja-2"), + ("granted_scope_codes", frozenset({"orgmetra.job_architecture.read"})), + ) + + for field_name, replacement in cases: + with self.subTest(field_name=field_name), self.assertRaises((AttributeError, TypeError)): + object.__setattr__(principal, field_name, replacement) + + self.assertEqual(principal.tenant_record_id, TENANT) + self.assertEqual(principal.actor_reference, "keyverse:actor-ja-1") + self.assertEqual(principal.granted_scope_codes, frozenset({SCOPE})) + + def test_structural_storage_preserves_value_object_semantics(self) -> None: + """Structural immutability must not collapse the principal into a raw tuple value.""" + principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse:actor-ja-1", + granted_scope_codes=frozenset({SCOPE}), + ) + equivalent = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse:actor-ja-1", + granted_scope_codes=frozenset({SCOPE}), + ) + raw_tuple = (TENANT, "keyverse:actor-ja-1", frozenset({SCOPE})) + + self.assertEqual(principal, equivalent) + self.assertEqual(hash(principal), hash(equivalent)) + self.assertNotEqual(principal, raw_tuple) + self.assertNotEqual(raw_tuple, principal) + self.assertEqual(pickle.loads(pickle.dumps(principal)), principal) + self.assertEqual( + repr(principal), + "AuthenticatedPrincipal(" + f"tenant_record_id={TENANT!r}, actor_reference='keyverse:actor-ja-1', " + f"granted_scope_codes={frozenset({SCOPE})!r})", + ) + + def test_rejects_corrupted_exact_uuid_state(self) -> None: + """An exact UUID with an invalid internal integer cannot become identity evidence.""" + tenant_record_id = UUID(TENANT.hex) + object.__setattr__(tenant_record_id, "int", "not-an-integer") + + with self.assertRaisesRegex(ValueError, "tenant_record_id must contain a valid UUID integer"): + AuthenticatedPrincipal( + tenant_record_id=tenant_record_id, + actor_reference="keyverse:actor-ja-1", + granted_scope_codes=frozenset({SCOPE}), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/job-analysis-api/tests/test_authenticated_principal_storage_revalidation.py b/services/job-analysis-api/tests/test_authenticated_principal_storage_revalidation.py new file mode 100644 index 000000000..450085f98 --- /dev/null +++ b/services/job-analysis-api/tests/test_authenticated_principal_storage_revalidation.py @@ -0,0 +1,102 @@ +"""Regression contracts for tuple-level principal storage revalidation.""" + +from __future__ import annotations + +import unittest +from uuid import UUID + +from orgmetra_job_analysis_api import AuthenticatedPrincipal + +TENANT = UUID("0198a412-6200-7000-8000-000000000101") +SCOPE = "orgmetra.job_architecture.read" + + +class _TextSubtype(str): + """Caller-controlled text runtime behavior that cannot become identity evidence.""" + + +class AuthenticatedPrincipalStorageRevalidationTests(unittest.TestCase): + """Require every public principal view to revalidate tuple-backed evidence.""" + + def test_tuple_constructor_bypass_cannot_publish_unvalidated_actor_evidence(self) -> None: + forged = tuple.__new__(AuthenticatedPrincipal, (TENANT.int, _TextSubtype("keyverse:actor-ja-1"), frozenset({SCOPE}))) + with self.assertRaisesRegex(ValueError, "stored authentication evidence"): + _ = forged.actor_reference + + def test_tuple_constructor_bypass_cannot_publish_unvalidated_scope_evidence(self) -> None: + forged = tuple.__new__(AuthenticatedPrincipal, (TENANT.int, "keyverse:actor-ja-1", frozenset({_TextSubtype(SCOPE)}))) + with self.assertRaisesRegex(ValueError, "stored authentication evidence"): + _ = forged.granted_scope_codes + + def test_tuple_constructor_bypass_cannot_publish_malformed_storage_shape(self) -> None: + forged = tuple.__new__(AuthenticatedPrincipal, (TENANT.int, "keyverse:actor-ja-1")) + with self.assertRaisesRegex(ValueError, "stored authentication evidence"): + _ = forged.tenant_record_id + + def test_malformed_storage_cannot_participate_in_value_semantics(self) -> None: + forged = tuple.__new__(AuthenticatedPrincipal, (TENANT.int, object(), frozenset({SCOPE}))) + canonical = AuthenticatedPrincipal(TENANT, "keyverse:actor-ja-1", frozenset({SCOPE})) + with self.assertRaisesRegex(ValueError, "stored authentication evidence"): + hash(forged) + with self.assertRaisesRegex(ValueError, "stored authentication evidence"): + _ = forged == canonical + with self.assertRaisesRegex(ValueError, "stored authentication evidence"): + repr(forged) + + def test_malformed_storage_cannot_escape_through_sequence_protocol(self) -> None: + forged = tuple.__new__(AuthenticatedPrincipal, (TENANT.int, _TextSubtype("keyverse:actor-ja-1"), frozenset({SCOPE}))) + for access in (lambda: forged[1], lambda: forged[:], lambda: list(forged), lambda: tuple(forged)): + with self.subTest(access=access), self.assertRaisesRegex(ValueError, "stored authentication evidence"): + access() + + def test_malformed_storage_cannot_escape_through_remaining_tuple_operations(self) -> None: + actor_reference = "keyverse:actor-ja-1" + forged = tuple.__new__(AuthenticatedPrincipal, (TENANT.int, _TextSubtype(actor_reference), frozenset({SCOPE}))) + comparison = (TENANT.int, "keyverse:actor-ja-2", frozenset({SCOPE})) + for access in ( + lambda: len(forged), + lambda: actor_reference in forged, + lambda: forged.count(actor_reference), + lambda: forged.index(actor_reference), + lambda: forged + (), + lambda: () + forged, + lambda: forged * 1, + lambda: 1 * forged, + lambda: forged < comparison, + lambda: forged <= comparison, + lambda: forged > comparison, + lambda: forged >= comparison, + ): + with self.subTest(access=access), self.assertRaisesRegex(ValueError, "stored authentication evidence"): + access() + + def test_valid_tuple_storage_remains_value_compatible_without_claiming_provenance(self) -> None: + structurally_valid = tuple.__new__(AuthenticatedPrincipal, (TENANT.int, "keyverse:actor-ja-1", frozenset({SCOPE}))) + canonical = AuthenticatedPrincipal(TENANT, "keyverse:actor-ja-1", frozenset({SCOPE})) + expected_storage = (TENANT.int, "keyverse:actor-ja-1", frozenset({SCOPE})) + comparison = (TENANT.int, "keyverse:actor-ja-2", frozenset({SCOPE})) + self.assertEqual(structurally_valid.tenant_record_id, TENANT) + self.assertEqual(structurally_valid.actor_reference, "keyverse:actor-ja-1") + self.assertEqual(structurally_valid.granted_scope_codes, frozenset({SCOPE})) + self.assertEqual(structurally_valid[0], TENANT.int) + self.assertEqual(structurally_valid[1:], expected_storage[1:]) + self.assertEqual(list(structurally_valid), list(expected_storage)) + self.assertEqual(tuple(structurally_valid), expected_storage) + self.assertEqual(len(structurally_valid), 3) + self.assertIn("keyverse:actor-ja-1", structurally_valid) + self.assertEqual(structurally_valid.count("keyverse:actor-ja-1"), 1) + self.assertEqual(structurally_valid.index("keyverse:actor-ja-1"), 1) + self.assertEqual(structurally_valid + (), expected_storage) + self.assertEqual(() + structurally_valid, expected_storage) + self.assertEqual(structurally_valid * 1, expected_storage) + self.assertEqual(1 * structurally_valid, expected_storage) + self.assertLess(structurally_valid, comparison) + self.assertLessEqual(structurally_valid, comparison) + self.assertFalse(structurally_valid > comparison) + self.assertFalse(structurally_valid >= comparison) + self.assertEqual(structurally_valid, canonical) + self.assertEqual(hash(structurally_valid), hash(canonical)) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/job-analysis-api/tests/test_bearer_header_budget.py b/services/job-analysis-api/tests/test_bearer_header_budget.py new file mode 100644 index 000000000..778f49435 --- /dev/null +++ b/services/job-analysis-api/tests/test_bearer_header_budget.py @@ -0,0 +1,26 @@ +"""Regression contract for pre-parse Job Analysis bearer-header budgeting.""" + +from __future__ import annotations + +import unittest + +from orgmetra_job_analysis_api.auth import AuthenticationFailed, extract_bearer_token + + +class BearerHeaderBudgetTests(unittest.TestCase): + """Bound exact authorization-header text before scheme parsing allocates work.""" + + def test_rejects_oversized_header_before_scheme_semantics(self) -> None: + oversized_header = "X" * 8200 + + with self.assertRaisesRegex(AuthenticationFailed, "authorization header length"): + extract_bearer_token(oversized_header) + + def test_accepts_maximum_valid_bearer_header(self) -> None: + token = "x" * 8192 + + self.assertEqual(extract_bearer_token(f"Bearer {token}"), token) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/job-analysis-api/tests/test_operational_uuid_runtime_integrity.py b/services/job-analysis-api/tests/test_operational_uuid_runtime_integrity.py new file mode 100644 index 000000000..ca7afecd3 --- /dev/null +++ b/services/job-analysis-api/tests/test_operational_uuid_runtime_integrity.py @@ -0,0 +1,84 @@ +"""Runtime-integrity contracts for operational UUID evidence boundaries.""" + +from __future__ import annotations + +import unittest +from uuid import UUID + +from orgmetra_hris_kernel import JobAnalysisSnapshot + +from orgmetra_job_analysis_api.snapshot import ( + read_job_analysis_snapshot, + validate_operational_uuid, +) +from fixtures import ( + ANALYSIS, + JOB, + TENANT, + clinical_psychologist_snapshot, + read_policy, + read_principal, +) + + +class _SpoofedHexUUID(UUID): + """Expose a reviewed resource hex while retaining another UUID value.""" + + @property + def hex(self) -> str: + """Return the authorized analysis identifier instead of stored identity.""" + return ANALYSIS.hex + + +class _RecordingReadPort: + """Record protected-read calls so validation ordering is observable.""" + + def __init__(self, result: JobAnalysisSnapshot) -> None: + self.result = result + self.calls: list[tuple[UUID, UUID]] = [] + + def read_snapshot( + self, + *, + tenant_record_id: UUID, + analysis_record_id: UUID, + ) -> JobAnalysisSnapshot: + """Return deterministic evidence after recording the requested identity.""" + self.calls.append((tenant_record_id, analysis_record_id)) + return self.result + + +class OperationalUUIDRuntimeIntegrityTests(unittest.TestCase): + """Require one detached exact UUID snapshot before authority or persistence use.""" + + def test_uuid_subclass_cannot_spoof_authorized_resource_before_read_port(self) -> None: + """Reject a UUID subtype whose public hex disagrees with its stored identity.""" + spoofed = _SpoofedHexUUID(str(JOB)) + port = _RecordingReadPort(clinical_psychologist_snapshot()) + + with self.assertRaises(ValueError): + read_job_analysis_snapshot( + principal=read_principal(), + tenant_record_id=TENANT, + analysis_record_id=spoofed, + purpose_code="job_analysis_read", + policy=read_policy(), + read_port=port, + ) + + self.assertEqual(port.calls, []) + + def test_validated_uuid_is_detached_from_caller_owned_alias(self) -> None: + """Mutation of the caller UUID after validation cannot rewrite accepted evidence.""" + caller_owned = UUID(str(ANALYSIS)) + + accepted = validate_operational_uuid("analysis_record_id", caller_owned) + object.__setattr__(caller_owned, "int", JOB.int) + + self.assertIs(type(accepted), UUID) + self.assertIsNot(accepted, caller_owned) + self.assertEqual(accepted, ANALYSIS) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/job-analysis-api/tests/test_persist_input_target_detachment.py b/services/job-analysis-api/tests/test_persist_input_target_detachment.py new file mode 100644 index 000000000..7d55041aa --- /dev/null +++ b/services/job-analysis-api/tests/test_persist_input_target_detachment.py @@ -0,0 +1,61 @@ +"""Regression contract for detaching the posted write target before persistence.""" + +from __future__ import annotations + +from uuid import UUID + +from orgmetra_hris_kernel import JobAnalysisSnapshot +from orgmetra_job_analysis_api.snapshot import persist_job_analysis_snapshot + +from fixtures import ( + ANALYSIS, + IDEMPOTENCY_KEY, + TENANT, + clinical_psychologist_document, + clinical_psychologist_snapshot, + write_policy, + write_principal, +) + + +class _ExecutableUUID(UUID): + """Raise if post-port target equality consults adapter-mutated input evidence.""" + + def __getattribute__(self, name: str) -> object: + """Fail when exact UUID comparison asks the mutated subtype for integer state.""" + if name == "int": + raise AssertionError("post-port input target UUID state executed") + return super().__getattribute__(name) + + +class _MutatingInputWritePort: + """Mutate the supplied input alias but return separate pristine persistence evidence.""" + + def __init__(self, result: JobAnalysisSnapshot) -> None: + self.result = result + + def persist_snapshot(self, **kwargs: object) -> JobAnalysisSnapshot: + """Rewrite only the port-owned input target after all pre-port checks completed.""" + supplied = kwargs["snapshot"] + assert type(supplied) is JobAnalysisSnapshot + object.__setattr__( + supplied, + "analysis_record_id", + _ExecutableUUID(str(ANALYSIS)), + ) + return self.result + + +def test_persist_target_comparison_uses_pre_port_detached_identity() -> None: + """Never reread an input snapshot target after handing its alias to the write port.""" + view = persist_job_analysis_snapshot( + principal=write_principal(), + tenant_record_id=TENANT, + document=clinical_psychologist_document(), + idempotency_key=IDEMPOTENCY_KEY, + purpose_code="job_analysis_write", + policy=write_policy(), + write_port=_MutatingInputWritePort(clinical_psychologist_snapshot()), + ) + + assert view.snapshot["analysis_record_id"] == str(ANALYSIS) diff --git a/services/job-analysis-api/tests/test_persist_snapshot_graph_runtime_integrity.py b/services/job-analysis-api/tests/test_persist_snapshot_graph_runtime_integrity.py new file mode 100644 index 000000000..396ae298a --- /dev/null +++ b/services/job-analysis-api/tests/test_persist_snapshot_graph_runtime_integrity.py @@ -0,0 +1,59 @@ +"""Regression contract for inert Job Analysis write-port result graphs.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +from orgmetra_hris_kernel import JobAnalysisSnapshot +from orgmetra_job_analysis_api.snapshot import ( + JobAnalysisIntegrityError, + persist_job_analysis_snapshot, +) + +from fixtures import ( + IDEMPOTENCY_KEY, + JOB, + TENANT, + clinical_psychologist_document, + clinical_psychologist_snapshot, + write_policy, + write_principal, +) + + +class _ExecutableUUID(UUID): + """Raise if comparison export stringifies unvalidated persistence evidence.""" + + def __str__(self) -> str: + """Fail if the write-port result reaches `to_snapshot()` before validation.""" + raise AssertionError("write-port result UUID stringification executed") + + +class _ReturningWritePort: + """Return one configured exact persistence result without normalizing it.""" + + def __init__(self, result: JobAnalysisSnapshot) -> None: + self.result = result + + def persist_snapshot(self, **_: object) -> JobAnalysisSnapshot: + """Return the configured result exactly as a defective adapter could.""" + return self.result + + +def test_persist_result_graph_is_validated_before_comparison_export() -> None: + """Reject executable nested evidence before serializing a write-port result.""" + persisted = clinical_psychologist_snapshot() + object.__setattr__(persisted, "job_record_id", _ExecutableUUID(str(JOB))) + + with pytest.raises(JobAnalysisIntegrityError, match="persisted snapshot graph"): + persist_job_analysis_snapshot( + principal=write_principal(), + tenant_record_id=TENANT, + document=clinical_psychologist_document(), + idempotency_key=IDEMPOTENCY_KEY, + purpose_code="job_analysis_write", + policy=write_policy(), + write_port=_ReturningWritePort(persisted), + ) diff --git a/services/job-analysis-api/tests/test_postgres.py b/services/job-analysis-api/tests/test_postgres.py index 61c6c8627..fcd0978ba 100644 --- a/services/job-analysis-api/tests/test_postgres.py +++ b/services/job-analysis-api/tests/test_postgres.py @@ -47,6 +47,8 @@ def execute(self, sql: str, parameters: tuple[object, ...] | None = None) -> Non """Record each SQL statement and advance the scripted response.""" self.executions.append((sql, parameters)) self._last = self.script.pop(0) if self.script else None + if self._last is None and "FROM idempotency_lock" in sql: + self._last = (None, None, None, None) def fetchone(self) -> object: """Return the row prepared by the previous execute.""" @@ -296,7 +298,7 @@ def test_idempotent_replay_returns_stored_snapshot_without_new_write(self) -> No digest = command_digest(snapshot=snapshot, position_record_id=None, criterion_blueprint_id=None) script = [ None, - (digest, ANALYSIS), + (digest, ANALYSIS, "keyverse:actor-ja-1", "job_analysis_write"), [_header_row()], _task_rows(), _ksao_rows(), @@ -310,10 +312,14 @@ def test_idempotent_replay_returns_stored_snapshot_without_new_write(self) -> No def test_idempotency_conflict_and_lost_snapshot_fail_closed(self) -> None: snapshot = clinical_psychologist_snapshot() digest = command_digest(snapshot=snapshot, position_record_id=None, criterion_blueprint_id=None) - port, _ = self._port([None, ("other" * 16, ANALYSIS)]) + port, _ = self._port( + [None, ("f" * 64, ANALYSIS, "keyverse:actor-ja-1", "job_analysis_write")] + ) with self.assertRaises(JobAnalysisIdempotencyConflict): self._persist(port, request_digest=digest) - port, _ = self._port([None, (digest, ANALYSIS), []]) + port, _ = self._port( + [None, (digest, ANALYSIS, "keyverse:actor-ja-1", "job_analysis_write"), []] + ) with self.assertRaisesRegex(JobAnalysisIntegrityError, "lost its snapshot"): self._persist(port, request_digest=digest) diff --git a/services/job-analysis-api/tests/test_postgres_audit_authorization_binding.py b/services/job-analysis-api/tests/test_postgres_audit_authorization_binding.py new file mode 100644 index 000000000..a46387c22 --- /dev/null +++ b/services/job-analysis-api/tests/test_postgres_audit_authorization_binding.py @@ -0,0 +1,297 @@ +"""Regression coverage for job-analysis authorization/audit binding at persistence.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone, tzinfo +from typing import TypeVar +from uuid import UUID + +import pytest + +from orgmetra_hris_kernel import AuditOutboxEvent +from orgmetra_job_analysis_api.postgres import PostgresJobAnalysisPort +from orgmetra_job_analysis_api.snapshot import JobAnalysisIntegrityError, command_digest +from fixtures import ANALYSIS, IDEMPOTENCY_KEY, OTHER_TENANT, TENANT, clinical_psychologist_snapshot + +_ACTOR_REFERENCE = "keyverse:actor-ja-1" +_PURPOSE_CODE = "job_analysis_write" +_RESOURCE_REFERENCE = f"job_analysis_snapshot:{ANALYSIS.hex}" +_AuditEventT = TypeVar("_AuditEventT", bound=AuditOutboxEvent) + + +class _AuditEventSubtype(AuditOutboxEvent): + """Trip if persistence consumes caller-defined audit behavior before type rejection.""" + + _TRIPWIRE_FIELDS = frozenset( + {"tenant_record_id", "resource_reference", "actor_reference", "purpose_code"} + ) + + def __getattribute__(self, name: str) -> object: + """Reject authority-field reads once the adversarial fixture is armed.""" + if name in _AuditEventSubtype._TRIPWIRE_FIELDS: + try: + armed = object.__getattribute__(self, "_tripwire_armed") + except AttributeError: + armed = False + if armed: + raise AssertionError(f"audit subtype authority field consumed before exact-type rejection: {name}") + return super().__getattribute__(name) + + def canonical_json(self) -> str: + """Reject canonical serialization if exact-type validation is reordered.""" + raise AssertionError("audit subtype canonical_json consumed before exact-type rejection") + + def content_digest(self) -> str: + """Reject digest serialization if exact-type validation is reordered.""" + raise AssertionError("audit subtype content_digest consumed before exact-type rejection") + + +class _ForgedAuthorityText(str): + """Retain hostile audit text while reporting equality with reviewed evidence.""" + + def __new__(cls, value: str, equal_to: str) -> _ForgedAuthorityText: + """Build valid-looking text whose equality does not describe its stored bytes.""" + instance = super().__new__(cls, value) + instance._equal_to = equal_to + return instance + + def __eq__(self, other: object) -> bool: + """Spoof equality only for the reviewed evidence value.""" + return other == self._equal_to + + def __ne__(self, other: object) -> bool: + """Keep inequality logically inverse to the forged equality result.""" + return not self.__eq__(other) + + __hash__ = str.__hash__ + + +class _AuditFieldMutatingTimezone(tzinfo): + """Mutate one exact audit-envelope field from the canonicalization callback surface.""" + + def __init__( + self, + field_name: str = "actor_reference", + field_value: object = "keyverse:actor-ja-canonicalization-drift", + ) -> None: + """Start inert so AuditOutboxEvent construction itself remains valid.""" + self._audit_event: AuditOutboxEvent | None = None + self._field_name = field_name + self._field_value = field_value + self._armed = False + + def arm(self, audit_event: AuditOutboxEvent) -> None: + """Enable the mutation only after the exact event constructor has returned.""" + self._audit_event = audit_event + self._armed = True + + def utcoffset(self, value: datetime | None) -> timedelta: + """Drift one field when canonicalization asks the timezone for its UTC offset.""" + del value + if self._armed and self._audit_event is not None: + object.__setattr__( + self._audit_event, + self._field_name, + self._field_value, + ) + return timedelta(0) + + def dst(self, value: datetime | None) -> timedelta: + """Expose a stable zero daylight-saving offset.""" + del value + return timedelta(0) + + def tzname(self, value: datetime | None) -> str: + """Return a deterministic test-only timezone name.""" + del value + return "UTC_TEST" + + +def _never_connect() -> object: + """Prove invalid durable evidence is rejected before database acquisition.""" + raise AssertionError("database acquired before job-analysis audit binding validation") + + +def _audit_event( + event_class: type[_AuditEventT] = AuditOutboxEvent, + **overrides: object, +) -> _AuditEventT: + """Build one shaped audit envelope whose evidence may be adversarially drifted.""" + snapshot = clinical_psychologist_snapshot() + values: dict[str, object] = { + "event_id": UUID("0198a412-6000-7000-8000-000000000401"), + "tenant_record_id": TENANT, + "source_service": "job_analysis_api", + "event_type": "orgmetra.job_architecture.snapshot_recorded", + "resource_reference": _RESOURCE_REFERENCE, + "actor_reference": _ACTOR_REFERENCE, + "purpose_code": _PURPOSE_CODE, + "reason_code": "snapshot_persisted", + "evidence_version_code": snapshot.analysis_version_code, + "result_code": "recorded", + "occurred_at": datetime(2026, 8, 18, 5, 1, tzinfo=timezone.utc), + "high_impact": False, + } + values.update(overrides) + event = event_class(**values) # type: ignore[arg-type] + if type(event) is _AuditEventSubtype: + object.__setattr__(event, "_tripwire_armed", True) + return event + + +def _persist_with_audit(audit_event: AuditOutboxEvent) -> None: + """Invoke the durable write boundary with one otherwise-valid command.""" + snapshot = clinical_psychologist_snapshot() + port = PostgresJobAnalysisPort(_never_connect) + port.persist_snapshot( + snapshot=snapshot, + idempotency_key=IDEMPOTENCY_KEY, + request_digest=command_digest( + snapshot=snapshot, + position_record_id=None, + criterion_blueprint_id=None, + ), + actor_reference=_ACTOR_REFERENCE, + purpose_code=_PURPOSE_CODE, + position_record_id=None, + criterion_blueprint_id=None, + audit_event=audit_event, + outbox_delivery_record_id=UUID("0198a412-6000-7000-8000-000000000402"), + write_command_id=UUID("0198a412-6000-7000-8000-000000000403"), + ) + + +@pytest.mark.parametrize( + "audit_event", + [ + _audit_event(actor_reference="keyverse:actor-ja-other"), + _audit_event(purpose_code="job_analysis_read"), + _audit_event(tenant_record_id=OTHER_TENANT), + _audit_event(resource_reference="job_analysis_snapshot:0198a412600070008000000000000999"), + ], +) +def test_job_analysis_audit_authority_drift_fails_before_database( + audit_event: AuditOutboxEvent, +) -> None: + """Command authority and durable audit provenance must describe the same write.""" + with pytest.raises(JobAnalysisIntegrityError, match="audit event does not match the job-analysis write authority"): + _persist_with_audit(audit_event) + + +@pytest.mark.parametrize( + ("field_name", "forged_value", "reviewed_value"), + [ + ( + "resource_reference", + "job_analysis_snapshot:0198a412600070008000000000000999", + _RESOURCE_REFERENCE, + ), + ("actor_reference", "keyverse:actor-ja-other", _ACTOR_REFERENCE), + ("purpose_code", "job_analysis_read", _PURPOSE_CODE), + ], +) +def test_exact_audit_event_rejects_forged_authority_text_before_database( + field_name: str, + forged_value: str, + reviewed_value: str, +) -> None: + """Exact envelope type must not let subtype-controlled equality authorize durable audit bytes.""" + audit_event = _audit_event( + **{field_name: _ForgedAuthorityText(forged_value, reviewed_value)} + ) + + with pytest.raises( + ValueError, + match=rf"audit_event\.{field_name} must be exact built-in text", + ): + _persist_with_audit(audit_event) + + +@pytest.mark.parametrize( + "audit_event", + [ + _audit_event(source_service="people_api"), + _audit_event(event_type="orgmetra.job_architecture.snapshot_superseded"), + _audit_event(reason_code="snapshot_corrected"), + _audit_event(evidence_version_code="unexpected:v1"), + _audit_event(result_code="updated"), + _audit_event(confirmation_reference="review:job-analysis-1"), + _audit_event(high_impact=True, confirmation_reference="review:job-analysis-1"), + ], +) +def test_job_analysis_audit_semantic_drift_fails_before_database( + audit_event: AuditOutboxEvent, +) -> None: + """Durable audit semantics must identify this exact successful snapshot-recording write.""" + with pytest.raises( + JobAnalysisIntegrityError, + match="audit event does not match the job-analysis snapshot semantics", + ): + _persist_with_audit(audit_event) + + +@pytest.mark.parametrize( + ("field_name", "forged_value", "reviewed_value"), + [ + ("source_service", "people_api", "job_analysis_api"), + ( + "event_type", + "orgmetra.job_architecture.snapshot_superseded", + "orgmetra.job_architecture.snapshot_recorded", + ), + ("reason_code", "snapshot_corrected", "snapshot_persisted"), + ("evidence_version_code", "unexpected:v1", clinical_psychologist_snapshot().analysis_version_code), + ("result_code", "updated", "recorded"), + ], +) +def test_exact_audit_event_rejects_forged_semantic_text_before_database( + field_name: str, + forged_value: str, + reviewed_value: str, +) -> None: + """Runtime text equality must not substitute for durable audit semantic bytes.""" + audit_event = _audit_event( + **{field_name: _ForgedAuthorityText(forged_value, reviewed_value)} + ) + + with pytest.raises( + ValueError, + match=rf"audit_event\.{field_name} must be exact built-in text", + ): + _persist_with_audit(audit_event) + + +def test_canonicalization_callback_cannot_drift_validated_audit_authority() -> None: + """Canonical bytes must be rechecked after any executable timezone callback.""" + callback_timezone = _AuditFieldMutatingTimezone() + audit_event = _audit_event( + occurred_at=datetime(2026, 8, 18, 5, 1, tzinfo=callback_timezone) + ) + callback_timezone.arm(audit_event) + + with pytest.raises( + JobAnalysisIntegrityError, + match="canonical audit evidence does not match validated authority", + ): + _persist_with_audit(audit_event) + + +def test_canonicalization_callback_cannot_drift_validated_audit_semantics() -> None: + """Frozen canonical bytes must still represent the pre-canonical semantic snapshot.""" + callback_timezone = _AuditFieldMutatingTimezone("result_code", "updated") + audit_event = _audit_event( + occurred_at=datetime(2026, 8, 18, 5, 1, tzinfo=callback_timezone) + ) + callback_timezone.arm(audit_event) + + with pytest.raises( + JobAnalysisIntegrityError, + match="canonical audit evidence does not match validated semantics", + ): + _persist_with_audit(audit_event) + + +def test_job_analysis_audit_subtype_fails_before_any_audit_or_database_access() -> None: + """Exact-type rejection must precede subtype-controlled fields, serialization, and DB I/O.""" + with pytest.raises(TypeError, match="audit_event must be an exact AuditOutboxEvent"): + _persist_with_audit(_audit_event(_AuditEventSubtype)) diff --git a/services/job-analysis-api/tests/test_postgres_audit_scalar_time_integrity.py b/services/job-analysis-api/tests/test_postgres_audit_scalar_time_integrity.py new file mode 100644 index 000000000..772f62617 --- /dev/null +++ b/services/job-analysis-api/tests/test_postgres_audit_scalar_time_integrity.py @@ -0,0 +1,153 @@ +"""Regression coverage for inert Job Analysis durable-audit scalar/time evidence.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone, tzinfo +from uuid import UUID + +import pytest + +from orgmetra_hris_kernel import AuditOutboxEvent +from orgmetra_job_analysis_api.postgres import PostgresJobAnalysisPort +from orgmetra_job_analysis_api.snapshot import command_digest +from fixtures import ANALYSIS, IDEMPOTENCY_KEY, TENANT, clinical_psychologist_snapshot + +_ACTOR_REFERENCE = "keyverse:actor-ja-1" +_PURPOSE_CODE = "job_analysis_write" +_RESOURCE_REFERENCE = f"job_analysis_snapshot:{ANALYSIS.hex}" + + +class _TripwireTimezone(tzinfo): + """Fail if durable validation executes caller-defined timezone behavior.""" + + def __init__(self) -> None: + self.calls = 0 + + def utcoffset(self, value: datetime | None) -> timedelta: + del value + self.calls += 1 + raise AssertionError("audit occurred_at timezone callback executed before rejection") + + def dst(self, value: datetime | None) -> timedelta: + del value + return timedelta(0) + + def tzname(self, value: datetime | None) -> str: + del value + return "TRIPWIRE" + + +class _ExecutableConfirmationReference(str): + """Trip if optional confirmation text reaches equality before exact-type rejection.""" + + def __eq__(self, other: object) -> bool: + del other + raise AssertionError("confirmation_reference equality executed before rejection") + + def __ne__(self, other: object) -> bool: + del other + raise AssertionError("confirmation_reference inequality executed before rejection") + + __hash__ = str.__hash__ + + +def _audit_event() -> AuditOutboxEvent: + """Build one valid exact audit envelope before low-level adversarial rewriting.""" + snapshot = clinical_psychologist_snapshot() + return AuditOutboxEvent( + event_id=UUID("0198a412-6000-7000-8000-000000000411"), + tenant_record_id=TENANT, + source_service="job_analysis_api", + event_type="orgmetra.job_architecture.snapshot_recorded", + resource_reference=_RESOURCE_REFERENCE, + actor_reference=_ACTOR_REFERENCE, + purpose_code=_PURPOSE_CODE, + reason_code="snapshot_persisted", + evidence_version_code=snapshot.analysis_version_code, + result_code="recorded", + occurred_at=datetime(2026, 8, 18, 5, 1, tzinfo=timezone.utc), + high_impact=False, + ) + + +def _never_connect() -> object: + """Prove malformed audit evidence is rejected before database acquisition.""" + raise AssertionError("database acquired before durable audit scalar/time validation") + + +def _persist_with_audit(audit_event: AuditOutboxEvent) -> None: + """Invoke the durable write boundary with one otherwise-valid command.""" + snapshot = clinical_psychologist_snapshot() + PostgresJobAnalysisPort(_never_connect).persist_snapshot( + snapshot=snapshot, + idempotency_key=IDEMPOTENCY_KEY, + request_digest=command_digest( + snapshot=snapshot, + position_record_id=None, + criterion_blueprint_id=None, + ), + actor_reference=_ACTOR_REFERENCE, + purpose_code=_PURPOSE_CODE, + position_record_id=None, + criterion_blueprint_id=None, + audit_event=audit_event, + outbox_delivery_record_id=UUID("0198a412-6000-7000-8000-000000000412"), + write_command_id=UUID("0198a412-6000-7000-8000-000000000413"), + ) + + +def test_durable_audit_rejects_executable_occurred_at_timezone_before_callback() -> None: + """Canonicalization must never execute a caller-defined durable-audit timezone.""" + audit_event = _audit_event() + tripwire = _TripwireTimezone() + object.__setattr__( + audit_event, + "occurred_at", + datetime(2026, 8, 18, 5, 1, tzinfo=tripwire), + ) + + with pytest.raises( + ValueError, + match="occurred_at must be an exact timezone-aware datetime", + ): + _persist_with_audit(audit_event) + + assert tripwire.calls == 0 + + +def test_durable_audit_rejects_non_boolean_high_impact_before_canonicalization() -> None: + """Low-level rewrites cannot make non-boolean audit semantics reach serialization.""" + audit_event = _audit_event() + tripwire = _TripwireTimezone() + object.__setattr__(audit_event, "high_impact", 0) + object.__setattr__( + audit_event, + "occurred_at", + datetime(2026, 8, 18, 5, 1, tzinfo=tripwire), + ) + + with pytest.raises(ValueError, match="high_impact must be a boolean"): + _persist_with_audit(audit_event) + + assert tripwire.calls == 0 + + +def test_durable_audit_rejects_executable_confirmation_before_canonicalization() -> None: + """Optional confirmation evidence must be inert exact text before serialization.""" + audit_event = _audit_event() + tripwire = _TripwireTimezone() + object.__setattr__( + audit_event, + "confirmation_reference", + _ExecutableConfirmationReference("review:job-analysis-1"), + ) + object.__setattr__( + audit_event, + "occurred_at", + datetime(2026, 8, 18, 5, 1, tzinfo=tripwire), + ) + + with pytest.raises(ValueError, match="confirmation_reference must be a string when supplied"): + _persist_with_audit(audit_event) + + assert tripwire.calls == 0 diff --git a/services/job-analysis-api/tests/test_postgres_command_scalar_validation.py b/services/job-analysis-api/tests/test_postgres_command_scalar_validation.py new file mode 100644 index 000000000..36c3c0588 --- /dev/null +++ b/services/job-analysis-api/tests/test_postgres_command_scalar_validation.py @@ -0,0 +1,128 @@ +"""Regression coverage for durable Job Analysis command scalars at persistence.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +import pytest + +from orgmetra_hris_kernel import AuditOutboxEvent +from orgmetra_job_analysis_api.postgres import PostgresJobAnalysisPort +from orgmetra_job_analysis_api.snapshot import command_digest +from fixtures import ANALYSIS, IDEMPOTENCY_KEY, TENANT, clinical_psychologist_snapshot + +_ACTOR_REFERENCE = "keyverse:actor-ja-1" +_PURPOSE_CODE = "job_analysis_write" +_RESOURCE_REFERENCE = f"job_analysis_snapshot:{ANALYSIS.hex}" + + +class _TextSubtype(str): + """Represent caller-defined executable text at the durable write boundary.""" + + +def _never_connect() -> object: + """Prove malformed command evidence is rejected before database acquisition.""" + raise AssertionError("database acquired before durable command scalar validation") + + +def _audit_event( + *, + actor_reference: str = _ACTOR_REFERENCE, + purpose_code: str = _PURPOSE_CODE, +) -> AuditOutboxEvent: + """Build one valid audit event matching the Job Analysis write authority.""" + snapshot = clinical_psychologist_snapshot() + return AuditOutboxEvent( + event_id=UUID("0198a412-6000-7000-8000-000000000411"), + tenant_record_id=TENANT, + source_service="job_analysis_api", + event_type="orgmetra.job_architecture.snapshot_recorded", + resource_reference=_RESOURCE_REFERENCE, + actor_reference=actor_reference, + purpose_code=purpose_code, + reason_code="snapshot_persisted", + evidence_version_code=snapshot.analysis_version_code, + result_code="recorded", + occurred_at=datetime(2026, 8, 18, 5, 1, tzinfo=timezone.utc), + high_impact=False, + ) + + +def _persist( + *, + idempotency_key: str = IDEMPOTENCY_KEY, + request_digest: str | None = None, + actor_reference: str = _ACTOR_REFERENCE, + purpose_code: str = _PURPOSE_CODE, +) -> None: + """Invoke the PostgreSQL port with otherwise-valid durable command evidence.""" + snapshot = clinical_psychologist_snapshot() + digest = request_digest + if digest is None: + digest = command_digest( + snapshot=snapshot, + position_record_id=None, + criterion_blueprint_id=None, + ) + PostgresJobAnalysisPort(_never_connect).persist_snapshot( + snapshot=snapshot, + idempotency_key=idempotency_key, + request_digest=digest, + actor_reference=actor_reference, + purpose_code=purpose_code, + position_record_id=None, + criterion_blueprint_id=None, + audit_event=_audit_event( + actor_reference=actor_reference, + purpose_code=purpose_code, + ), + outbox_delivery_record_id=UUID("0198a412-6000-7000-8000-000000000412"), + write_command_id=UUID("0198a412-6000-7000-8000-000000000413"), + ) + + +@pytest.mark.parametrize( + "idempotency_key", + [ + _TextSubtype(IDEMPOTENCY_KEY), + f"{IDEMPOTENCY_KEY}\n", + ], +) +def test_durable_idempotency_key_fails_closed_before_database(idempotency_key: str) -> None: + """Persistence must reject executable or control-character Idempotency-Key text.""" + with pytest.raises(ValueError, match="idempotency_key"): + _persist(idempotency_key=idempotency_key) + + +@pytest.mark.parametrize( + "request_digest", + [ + _TextSubtype("a" * 64), + "not-a-lowercase-sha256-digest", + ], +) +def test_durable_request_digest_fails_closed_before_database(request_digest: str) -> None: + """Persistence must accept only exact lowercase SHA-256 command digests.""" + with pytest.raises(ValueError, match="request_digest"): + _persist(request_digest=request_digest) + + +@pytest.mark.parametrize( + ("field_name", "actor_reference", "purpose_code"), + [ + ("actor_reference", _TextSubtype(_ACTOR_REFERENCE), _PURPOSE_CODE), + ("purpose_code", _ACTOR_REFERENCE, _TextSubtype(_PURPOSE_CODE)), + ], +) +def test_durable_authority_text_fails_closed_before_database( + field_name: str, + actor_reference: str, + purpose_code: str, +) -> None: + """Actor and purpose authority must be exact immutable text before DB acquisition.""" + with pytest.raises(ValueError, match=field_name): + _persist( + actor_reference=actor_reference, + purpose_code=purpose_code, + ) diff --git a/services/job-analysis-api/tests/test_postgres_concurrency.py b/services/job-analysis-api/tests/test_postgres_concurrency.py index 67f2abdcf..5484dd7b3 100644 --- a/services/job-analysis-api/tests/test_postgres_concurrency.py +++ b/services/job-analysis-api/tests/test_postgres_concurrency.py @@ -10,7 +10,6 @@ from orgmetra_job_analysis_api.postgres import ( PostgresJobAnalysisPort, _IDEMPOTENCY_LOOKUP_SQL, - _constraint_name, _is_unique_violation, ) from orgmetra_job_analysis_api.snapshot import ( @@ -22,22 +21,11 @@ from fixtures import IDEMPOTENCY_KEY, JOB, clinical_psychologist_snapshot -class _Diagnostic: - """Expose the PostgreSQL constraint name carried by a driver error.""" - - def __init__(self, constraint_name: str) -> None: - self.constraint_name = constraint_name - - class _UniqueViolation(Exception): """Mimic a DB-API unique violation from psycopg-style drivers.""" sqlstate = "23505" - def __init__(self, constraint_name: str) -> None: - super().__init__(constraint_name) - self.diag = _Diagnostic(constraint_name) - class _ConstraintCursor: """Return valid parent lookups and fail at one selected insert constraint.""" @@ -67,7 +55,7 @@ def execute(self, sql: str, parameters: tuple[object, ...] | None = None) -> Non self._last = None return if "FROM public.job_analysis_write_command" in sql: - self._last = (None, None) + self._last = (None, None, None, None) elif "FROM public.job_profile" in sql: self._last = (JOB,) else: @@ -149,27 +137,28 @@ def test_idempotency_lookup_serializes_the_tenant_and_key_before_reading(self) - self.assertIn("idempotency_key", normalized) def test_unique_violation_metadata_is_read_without_driver_lock_in(self) -> None: - """Support modern and legacy PostgreSQL DB-API error attributes.""" + """Support modern and legacy PostgreSQL DB-API SQLSTATE attributes.""" error = _UniqueViolation("job_analysis_snapshot_job_version_unique") self.assertTrue(_is_unique_violation(error)) - self.assertEqual( - _constraint_name(error), - "job_analysis_snapshot_job_version_unique", - ) legacy = Exception("legacy") legacy.pgcode = "23505" # type: ignore[attr-defined] self.assertTrue(_is_unique_violation(legacy)) self.assertFalse(_is_unique_violation(RuntimeError("other"))) - self.assertIsNone(_constraint_name(RuntimeError("other"))) def test_snapshot_version_race_maps_to_integrity_error(self) -> None: - """Do not leak a raw driver exception for a concurrent job/version write.""" - with self.assertRaisesRegex(JobAnalysisIntegrityError, "already exists"): + """Normalize a unique race without depending on optional driver diagnostics.""" + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + r"^job-analysis snapshot identity or version already exists$", + ): _persist_with_constraint("job_analysis_snapshot_job_version_unique") def test_command_key_race_maps_to_idempotency_conflict(self) -> None: - """Do not expose a raw driver error if an uncoordinated writer wins the key.""" - with self.assertRaisesRegex(JobAnalysisIdempotencyConflict, "concurrently"): + """Normalize a command race without depending on optional driver diagnostics.""" + with self.assertRaisesRegex( + JobAnalysisIdempotencyConflict, + r"^idempotency or command identity was recorded concurrently$", + ): _persist_with_constraint("job_analysis_write_command_idempotency_unique") def test_non_unique_snapshot_failure_is_not_reclassified(self) -> None: diff --git a/services/job-analysis-api/tests/test_postgres_driver_error_metadata_integrity.py b/services/job-analysis-api/tests/test_postgres_driver_error_metadata_integrity.py new file mode 100644 index 000000000..e82e3181c --- /dev/null +++ b/services/job-analysis-api/tests/test_postgres_driver_error_metadata_integrity.py @@ -0,0 +1,83 @@ +"""Runtime-integrity regressions for PostgreSQL driver SQLSTATE metadata.""" + +from __future__ import annotations + +import unittest + +from orgmetra_job_analysis_api.postgres import _is_unique_violation + + +class _ExecutableText(str): + """Fail if driver-provided text participates in caller-defined equality.""" + + def __eq__(self, other: object) -> bool: + raise AssertionError("driver text equality must not execute") + + +class _FallbackProbe(Exception): + """Expose modern SQLSTATE while making legacy fallback access observable.""" + + sqlstate = "23505" + + @property + def pgcode(self) -> str: + raise AssertionError("legacy pgcode must not be read when sqlstate is present") + + +class _ExecutableStateError(Exception): + """Carry a string subtype that must not be trusted as SQLSTATE evidence.""" + + sqlstate = _ExecutableText("23505") + + +class _AttributeTrapStateError(Exception): + """Expose inert class metadata behind executable dynamic attribute access.""" + + sqlstate = "23505" + + def __getattribute__(self, name: str) -> object: + if name in {"sqlstate", "pgcode"}: + raise AssertionError("driver metadata lookup must not execute __getattribute__") + return super().__getattribute__(name) + + +class _ExecutableStatePropertyError(Exception): + """Expose SQLSTATE only through a descriptor that must not execute.""" + + @property + def sqlstate(self) -> str: + raise AssertionError("driver SQLSTATE descriptor must not execute") + + +class _LegacyUniqueViolation(Exception): + """Mimic a legacy PostgreSQL DB-API error exposing only pgcode.""" + + pgcode = "23505" + + +class PostgresDriverErrorMetadataIntegrityTests(unittest.TestCase): + """Require inert built-in SQLSTATE evidence before unique classification.""" + + def test_modern_sqlstate_does_not_touch_legacy_fallback(self) -> None: + """Do not execute a legacy fallback accessor after modern SQLSTATE exists.""" + self.assertTrue(_is_unique_violation(_FallbackProbe("unique"))) + + def test_sqlstate_subtype_is_not_compared_as_unique_violation_evidence(self) -> None: + """Reject executable text before equality-based SQLSTATE classification.""" + self.assertFalse(_is_unique_violation(_ExecutableStateError("unique"))) + + def test_sqlstate_lookup_does_not_execute_dynamic_attribute_access(self) -> None: + """Read inert stored SQLSTATE without invoking a driver override.""" + self.assertTrue(_is_unique_violation(_AttributeTrapStateError("unique"))) + + def test_sqlstate_descriptor_is_not_executed_for_classification(self) -> None: + """Treat executable SQLSTATE descriptors as unavailable evidence.""" + self.assertFalse(_is_unique_violation(_ExecutableStatePropertyError("unique"))) + + def test_legacy_builtin_pgcode_remains_supported(self) -> None: + """Retain DB-API compatibility when only inert legacy pgcode is available.""" + self.assertTrue(_is_unique_violation(_LegacyUniqueViolation("unique"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/job-analysis-api/tests/test_postgres_idempotency_authority.py b/services/job-analysis-api/tests/test_postgres_idempotency_authority.py index ccf9bbf2b..96ebf97ba 100644 --- a/services/job-analysis-api/tests/test_postgres_idempotency_authority.py +++ b/services/job-analysis-api/tests/test_postgres_idempotency_authority.py @@ -2,10 +2,16 @@ from __future__ import annotations +from dataclasses import replace import unittest +from uuid import UUID from orgmetra_job_analysis_api.postgres import PostgresJobAnalysisPort, _IDEMPOTENCY_LOOKUP_SQL -from orgmetra_job_analysis_api.snapshot import JobAnalysisIdempotencyConflict, command_digest +from orgmetra_job_analysis_api.snapshot import ( + JobAnalysisIdempotencyConflict, + JobAnalysisIntegrityError, + command_digest, +) from fixtures import ANALYSIS, IDEMPOTENCY_KEY, clinical_psychologist_snapshot from test_postgres import ( @@ -19,17 +25,34 @@ ) +_UNSET = object() + + +class _AlwaysEqualText(str): + """Model a non-canonical DB-returned text value that lies during comparison.""" + + def __eq__(self, other: object) -> bool: + return True + + def __ne__(self, other: object) -> bool: + return False + + class PostgresIdempotencyAuthorityTests(unittest.TestCase): - """Prove a durable idempotency key cannot cross actor or purpose authority.""" + """Prove a durable idempotency key cannot cross command or authority identity.""" def _persist_replay( self, *, - stored_actor_reference: str, - stored_purpose_code: str, + stored_actor_reference: object, + stored_purpose_code: object, + stored_analysis_record_id: object = ANALYSIS, + stored_request_digest: object = _UNSET, + include_stored_authority: bool = True, actor_reference: str = "keyverse:actor-ja-1", purpose_code: str = "job_analysis_write", include_snapshot: bool = False, + stored_snapshot_header: tuple[object, ...] | None = None, ) -> object: snapshot = clinical_psychologist_snapshot() digest = command_digest( @@ -37,17 +60,27 @@ def _persist_replay( position_record_id=None, criterion_blueprint_id=None, ) - script: list[object] = [ - None, - ( - digest, - ANALYSIS, + durable_digest = digest if stored_request_digest is _UNSET else stored_request_digest + durable_row: tuple[object, ...] + if include_stored_authority: + durable_row = ( + durable_digest, + stored_analysis_record_id, stored_actor_reference, stored_purpose_code, - ), - ] + ) + else: + durable_row = (durable_digest, stored_analysis_record_id) + script: list[object] = [None, durable_row] if include_snapshot: - script.extend([[_header_row()], _task_rows(), _ksao_rows(), _link_rows()]) + script.extend( + [ + [stored_snapshot_header if stored_snapshot_header is not None else _header_row()], + _task_rows(), + _ksao_rows(), + _link_rows(), + ] + ) cursor = FakeCursor(script) port = PostgresJobAnalysisPort(lambda: FakeConnection(cursor)) return port.persist_snapshot( @@ -59,12 +92,8 @@ def _persist_replay( position_record_id=None, criterion_blueprint_id=None, audit_event=_audit_event(), - outbox_delivery_record_id=__import__("uuid").UUID( - "0198a412-6000-7000-8000-000000000302" - ), - write_command_id=__import__("uuid").UUID( - "0198a412-6000-7000-8000-000000000303" - ), + outbox_delivery_record_id=UUID("0198a412-6000-7000-8000-000000000302"), + write_command_id=UUID("0198a412-6000-7000-8000-000000000303"), ) def test_lookup_reads_the_immutable_actor_and_purpose_binding(self) -> None: @@ -73,6 +102,53 @@ def test_lookup_reads_the_immutable_actor_and_purpose_binding(self) -> None: self.assertIn("actor_reference", normalized) self.assertIn("purpose_code", normalized) + def test_authorityless_durable_row_fails_closed(self) -> None: + """Reject a replay row that does not have the four columns selected by the SQL contract.""" + with self.assertRaisesRegex(JobAnalysisIntegrityError, "durable command row"): + self._persist_replay( + stored_actor_reference="keyverse:actor-ja-1", + stored_purpose_code="job_analysis_write", + include_stored_authority=False, + include_snapshot=True, + ) + + def test_partial_null_durable_row_fails_closed(self) -> None: + """Only the all-NULL LEFT JOIN projection can mean that no durable command exists.""" + with self.assertRaisesRegex(JobAnalysisIntegrityError, "partial-null"): + self._persist_replay( + stored_actor_reference="keyverse:actor-ja-1", + stored_purpose_code="job_analysis_write", + stored_request_digest=None, + ) + + def test_noncanonical_stored_digest_cannot_bypass_digest_binding(self) -> None: + """Revalidate database-returned digest text before using equality for authority.""" + with self.assertRaisesRegex(JobAnalysisIntegrityError, "durable command"): + self._persist_replay( + stored_actor_reference="keyverse:actor-ja-1", + stored_purpose_code="job_analysis_write", + stored_request_digest=_AlwaysEqualText("f" * 64), + include_snapshot=True, + ) + + def test_noncanonical_stored_actor_cannot_bypass_actor_binding(self) -> None: + """Reject a driver-returned actor text subtype before comparing authority identity.""" + with self.assertRaisesRegex(JobAnalysisIntegrityError, "durable command"): + self._persist_replay( + stored_actor_reference=_AlwaysEqualText("keyverse:actor-ja-other"), + stored_purpose_code="job_analysis_write", + include_snapshot=True, + ) + + def test_noncanonical_stored_purpose_cannot_bypass_purpose_binding(self) -> None: + """Reject a driver-returned purpose text subtype before comparing purpose authority.""" + with self.assertRaisesRegex(JobAnalysisIntegrityError, "durable command"): + self._persist_replay( + stored_actor_reference="keyverse:actor-ja-1", + stored_purpose_code=_AlwaysEqualText("job_analysis_read"), + include_snapshot=True, + ) + def test_same_key_and_digest_cannot_replay_under_a_different_actor(self) -> None: """Prevent one authorized principal from inheriting another actor's command.""" with self.assertRaisesRegex(JobAnalysisIdempotencyConflict, "actor"): @@ -83,8 +159,45 @@ def test_same_key_and_digest_cannot_replay_under_a_different_purpose(self) -> No with self.assertRaisesRegex(JobAnalysisIdempotencyConflict, "purpose"): self._persist_replay(stored_actor_reference="keyverse:actor-ja-1", stored_purpose_code="job_analysis_read") - def test_exact_actor_and_purpose_replay_returns_the_stored_snapshot(self) -> None: - """Preserve the successful retry contract for the exact original authority.""" + def test_same_digest_cannot_replay_a_different_snapshot_identity(self) -> None: + """A durable digest cannot authorize replay of another persisted snapshot identity.""" + foreign_analysis_record_id = UUID("0198a412-6000-7000-8000-000000000399") + with self.assertRaisesRegex(JobAnalysisIntegrityError, "analysis_record_id"): + self._persist_replay( + stored_actor_reference="keyverse:actor-ja-1", + stored_purpose_code="job_analysis_write", + stored_analysis_record_id=foreign_analysis_record_id, + ) + + def test_malformed_stored_snapshot_identity_fails_closed(self) -> None: + """Corrupt durable replay identity is an integrity failure, not a load target.""" + with self.assertRaisesRegex(JobAnalysisIntegrityError, "invalid analysis_record_id"): + self._persist_replay( + stored_actor_reference="keyverse:actor-ja-1", + stored_purpose_code="job_analysis_write", + stored_analysis_record_id="0198a412-6000-7000-8000-000000000399", + ) + + def test_same_command_row_cannot_replay_different_snapshot_content(self) -> None: + """Bind returned durable snapshot semantics back to the idempotency command digest.""" + requested_snapshot = clinical_psychologist_snapshot() + altered_snapshot = replace( + requested_snapshot, + analysis_version_code="clinical-psychologist:v2", + ) + altered_header = list(_header_row(digest=altered_snapshot.content_digest())) + altered_header[3] = altered_snapshot.analysis_version_code + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "recorded command digest"): + self._persist_replay( + stored_actor_reference="keyverse:actor-ja-1", + stored_purpose_code="job_analysis_write", + include_snapshot=True, + stored_snapshot_header=tuple(altered_header), + ) + + def test_exact_actor_purpose_and_snapshot_replay_returns_the_stored_snapshot(self) -> None: + """Preserve the successful retry contract for the exact original command authority.""" replayed = self._persist_replay( stored_actor_reference="keyverse:actor-ja-1", stored_purpose_code="job_analysis_write", diff --git a/services/job-analysis-api/tests/test_postgres_idempotency_lookup_projection.py b/services/job-analysis-api/tests/test_postgres_idempotency_lookup_projection.py new file mode 100644 index 000000000..4087f380e --- /dev/null +++ b/services/job-analysis-api/tests/test_postgres_idempotency_lookup_projection.py @@ -0,0 +1,117 @@ +"""Fail-closed regression for the idempotency lookup projection contract.""" + +from __future__ import annotations + +import unittest +from uuid import UUID + +from orgmetra_job_analysis_api.postgres import PostgresJobAnalysisPort +from orgmetra_job_analysis_api.snapshot import JobAnalysisIntegrityError, command_digest + +from fixtures import IDEMPOTENCY_KEY, clinical_psychologist_snapshot +from test_postgres import FakeConnection, FakeCursor, _audit_event + + +class _MissingProjectionCursor(FakeCursor): + """Model a DB-API cursor violating the one-row LEFT JOIN projection contract.""" + + def fetchone(self) -> object: + """Return no row only for the idempotency projection under test.""" + if self.executions and "FROM idempotency_lock" in self.executions[-1][0]: + return None + return super().fetchone() + + +class _GeneratorProjectionCursor(FakeCursor): + """Model an executable iterable where a fixed DB-API projection row is required.""" + + def fetchone(self) -> object: + """Return a four-value generator only for the idempotency projection.""" + if self.executions and "FROM idempotency_lock" in self.executions[-1][0]: + return (value for value in (None, None, None, None)) + return super().fetchone() + + +class _ExecutableTupleProjection(tuple[object, ...]): + """Expose whether fixed-row validation dispatches caller-controlled iteration.""" + + iterated = False + + def __iter__(self): # type: ignore[override] + """Fail if validation executes this untrusted sequence hook.""" + type(self).iterated = True + raise RuntimeError("projection iterator executed") + + +class _ExecutableTupleProjectionCursor(FakeCursor): + """Return a tuple subclass whose iterator is executable boundary behavior.""" + + def fetchone(self) -> object: + """Return executable tuple storage only for the idempotency projection.""" + if self.executions and "FROM idempotency_lock" in self.executions[-1][0]: + return _ExecutableTupleProjection((None, None, None, None)) + return super().fetchone() + + +class PostgresIdempotencyLookupProjectionTests(unittest.TestCase): + """Require the advisory-lock LEFT JOIN to return its one-row projection.""" + + def _persist(self, cursor: FakeCursor) -> None: + """Execute one write attempt against the supplied projection cursor.""" + snapshot = clinical_psychologist_snapshot() + port = PostgresJobAnalysisPort(lambda: FakeConnection(cursor)) + port.persist_snapshot( + snapshot=snapshot, + idempotency_key=IDEMPOTENCY_KEY, + request_digest=command_digest( + snapshot=snapshot, + position_record_id=None, + criterion_blueprint_id=None, + ), + actor_reference="keyverse:actor-ja-1", + purpose_code="job_analysis_write", + position_record_id=None, + criterion_blueprint_id=None, + audit_event=_audit_event(), + outbox_delivery_record_id=UUID("0198a412-6000-7000-8000-000000000302"), + write_command_id=UUID("0198a412-6000-7000-8000-000000000303"), + ) + + def test_missing_lookup_projection_fails_before_scope_reads(self) -> None: + """Treat DB-API ``None`` as impossible evidence, not as command absence.""" + cursor = _MissingProjectionCursor([None, None]) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "lookup.*projection"): + self._persist(cursor) + + self.assertFalse( + any("FROM public.job_profile" in statement for statement, _ in cursor.executions) + ) + + def test_generator_lookup_projection_fails_before_scope_reads(self) -> None: + """Reject arbitrary iterables even when they yield the four selected values.""" + cursor = _GeneratorProjectionCursor([None, None]) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "durable command.*shape"): + self._persist(cursor) + + self.assertFalse( + any("FROM public.job_profile" in statement for statement, _ in cursor.executions) + ) + + def test_executable_tuple_projection_is_rejected_without_iteration(self) -> None: + """Reject tuple subclasses before their caller-controlled iterator can run.""" + _ExecutableTupleProjection.iterated = False + cursor = _ExecutableTupleProjectionCursor([None, None]) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "durable command.*shape"): + self._persist(cursor) + + self.assertFalse(_ExecutableTupleProjection.iterated) + self.assertFalse( + any("FROM public.job_profile" in statement for statement, _ in cursor.executions) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/job-analysis-api/tests/test_postgres_operational_uuid_detachment.py b/services/job-analysis-api/tests/test_postgres_operational_uuid_detachment.py new file mode 100644 index 000000000..7f1adf285 --- /dev/null +++ b/services/job-analysis-api/tests/test_postgres_operational_uuid_detachment.py @@ -0,0 +1,121 @@ +"""Regression coverage for detached operational UUIDs at the PostgreSQL port.""" + +from __future__ import annotations + +import unittest +from uuid import UUID + +from orgmetra_job_analysis_api.postgres import PostgresJobAnalysisPort +from orgmetra_job_analysis_api.snapshot import command_digest +from fixtures import ( + ANALYSIS, + IDEMPOTENCY_KEY, + JOB, + OTHER_TENANT, + TENANT, + clinical_psychologist_snapshot, +) +from test_postgres import ( + FakeConnection, + FakeCursor, + _audit_event, + _header_row, + _ksao_rows, + _link_rows, + _task_rows, +) + + +class PostgresOperationalUUIDDetachmentTests(unittest.TestCase): + """Require validated UUID values to be snapshotted before DB acquisition hooks run.""" + + def test_read_port_uses_detached_uuid_values_after_connection_factory_mutates_aliases(self) -> None: + """A caller alias cannot retarget tenant or analysis identity after validation.""" + tenant_record_id = UUID(str(TENANT)) + analysis_record_id = UUID(str(ANALYSIS)) + cursor = FakeCursor( + [None, None, [_header_row()], _task_rows(), _ksao_rows(), _link_rows()] + ) + + def connection_factory() -> FakeConnection: + object.__setattr__(tenant_record_id, "int", OTHER_TENANT.int) + object.__setattr__(analysis_record_id, "int", JOB.int) + return FakeConnection(cursor) + + resolved = PostgresJobAnalysisPort(connection_factory).read_snapshot( + tenant_record_id=tenant_record_id, + analysis_record_id=analysis_record_id, + ) + + self.assertIsNotNone(resolved) + self.assertEqual(resolved.analysis_record_id, ANALYSIS) + self.assertEqual(cursor.executions[1][1], (str(TENANT),)) + self.assertEqual(cursor.executions[2][1], (TENANT, ANALYSIS)) + + def test_write_port_uses_detached_command_ids_after_connection_factory_mutates_aliases(self) -> None: + """Validated write/outbox identities cannot be rewritten before SQL insertion.""" + snapshot = clinical_psychologist_snapshot() + write_command_id = UUID("0198a412-6000-7000-8000-000000000413") + outbox_delivery_record_id = UUID("0198a412-6000-7000-8000-000000000412") + expected_write_command_id = UUID(str(write_command_id)) + expected_outbox_delivery_record_id = UUID(str(outbox_delivery_record_id)) + mutated_write_command_id = UUID("0198a412-6000-7000-8000-000000000499") + mutated_outbox_delivery_record_id = UUID("0198a412-6000-7000-8000-000000000498") + write_statement_count = ( + 1 + + len(snapshot.tasks) + + len(snapshot.ksao_requirements) + + len(snapshot.task_ksao_links) + + 2 + ) + cursor = FakeCursor([None, None, (JOB,)] + [None] * write_statement_count) + + def connection_factory() -> FakeConnection: + object.__setattr__(write_command_id, "int", mutated_write_command_id.int) + object.__setattr__( + outbox_delivery_record_id, + "int", + mutated_outbox_delivery_record_id.int, + ) + return FakeConnection(cursor) + + PostgresJobAnalysisPort(connection_factory).persist_snapshot( + snapshot=snapshot, + idempotency_key=IDEMPOTENCY_KEY, + request_digest=command_digest( + snapshot=snapshot, + position_record_id=None, + criterion_blueprint_id=None, + ), + actor_reference="keyverse:actor-ja-1", + purpose_code="job_analysis_write", + position_record_id=None, + criterion_blueprint_id=None, + audit_event=_audit_event(), + outbox_delivery_record_id=outbox_delivery_record_id, + write_command_id=write_command_id, + ) + + parameter_sets = [parameters for _, parameters in cursor.executions if parameters] + self.assertTrue( + any(expected_write_command_id in parameters for parameters in parameter_sets) + ) + self.assertTrue( + any( + expected_outbox_delivery_record_id in parameters + for parameters in parameter_sets + ) + ) + self.assertFalse( + any(mutated_write_command_id in parameters for parameters in parameter_sets) + ) + self.assertFalse( + any( + mutated_outbox_delivery_record_id in parameters + for parameters in parameter_sets + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/job-analysis-api/tests/test_postgres_read_projection_integrity.py b/services/job-analysis-api/tests/test_postgres_read_projection_integrity.py new file mode 100644 index 000000000..854c0fa2f --- /dev/null +++ b/services/job-analysis-api/tests/test_postgres_read_projection_integrity.py @@ -0,0 +1,360 @@ +"""Regression coverage for fixed PostgreSQL read projections and target identity.""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime, tzinfo +import unittest +from uuid import UUID +from zoneinfo import ZoneInfo + +from orgmetra_job_analysis_api.postgres import PostgresJobAnalysisPort +from orgmetra_job_analysis_api.snapshot import JobAnalysisIntegrityError +from fixtures import ANALYSIS, JOB, OTHER_TENANT, RECORDED_AT, TENANT +from test_postgres import ( + FakeConnection, + FakeCursor, + _header_row, + _ksao_rows, + _link_rows, + _task_rows, +) + + +class _EqualityForgedUUID(UUID): + """Model DB-returned UUID evidence that forges equality before validation.""" + + def __eq__(self, other: object) -> bool: + return True + + def __ne__(self, other: object) -> bool: + return False + + +class _ExecutableUUID(UUID): + """Model a durable UUID scalar that executes when kernel ownership is compared.""" + + calls = 0 + + def __eq__(self, other: object) -> bool: + type(self).calls += 1 + raise AssertionError("durable UUID equality executed before exact validation") + + def __ne__(self, other: object) -> bool: + type(self).calls += 1 + raise AssertionError("durable UUID inequality executed before exact validation") + + def __hash__(self) -> int: + type(self).calls += 1 + raise AssertionError("durable UUID hashing executed before exact validation") + + +class _ExecutableDigest(str): + """Model durable digest text whose comparison would execute adapter-owned code.""" + + calls = 0 + + def __eq__(self, other: object) -> bool: + type(self).calls += 1 + raise AssertionError("durable digest equality executed before exact validation") + + def __ne__(self, other: object) -> bool: + type(self).calls += 1 + raise AssertionError("durable digest inequality executed before exact validation") + + +class _ExecutableText(str): + """Model durable text whose normalization would dispatch a custom method.""" + + calls = 0 + + def split(self, *args: object, **kwargs: object) -> list[str]: + type(self).calls += 1 + raise AssertionError("durable text split executed before exact validation") + + +class _ExecutableInt(int): + """Model a durable ordinal whose range comparison would execute custom code.""" + + calls = 0 + + def __ge__(self, other: object) -> bool: + type(self).calls += 1 + raise AssertionError("durable integer comparison executed before exact validation") + + def __le__(self, other: object) -> bool: + type(self).calls += 1 + raise AssertionError("durable integer comparison executed before exact validation") + + def __lt__(self, other: object) -> bool: + type(self).calls += 1 + raise AssertionError("durable integer comparison executed before exact validation") + + def __gt__(self, other: object) -> bool: + type(self).calls += 1 + raise AssertionError("durable integer comparison executed before exact validation") + + +class _ExecutableDatetime(datetime): + """Model a durable instant whose offset lookup would execute custom code.""" + + calls = 0 + + def utcoffset(self) -> object: + type(self).calls += 1 + raise AssertionError("durable datetime offset executed before exact validation") + + +class _ExecutableTzinfo(tzinfo): + """Model an exact datetime carrying executable non-standard timezone evidence.""" + + calls = 0 + + def utcoffset(self, dt: datetime | None) -> object: + type(self).calls += 1 + raise AssertionError("durable timezone offset executed before exact validation") + + def dst(self, dt: datetime | None) -> None: + return None + + def tzname(self, dt: datetime | None) -> str: + return "executable" + + +class _BrokenSequence(Sequence[object]): + """Model a DB-API row sequence that fails while values are detached.""" + + def __len__(self) -> int: + return 1 + + def __getitem__(self, index: int) -> object: + raise TypeError("broken row sequence") + + +class PostgresReadProjectionIntegrityTests(unittest.TestCase): + """Require every fixed read projection to match its SQL row contract.""" + + def _read(self, script: list[object]) -> None: + cursor = FakeCursor(script) + port = PostgresJobAnalysisPort(lambda: FakeConnection(cursor)) + port.read_snapshot(tenant_record_id=TENANT, analysis_record_id=ANALYSIS) + + def _valid_script( + self, + *, + headers: object | None = None, + tasks: object | None = None, + ksaos: object | None = None, + links: object | None = None, + ) -> list[object]: + return [ + None, + None, + [_header_row()] if headers is None else headers, + _task_rows() if tasks is None else tasks, + _ksao_rows() if ksaos is None else ksaos, + _link_rows() if links is None else links, + ] + + def test_read_rejects_invalid_snapshot_header_shape(self) -> None: + header = _header_row() + malformed_rows = ( + object(), + _BrokenSequence(), + header[:-1], + header + ("surplus",), + (value for value in header), + ) + for row in malformed_rows: + with self.subTest(row_type=type(row).__name__): + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_analysis_snapshot row has invalid shape", + ): + self._read(self._valid_script(headers=[row])) + + def test_read_rejects_invalid_task_projection_shape(self) -> None: + canonical = _task_rows() + malformed_rows = ( + object(), + canonical[0][:-1], + canonical[0] + ("surplus",), + (value for value in canonical[0]), + ) + for row in malformed_rows: + with self.subTest(row_type=type(row).__name__): + rows = list(canonical) + rows[0] = row # type: ignore[assignment] + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_analysis_task_item row has invalid shape", + ): + self._read(self._valid_script(tasks=rows)) + + def test_read_rejects_invalid_ksao_projection_shape(self) -> None: + canonical = _ksao_rows() + malformed_rows = ( + object(), + canonical[0][:-1], + canonical[0] + ("surplus",), + (value for value in canonical[0]), + ) + for row in malformed_rows: + with self.subTest(row_type=type(row).__name__): + rows = list(canonical) + rows[0] = row # type: ignore[assignment] + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_analysis_ksao_item row has invalid shape", + ): + self._read(self._valid_script(ksaos=rows)) + + def test_read_rejects_invalid_link_projection_shape(self) -> None: + canonical = _link_rows() + malformed_rows = ( + object(), + canonical[0][:-1], + canonical[0] + ("surplus",), + (value for value in canonical[0]), + ) + for row in malformed_rows: + with self.subTest(row_type=type(row).__name__): + rows = list(canonical) + rows[0] = row # type: ignore[assignment] + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_analysis_task_ksao_link row has invalid shape", + ): + self._read(self._valid_script(links=rows)) + + def test_read_exact_validates_returned_target_identity_before_equality(self) -> None: + forged_tenant = _EqualityForgedUUID(str(OTHER_TENANT)) + forged_analysis = _EqualityForgedUUID("0198a412-6000-7000-8000-000000000499") + cases = ( + ( + _header_row(tenant_record_id=forged_tenant), + "job_analysis_snapshot.tenant_record_id row has invalid identity", + ), + ( + _header_row(analysis_record_id=forged_analysis), + "job_analysis_snapshot.analysis_record_id row has invalid identity", + ), + ) + for header, expected in cases: + with self.subTest(expected=expected): + with self.assertRaisesRegex(JobAnalysisIntegrityError, expected): + self._read(self._valid_script(headers=[header])) + + def test_read_rejects_executable_stored_job_identity_before_kernel_use(self) -> None: + canonical = _header_row() + executable_job = _ExecutableUUID(str(JOB)) + header = canonical[:2] + (executable_job,) + canonical[3:] + _ExecutableUUID.calls = 0 + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_analysis_snapshot.job_profile_id row has invalid identity", + ): + self._read(self._valid_script(headers=[header])) + + self.assertEqual(_ExecutableUUID.calls, 0) + + def test_read_rejects_executable_stored_digest_before_comparison(self) -> None: + canonical = _header_row() + digest = _ExecutableDigest(canonical[9]) + header = canonical[:9] + (digest,) + canonical[10:] + _ExecutableDigest.calls = 0 + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_analysis_snapshot.content_digest_sha256 row has invalid scalar evidence", + ): + self._read(self._valid_script(headers=[header])) + + self.assertEqual(_ExecutableDigest.calls, 0) + + def test_read_rejects_executable_task_text_before_kernel_normalization(self) -> None: + rows = list(_task_rows()) + row = rows[0] + rows[0] = row[:1] + (_ExecutableText(row[1]),) + row[2:] + _ExecutableText.calls = 0 + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_analysis_task_item.task_statement row has invalid scalar evidence", + ): + self._read(self._valid_script(tasks=rows)) + + self.assertEqual(_ExecutableText.calls, 0) + + def test_read_rejects_executable_task_level_before_kernel_comparison(self) -> None: + rows = list(_task_rows()) + row = rows[0] + rows[0] = row[:2] + (_ExecutableInt(row[2]),) + row[3:] + _ExecutableInt.calls = 0 + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_analysis_task_item.importance_level row has invalid scalar evidence", + ): + self._read(self._valid_script(tasks=rows)) + + self.assertEqual(_ExecutableInt.calls, 0) + + def test_read_rejects_executable_recorded_at_before_offset_lookup(self) -> None: + executable = _ExecutableDatetime( + RECORDED_AT.year, + RECORDED_AT.month, + RECORDED_AT.day, + RECORDED_AT.hour, + RECORDED_AT.minute, + RECORDED_AT.second, + RECORDED_AT.microsecond, + tzinfo=RECORDED_AT.tzinfo, + ) + canonical = _header_row() + header = canonical[:6] + (executable,) + canonical[7:] + _ExecutableDatetime.calls = 0 + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_analysis_snapshot.recorded_at row has invalid scalar evidence", + ): + self._read(self._valid_script(headers=[header])) + + self.assertEqual(_ExecutableDatetime.calls, 0) + + def test_read_rejects_exact_datetime_with_executable_timezone_before_offset_lookup(self) -> None: + executable_timezone = _ExecutableTzinfo() + executable = datetime( + RECORDED_AT.year, + RECORDED_AT.month, + RECORDED_AT.day, + RECORDED_AT.hour, + RECORDED_AT.minute, + RECORDED_AT.second, + RECORDED_AT.microsecond, + tzinfo=executable_timezone, + ) + canonical = _header_row() + header = canonical[:6] + (executable,) + canonical[7:] + _ExecutableTzinfo.calls = 0 + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_analysis_snapshot.recorded_at row has invalid scalar evidence", + ): + self._read(self._valid_script(headers=[header])) + + self.assertEqual(_ExecutableTzinfo.calls, 0) + + def test_read_accepts_psycopg3_zoneinfo_timestamptz_projection(self) -> None: + canonical = _header_row() + zoneinfo_recorded_at = RECORDED_AT.astimezone(ZoneInfo("UTC")) + header = canonical[:6] + (zoneinfo_recorded_at,) + canonical[7:] + + self._read(self._valid_script(headers=[header])) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/job-analysis-api/tests/test_postgres_read_service_zoneinfo_compatibility.py b/services/job-analysis-api/tests/test_postgres_read_service_zoneinfo_compatibility.py new file mode 100644 index 000000000..189c75802 --- /dev/null +++ b/services/job-analysis-api/tests/test_postgres_read_service_zoneinfo_compatibility.py @@ -0,0 +1,58 @@ +"""Regression for Psycopg ZoneInfo timestamps crossing the governed read boundary.""" + +from __future__ import annotations + +import unittest +from zoneinfo import ZoneInfo + +from orgmetra_job_analysis_api.postgres import PostgresJobAnalysisPort +from orgmetra_job_analysis_api.snapshot import read_job_analysis_snapshot + +from fixtures import ANALYSIS, RECORDED_AT, TENANT, read_policy, read_principal +from test_postgres import ( + FakeConnection, + FakeCursor, + _header_row, + _ksao_rows, + _link_rows, + _task_rows, +) + + +class PostgresReadServiceZoneInfoCompatibilityTests(unittest.TestCase): + """Keep accepted Psycopg timestamptz evidence valid through customer export.""" + + def test_read_accepts_psycopg_zoneinfo_timestamp_through_governed_export(self) -> None: + """A standard-library ZoneInfo row accepted by the port must remain readable.""" + header = _header_row() + zoneinfo_recorded_at = RECORDED_AT.astimezone(ZoneInfo("Asia/Seoul")) + header = header[:6] + (zoneinfo_recorded_at,) + header[7:] + cursor = FakeCursor( + [ + None, + None, + [header], + _task_rows(), + _ksao_rows(), + _link_rows(), + ] + ) + port = PostgresJobAnalysisPort(lambda: FakeConnection(cursor)) + + view = read_job_analysis_snapshot( + principal=read_principal(), + tenant_record_id=TENANT, + analysis_record_id=ANALYSIS, + purpose_code="job_analysis_read", + policy=read_policy(), + read_port=port, + ) + + self.assertEqual( + view.snapshot["recorded_at"], + RECORDED_AT.isoformat().replace("+00:00", "Z"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/job-analysis-api/tests/test_postgres_request_digest_binding.py b/services/job-analysis-api/tests/test_postgres_request_digest_binding.py new file mode 100644 index 000000000..d5e66618e --- /dev/null +++ b/services/job-analysis-api/tests/test_postgres_request_digest_binding.py @@ -0,0 +1,37 @@ +"""Regression coverage for semantic request-digest binding at the PostgreSQL boundary.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +from orgmetra_job_analysis_api.postgres import PostgresJobAnalysisPort +from orgmetra_job_analysis_api.snapshot import JobAnalysisIntegrityError +from fixtures import IDEMPOTENCY_KEY, clinical_psychologist_snapshot +from test_postgres import _audit_event + + +def test_write_port_rejects_well_formed_digest_for_different_command_before_database() -> None: + """A syntactically valid digest cannot redefine idempotency semantics at the durable port.""" + snapshot = clinical_psychologist_snapshot() + + def never_connect() -> object: + raise AssertionError("database acquired before request-digest binding validation") + + with pytest.raises( + JobAnalysisIntegrityError, + match="request_digest does not match detached snapshot command", + ): + PostgresJobAnalysisPort(never_connect).persist_snapshot( + snapshot=snapshot, + idempotency_key=IDEMPOTENCY_KEY, + request_digest="0" * 64, + actor_reference="keyverse:actor-ja-1", + purpose_code="job_analysis_write", + position_record_id=None, + criterion_blueprint_id=None, + audit_event=_audit_event(), + outbox_delivery_record_id=UUID("0198a412-6000-7000-8000-000000000412"), + write_command_id=UUID("0198a412-6000-7000-8000-000000000413"), + ) diff --git a/services/job-analysis-api/tests/test_postgres_row_collection_integrity.py b/services/job-analysis-api/tests/test_postgres_row_collection_integrity.py new file mode 100644 index 000000000..2050b9d6e --- /dev/null +++ b/services/job-analysis-api/tests/test_postgres_row_collection_integrity.py @@ -0,0 +1,91 @@ +"""Regression coverage for inert PostgreSQL fixed-projection row collections.""" + +from __future__ import annotations + +import unittest + +from orgmetra_job_analysis_api.postgres import PostgresJobAnalysisPort +from orgmetra_job_analysis_api.snapshot import JobAnalysisIntegrityError +from fixtures import ANALYSIS, TENANT +from test_postgres import ( + FakeConnection, + FakeCursor, + _header_row, + _ksao_rows, + _link_rows, + _task_rows, +) + + +class _ExecutableRowCollection(list[object]): + """Fail if database collection hooks run before the durable boundary.""" + + def __bool__(self) -> bool: + raise AssertionError("row collection truthiness executed") + + def __len__(self) -> int: + raise AssertionError("row collection length executed") + + def __getitem__(self, index: object) -> object: + raise AssertionError("row collection indexing executed") + + def __iter__(self): + raise AssertionError("row collection iteration executed") + + +class _CollectionCursor(FakeCursor): + """Return one executable outer collection at a selected fetch boundary.""" + + def __init__(self, script: list[object], *, dangerous_fetch: str) -> None: + super().__init__(script) + self.dangerous_fetch = dangerous_fetch + self.fetchall_count = 0 + + def fetchmany(self, size: int) -> object: + if self.dangerous_fetch == "headers": + return _ExecutableRowCollection([_header_row()]) + return super().fetchmany(size) + + def fetchall(self) -> object: + self.fetchall_count += 1 + if self.dangerous_fetch == "tasks" and self.fetchall_count == 1: + return _ExecutableRowCollection(_task_rows()) + return super().fetchall() + + +class PostgresRowCollectionIntegrityTests(unittest.TestCase): + """Require inert collection containers before any row access occurs.""" + + @staticmethod + def _script() -> list[object]: + return [ + None, + None, + [_header_row()], + _task_rows(), + _ksao_rows(), + _link_rows(), + ] + + def _read(self, *, dangerous_fetch: str) -> None: + cursor = _CollectionCursor(self._script(), dangerous_fetch=dangerous_fetch) + port = PostgresJobAnalysisPort(lambda: FakeConnection(cursor)) + port.read_snapshot(tenant_record_id=TENANT, analysis_record_id=ANALYSIS) + + def test_read_rejects_executable_header_collection_before_hooks(self) -> None: + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_analysis_snapshot row collection has invalid shape", + ): + self._read(dangerous_fetch="headers") + + def test_read_rejects_executable_child_collection_before_iteration(self) -> None: + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_analysis_task_item row collection has invalid shape", + ): + self._read(dangerous_fetch="tasks") + + +if __name__ == "__main__": + unittest.main() diff --git a/services/job-analysis-api/tests/test_postgres_scope_projection_integrity.py b/services/job-analysis-api/tests/test_postgres_scope_projection_integrity.py new file mode 100644 index 000000000..9269ac30c --- /dev/null +++ b/services/job-analysis-api/tests/test_postgres_scope_projection_integrity.py @@ -0,0 +1,215 @@ +"""Regression coverage for scope-query target identity at the durable port.""" + +from __future__ import annotations + +import unittest +from uuid import UUID + +from orgmetra_job_analysis_api.postgres import PostgresJobAnalysisPort +from orgmetra_job_analysis_api.snapshot import JobAnalysisIntegrityError, command_digest +from fixtures import CRITERION, IDEMPOTENCY_KEY, JOB, POSITION, clinical_psychologist_snapshot +from test_postgres import FakeConnection, FakeCursor, _audit_event + + +_WRONG_JOB = UUID("0198a412-6000-7000-8000-000000000491") +_WRONG_POSITION = UUID("0198a412-6000-7000-8000-000000000492") +_WRONG_CRITERION = UUID("0198a412-6000-7000-8000-000000000493") + + +class _EqualityForgedUUID(UUID): + """Model a DB-API UUID subtype that can forge equality at a trust boundary.""" + + def __eq__(self, other: object) -> bool: + return True + + def __ne__(self, other: object) -> bool: + return False + + +_FORGED_JOB = _EqualityForgedUUID(str(_WRONG_JOB)) +_FORGED_POSITION = _EqualityForgedUUID(str(_WRONG_POSITION)) +_FORGED_CRITERION = _EqualityForgedUUID(str(_WRONG_CRITERION)) + + +class PostgresScopeProjectionIntegrityTests(unittest.TestCase): + """Require scope rows to prove the exact Job, Position, and Criterion queried.""" + + def _persist( + self, + cursor: FakeCursor, + *, + position_record_id: UUID | None = None, + criterion_blueprint_id: UUID | None = None, + ) -> None: + snapshot = clinical_psychologist_snapshot() + PostgresJobAnalysisPort(lambda: FakeConnection(cursor)).persist_snapshot( + snapshot=snapshot, + idempotency_key=IDEMPOTENCY_KEY, + request_digest=command_digest( + snapshot=snapshot, + position_record_id=position_record_id, + criterion_blueprint_id=criterion_blueprint_id, + ), + actor_reference="keyverse:actor-ja-1", + purpose_code="job_analysis_write", + position_record_id=position_record_id, + criterion_blueprint_id=criterion_blueprint_id, + audit_event=_audit_event(), + outbox_delivery_record_id=UUID("0198a412-6000-7000-8000-000000000494"), + write_command_id=UUID("0198a412-6000-7000-8000-000000000495"), + ) + + def _assert_no_write(self, cursor: FakeCursor) -> None: + self.assertFalse( + any( + statement.startswith("INSERT INTO") + or "record_audit_outbox_event" in statement + for statement, _ in cursor.executions + ) + ) + + def test_job_scope_projection_must_name_requested_job(self) -> None: + cursor = FakeCursor([None, None, (_WRONG_JOB,)]) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "job_profile scope row"): + self._persist(cursor) + + self._assert_no_write(cursor) + + def test_position_scope_projection_must_name_requested_position(self) -> None: + cursor = FakeCursor([None, None, (JOB,), (_WRONG_POSITION, JOB)]) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "position_record scope row"): + self._persist(cursor, position_record_id=POSITION) + + self._assert_no_write(cursor) + + def test_criterion_scope_projection_must_name_requested_criterion(self) -> None: + cursor = FakeCursor([None, None, (JOB,), (_WRONG_CRITERION, JOB)]) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "criterion_blueprint scope row"): + self._persist(cursor, criterion_blueprint_id=CRITERION) + + self._assert_no_write(cursor) + + def test_job_scope_projection_rejects_equality_forging_uuid_subtype(self) -> None: + cursor = FakeCursor([None, None, (_FORGED_JOB,)]) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "job_profile scope row"): + self._persist(cursor) + + self._assert_no_write(cursor) + + def test_position_scope_projection_rejects_equality_forging_uuid_subtype(self) -> None: + cursor = FakeCursor([None, None, (JOB,), (_FORGED_POSITION, JOB)]) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "position_record scope row"): + self._persist(cursor, position_record_id=POSITION) + + self._assert_no_write(cursor) + + def test_criterion_scope_projection_rejects_equality_forging_uuid_subtype(self) -> None: + cursor = FakeCursor([None, None, (JOB,), (_FORGED_CRITERION, JOB)]) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "criterion_blueprint scope row"): + self._persist(cursor, criterion_blueprint_id=CRITERION) + + self._assert_no_write(cursor) + + def test_job_scope_projection_rejects_invalid_shape(self) -> None: + for malformed_row in (object(), (), (JOB, JOB)): + with self.subTest(malformed_row=malformed_row): + cursor = FakeCursor([None, None, malformed_row]) + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_profile scope row has invalid shape", + ): + self._persist(cursor) + + self._assert_no_write(cursor) + + def test_position_scope_projection_rejects_invalid_shape(self) -> None: + for malformed_row in (object(), (POSITION,), (POSITION, JOB, JOB)): + with self.subTest(malformed_row=malformed_row): + cursor = FakeCursor([None, None, (JOB,), malformed_row]) + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "position_record scope row has invalid shape", + ): + self._persist(cursor, position_record_id=POSITION) + + self._assert_no_write(cursor) + + def test_criterion_scope_projection_rejects_invalid_shape(self) -> None: + for malformed_row in (object(), (CRITERION,), (CRITERION, JOB, JOB)): + with self.subTest(malformed_row=malformed_row): + cursor = FakeCursor([None, None, (JOB,), malformed_row]) + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "criterion_blueprint scope row has invalid shape", + ): + self._persist(cursor, criterion_blueprint_id=CRITERION) + + self._assert_no_write(cursor) + + def test_job_scope_projection_rejects_non_sequence_iterable(self) -> None: + cursor = FakeCursor([None, None, (value for value in (JOB,))]) + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "job_profile scope row has invalid shape", + ): + self._persist(cursor) + + self._assert_no_write(cursor) + + def test_position_scope_projection_rejects_non_sequence_iterable(self) -> None: + cursor = FakeCursor([None, None, (JOB,), (value for value in (POSITION, JOB))]) + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "position_record scope row has invalid shape", + ): + self._persist(cursor, position_record_id=POSITION) + + self._assert_no_write(cursor) + + def test_criterion_scope_projection_rejects_non_sequence_iterable(self) -> None: + cursor = FakeCursor([None, None, (JOB,), (value for value in (CRITERION, JOB))]) + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "criterion_blueprint scope row has invalid shape", + ): + self._persist(cursor, criterion_blueprint_id=CRITERION) + + self._assert_no_write(cursor) + + def test_position_scope_projection_rejects_forged_job_relationship_uuid(self) -> None: + cursor = FakeCursor([None, None, (JOB,), (POSITION, _FORGED_JOB)]) + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "position_record.job_profile_id scope row", + ): + self._persist(cursor, position_record_id=POSITION) + + self._assert_no_write(cursor) + + def test_criterion_scope_projection_rejects_forged_job_relationship_uuid(self) -> None: + cursor = FakeCursor([None, None, (JOB,), (CRITERION, _FORGED_JOB)]) + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "criterion_blueprint.job_profile_id scope row", + ): + self._persist(cursor, criterion_blueprint_id=CRITERION) + + self._assert_no_write(cursor) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/services/job-analysis-api/tests/test_postgres_snapshot_detachment.py b/services/job-analysis-api/tests/test_postgres_snapshot_detachment.py new file mode 100644 index 000000000..2b7bbf3df --- /dev/null +++ b/services/job-analysis-api/tests/test_postgres_snapshot_detachment.py @@ -0,0 +1,108 @@ +"""Regression coverage for detached Job Analysis snapshots at the PostgreSQL boundary.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +from orgmetra_job_analysis_api.postgres import PostgresJobAnalysisPort +from orgmetra_job_analysis_api.snapshot import JobAnalysisIntegrityError, command_digest +from fixtures import IDEMPOTENCY_KEY, JOB, clinical_psychologist_snapshot +from test_postgres import FakeConnection, FakeCursor, _audit_event + +_MUTATED_ANALYSIS = UUID("0198a412-6000-7000-8000-000000000499") + + +def test_write_port_detaches_snapshot_before_executable_database_acquisition() -> None: + """Database hooks cannot retarget or rewrite already-authorized snapshot evidence.""" + snapshot = clinical_psychologist_snapshot() + expected_snapshot = snapshot.to_snapshot() + expected_analysis_id = snapshot.analysis_record_id + expected_version = snapshot.analysis_version_code + expected_task_statement = snapshot.tasks[0].task_statement + write_statement_count = ( + 1 + + len(snapshot.tasks) + + len(snapshot.ksao_requirements) + + len(snapshot.task_ksao_links) + + 2 + ) + cursor = FakeCursor([None, None, (JOB,)] + [None] * write_statement_count) + + def connection_factory() -> FakeConnection: + object.__setattr__(snapshot, "analysis_record_id", _MUTATED_ANALYSIS) + object.__setattr__(snapshot, "analysis_version_code", "clinical-psychologist:mutated") + object.__setattr__(snapshot.tasks[0], "task_statement", "Mutated task statement after authorization") + return FakeConnection(cursor) + + persisted = PostgresJobAnalysisPort(connection_factory).persist_snapshot( + snapshot=snapshot, + idempotency_key=IDEMPOTENCY_KEY, + request_digest=command_digest( + snapshot=snapshot, + position_record_id=None, + criterion_blueprint_id=None, + ), + actor_reference="keyverse:actor-ja-1", + purpose_code="job_analysis_write", + position_record_id=None, + criterion_blueprint_id=None, + audit_event=_audit_event(), + outbox_delivery_record_id=UUID("0198a412-6000-7000-8000-000000000412"), + write_command_id=UUID("0198a412-6000-7000-8000-000000000413"), + ) + + snapshot_insert = next( + parameters + for statement, parameters in cursor.executions + if statement.startswith("INSERT INTO public.job_analysis_snapshot") + ) + task_insert = next( + parameters + for statement, parameters in cursor.executions + if statement.startswith("INSERT INTO public.job_analysis_task_item") + ) + command_insert = next( + parameters + for statement, parameters in cursor.executions + if statement.startswith("INSERT INTO public.job_analysis_write_command") + ) + + assert snapshot_insert[1] == expected_analysis_id + assert snapshot_insert[5] == expected_version + assert task_insert[1] == expected_analysis_id + assert task_insert[3] == expected_task_statement + assert command_insert[2] == expected_analysis_id + assert persisted.to_snapshot() == expected_snapshot + + +def test_write_port_rejects_noncanonical_snapshot_before_database_acquisition() -> None: + """A low-level-normalizable mutation must not silently change durable evidence.""" + snapshot = clinical_psychologist_snapshot() + original_statement = snapshot.tasks[0].task_statement + object.__setattr__(snapshot.tasks[0], "task_statement", f" {original_statement} ") + + def never_connect() -> object: + raise AssertionError("database acquired before noncanonical snapshot rejection") + + with pytest.raises( + JobAnalysisIntegrityError, + match="detached snapshot does not match canonical evidence", + ): + PostgresJobAnalysisPort(never_connect).persist_snapshot( + snapshot=snapshot, + idempotency_key=IDEMPOTENCY_KEY, + request_digest=command_digest( + snapshot=snapshot, + position_record_id=None, + criterion_blueprint_id=None, + ), + actor_reference="keyverse:actor-ja-1", + purpose_code="job_analysis_write", + position_record_id=None, + criterion_blueprint_id=None, + audit_event=_audit_event(), + outbox_delivery_record_id=UUID("0198a412-6000-7000-8000-000000000412"), + write_command_id=UUID("0198a412-6000-7000-8000-000000000413"), + ) diff --git a/services/job-analysis-api/tests/test_read_snapshot_graph_runtime_integrity.py b/services/job-analysis-api/tests/test_read_snapshot_graph_runtime_integrity.py new file mode 100644 index 000000000..c5bef87dc --- /dev/null +++ b/services/job-analysis-api/tests/test_read_snapshot_graph_runtime_integrity.py @@ -0,0 +1,158 @@ +"""Regression contract for inert Job Analysis read-result graphs.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, tzinfo +import unittest +from uuid import UUID + +from orgmetra_hris_kernel import JobAnalysisSnapshot +from orgmetra_job_analysis_api.snapshot import ( + JobAnalysisIntegrityError, + read_job_analysis_snapshot, +) + +from fixtures import ( + ANALYSIS, + JOB, + TENANT, + clinical_psychologist_snapshot, + read_policy, + read_principal, +) + + +class _ExecutableUUID(UUID): + """Raise if response export stringifies caller-controlled identity evidence.""" + + def __str__(self) -> str: + """Fail if a low-level rewritten UUID reaches snapshot export.""" + raise AssertionError("returned snapshot UUID stringification executed") + + +class _ExecutableTuple(tuple[object, ...]): + """Raise if response export iterates a caller-controlled collection.""" + + def __iter__(self): # type: ignore[override] + """Fail if a low-level rewritten collection reaches sorting/export.""" + raise AssertionError("returned snapshot collection iteration executed") + + +class _ExecutableText(str): + """Represent non-inert text that must not cross the read-result boundary.""" + + +class _ExecutableTimezone(tzinfo): + """Raise if datetime canonicalization delegates to caller-controlled timezone code.""" + + def utcoffset(self, dt: datetime | None) -> timedelta: + """Fail if response export asks the hostile timezone for an offset.""" + raise AssertionError("returned snapshot timezone offset executed") + + def dst(self, dt: datetime | None) -> timedelta: + """Return a nominal DST value; export must not call this implementation.""" + return timedelta(0) + + def tzname(self, dt: datetime | None) -> str: + """Return a nominal name; export must reject the timezone before use.""" + return "EXECUTABLE" + + +class _ReadPort: + """Return one configured exact snapshot without normalizing live fields.""" + + def __init__(self, snapshot: JobAnalysisSnapshot) -> None: + self.snapshot = snapshot + + def read_snapshot( + self, + *, + tenant_record_id: UUID, + analysis_record_id: UUID, + ) -> JobAnalysisSnapshot: + """Return the configured snapshot exactly as supplied by the test.""" + return self.snapshot + + +def _read(snapshot: JobAnalysisSnapshot) -> None: + """Execute the governed read path for one adversarial returned snapshot.""" + read_job_analysis_snapshot( + principal=read_principal(), + tenant_record_id=TENANT, + analysis_record_id=ANALYSIS, + purpose_code="job_analysis_read", + policy=read_policy(), + read_port=_ReadPort(snapshot), + ) + + +class ReadSnapshotGraphRuntimeIntegrityTests(unittest.TestCase): + """Reject executable non-target graph evidence before customer export.""" + + def test_root_job_identity_is_exact_gated_before_stringification(self) -> None: + """Reject an executable Job UUID even when route target identities are valid.""" + snapshot = clinical_psychologist_snapshot() + object.__setattr__(snapshot, "job_record_id", _ExecutableUUID(str(JOB))) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "resolved snapshot graph"): + _read(snapshot) + + def test_task_collection_is_exact_gated_before_iteration(self) -> None: + """Reject an executable tuple before sorting the returned Task evidence.""" + snapshot = clinical_psychologist_snapshot() + object.__setattr__(snapshot, "tasks", _ExecutableTuple(snapshot.tasks)) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "resolved snapshot graph"): + _read(snapshot) + + def test_nested_task_identity_is_exact_gated_before_sort_key_stringification(self) -> None: + """Reject executable nested UUID evidence before deterministic ordering.""" + snapshot = clinical_psychologist_snapshot() + object.__setattr__( + snapshot.tasks[0], + "task_record_id", + _ExecutableUUID(str(snapshot.tasks[0].task_record_id)), + ) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "resolved snapshot graph"): + _read(snapshot) + + def test_root_text_is_exact_gated_before_response_export(self) -> None: + """Reject a text subtype even if exporting it would not call an override yet.""" + snapshot = clinical_psychologist_snapshot() + object.__setattr__( + snapshot, + "analysis_version_code", + _ExecutableText(snapshot.analysis_version_code), + ) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "resolved snapshot graph"): + _read(snapshot) + + def test_datetime_timezone_is_exact_gated_before_canonicalization(self) -> None: + """Reject caller-controlled timezone behavior before `_utc_text` can execute it.""" + snapshot = clinical_psychologist_snapshot() + object.__setattr__( + snapshot, + "recorded_at", + datetime(2026, 8, 18, 5, 0, tzinfo=_ExecutableTimezone()), + ) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "resolved snapshot graph"): + _read(snapshot) + + def test_nested_source_timezone_is_exact_gated_before_canonicalization(self) -> None: + """Reject executable provenance time below a Task before source export.""" + snapshot = clinical_psychologist_snapshot() + object.__setattr__( + snapshot.tasks[0].source, + "retrieved_at", + datetime(2026, 8, 18, 3, 0, tzinfo=_ExecutableTimezone()), + ) + + with self.assertRaisesRegex(JobAnalysisIntegrityError, "resolved snapshot graph"): + _read(snapshot) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/job-analysis-api/tests/test_read_snapshot_target_runtime_integrity.py b/services/job-analysis-api/tests/test_read_snapshot_target_runtime_integrity.py new file mode 100644 index 000000000..ffbd180f1 --- /dev/null +++ b/services/job-analysis-api/tests/test_read_snapshot_target_runtime_integrity.py @@ -0,0 +1,88 @@ +"""Regression contract for inert Job Analysis read-target evidence.""" + +from __future__ import annotations + +import unittest +from uuid import UUID + +from orgmetra_hris_kernel import JobAnalysisSnapshot +from orgmetra_job_analysis_api.snapshot import ( + JobAnalysisIntegrityError, + read_job_analysis_snapshot, +) + +from fixtures import ( + ANALYSIS, + TENANT, + clinical_psychologist_snapshot, + read_policy, + read_principal, +) + + +class _ExecutableUUID(UUID): + """Raise if a returned durable identity executes before exact validation.""" + + def __eq__(self, other: object) -> bool: + """Fail if target matching invokes caller-controlled equality.""" + raise AssertionError("returned UUID equality executed") + + def __ne__(self, other: object) -> bool: + """Fail if target matching invokes caller-controlled inequality.""" + raise AssertionError("returned UUID inequality executed") + + def __str__(self) -> str: + """Fail if export stringifies caller-controlled identity evidence.""" + raise AssertionError("returned UUID stringification executed") + + +class _ReadPort: + """Return one configured exact snapshot from the application read boundary.""" + + def __init__(self, snapshot: JobAnalysisSnapshot) -> None: + self.snapshot = snapshot + + def read_snapshot( + self, + *, + tenant_record_id: UUID, + analysis_record_id: UUID, + ) -> JobAnalysisSnapshot: + """Return the configured snapshot without normalizing its live fields.""" + return self.snapshot + + +class ReadSnapshotTargetRuntimeIntegrityTests(unittest.TestCase): + """Reject executable returned target identity before equality or export.""" + + def test_returned_target_uuid_subtype_fails_before_runtime_hooks(self) -> None: + """Treat low-level rewritten tenant/analysis identities as corrupt evidence.""" + cases = ( + ("tenant_record_id", TENANT), + ("analysis_record_id", ANALYSIS), + ) + for field_name, authorized_value in cases: + with self.subTest(field_name=field_name): + snapshot = clinical_psychologist_snapshot() + object.__setattr__( + snapshot, + field_name, + _ExecutableUUID(str(authorized_value)), + ) + + with self.assertRaisesRegex( + JobAnalysisIntegrityError, + "resolved snapshot target identity", + ): + read_job_analysis_snapshot( + principal=read_principal(), + tenant_record_id=TENANT, + analysis_record_id=ANALYSIS, + purpose_code="job_analysis_read", + policy=read_policy(), + read_port=_ReadPort(snapshot), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/job-analysis-api/tests/test_snapshot.py b/services/job-analysis-api/tests/test_snapshot.py index 0a887c9a4..faf7a59bb 100644 --- a/services/job-analysis-api/tests/test_snapshot.py +++ b/services/job-analysis-api/tests/test_snapshot.py @@ -34,6 +34,18 @@ ) +class _ExecutableIdempotencyKey(str): + """Model caller text that tries to execute through sequence validation hooks.""" + + def __len__(self) -> int: + """Raise if validation executes caller-controlled length behavior.""" + raise RuntimeError("idempotency key length hook executed") + + def __iter__(self): + """Raise if validation executes caller-controlled iteration behavior.""" + raise RuntimeError("idempotency key iteration hook executed") + + class RecordingWritePort: """Capture the exact write-port arguments, including Idempotency-Key.""" @@ -238,6 +250,23 @@ def test_integrity_mismatch_from_write_port_fails_closed(self) -> None: write_port=port, ) + def test_rejects_executable_idempotency_text_before_sequence_hooks(self) -> None: + """Reject a str subtype before caller-defined length or iteration can execute.""" + port = RecordingWritePort() + + with self.assertRaisesRegex(ValueError, "idempotency_key"): + persist_job_analysis_snapshot( + principal=write_principal(), + tenant_record_id=TENANT, + document=clinical_psychologist_document(), + idempotency_key=_ExecutableIdempotencyKey(IDEMPOTENCY_KEY), + purpose_code="job_analysis_write", + policy=write_policy(), + write_port=port, + ) + + self.assertEqual(port.calls, []) + def test_rejects_reserved_tenant_and_short_idempotency_key(self) -> None: port = RecordingWritePort() with self.assertRaises(ValueError): diff --git a/services/job-analysis-api/tests/test_snapshot_document_runtime_integrity.py b/services/job-analysis-api/tests/test_snapshot_document_runtime_integrity.py new file mode 100644 index 000000000..3a9d7bc75 --- /dev/null +++ b/services/job-analysis-api/tests/test_snapshot_document_runtime_integrity.py @@ -0,0 +1,222 @@ +"""Regressions for executable posted snapshot values before authorization.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone, tzinfo + +import pytest + +from orgmetra_job_analysis_api.snapshot import snapshot_from_document +from fixtures import TENANT, clinical_psychologist_document + + +class _ExecutableMapping(dict): + """Trip if snapshot parsing iterates or reads a caller-defined mapping subtype.""" + + def __iter__(self): + """Reject parser iteration before exact container validation.""" + raise AssertionError("mapping subtype iteration executed") + + def get(self, key: object, default: object = None) -> object: + """Reject parser field reads before exact container validation.""" + raise AssertionError("mapping subtype get executed") + + +class _ExecutableList(list): + """Trip if snapshot parsing consumes a caller-defined list subtype.""" + + def __len__(self) -> int: + """Reject truthiness/size checks before exact container validation.""" + raise AssertionError("list subtype length executed") + + def __iter__(self): + """Reject item iteration before exact container validation.""" + raise AssertionError("list subtype iteration executed") + + +class _ExecutableText(str): + """Trip if validation executes caller-defined string behavior.""" + + def replace(self, old: str, new: str, count: int = -1) -> str: + """Reject ISO timestamp normalization before exact text validation.""" + raise AssertionError("text subtype replace executed") + + def split(self, sep: str | None = None, maxsplit: int = -1) -> list[str]: + """Reject kernel text normalization before exact leaf validation.""" + raise AssertionError("text subtype split executed") + + +class _ExecutableFieldName(str): + """Trip if unknown-field validation hashes a caller-defined key before exact gating.""" + + armed: bool + + def __new__(cls, value: str): + """Create an initially inert key so the test mapping itself can be assembled.""" + instance = super().__new__(cls, value) + instance.armed = False + return instance + + def __hash__(self) -> int: + """Reject set membership after the fixture is armed.""" + if self.armed: + raise AssertionError("field-name subtype hash executed") + return str.__hash__(self) + + def __eq__(self, other: object) -> bool: + """Preserve normal string equality while hash remains the execution tripwire.""" + return bool(str.__eq__(self, other)) + + +class _ExecutableInteger(int): + """Trip if kernel ordinal validation compares a caller-defined integer subtype.""" + + def __ge__(self, other: object) -> bool: + """Reject lower-bound comparison before exact integer validation.""" + raise AssertionError("integer subtype comparison executed") + + def __le__(self, other: object) -> bool: + """Reject upper-bound comparison before exact integer validation.""" + raise AssertionError("integer subtype comparison executed") + + def __lt__(self, other: object) -> bool: + """Reject strict lower-bound comparison before exact integer validation.""" + raise AssertionError("integer subtype comparison executed") + + def __gt__(self, other: object) -> bool: + """Reject strict upper-bound comparison before exact integer validation.""" + raise AssertionError("integer subtype comparison executed") + + +class _ExecutableDateTime(datetime): + """Trip if timezone validation consumes a caller-defined datetime subtype.""" + + def utcoffset(self): + """Reject timezone behavior before exact datetime validation.""" + raise AssertionError("datetime subtype utcoffset executed") + + +class _ExecutableTimezone(tzinfo): + """Trip if an exact datetime can delegate validation to caller-defined tzinfo.""" + + def utcoffset(self, value: datetime | None) -> timedelta: + """Reject caller-controlled UTC offset computation.""" + raise AssertionError("timezone provider utcoffset executed") + + def dst(self, value: datetime | None) -> timedelta: + """Provide the abstract method without making it usable by the parser.""" + return timedelta(0) + + +def test_rejects_executable_top_level_mapping_before_iteration() -> None: + """The posted document must be an inert built-in mapping before any field scan.""" + posted = _ExecutableMapping(clinical_psychologist_document()) + + with pytest.raises(ValueError, match="snapshot document must be an object"): + snapshot_from_document(posted, tenant_record_id=TENANT) + + +def test_rejects_executable_field_name_before_hash_or_membership() -> None: + """Exact built-in field names must be established before schema membership checks.""" + posted = clinical_psychologist_document() + value = posted.pop("analysis_record_id") + field_name = _ExecutableFieldName("analysis_record_id") + posted[field_name] = value + field_name.armed = True + + with pytest.raises(ValueError, match="field names must be exact built-in text"): + snapshot_from_document(posted, tenant_record_id=TENANT) + + +def test_rejects_executable_task_list_before_truthiness_or_iteration() -> None: + """Repeated snapshot members must be inert built-in lists before size checks.""" + posted = clinical_psychologist_document() + posted["tasks"] = _ExecutableList(posted["tasks"]) + + with pytest.raises(ValueError, match="tasks must be a non-empty list"): + snapshot_from_document(posted, tenant_record_id=TENANT) + + +def test_rejects_executable_nested_source_mapping_before_field_reads() -> None: + """Nested evidence objects must be exact mappings before `.get` or iteration.""" + posted = clinical_psychologist_document() + first_task = dict(posted["tasks"][0]) + first_task["source"] = _ExecutableMapping(first_task["source"]) + posted["tasks"] = [first_task, *posted["tasks"][1:]] + + with pytest.raises(ValueError, match="source must be an object"): + snapshot_from_document(posted, tenant_record_id=TENANT) + + +def test_rejects_executable_task_text_before_kernel_normalization() -> None: + """Leaf text must be exact built-in text before kernel `.split` normalization.""" + posted = clinical_psychologist_document() + first_task = dict(posted["tasks"][0]) + first_task["task_statement"] = _ExecutableText(first_task["task_statement"]) + posted["tasks"] = [first_task, *posted["tasks"][1:]] + + with pytest.raises(ValueError, match="task_statement must be exact built-in text"): + snapshot_from_document(posted, tenant_record_id=TENANT) + + +def test_rejects_executable_rating_before_kernel_comparison() -> None: + """Ordinal ratings must be exact built-in integers before range comparison.""" + posted = clinical_psychologist_document() + first_task = dict(posted["tasks"][0]) + first_task["importance_level"] = _ExecutableInteger(first_task["importance_level"]) + posted["tasks"] = [first_task, *posted["tasks"][1:]] + + with pytest.raises(ValueError, match="importance_level must be an exact built-in integer"): + snapshot_from_document(posted, tenant_record_id=TENANT) + + +def test_rejects_non_boolean_essential_flag_at_request_boundary() -> None: + """Boolean relationship flags must not reuse the integer acceptance surface.""" + posted = clinical_psychologist_document() + first_link = dict(posted["task_ksao_links"][0]) + first_link["essential_for_task"] = 1 + posted["task_ksao_links"] = [first_link, *posted["task_ksao_links"][1:]] + + with pytest.raises(ValueError, match="essential_for_task must be an exact built-in boolean"): + snapshot_from_document(posted, tenant_record_id=TENANT) + + +def test_rejects_executable_timestamp_text_before_replace() -> None: + """ISO timestamp parsing must exact-gate text before `.replace` can execute.""" + posted = clinical_psychologist_document() + posted["recorded_at"] = _ExecutableText(posted["recorded_at"]) + + with pytest.raises(ValueError, match="recorded_at must be an ISO-8601 datetime"): + snapshot_from_document(posted, tenant_record_id=TENANT) + + +def test_rejects_executable_datetime_before_timezone_behavior() -> None: + """Direct Python datetime support must not admit executable datetime subtypes.""" + posted = clinical_psychologist_document() + posted["recorded_at"] = _ExecutableDateTime( + 2026, + 8, + 18, + 5, + 0, + tzinfo=timezone.utc, + ) + + with pytest.raises(ValueError, match="recorded_at must be an ISO-8601 datetime"): + snapshot_from_document(posted, tenant_record_id=TENANT) + + +def test_rejects_executable_timezone_provider_before_utcoffset() -> None: + """An exact datetime must not delegate trust validation to caller-defined tzinfo.""" + posted = clinical_psychologist_document() + posted["recorded_at"] = datetime( + 2026, + 8, + 18, + 5, + 0, + tzinfo=_ExecutableTimezone(), + ) + + with pytest.raises(ValueError, match="recorded_at must use a fixed UTC offset"): + snapshot_from_document(posted, tenant_record_id=TENANT) diff --git a/services/job-analysis-api/tests/test_snapshot_returned_graph_capture_integrity.py b/services/job-analysis-api/tests/test_snapshot_returned_graph_capture_integrity.py new file mode 100644 index 000000000..bb9245840 --- /dev/null +++ b/services/job-analysis-api/tests/test_snapshot_returned_graph_capture_integrity.py @@ -0,0 +1,123 @@ +"""Regression contract for binding returned snapshot validation to emitted evidence.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +import orgmetra_job_analysis_api.snapshot as snapshot_module +from orgmetra_hris_kernel import JobAnalysisSnapshot +from orgmetra_job_analysis_api.snapshot import ( + persist_job_analysis_snapshot, + read_job_analysis_snapshot, +) + +from fixtures import ( + ANALYSIS, + IDEMPOTENCY_KEY, + JOB, + TENANT, + clinical_psychologist_document, + clinical_psychologist_snapshot, + read_policy, + read_principal, + write_policy, + write_principal, +) + + +class _ExecutableUUID(UUID): + """Raise if evidence installed after validation reaches snapshot export.""" + + def __str__(self) -> str: + """Fail if a checked-versus-emitted gap rereads the mutated live graph.""" + raise AssertionError("post-validation UUID stringification executed") + + +class _ReturningWritePort: + """Return one retained exact snapshot from the persistence boundary.""" + + def __init__(self, result: JobAnalysisSnapshot) -> None: + self.result = result + + def persist_snapshot(self, **_: object) -> JobAnalysisSnapshot: + """Return the exact retained result without normalizing it.""" + return self.result + + +class _ReadPort: + """Return one retained exact snapshot from the read boundary.""" + + def __init__(self, result: JobAnalysisSnapshot) -> None: + self.result = result + + def read_snapshot( + self, + *, + tenant_record_id: UUID, + analysis_record_id: UUID, + ) -> JobAnalysisSnapshot: + """Return the exact retained result without normalizing it.""" + return self.result + + +def _mutate_after_runtime_validation( + monkeypatch: pytest.MonkeyPatch, + returned: JobAnalysisSnapshot, +) -> None: + """Inject a deterministic retained-adapter mutation after the validation pass.""" + original = snapshot_module._validate_resolved_snapshot_graph_runtime + + def validate_then_mutate(snapshot: JobAnalysisSnapshot): + captured = original(snapshot) + if snapshot is returned: + object.__setattr__(snapshot, "job_record_id", _ExecutableUUID(str(JOB))) + return captured + + monkeypatch.setattr( + snapshot_module, + "_validate_resolved_snapshot_graph_runtime", + validate_then_mutate, + ) + + +def test_read_uses_the_exact_graph_captured_by_runtime_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not reread a retained read-port graph after it passed validation.""" + returned = clinical_psychologist_snapshot() + expected = returned.to_snapshot() + _mutate_after_runtime_validation(monkeypatch, returned) + + view = read_job_analysis_snapshot( + principal=read_principal(), + tenant_record_id=TENANT, + analysis_record_id=ANALYSIS, + purpose_code="job_analysis_read", + policy=read_policy(), + read_port=_ReadPort(returned), + ) + + assert view.snapshot == expected + + +def test_write_uses_the_exact_graph_captured_by_runtime_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not reread a retained write-port graph after it passed validation.""" + returned = clinical_psychologist_snapshot() + expected = returned.to_snapshot() + _mutate_after_runtime_validation(monkeypatch, returned) + + view = persist_job_analysis_snapshot( + principal=write_principal(), + tenant_record_id=TENANT, + document=clinical_psychologist_document(), + idempotency_key=IDEMPOTENCY_KEY, + purpose_code="job_analysis_write", + policy=write_policy(), + write_port=_ReturningWritePort(returned), + ) + + assert view.snapshot == expected diff --git a/services/job-analysis-api/tests/test_snapshot_returned_owner_coherence.py b/services/job-analysis-api/tests/test_snapshot_returned_owner_coherence.py new file mode 100644 index 000000000..8d960e3f1 --- /dev/null +++ b/services/job-analysis-api/tests/test_snapshot_returned_owner_coherence.py @@ -0,0 +1,107 @@ +"""Regression contract for returned Job Analysis ownership coherence.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +from orgmetra_hris_kernel import JobAnalysisSnapshot +from orgmetra_job_analysis_api.snapshot import ( + JobAnalysisIntegrityError, + persist_job_analysis_snapshot, + read_job_analysis_snapshot, +) + +from fixtures import ( + ANALYSIS, + IDEMPOTENCY_KEY, + TENANT, + clinical_psychologist_document, + clinical_psychologist_snapshot, + read_policy, + read_principal, + write_policy, + write_principal, +) + +_OTHER_TENANT = UUID("0198a412-6000-7000-8000-000000000491") +_OTHER_JOB = UUID("0198a412-6000-7000-8000-000000000492") + + +class _ReadPort: + """Return one exact snapshot without repairing low-level ownership drift.""" + + def __init__(self, snapshot: JobAnalysisSnapshot) -> None: + self.snapshot = snapshot + + def read_snapshot(self, **_: object) -> JobAnalysisSnapshot: + """Return the configured snapshot exactly as durable evidence supplied it.""" + return self.snapshot + + +class _WritePort: + """Return one exact snapshot without repairing low-level ownership drift.""" + + def __init__(self, snapshot: JobAnalysisSnapshot) -> None: + self.snapshot = snapshot + + def persist_snapshot(self, **_: object) -> JobAnalysisSnapshot: + """Return the configured snapshot exactly as durable evidence supplied it.""" + return self.snapshot + + +def _read(snapshot: JobAnalysisSnapshot) -> None: + """Execute the governed read path for one contradictory returned graph.""" + read_job_analysis_snapshot( + principal=read_principal(), + tenant_record_id=TENANT, + analysis_record_id=ANALYSIS, + purpose_code="job_analysis_read", + policy=read_policy(), + read_port=_ReadPort(snapshot), + ) + + +def test_read_rejects_task_tenant_drift_before_canonicalization() -> None: + """A Task cannot be silently re-parented to the snapshot tenant during export.""" + snapshot = clinical_psychologist_snapshot() + object.__setattr__(snapshot.tasks[0], "tenant_record_id", _OTHER_TENANT) + + with pytest.raises(JobAnalysisIntegrityError, match="ownership"): + _read(snapshot) + + +def test_read_rejects_ksao_job_drift_before_canonicalization() -> None: + """A KSAO cannot be silently re-parented to the snapshot Job during export.""" + snapshot = clinical_psychologist_snapshot() + object.__setattr__(snapshot.ksao_requirements[0], "job_record_id", _OTHER_JOB) + + with pytest.raises(JobAnalysisIntegrityError, match="ownership"): + _read(snapshot) + + +def test_read_rejects_fja_tenant_drift_before_canonicalization() -> None: + """The FJA profile must retain the exact returned snapshot tenant ownership.""" + snapshot = clinical_psychologist_snapshot() + object.__setattr__(snapshot.fja_profile, "tenant_record_id", _OTHER_TENANT) + + with pytest.raises(JobAnalysisIntegrityError, match="ownership"): + _read(snapshot) + + +def test_write_rejects_fja_job_drift_before_posted_document_comparison() -> None: + """Write-result capture must reject FJA ownership drift instead of normalizing it.""" + snapshot = clinical_psychologist_snapshot() + object.__setattr__(snapshot.fja_profile, "job_record_id", _OTHER_JOB) + + with pytest.raises(JobAnalysisIntegrityError, match="persisted snapshot graph"): + persist_job_analysis_snapshot( + principal=write_principal(), + tenant_record_id=TENANT, + document=clinical_psychologist_document(), + idempotency_key=IDEMPOTENCY_KEY, + purpose_code="job_analysis_write", + policy=write_policy(), + write_port=_WritePort(snapshot), + ) diff --git a/services/job-analysis-api/tests/test_snapshot_runtime_integrity.py b/services/job-analysis-api/tests/test_snapshot_runtime_integrity.py new file mode 100644 index 000000000..413ac7c51 --- /dev/null +++ b/services/job-analysis-api/tests/test_snapshot_runtime_integrity.py @@ -0,0 +1,211 @@ +"""Regression coverage for executable snapshot subtypes at Job Analysis durable boundaries.""" + +from __future__ import annotations + +from dataclasses import fields +from datetime import datetime, timezone +from uuid import UUID + +import pytest + +from orgmetra_hris_kernel import AuditOutboxEvent, JobAnalysisSnapshot +from orgmetra_job_analysis_api.postgres import PostgresJobAnalysisPort +from orgmetra_job_analysis_api.snapshot import ( + JobAnalysisIntegrityError, + command_digest, + persist_job_analysis_snapshot, + read_job_analysis_snapshot, +) +from fixtures import ( + ANALYSIS, + IDEMPOTENCY_KEY, + TENANT, + clinical_psychologist_document, + clinical_psychologist_snapshot, + read_policy, + read_principal, + write_policy, + write_principal, +) + +_ACTOR_REFERENCE = "keyverse:actor-ja-1" +_PURPOSE_CODE = "job_analysis_write" + + +class _SnapshotSubtype(JobAnalysisSnapshot): + """Trip if command or persistence code consumes caller-defined snapshot behavior.""" + + _TRIPWIRE_FIELDS = frozenset({"analysis_record_id", "tenant_record_id", "job_record_id"}) + + def __getattribute__(self, name: str) -> object: + """Reject authority-field reads once the adversarial fixture is armed.""" + if name in _SnapshotSubtype._TRIPWIRE_FIELDS: + try: + armed = object.__getattribute__(self, "_tripwire_armed") + except AttributeError: + armed = False + if armed: + raise AssertionError(f"snapshot subtype field consumed before exact-type rejection: {name}") + return super().__getattribute__(name) + + def canonical_json(self) -> str: + """Reject canonical serialization if command validation is reordered.""" + raise AssertionError("snapshot subtype canonical_json consumed before exact-type rejection") + + def content_digest(self) -> str: + """Reject digest serialization if persistence validation is reordered.""" + raise AssertionError("snapshot subtype content_digest consumed before exact-type rejection") + + def to_snapshot(self) -> dict[str, object]: + """Reject document export if a service consumes subtype behavior before validation.""" + raise AssertionError("snapshot subtype to_snapshot consumed before exact-type rejection") + + +class _ReturningWritePort: + """Return a configured persistence result without consuming it.""" + + def __init__(self, result: JobAnalysisSnapshot) -> None: + self.result = result + + def persist_snapshot(self, **_: object) -> JobAnalysisSnapshot: + """Return the adversarial value exactly as a compromised adapter could.""" + return self.result + + +class _ReturningReadPort: + """Return a configured read result without consuming it.""" + + def __init__(self, result: JobAnalysisSnapshot) -> None: + self.result = result + + def read_snapshot(self, **_: object) -> JobAnalysisSnapshot: + """Return the adversarial value exactly as a compromised adapter could.""" + return self.result + + +class _MutatingWritePort: + """Mutate the exact canonical snapshot supplied by the service and return the alias.""" + + def persist_snapshot(self, **kwargs: object) -> JobAnalysisSnapshot: + """Simulate a defective adapter that rewrites evidence through low-level mutation.""" + snapshot = kwargs["snapshot"] + assert type(snapshot) is JobAnalysisSnapshot + object.__setattr__(snapshot, "analysis_version_code", "clinical-psychologist:mutated") + return snapshot + + +def _snapshot_subtype() -> JobAnalysisSnapshot: + """Clone one valid kernel snapshot into a caller-defined runtime subtype and arm it.""" + snapshot = clinical_psychologist_snapshot() + values = { + field.name: getattr(snapshot, field.name) + for field in fields(JobAnalysisSnapshot) + if field.init + } + subtype = _SnapshotSubtype(**values) + object.__setattr__(subtype, "_tripwire_armed", True) + return subtype + + +def _audit_event(snapshot: JobAnalysisSnapshot) -> AuditOutboxEvent: + """Build the canonical audit envelope for the otherwise-valid durable write.""" + return AuditOutboxEvent( + event_id=UUID("0198a412-6000-7000-8000-000000000411"), + tenant_record_id=TENANT, + source_service="job_analysis_api", + event_type="orgmetra.job_architecture.snapshot_recorded", + resource_reference=f"job_analysis_snapshot:{ANALYSIS.hex}", + actor_reference=_ACTOR_REFERENCE, + purpose_code=_PURPOSE_CODE, + reason_code="snapshot_persisted", + evidence_version_code=snapshot.analysis_version_code, + result_code="recorded", + occurred_at=datetime(2026, 8, 18, 5, 1, tzinfo=timezone.utc), + high_impact=False, + ) + + +def _never_connect() -> object: + """Prove invalid snapshot runtime types fail before database acquisition.""" + raise AssertionError("database acquired before exact JobAnalysisSnapshot rejection") + + +def test_command_digest_rejects_snapshot_subtype_before_serialization() -> None: + """Semantic idempotency must not execute caller-defined snapshot serialization.""" + with pytest.raises(TypeError, match="snapshot must be an exact JobAnalysisSnapshot"): + command_digest( + snapshot=_snapshot_subtype(), + position_record_id=None, + criterion_blueprint_id=None, + ) + + +def test_postgres_rejects_snapshot_subtype_before_fields_or_database() -> None: + """Persistence must reject executable snapshot subtypes before field access or DB I/O.""" + base_snapshot = clinical_psychologist_snapshot() + port = PostgresJobAnalysisPort(_never_connect) + + with pytest.raises(TypeError, match="snapshot must be an exact JobAnalysisSnapshot"): + port.persist_snapshot( + snapshot=_snapshot_subtype(), + idempotency_key=IDEMPOTENCY_KEY, + request_digest=command_digest( + snapshot=base_snapshot, + position_record_id=None, + criterion_blueprint_id=None, + ), + actor_reference=_ACTOR_REFERENCE, + purpose_code=_PURPOSE_CODE, + position_record_id=None, + criterion_blueprint_id=None, + audit_event=_audit_event(base_snapshot), + outbox_delivery_record_id=UUID("0198a412-6000-7000-8000-000000000412"), + write_command_id=UUID("0198a412-6000-7000-8000-000000000413"), + ) + + +def test_persist_use_case_rejects_write_port_snapshot_subtype_before_export() -> None: + """The service must reject an executable persistence result before document export.""" + with pytest.raises( + JobAnalysisIntegrityError, + match="persisted snapshot has an invalid runtime type", + ): + persist_job_analysis_snapshot( + principal=write_principal(), + tenant_record_id=TENANT, + document=clinical_psychologist_document(), + idempotency_key=IDEMPOTENCY_KEY, + purpose_code=_PURPOSE_CODE, + policy=write_policy(), + write_port=_ReturningWritePort(_snapshot_subtype()), + ) + + +def test_persist_use_case_detects_exact_snapshot_mutation_by_write_port() -> None: + """Compare persistence to evidence detached before the port can mutate the supplied object.""" + with pytest.raises(JobAnalysisIntegrityError, match="escaped posted payload"): + persist_job_analysis_snapshot( + principal=write_principal(), + tenant_record_id=TENANT, + document=clinical_psychologist_document(), + idempotency_key=IDEMPOTENCY_KEY, + purpose_code=_PURPOSE_CODE, + policy=write_policy(), + write_port=_MutatingWritePort(), + ) + + +def test_read_use_case_rejects_read_port_snapshot_subtype_before_fields_or_export() -> None: + """The service must reject an executable repository result before authority-field access.""" + with pytest.raises( + JobAnalysisIntegrityError, + match="resolved snapshot has an invalid runtime type", + ): + read_job_analysis_snapshot( + principal=read_principal(), + tenant_record_id=TENANT, + analysis_record_id=ANALYSIS, + purpose_code="job_analysis_read", + policy=read_policy(), + read_port=_ReturningReadPort(_snapshot_subtype()), + ) diff --git a/services/people-api/src/orgmetra_people_api/auth.py b/services/people-api/src/orgmetra_people_api/auth.py index 1bd1cf073..e06614da1 100644 --- a/services/people-api/src/orgmetra_people_api/auth.py +++ b/services/people-api/src/orgmetra_people_api/auth.py @@ -8,46 +8,210 @@ from __future__ import annotations -from dataclasses import dataclass import re +import sys +from collections.abc import Iterator from typing import Protocol, runtime_checkable from uuid import UUID _MAX_UUID_INT = (1 << 128) - 1 _REFERENCE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$") _SCOPE_PATTERN = re.compile(r"^orgmetra(?:\.[a-z][a-z0-9_]*){2,}$") +_MAX_BEARER_TOKEN_LENGTH = 8192 +_MAX_AUTHORIZATION_HEADER_LENGTH = 8199 + + +def _validated_principal_storage( + value: tuple[int, str, frozenset[str]], +) -> tuple[int, str, frozenset[str]]: + """Return exact tuple-backed identity evidence or reject malformed storage. + + ``tuple.__new__`` can instantiate a tuple subclass without invoking that + subclass's public constructor. Public principal behavior therefore cannot + assume that tuple storage was validated merely because the runtime class is + exact. Revalidating the raw built-in tuple slots keeps request-edge consumers + fail-closed without treating Python construction history as policy authority. + """ + if tuple.__len__(value) != 3: + raise ValueError("stored authentication evidence is malformed.") + tenant_record_id_int = tuple.__getitem__(value, 0) + actor_reference = tuple.__getitem__(value, 1) + granted_scope_codes = tuple.__getitem__(value, 2) + if ( + type(tenant_record_id_int) is not int + or not 0 <= tenant_record_id_int <= _MAX_UUID_INT + or tenant_record_id_int in (0, _MAX_UUID_INT) + ): + raise ValueError("stored authentication evidence is malformed.") + if type(actor_reference) is not str or _REFERENCE_PATTERN.fullmatch(actor_reference) is None: + raise ValueError("stored authentication evidence is malformed.") + if type(granted_scope_codes) is not frozenset or not granted_scope_codes: + raise ValueError("stored authentication evidence is malformed.") + if any( + type(scope) is not str or _SCOPE_PATTERN.fullmatch(scope) is None + for scope in granted_scope_codes + ): + raise ValueError("stored authentication evidence is malformed.") + return tenant_record_id_int, actor_reference, granted_scope_codes class AuthenticationFailed(RuntimeError): """Indicate that bearer authentication evidence is absent or malformed.""" -@dataclass(frozen=True, slots=True) -class AuthenticatedPrincipal: - """Identity attributes that may be trusted only after token authentication. +class AuthenticatedPrincipal(tuple[int, str, frozenset[str]]): + """Structurally immutable identity evidence returned by token authentication. ``tenant_record_id`` binds the authenticated actor to one Orgmetra tenant. ``actor_reference`` is opaque audit correlation rather than a person record identifier. ``granted_scope_codes`` carries explicit operation capabilities; it never carries an HR purpose decision. - """ - tenant_record_id: UUID - actor_reference: str - granted_scope_codes: frozenset[str] + Tuple-backed storage deliberately leaves no writable instance slots. The + tenant UUID is stored as its validated integer and reconstructed on access, + so neither the caller's UUID nor a returned UUID aliases stored authority. + Public access also revalidates all raw tuple slots because callers inside the + service TCB can invoke ``tuple.__new__`` without this class's constructor. + """ - def __post_init__(self) -> None: - """Reject sentinel identities, mutable grants, wildcards, and bad references.""" - if not isinstance(self.tenant_record_id, UUID): + __slots__ = () + __match_args__ = ("tenant_record_id", "actor_reference", "granted_scope_codes") + + def __new__( + cls, + tenant_record_id: UUID, + actor_reference: str, + granted_scope_codes: frozenset[str], + ) -> AuthenticatedPrincipal: + """Validate, detach, and store exact authentication evidence once.""" + if type(tenant_record_id) is not UUID: raise ValueError("tenant_record_id must be a UUID.") - if self.tenant_record_id.int in (0, _MAX_UUID_INT): + tenant_record_id_int = tenant_record_id.int + if type(tenant_record_id_int) is not int or not 0 <= tenant_record_id_int <= _MAX_UUID_INT: + raise ValueError("tenant_record_id must contain a valid UUID integer.") + if tenant_record_id_int in (0, _MAX_UUID_INT): raise ValueError("tenant_record_id must not use a reserved UUID sentinel.") - if not isinstance(self.actor_reference, str) or _REFERENCE_PATTERN.fullmatch(self.actor_reference) is None: + if type(actor_reference) is not str or _REFERENCE_PATTERN.fullmatch(actor_reference) is None: raise ValueError("actor_reference must be a namespaced opaque reference.") - if not isinstance(self.granted_scope_codes, frozenset) or not self.granted_scope_codes: + if type(granted_scope_codes) is not frozenset or not granted_scope_codes: raise ValueError("granted_scope_codes must be a non-empty frozenset.") - if any(not isinstance(scope, str) or _SCOPE_PATTERN.fullmatch(scope) is None for scope in self.granted_scope_codes): + if any(type(scope) is not str or _SCOPE_PATTERN.fullmatch(scope) is None for scope in granted_scope_codes): raise ValueError("granted_scope_codes must contain explicit Orgmetra scopes.") + return tuple.__new__(cls, (tenant_record_id_int, actor_reference, granted_scope_codes)) + + def __len__(self) -> int: + """Report sequence length only after stored authentication evidence is valid.""" + _validated_principal_storage(self) + return 3 + + def __getitem__( + self, + key: int | slice, + ) -> int | str | frozenset[str] | tuple[int, str, frozenset[str]]: + """Expose sequence items only after all stored authentication evidence is valid.""" + return _validated_principal_storage(self)[key] + + def __iter__(self) -> Iterator[int | str | frozenset[str]]: + """Iterate only after all stored authentication evidence is revalidated.""" + return iter(_validated_principal_storage(self)) + + def __contains__(self, value: object) -> bool: + """Search only revalidated authentication evidence.""" + return value in _validated_principal_storage(self) + + def count(self, value: object) -> int: + """Count matches only in revalidated authentication evidence.""" + return _validated_principal_storage(self).count(value) + + def index(self, value: object, start: int = 0, stop: int = sys.maxsize) -> int: + """Locate a value only in revalidated authentication evidence.""" + return _validated_principal_storage(self).index(value, start, stop) + + def __add__(self, other: tuple[object, ...]) -> tuple[object, ...]: + """Concatenate only after this principal's stored evidence is revalidated.""" + return _validated_principal_storage(self) + other + + def __radd__(self, other: tuple[object, ...]) -> tuple[object, ...]: + """Right-concatenate only after this principal's stored evidence is revalidated.""" + return other + _validated_principal_storage(self) + + def __mul__(self, count: int) -> tuple[object, ...]: + """Repeat only revalidated authentication evidence.""" + return _validated_principal_storage(self) * count + + def __rmul__(self, count: int) -> tuple[object, ...]: + """Right-repeat only revalidated authentication evidence.""" + return count * _validated_principal_storage(self) + + def __lt__(self, other: tuple[object, ...]) -> bool: + """Order only after this principal's stored evidence is revalidated.""" + return _validated_principal_storage(self) < other + + def __le__(self, other: tuple[object, ...]) -> bool: + """Order only after this principal's stored evidence is revalidated.""" + return _validated_principal_storage(self) <= other + + def __gt__(self, other: tuple[object, ...]) -> bool: + """Order only after this principal's stored evidence is revalidated.""" + return _validated_principal_storage(self) > other + + def __ge__(self, other: tuple[object, ...]) -> bool: + """Order only after this principal's stored evidence is revalidated.""" + return _validated_principal_storage(self) >= other + + @property + def tenant_record_id(self) -> UUID: + """Return a detached authenticated tenant identifier value.""" + tenant_record_id_int, _, _ = _validated_principal_storage(self) + return UUID(int=tenant_record_id_int) + + @property + def actor_reference(self) -> str: + """Return the opaque authenticated actor correlation reference.""" + _, actor_reference, _ = _validated_principal_storage(self) + return actor_reference + + @property + def granted_scope_codes(self) -> frozenset[str]: + """Return the exact operation scopes issued at authentication.""" + _, _, granted_scope_codes = _validated_principal_storage(self) + return granted_scope_codes + + def __repr__(self) -> str: + """Render the same field-oriented diagnostic shape as the prior value object.""" + tenant_record_id_int, actor_reference, granted_scope_codes = _validated_principal_storage(self) + return ( + "AuthenticatedPrincipal(" + f"tenant_record_id={UUID(int=tenant_record_id_int)!r}, " + f"actor_reference={actor_reference!r}, " + f"granted_scope_codes={granted_scope_codes!r})" + ) + + def __eq__(self, other: object) -> bool: + """Compare only another exact authenticated-principal value.""" + if type(other) is not AuthenticatedPrincipal: + return False + return _validated_principal_storage(self) == _validated_principal_storage(other) + + def __ne__(self, other: object) -> bool: + """Keep inequality consistent with strict principal-only equality.""" + if type(other) is not AuthenticatedPrincipal: + return True + return _validated_principal_storage(self) != _validated_principal_storage(other) + + def __hash__(self) -> int: + """Hash only revalidated immutable authentication evidence.""" + return hash(_validated_principal_storage(self)) + + def __getnewargs__(self) -> tuple[UUID, str, frozenset[str]]: + """Preserve validated constructor arguments for standard value reconstruction.""" + tenant_record_id_int, actor_reference, granted_scope_codes = _validated_principal_storage(self) + return (UUID(int=tenant_record_id_int), actor_reference, granted_scope_codes) + + def __init_subclass__(cls, **kwargs: object) -> None: + """Prevent executable principal subclasses from overriding authenticated evidence.""" + del kwargs + raise TypeError("AuthenticatedPrincipal must not be subclassed") @runtime_checkable @@ -66,11 +230,15 @@ def extract_bearer_token(authorization_header: str | None) -> str: """ if authorization_header is None: raise AuthenticationFailed("bearer authentication is required") + if type(authorization_header) is not str: + raise AuthenticationFailed("authorization header must be plain text") + if len(authorization_header) > _MAX_AUTHORIZATION_HEADER_LENGTH: + raise AuthenticationFailed("authorization header length is invalid") parts = authorization_header.split(" ", 1) if len(parts) != 2 or parts[0].casefold() != "bearer": raise AuthenticationFailed("authorization must use the Bearer scheme") token = parts[1] - if not token or len(token) > 8192: + if not token or len(token) > _MAX_BEARER_TOKEN_LENGTH: raise AuthenticationFailed("bearer token length is invalid") if any(ord(character) < 0x21 or ord(character) > 0x7E for character in token): raise AuthenticationFailed("bearer token contains invalid characters") diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 6baeac684..7016eb018 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -19,7 +19,11 @@ from typing import Protocol, runtime_checkable from uuid import UUID, uuid5 -from orgmetra_keyverse_adapter import AuthorizationDecision, PurposeBoundAccessPolicy +from orgmetra_keyverse_adapter import ( + AuthorizationDecision, + PurposeBoundAccessPolicy, + validate_authorization_decision, +) from orgmetra_people_api.auth import AuthenticatedPrincipal from orgmetra_people_api.authorization import authorize_resource_fields @@ -113,10 +117,10 @@ def mutation_command_digest( """Hash method, route, tenant, actor, purpose, and semantic command fields. Generated record identifiers are excluded so a retry that allocates fresh - UUIDs still matches the first committed command. + UUIDs still matches the first committed command. Authorization evidence is + semantically revalidated and detached before any durable digest reads it. """ - if not isinstance(authorization, AuthorizationDecision): - raise TypeError("authorization must be an AuthorizationDecision") + decision = validate_authorization_decision(authorization) if isinstance(command, EmploymentMutationCommand): route = "employment-records" semantic_command: dict[str, object] = { @@ -151,10 +155,10 @@ def mutation_command_digest( else: raise TypeError("command must be a governed People mutation command") payload = { - "actor_reference": authorization.actor_reference, + "actor_reference": decision.actor_reference, "command_route": route, "method": "POST", - "purpose_code": authorization.purpose_code, + "purpose_code": decision.purpose_code, "semantic_command": semantic_command, "tenant_record_id": str(command.tenant_record_id), } @@ -445,4 +449,4 @@ def parse_allocation_ratio(raw_value: object) -> Decimal: """Parse the OpenAPI allocation token into an exact four-decimal ratio.""" if not isinstance(raw_value, str) or re.fullmatch(r"^(0\.[0-9]{4}|1\.0000)$", raw_value) is None: raise ValueError("allocation_ratio must match 0.0001-1.0000 four-decimal form.") - return Decimal(raw_value) + return Decimal(raw_value) \ No newline at end of file diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index 4c328e02f..a02625375 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -20,7 +20,7 @@ from uuid import UUID from orgmetra_hris_kernel.audit import AuditOutboxEvent -from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_keyverse_adapter import AuthorizationDecision, validate_authorization_decision from orgmetra_people_api.hire import ( HireAcceptanceCommand, @@ -174,30 +174,46 @@ def _is_aware_datetime(value: object) -> bool: def _validate_authorization(command: HireAcceptanceCommand, authorization: object) -> AuthorizationDecision: - """Require an exact allow decision for this immutable selection decision.""" + """Revalidate and detach the exact allow decision before opening a transaction.""" expected_reference = f"selection_decision:{command.selection_decision_id.hex}" - if not isinstance(authorization, AuthorizationDecision): - raise HireDecisionIntegrityError("hire mutation requires a typed authorization decision") + try: + snapshot = validate_authorization_decision(authorization) # type: ignore[arg-type] + except (TypeError, ValueError) as error: + raise HireDecisionIntegrityError("hire mutation requires coherent authorization evidence") from error if ( - not authorization.allowed - or authorization.tenant_record_id != command.tenant_record_id - or authorization.resource_reference != expected_reference - or authorization.resource_kind != "selection_decision" - or authorization.operation_code != "materialize_worker" - or authorization.requested_fields != _HIRE_MUTATION_FIELDS - or authorization.authorized_fields != _HIRE_MUTATION_FIELDS + not snapshot.allowed + or snapshot.tenant_record_id_int != command.tenant_record_id.int + or snapshot.resource_reference != expected_reference + or snapshot.resource_kind != "selection_decision" + or snapshot.operation_code != "materialize_worker" + or snapshot.requested_fields != _HIRE_MUTATION_FIELDS + or snapshot.authorized_fields != _HIRE_MUTATION_FIELDS ): raise HireDecisionIntegrityError("hire mutation authorization does not match the exact decision") - return authorization + return AuthorizationDecision( + allowed=snapshot.allowed, + tenant_record_id=UUID(int=snapshot.tenant_record_id_int), + actor_reference=snapshot.actor_reference, + resource_reference=snapshot.resource_reference, + policy_version_code=snapshot.policy_version_code, + purpose_code=snapshot.purpose_code, + operation_code=snapshot.operation_code, + resource_kind=snapshot.resource_kind, + requested_fields=snapshot.requested_fields, + authorized_fields=snapshot.authorized_fields, + reason_code=snapshot.reason_code, + next_action=snapshot.next_action, + ) def _hire_command_digest(command: HireAcceptanceCommand, authorization: AuthorizationDecision) -> str: - """Hash the exact confirmed-hire semantics without storing necessary PII in audit evidence.""" + """Hash confirmed-hire semantics from a freshly validated authorization snapshot.""" + decision = validate_authorization_decision(authorization) payload = { - "actor_reference": authorization.actor_reference, + "actor_reference": decision.actor_reference, "command_route": _HIRE_IDEMPOTENCY_ROUTE, "method": "POST", - "purpose_code": authorization.purpose_code, + "purpose_code": decision.purpose_code, "semantic_command": { "audit_event_record_id": str(command.audit_event_record_id), "candidate_profile_id": str(command.candidate_profile_id), @@ -453,4 +469,4 @@ def accept_hire( person_record_id=command.person_record_id, employment_record_id=command.employment_record_id, candidate_worker_conversion_record_id=command.candidate_worker_conversion_record_id, - ) + ) \ No newline at end of file diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index d94832cf8..7534674db 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -26,7 +26,7 @@ validate_assignment_write, validate_person_employment_exclusivity, ) -from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_keyverse_adapter import AuthorizationDecision, validate_authorization_decision from orgmetra_people_api.mutations import ( AssignmentMutationCommand, @@ -321,20 +321,35 @@ def _require_authorization( resource_kind: str, requested_fields: frozenset[str], ) -> AuthorizationDecision: - """Require an exact allow decision for the intended mutation target.""" - if not isinstance(authorization, AuthorizationDecision): - raise PeopleMutationIntegrityError("people mutation requires a typed authorization decision") + """Revalidate and detach the exact allow decision before opening a transaction.""" + try: + snapshot = validate_authorization_decision(authorization) # type: ignore[arg-type] + except (TypeError, ValueError) as error: + raise PeopleMutationIntegrityError("people mutation requires coherent authorization evidence") from error if ( - not authorization.allowed - or authorization.tenant_record_id != tenant_record_id - or authorization.resource_reference != resource_reference - or authorization.resource_kind != resource_kind - or authorization.operation_code != "create_record" - or authorization.requested_fields != requested_fields - or authorization.authorized_fields != requested_fields + not snapshot.allowed + or snapshot.tenant_record_id_int != tenant_record_id.int + or snapshot.resource_reference != resource_reference + or snapshot.resource_kind != resource_kind + or snapshot.operation_code != "create_record" + or snapshot.requested_fields != requested_fields + or snapshot.authorized_fields != requested_fields ): raise PeopleMutationIntegrityError("people mutation authorization does not match the exact record") - return authorization + return AuthorizationDecision( + allowed=snapshot.allowed, + tenant_record_id=UUID(int=snapshot.tenant_record_id_int), + actor_reference=snapshot.actor_reference, + resource_reference=snapshot.resource_reference, + policy_version_code=snapshot.policy_version_code, + purpose_code=snapshot.purpose_code, + operation_code=snapshot.operation_code, + resource_kind=snapshot.resource_kind, + requested_fields=snapshot.requested_fields, + authorized_fields=snapshot.authorized_fields, + reason_code=snapshot.reason_code, + next_action=snapshot.next_action, + ) def _record_audit( @@ -834,4 +849,4 @@ def create_assignment( authorization=decision, created_record_id=command.assignment_record_id, ) - return AssignmentMutationResult(assignment_record_id=command.assignment_record_id) + return AssignmentMutationResult(assignment_record_id=command.assignment_record_id) \ No newline at end of file diff --git a/services/people-api/tests/authorization_test_support.py b/services/people-api/tests/authorization_test_support.py new file mode 100644 index 000000000..b041d0295 --- /dev/null +++ b/services/people-api/tests/authorization_test_support.py @@ -0,0 +1,52 @@ +"""Test support that obtains authorization evidence through the public evaluator.""" + +from __future__ import annotations + +from uuid import UUID + +from orgmetra_keyverse_adapter import ( + AuthorizationDecision, + PurposeBoundAccessPolicy, + PurposeBoundAccessRequest, + evaluate_purpose_bound_access, +) + + +def issued_authorization( + *, + tenant_record_id: UUID, + actor_reference: str, + resource_reference: str, + policy_version_code: str, + purpose_code: str, + operation_code: str, + resource_kind: str, + requested_fields: frozenset[str], + required_scope_code: str, + granted_scope_codes: frozenset[str] | None = None, + permitted_fields: frozenset[str] | None = None, + policy_purpose_code: str | None = None, +) -> AuthorizationDecision: + """Return evidence produced by the same purpose-bound evaluation used in production.""" + policy = PurposeBoundAccessPolicy( + tenant_record_id=tenant_record_id, + policy_version_code=policy_version_code, + resource_kind=resource_kind, + purpose_code=policy_purpose_code or purpose_code, + operation_code=operation_code, + required_scope_code=required_scope_code, + permitted_fields=permitted_fields or requested_fields, + ) + request = PurposeBoundAccessRequest( + tenant_record_id=tenant_record_id, + actor_tenant_record_id=tenant_record_id, + resource_tenant_record_id=tenant_record_id, + actor_reference=actor_reference, + resource_reference=resource_reference, + purpose_code=purpose_code, + operation_code=operation_code, + resource_kind=resource_kind, + requested_fields=requested_fields, + granted_scope_codes=granted_scope_codes or frozenset({required_scope_code}), + ) + return evaluate_purpose_bound_access(request=request, policy=policy) diff --git a/services/people-api/tests/test_auth.py b/services/people-api/tests/test_auth.py index 9df2f76a2..9ccc6841a 100644 --- a/services/people-api/tests/test_auth.py +++ b/services/people-api/tests/test_auth.py @@ -12,6 +12,13 @@ OTHER_TENANT = UUID("0198a412-6000-7000-8000-000000000002") +class _ExecutableHeader(str): + """Model caller-defined header text that executes during polymorphic parsing.""" + + def split(self, *args: object, **kwargs: object) -> list[str]: + raise AssertionError("executable header split must not run") + + class BearerBoundaryTests(unittest.TestCase): """Prove that malformed token syntax never reaches an injected authenticator.""" @@ -28,6 +35,10 @@ def test_rejects_hidden_control_non_ascii_and_unbounded_tokens(self) -> None: with self.subTest(token_length=len(token)), self.assertRaises(AuthenticationFailed): extract_bearer_token(f"Bearer {token}") + def test_rejects_executable_string_subtype_before_parsing(self) -> None: + with self.assertRaisesRegex(AuthenticationFailed, "authorization header"): + extract_bearer_token(_ExecutableHeader("Bearer forged-token")) + class PrincipalBoundaryTests(unittest.TestCase): """Keep authenticated identity/scope facts narrow and immutable.""" diff --git a/services/people-api/tests/test_authenticated_principal_runtime_types.py b/services/people-api/tests/test_authenticated_principal_runtime_types.py new file mode 100644 index 000000000..6d93555bd --- /dev/null +++ b/services/people-api/tests/test_authenticated_principal_runtime_types.py @@ -0,0 +1,152 @@ +"""Regression contracts for authenticated principal runtime-type integrity.""" + +from __future__ import annotations + +import pickle +import unittest +from uuid import UUID + +from orgmetra_people_api import AuthenticatedPrincipal + +TENANT = UUID("0198a412-6000-7000-8000-000000000001") +OTHER_TENANT = UUID("0198a412-6000-7000-8000-000000000002") +SCOPE = "orgmetra.people.read" + + +class _UUIDSubtype(UUID): + """Caller-defined UUID subtype that must not cross the authentication boundary.""" + + +class _TextSubtype(str): + """Caller-defined text subtype that must not carry identity or scope evidence.""" + + +class _ScopeSetSubtype(frozenset[str]): + """Caller-defined immutable-set subtype that must not carry scope evidence.""" + + +class AuthenticatedPrincipalRuntimeTypeTests(unittest.TestCase): + """Require exact canonical authentication evidence at principal construction.""" + + def test_rejects_trust_bearing_runtime_subtypes(self) -> None: + cases = ( + { + "tenant_record_id": _UUIDSubtype(TENANT.hex), + "actor_reference": "keyverse:actor-1", + "granted_scope_codes": frozenset({SCOPE}), + }, + { + "tenant_record_id": TENANT, + "actor_reference": _TextSubtype("keyverse:actor-1"), + "granted_scope_codes": frozenset({SCOPE}), + }, + { + "tenant_record_id": TENANT, + "actor_reference": "keyverse:actor-1", + "granted_scope_codes": _ScopeSetSubtype({SCOPE}), + }, + { + "tenant_record_id": TENANT, + "actor_reference": "keyverse:actor-1", + "granted_scope_codes": frozenset({_TextSubtype(SCOPE)}), + }, + ) + for values in cases: + with self.subTest(values=values), self.assertRaises(ValueError): + AuthenticatedPrincipal(**values) + + def test_principal_runtime_class_cannot_be_subclassed(self) -> None: + """Executable principal subclasses cannot override authenticated evidence access.""" + with self.assertRaisesRegex(TypeError, "AuthenticatedPrincipal must not be subclassed"): + type("_PrincipalSubtype", (AuthenticatedPrincipal,), {}) + + def test_tenant_uuid_is_detached_from_caller_owned_instance(self) -> None: + """Post-construction mutation of the caller UUID cannot retarget the principal.""" + tenant_record_id = UUID(TENANT.hex) + principal = AuthenticatedPrincipal( + tenant_record_id=tenant_record_id, + actor_reference="keyverse:actor-1", + granted_scope_codes=frozenset({SCOPE}), + ) + + object.__setattr__(tenant_record_id, "int", OTHER_TENANT.int) + + self.assertEqual(principal.tenant_record_id, TENANT) + self.assertIsNot(principal.tenant_record_id, tenant_record_id) + + def test_returned_tenant_uuid_cannot_retarget_principal(self) -> None: + """Mutating a returned UUID value cannot rewrite stored tenant evidence.""" + principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse:actor-1", + granted_scope_codes=frozenset({SCOPE}), + ) + returned_tenant_record_id = principal.tenant_record_id + + object.__setattr__(returned_tenant_record_id, "int", OTHER_TENANT.int) + + self.assertEqual(principal.tenant_record_id, TENANT) + self.assertIsNot(principal.tenant_record_id, returned_tenant_record_id) + + def test_principal_evidence_cannot_be_rewritten_after_authentication(self) -> None: + """Low-level writes must not replace authenticated evidence on a live principal.""" + principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse:actor-1", + granted_scope_codes=frozenset({SCOPE}), + ) + cases = ( + ("tenant_record_id", OTHER_TENANT), + ("actor_reference", "keyverse:actor-2"), + ("granted_scope_codes", frozenset({"orgmetra.people.write"})), + ) + + for field_name, replacement in cases: + with self.subTest(field_name=field_name), self.assertRaises((AttributeError, TypeError)): + object.__setattr__(principal, field_name, replacement) + + self.assertEqual(principal.tenant_record_id, TENANT) + self.assertEqual(principal.actor_reference, "keyverse:actor-1") + self.assertEqual(principal.granted_scope_codes, frozenset({SCOPE})) + + def test_structural_storage_preserves_value_object_semantics(self) -> None: + """Structural immutability must not collapse the principal into a raw tuple value.""" + principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse:actor-1", + granted_scope_codes=frozenset({SCOPE}), + ) + equivalent = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse:actor-1", + granted_scope_codes=frozenset({SCOPE}), + ) + raw_tuple = (TENANT, "keyverse:actor-1", frozenset({SCOPE})) + + self.assertEqual(principal, equivalent) + self.assertEqual(hash(principal), hash(equivalent)) + self.assertNotEqual(principal, raw_tuple) + self.assertNotEqual(raw_tuple, principal) + self.assertEqual(pickle.loads(pickle.dumps(principal)), principal) + self.assertEqual( + repr(principal), + "AuthenticatedPrincipal(" + f"tenant_record_id={TENANT!r}, actor_reference='keyverse:actor-1', " + f"granted_scope_codes={frozenset({SCOPE})!r})", + ) + + def test_rejects_corrupted_exact_uuid_state(self) -> None: + """An exact UUID with an invalid internal integer cannot become identity evidence.""" + tenant_record_id = UUID(TENANT.hex) + object.__setattr__(tenant_record_id, "int", "not-an-integer") + + with self.assertRaisesRegex(ValueError, "tenant_record_id must contain a valid UUID integer"): + AuthenticatedPrincipal( + tenant_record_id=tenant_record_id, + actor_reference="keyverse:actor-1", + granted_scope_codes=frozenset({SCOPE}), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/people-api/tests/test_authenticated_principal_storage_revalidation.py b/services/people-api/tests/test_authenticated_principal_storage_revalidation.py new file mode 100644 index 000000000..df6b53357 --- /dev/null +++ b/services/people-api/tests/test_authenticated_principal_storage_revalidation.py @@ -0,0 +1,153 @@ +"""Regression contracts for tuple-level principal storage revalidation.""" + +from __future__ import annotations + +import unittest +from uuid import UUID + +from orgmetra_people_api import AuthenticatedPrincipal + +TENANT = UUID("0198a412-6200-7000-8000-000000000001") +SCOPE = "orgmetra.people.read" + + +class _TextSubtype(str): + """Caller-controlled text runtime behavior that cannot become identity evidence.""" + + +class AuthenticatedPrincipalStorageRevalidationTests(unittest.TestCase): + """Require every public principal view to revalidate tuple-backed evidence.""" + + def test_tuple_constructor_bypass_cannot_publish_unvalidated_actor_evidence(self) -> None: + """A direct base-tuple constructor must not bypass actor runtime validation.""" + forged = tuple.__new__( + AuthenticatedPrincipal, + (TENANT.int, _TextSubtype("keyverse:actor-1"), frozenset({SCOPE})), + ) + + with self.assertRaisesRegex(ValueError, "stored authentication evidence"): + _ = forged.actor_reference + + def test_tuple_constructor_bypass_cannot_publish_unvalidated_scope_evidence(self) -> None: + """A direct base-tuple constructor must not bypass scope runtime validation.""" + forged = tuple.__new__( + AuthenticatedPrincipal, + (TENANT.int, "keyverse:actor-1", frozenset({_TextSubtype(SCOPE)})), + ) + + with self.assertRaisesRegex(ValueError, "stored authentication evidence"): + _ = forged.granted_scope_codes + + def test_tuple_constructor_bypass_cannot_publish_malformed_storage_shape(self) -> None: + """Malformed tuple arity must fail through the stable principal integrity contract.""" + forged = tuple.__new__(AuthenticatedPrincipal, (TENANT.int, "keyverse:actor-1")) + + with self.assertRaisesRegex(ValueError, "stored authentication evidence"): + _ = forged.tenant_record_id + + def test_malformed_storage_cannot_participate_in_value_semantics(self) -> None: + """Hash, comparison, and repr must not legitimize malformed authentication evidence.""" + forged = tuple.__new__( + AuthenticatedPrincipal, + (TENANT.int, object(), frozenset({SCOPE})), + ) + canonical = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse:actor-1", + granted_scope_codes=frozenset({SCOPE}), + ) + + with self.assertRaisesRegex(ValueError, "stored authentication evidence"): + hash(forged) + with self.assertRaisesRegex(ValueError, "stored authentication evidence"): + _ = forged == canonical + with self.assertRaisesRegex(ValueError, "stored authentication evidence"): + repr(forged) + + def test_malformed_storage_cannot_escape_through_sequence_protocol(self) -> None: + """Ordinary tuple-like access must not expose raw unvalidated identity evidence.""" + forged = tuple.__new__( + AuthenticatedPrincipal, + (TENANT.int, _TextSubtype("keyverse:actor-1"), frozenset({SCOPE})), + ) + + for access in ( + lambda: forged[1], + lambda: forged[:], + lambda: list(forged), + lambda: tuple(forged), + ): + with self.subTest(access=access), self.assertRaisesRegex( + ValueError, + "stored authentication evidence", + ): + access() + + def test_malformed_storage_cannot_escape_through_remaining_tuple_operations(self) -> None: + """Tuple helpers and operators must validate stored evidence before using it.""" + actor_reference = "keyverse:actor-1" + forged = tuple.__new__( + AuthenticatedPrincipal, + (TENANT.int, _TextSubtype(actor_reference), frozenset({SCOPE})), + ) + comparison = (TENANT.int, "keyverse:actor-2", frozenset({SCOPE})) + + for access in ( + lambda: len(forged), + lambda: actor_reference in forged, + lambda: forged.count(actor_reference), + lambda: forged.index(actor_reference), + lambda: forged + (), + lambda: () + forged, + lambda: forged * 1, + lambda: 1 * forged, + lambda: forged < comparison, + lambda: forged <= comparison, + lambda: forged > comparison, + lambda: forged >= comparison, + ): + with self.subTest(access=access), self.assertRaisesRegex( + ValueError, + "stored authentication evidence", + ): + access() + + def test_valid_tuple_storage_remains_value_compatible_without_claiming_provenance(self) -> None: + """Valid structural evidence stays readable without treating construction history as authority.""" + structurally_valid = tuple.__new__( + AuthenticatedPrincipal, + (TENANT.int, "keyverse:actor-1", frozenset({SCOPE})), + ) + canonical = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse:actor-1", + granted_scope_codes=frozenset({SCOPE}), + ) + expected_storage = (TENANT.int, "keyverse:actor-1", frozenset({SCOPE})) + comparison = (TENANT.int, "keyverse:actor-2", frozenset({SCOPE})) + + self.assertEqual(structurally_valid.tenant_record_id, TENANT) + self.assertEqual(structurally_valid.actor_reference, "keyverse:actor-1") + self.assertEqual(structurally_valid.granted_scope_codes, frozenset({SCOPE})) + self.assertEqual(structurally_valid[0], TENANT.int) + self.assertEqual(structurally_valid[1:], expected_storage[1:]) + self.assertEqual(list(structurally_valid), list(expected_storage)) + self.assertEqual(tuple(structurally_valid), expected_storage) + self.assertEqual(len(structurally_valid), 3) + self.assertIn("keyverse:actor-1", structurally_valid) + self.assertEqual(structurally_valid.count("keyverse:actor-1"), 1) + self.assertEqual(structurally_valid.index("keyverse:actor-1"), 1) + self.assertEqual(structurally_valid + (), expected_storage) + self.assertEqual(() + structurally_valid, expected_storage) + self.assertEqual(structurally_valid * 1, expected_storage) + self.assertEqual(1 * structurally_valid, expected_storage) + self.assertLess(structurally_valid, comparison) + self.assertLessEqual(structurally_valid, comparison) + self.assertFalse(structurally_valid > comparison) + self.assertFalse(structurally_valid >= comparison) + self.assertEqual(structurally_valid, canonical) + self.assertEqual(hash(structurally_valid), hash(canonical)) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/people-api/tests/test_authorization_decision_durable_revalidation.py b/services/people-api/tests/test_authorization_decision_durable_revalidation.py new file mode 100644 index 000000000..78ab4c2d3 --- /dev/null +++ b/services/people-api/tests/test_authorization_decision_durable_revalidation.py @@ -0,0 +1,57 @@ +"""Regressions for semantic revalidation at durable People authorization boundaries.""" + +from __future__ import annotations + +import unittest + +from orgmetra_people_api.hire import HireDecisionIntegrityError +from orgmetra_people_api.mutations import PeopleMutationIntegrityError, mutation_command_digest +from orgmetra_people_api.postgres_hire import _validate_authorization as validate_hire_authorization +from orgmetra_people_api.postgres_mutations import _require_authorization as require_people_authorization +from test_people_mutations import EMPLOYMENT, TENANT, employment_command +from test_postgres_hire_acceptance import allowed_authorization, command as hire_command +from test_postgres_people_mutations import employment_authorization + + +def _contradict_allowed_decision(decision: object) -> object: + """Simulate post-construction corruption that leaves the runtime class unchanged.""" + object.__setattr__(decision, "reason_code", "access_denied") + return decision + + +class AuthorizationDecisionDurableRevalidationTests(unittest.TestCase): + """Require durable consumers to reject contradictory exact decision objects.""" + + def test_semantic_digest_revalidates_decision_before_reading_evidence(self) -> None: + """Idempotency evidence must not hash a contradictory allow decision.""" + decision = _contradict_allowed_decision(employment_authorization()) + + with self.assertRaises(ValueError): + mutation_command_digest( + command=employment_command(), + authorization=decision, # type: ignore[arg-type] + ) + + def test_generic_postgres_boundary_revalidates_before_accepting_allow(self) -> None: + """Generic persistence must reject a contradictory exact decision before SQL.""" + decision = _contradict_allowed_decision(employment_authorization()) + + with self.assertRaises(PeopleMutationIntegrityError): + require_people_authorization( + authorization=decision, + tenant_record_id=TENANT, + resource_reference=f"employment_record:{EMPLOYMENT.hex}", + resource_kind="employment_record", + requested_fields=frozenset({"employment_record"}), + ) + + def test_hire_postgres_boundary_revalidates_before_accepting_allow(self) -> None: + """Hire persistence must reject a contradictory exact decision before SQL.""" + decision = _contradict_allowed_decision(allowed_authorization()) + + with self.assertRaises(HireDecisionIntegrityError): + validate_hire_authorization(hire_command(), decision) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/people-api/tests/test_authorization_revalidation_precedes_database.py b/services/people-api/tests/test_authorization_revalidation_precedes_database.py new file mode 100644 index 000000000..a646fe8e0 --- /dev/null +++ b/services/people-api/tests/test_authorization_revalidation_precedes_database.py @@ -0,0 +1,110 @@ +"""Regressions proving corrupted authorization evidence cannot acquire a DB connection.""" + +from __future__ import annotations + +import unittest + +from orgmetra_people_api.hire import HireDecisionIntegrityError +from orgmetra_people_api.mutations import PeopleMutationIntegrityError, mutation_command_digest +from orgmetra_people_api.postgres_hire import PostgresHireAcceptancePort, _hire_command_digest +from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort +from test_people_mutations import assignment_command, employment_command, position_command +from test_postgres_hire_acceptance import allowed_authorization, command as hire_command +from test_postgres_people_mutations import ( + assignment_authorization, + employment_authorization, + position_authorization, +) + + +def _contradict(decision: object) -> object: + """Corrupt a field not used by the old local allow/matching predicates.""" + object.__setattr__(decision, "reason_code", "access_denied") + return decision + + +class CountingConnectionFactory: + """Fail if a persistence path asks for a connection after invalid evidence.""" + + def __init__(self) -> None: + self.call_count = 0 + + def __call__(self) -> object: + self.call_count += 1 + raise AssertionError("database connection must not be acquired") + + +class AuthorizationRevalidationBeforeDatabaseTests(unittest.TestCase): + """Keep semantic authorization validation ahead of every People SQL boundary.""" + + def test_hire_port_rejects_corruption_before_connection_factory(self) -> None: + """Confirmed-hire persistence must reject invalid evidence before SQL setup.""" + factory = CountingConnectionFactory() + port = PostgresHireAcceptancePort(factory) + + with self.assertRaises(HireDecisionIntegrityError): + port.accept_hire( + command=hire_command(), + authorization=_contradict(allowed_authorization()), # type: ignore[arg-type] + ) + + self.assertEqual(factory.call_count, 0) + + def test_employment_port_rejects_corruption_before_connection_factory(self) -> None: + """Employment persistence must reject invalid evidence before SQL setup.""" + factory = CountingConnectionFactory() + port = PostgresPeopleMutationPort(factory) + + with self.assertRaises(PeopleMutationIntegrityError): + port.create_employment( + command=employment_command(), + authorization=_contradict(employment_authorization()), # type: ignore[arg-type] + ) + + self.assertEqual(factory.call_count, 0) + + def test_position_port_rejects_corruption_before_connection_factory(self) -> None: + """Position persistence must reject invalid evidence before SQL setup.""" + factory = CountingConnectionFactory() + port = PostgresPeopleMutationPort(factory) + + with self.assertRaises(PeopleMutationIntegrityError): + port.create_position( + command=position_command(), + authorization=_contradict(position_authorization()), # type: ignore[arg-type] + ) + + self.assertEqual(factory.call_count, 0) + + def test_assignment_port_rejects_corruption_before_connection_factory(self) -> None: + """Assignment persistence must reject invalid evidence before SQL setup.""" + factory = CountingConnectionFactory() + port = PostgresPeopleMutationPort(factory) + + with self.assertRaises(PeopleMutationIntegrityError): + port.create_assignment( + command=assignment_command(), + authorization=_contradict(assignment_authorization()), # type: ignore[arg-type] + ) + + self.assertEqual(factory.call_count, 0) + + def test_generic_digest_revalidates_before_reading_corrupt_evidence(self) -> None: + """Idempotency hashing must reject contradictory decision data before hashing it.""" + with self.assertRaises(ValueError): + mutation_command_digest( + command=employment_command(), + authorization=_contradict(employment_authorization()), # type: ignore[arg-type] + ) + + def test_hire_digest_revalidates_before_reading_corrupt_evidence(self) -> None: + """Hire replay hashing must reject contradictory decision data before hashing it.""" + with self.assertRaises(ValueError): + _hire_command_digest( + hire_command(), + _contradict(allowed_authorization()), # type: ignore[arg-type] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/people-api/tests/test_bearer_header_budget.py b/services/people-api/tests/test_bearer_header_budget.py new file mode 100644 index 000000000..81761afcf --- /dev/null +++ b/services/people-api/tests/test_bearer_header_budget.py @@ -0,0 +1,26 @@ +"""Regression contract for pre-parse People bearer-header budgeting.""" + +from __future__ import annotations + +import unittest + +from orgmetra_people_api.auth import AuthenticationFailed, extract_bearer_token + + +class BearerHeaderBudgetTests(unittest.TestCase): + """Bound exact authorization-header text before scheme parsing allocates work.""" + + def test_rejects_oversized_header_before_scheme_semantics(self) -> None: + oversized_header = "X" * 8200 + + with self.assertRaisesRegex(AuthenticationFailed, "authorization header length"): + extract_bearer_token(oversized_header) + + def test_accepts_maximum_valid_bearer_header(self) -> None: + token = "x" * 8192 + + self.assertEqual(extract_bearer_token(f"Bearer {token}"), token) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/people-api/tests/test_decision_reason_binding.py b/services/people-api/tests/test_decision_reason_binding.py index dd1b11eed..8178ae2d9 100644 --- a/services/people-api/tests/test_decision_reason_binding.py +++ b/services/people-api/tests/test_decision_reason_binding.py @@ -10,6 +10,7 @@ from orgmetra_keyverse_adapter import AuthorizationDecision from orgmetra_people_api.mutation_http import _command_for_route from orgmetra_people_api.mutations import EmploymentMutationCommand, mutation_command_digest +from authorization_test_support import issued_authorization TENANT = UUID("0198a412-8a00-7000-8000-000000000001") PERSON = UUID("0198a412-8a00-7000-8000-000000000002") @@ -64,9 +65,8 @@ def _command(payload: dict[str, object]) -> EmploymentMutationCommand: def _authorization(command: EmploymentMutationCommand) -> AuthorizationDecision: - """Return matching PII-minimized authorization evidence for digest comparison.""" - return AuthorizationDecision( - allowed=True, + """Return evaluator-issued authorization evidence for digest comparison.""" + return issued_authorization( tenant_record_id=TENANT, actor_reference="keyverse_subject:operator-20", resource_reference=f"employment_record:{command.employment_record_id.hex}", @@ -75,9 +75,7 @@ def _authorization(command: EmploymentMutationCommand) -> AuthorizationDecision: operation_code="create_record", resource_kind="employment_record", requested_fields=frozenset({"employment_record"}), - authorized_fields=frozenset({"employment_record"}), - reason_code="access_permitted", - next_action="Continue with only the authorized fields.", + required_scope_code="orgmetra.people.write", ) diff --git a/services/people-api/tests/test_evidence_reference_binding_regression.py b/services/people-api/tests/test_evidence_reference_binding_regression.py index 5c0d061c3..11d04640b 100644 --- a/services/people-api/tests/test_evidence_reference_binding_regression.py +++ b/services/people-api/tests/test_evidence_reference_binding_regression.py @@ -9,6 +9,7 @@ from orgmetra_keyverse_adapter import AuthorizationDecision from orgmetra_people_api.mutation_http import _command_for_route from orgmetra_people_api.mutations import mutation_command_digest +from authorization_test_support import issued_authorization TENANT = UUID("0198a412-8200-7000-8000-000000000001") PERSON = UUID("0198a412-8200-7000-8000-000000000020") @@ -54,9 +55,8 @@ def command_for(evidence_references: list[object]): def authorization() -> AuthorizationDecision: - """Return the exact allow decision used solely to derive command digests.""" - return AuthorizationDecision( - allowed=True, + """Return evaluator-issued allow evidence used solely for command digests.""" + return issued_authorization( tenant_record_id=TENANT, actor_reference="keyverse_subject:operator-99", resource_reference=f"employment_record:{EMPLOYMENT.hex}", @@ -65,9 +65,7 @@ def authorization() -> AuthorizationDecision: operation_code="create_record", resource_kind="employment_record", requested_fields=frozenset({"employment_record"}), - authorized_fields=frozenset({"employment_record"}), - reason_code="access_permitted", - next_action="continue", + required_scope_code="orgmetra.people.write", ) diff --git a/services/people-api/tests/test_people_mutations.py b/services/people-api/tests/test_people_mutations.py index b629355e9..a1bca2980 100644 --- a/services/people-api/tests/test_people_mutations.py +++ b/services/people-api/tests/test_people_mutations.py @@ -7,7 +7,7 @@ import unittest from uuid import UUID -from orgmetra_keyverse_adapter import AuthorizationDecision, AuthorizationDeniedError, PurposeBoundAccessPolicy +from orgmetra_keyverse_adapter import AuthorizationDeniedError, PurposeBoundAccessPolicy from orgmetra_people_api.auth import AuthenticatedPrincipal from orgmetra_people_api.mutations import ( AssignmentMutationCommand, @@ -25,6 +25,7 @@ parse_allocation_ratio, validate_idempotency_key, ) +from authorization_test_support import issued_authorization TENANT = UUID("0198a412-8000-7000-8000-000000000001") PERSON = UUID("0198a412-8000-7000-8000-000000000020") @@ -326,8 +327,7 @@ def test_service_requires_typed_commands_ports_and_results(self) -> None: ) def test_command_digest_excludes_generated_ids_and_changes_with_semantics(self) -> None: - authorization = AuthorizationDecision( - allowed=True, + authorization = issued_authorization( tenant_record_id=TENANT, actor_reference="keyverse_subject:operator-17", resource_reference=f"employment_record:{EMPLOYMENT.hex}", @@ -336,9 +336,7 @@ def test_command_digest_excludes_generated_ids_and_changes_with_semantics(self) operation_code="create_record", resource_kind="employment_record", requested_fields=frozenset({"employment_record"}), - authorized_fields=frozenset({"employment_record"}), - reason_code="access_permitted", - next_action="continue", + required_scope_code="orgmetra.people.write", ) first = mutation_command_digest(command=employment_command(), authorization=authorization) retried = mutation_command_digest( diff --git a/services/people-api/tests/test_postgres_hire_acceptance.py b/services/people-api/tests/test_postgres_hire_acceptance.py index ccd6009ff..fe581f4ba 100644 --- a/services/people-api/tests/test_postgres_hire_acceptance.py +++ b/services/people-api/tests/test_postgres_hire_acceptance.py @@ -21,6 +21,7 @@ PostgresHireAcceptancePort, _hire_command_digest, ) +from authorization_test_support import issued_authorization TENANT = UUID("0198a412-7100-7000-8000-000000000001") CANDIDATE = UUID("0198a412-7100-7000-8000-000000000010") @@ -112,9 +113,8 @@ def policy() -> PurposeBoundAccessPolicy: def allowed_authorization() -> AuthorizationDecision: - """Return the exact allow decision produced for the deterministic test command.""" - return AuthorizationDecision( - allowed=True, + """Return the exact allow decision issued for the deterministic test command.""" + return issued_authorization( tenant_record_id=TENANT, actor_reference=ACTOR, resource_reference=f"selection_decision:{DECISION.hex}", @@ -123,9 +123,7 @@ def allowed_authorization() -> AuthorizationDecision: operation_code="materialize_worker", resource_kind="selection_decision", requested_fields=frozenset({"candidate_worker_conversion"}), - authorized_fields=frozenset({"candidate_worker_conversion"}), - reason_code="access_permitted", - next_action="continue", + required_scope_code="orgmetra.people.materialize_worker", ) @@ -425,8 +423,7 @@ def factory() -> FakeConnection: return FakeConnection(FakeCursor([[], [decision_row()]])) port = PostgresHireAcceptancePort(factory) - forged = AuthorizationDecision( - allowed=False, + denied = issued_authorization( tenant_record_id=TENANT, actor_reference=ACTOR, resource_reference=f"selection_decision:{DECISION.hex}", @@ -435,28 +432,23 @@ def factory() -> FakeConnection: operation_code="materialize_worker", resource_kind="selection_decision", requested_fields=frozenset({"candidate_worker_conversion"}), - authorized_fields=frozenset(), - reason_code="access_denied", - next_action="stop", + required_scope_code="orgmetra.people.materialize_worker", + granted_scope_codes=frozenset({"orgmetra.people.read"}), ) - invalid_authorizations: tuple[object, ...] = ( - object(), - forged, - AuthorizationDecision( - allowed=True, - tenant_record_id=TENANT, - actor_reference=ACTOR, - resource_reference="selection_decision:wrong-target", - policy_version_code="people-hire-v1", - purpose_code=PURPOSE, - operation_code="materialize_worker", - resource_kind="selection_decision", - requested_fields=frozenset({"candidate_worker_conversion"}), - authorized_fields=frozenset({"candidate_worker_conversion"}), - reason_code="access_permitted", - next_action="continue", - ), + wrong_target = issued_authorization( + tenant_record_id=TENANT, + actor_reference=ACTOR, + resource_reference="selection_decision:wrong-target", + policy_version_code="people-hire-v1", + purpose_code=PURPOSE, + operation_code="materialize_worker", + resource_kind="selection_decision", + requested_fields=frozenset({"candidate_worker_conversion"}), + required_scope_code="orgmetra.people.materialize_worker", ) + self.assertFalse(denied.allowed) + self.assertTrue(wrong_target.allowed) + invalid_authorizations: tuple[object, ...] = (object(), denied, wrong_target) for authorization in invalid_authorizations: with self.subTest(authorization=authorization), self.assertRaisesRegex( HireDecisionIntegrityError, diff --git a/services/people-api/tests/test_postgres_people_mutations.py b/services/people-api/tests/test_postgres_people_mutations.py index 0175fc6d4..49880f01c 100644 --- a/services/people-api/tests/test_postgres_people_mutations.py +++ b/services/people-api/tests/test_postgres_people_mutations.py @@ -20,6 +20,7 @@ mutation_command_digest, ) from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort +from authorization_test_support import issued_authorization from test_people_mutations import ( TENANT, PERSON, @@ -100,9 +101,8 @@ def cursor(self) -> ScriptedCursor: def employment_authorization() -> AuthorizationDecision: - """Return the exact allow decision created by the employment policy.""" - return AuthorizationDecision( - allowed=True, + """Return the exact allow decision issued by the employment policy evaluator.""" + return issued_authorization( tenant_record_id=TENANT, actor_reference=ACTOR, resource_reference=f"employment_record:{EMPLOYMENT.hex}", @@ -111,16 +111,13 @@ def employment_authorization() -> AuthorizationDecision: operation_code="create_record", resource_kind="employment_record", requested_fields=frozenset({"employment_record"}), - authorized_fields=frozenset({"employment_record"}), - reason_code="access_permitted", - next_action="continue", + required_scope_code="orgmetra.people.write", ) def position_authorization() -> AuthorizationDecision: - """Return the exact allow decision created by the position policy.""" - return AuthorizationDecision( - allowed=True, + """Return the exact allow decision issued by the position policy evaluator.""" + return issued_authorization( tenant_record_id=TENANT, actor_reference=ACTOR, resource_reference=f"position_record:{POSITION.hex}", @@ -129,16 +126,13 @@ def position_authorization() -> AuthorizationDecision: operation_code="create_record", resource_kind="position_record", requested_fields=frozenset({"position_record"}), - authorized_fields=frozenset({"position_record"}), - reason_code="access_permitted", - next_action="continue", + required_scope_code="orgmetra.job_architecture.write", ) def assignment_authorization() -> AuthorizationDecision: - """Return the exact allow decision created by the assignment policy.""" - return AuthorizationDecision( - allowed=True, + """Return the exact allow decision issued by the assignment policy evaluator.""" + return issued_authorization( tenant_record_id=TENANT, actor_reference=ACTOR, resource_reference=f"assignment_record:{ASSIGNMENT.hex}", @@ -147,9 +141,7 @@ def assignment_authorization() -> AuthorizationDecision: operation_code="create_record", resource_kind="assignment_record", requested_fields=frozenset({"assignment_record"}), - authorized_fields=frozenset({"assignment_record"}), - reason_code="access_permitted", - next_action="continue", + required_scope_code="orgmetra.people.write", ) @@ -432,8 +424,7 @@ def factory() -> FakeConnection: port = PostgresPeopleMutationPort(factory) with self.assertRaisesRegex(PeopleMutationIntegrityError, "authorization"): port.create_employment(command=employment_command(), authorization=object()) # type: ignore[arg-type] - denied = AuthorizationDecision( - allowed=False, + denied = issued_authorization( tenant_record_id=TENANT, actor_reference=ACTOR, resource_reference=f"employment_record:{EMPLOYMENT.hex}", @@ -442,10 +433,10 @@ def factory() -> FakeConnection: operation_code="create_record", resource_kind="employment_record", requested_fields=frozenset({"employment_record"}), - authorized_fields=frozenset(), - reason_code="access_denied", - next_action="stop", + required_scope_code="orgmetra.people.write", + granted_scope_codes=frozenset({"orgmetra.people.read"}), ) + self.assertFalse(denied.allowed) with self.assertRaisesRegex(PeopleMutationIntegrityError, "authorization"): port.create_employment(command=employment_command(), authorization=denied) with self.assertRaisesRegex(TypeError, "EmploymentMutationCommand"):