diff --git a/CHANGELOG.md b/CHANGELOG.md index 16454da3d..bf0bbdd30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,9 @@ 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. +- `orgmetra_keyverse_adapter` now validates exact opaque issuer/subject input only as non-authorizing identity candidate data; it rejects credential material and cannot create a persistence-authorizing person binding until an immutable released/versioned Keyverse subject-assertion trust contract is available through the Orgmetra ACL. - Design tokens for the repeating HR actions: approve, review, correct, request evidence, compare, export, and escalate. - ADR 0004 for employment/position versions and assignment-employment binding. - Foundation product baseline for Orgmetra as an evidence-centered HRIS/HCM. @@ -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. +- Keyverse identity candidates now reject forged or non-canonical input before any trust transition: tenant/person identifiers must be detached exact operational UUIDs with in-range integer payloads outside Nil/Max, and issuer/subject must be exact non-blank canonical text. These checks establish input integrity only; raw issuer/subject syntax is never authentication evidence, `ExternalIdentityBindingCandidate` is explicitly non-authorizing, and durable binding fails closed until a released/versioned Keyverse subject-assertion trust contract can be consumed through the Orgmetra ACL. - 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/manifest.json b/manifest.json index f7b6cf55e..68acba4ff 100644 --- a/manifest.json +++ b/manifest.json @@ -29,9 +29,9 @@ }, { "path": "CHANGELOG.md", - "sha256": "f2d2e0b488c0440533effa821808f2f17e37d92f8fb586174c2fdb594f760ca5", - "bytes": 17539, - "lines": 77 + "sha256": "a1c283260fe0194aaeb5e806fc6e325262e19062b6c229792dfd405aae596d6c", + "bytes": 18331, + "lines": 78 }, { "path": "CLAUDE.md", @@ -472,4 +472,4 @@ "lines": 637 } ] -} +} \ No newline at end of file diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/__init__.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/__init__.py index fcfbd63ff..5a8bd6ced 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. +"""Keyverse identity candidates 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. +Orgmetra never stores passwords, passkeys, or raw credentials on a person record. +Raw issuer/subject input may be validated as non-authorizing candidate data, but +it cannot become a durable identity binding until Keyverse publishes immutable, +versioned subject-assertion trust evidence that an Orgmetra ACL can consume. """ from orgmetra_keyverse_adapter.authorization import ( @@ -17,18 +16,22 @@ ) from orgmetra_keyverse_adapter.binding import ( CredentialRejectedError, - ExternalIdentityBinding, + ExternalIdentityBindingCandidate, + IdentityBindingTrustUnavailableError, bind_identity_subject, + validate_identity_subject_candidate, ) __all__ = [ "AuthorizationDecision", "AuthorizationDeniedError", "CredentialRejectedError", - "ExternalIdentityBinding", + "ExternalIdentityBindingCandidate", + "IdentityBindingTrustUnavailableError", "PurposeBoundAccessPolicy", "PurposeBoundAccessRequest", "bind_identity_subject", "evaluate_purpose_bound_access", "require_purpose_bound_access", + "validate_identity_subject_candidate", ] diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py index 089d837cf..b3856921c 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py @@ -1,10 +1,11 @@ -"""Opaque Keyverse subject binding without credential storage.""" +"""Validate Keyverse subject candidates without manufacturing authentication authority.""" from __future__ import annotations -from dataclasses import dataclass +from typing import Never from uuid import UUID +_MAX_UUID_INT = (1 << 128) - 1 _FORBIDDEN_FIELD_NAMES = frozenset( { "password", @@ -14,64 +15,221 @@ "credential", "refresh_token", "access_token", + "id_token", + "client_secret", + "api_key", } ) class CredentialRejectedError(ValueError): - """A caller tried to persist a credential instead of an identity subject.""" + """A caller supplied missing identity data or credential material.""" def __init__(self, message: str, *, next_action: str) -> None: - """Tell the integrator to keep secrets in Keyverse.""" + """Retain one safe recovery action without retaining credential content.""" super().__init__(message) self.next_action = next_action -@dataclass(frozen=True, slots=True) -class ExternalIdentityBinding: - """Durable link from a Keyverse subject to an Orgmetra person.""" +class IdentityBindingTrustUnavailableError(RuntimeError): + """A durable bind was requested without released Keyverse trust evidence.""" - tenant_record_id: UUID - person_record_id: UUID - identity_issuer: str - identity_subject: str + def __init__(self) -> None: + """Direct the caller to the owner-published trust contract instead of a local bypass.""" + super().__init__("Released Keyverse subject-assertion trust evidence is required for a durable binding.") + self.next_action = "integrate_released_keyverse_subject_assertion_contract" -def bind_identity_subject( - *, - tenant_record_id: UUID, - person_record_id: UUID, - identity_issuer: str, - identity_subject: str, - extra_claims: dict[str, str] | None = None, -) -> ExternalIdentityBinding: - """Bind a Keyverse subject to a person after authentication succeeds. - - Args: - tenant_record_id: Tenant that owns the person. - person_record_id: Orgmetra person being linked. - identity_issuer: Keyverse issuer URL or identifier. - identity_subject: Opaque subject. Never a password or passkey. - extra_claims: Optional non-secret claims. Secret field names are rejected. - - Returns: - The binding to persist. Review it, then continue the HR action. +def _validate_operational_uuid(field_name: str, value: object) -> int: + """Return the detached integer of one exact operational UUID. + + An exact ``uuid.UUID`` can still have its internal ``int`` slot rewritten with + ``object.__setattr__``, so the retained payload must be proven to be an exact + built-in integer inside the 128-bit construction range before the reserved + Nil/Max sentinels are compared. Returning only the checked scalar lets the + candidate reconstruct its own UUID instead of retaining a caller-owned alias. """ + if type(value) is not UUID: + raise ValueError(f"{field_name} must be an operational UUID.") + identity = value.int + if type(identity) is not int or not 0 <= identity <= _MAX_UUID_INT: + raise ValueError(f"{field_name} must be an operational UUID.") + if identity in (0, _MAX_UUID_INT): + raise ValueError(f"{field_name} must be an operational UUID.") + return identity + + +def _validate_canonical_text(field_name: str, value: object) -> str: + """Require exact built-in text that is non-blank and already canonical. + + Candidate identity still crosses a trust boundary even though it is not + authorization evidence. Exact canonical text prevents executable ``str`` + subtypes or ``strip``-shaped impostors from surviving validation. + """ + if type(value) is not str: + raise ValueError(f"{field_name} must be exact text.") + if not value.strip(): + raise ValueError(f"{field_name} must be non-blank text.") + if value != value.strip(): + raise ValueError(f"{field_name} must be canonical text without surrounding whitespace.") + return value + + +def _validate_extra_claim_names(extra_claims: object) -> tuple[str, ...]: + """Detach only inert canonical claim names needed for credential screening. + + Claim values are intentionally ignored because candidate validation does not + retain or interpret them. Requiring an exact built-in ``dict`` and canonical + exact built-in string keys avoids invoking caller-defined behavior and prevents + surrounding whitespace from disguising a credential-shaped claim name. + """ + if extra_claims is None: + return () + if type(extra_claims) is not dict: + raise ValueError("extra_claims must be an exact dict.") + + names: list[str] = [] + for name in extra_claims: + if type(name) is not str: + raise ValueError("extra claim names must be exact text.") + names.append(_validate_canonical_text("extra claim name", name)) + return tuple(names) + + +class ExternalIdentityBindingCandidate(tuple): + """Validated, structurally immutable identity candidate with no persistence authority. + + Tenant and person identities are stored as checked integer scalars and exposed + only through freshly constructed UUID views, so a caller cannot mutate retained + candidate identity through a returned ``UUID`` object. Issuer and subject are + canonical exact text. This value still proves only local input integrity: + low-level tuple construction can bypass the public constructor, so any + consequential consumer must reconstruct or revalidate the evidence rather than + treat this Python value as authentication or authorization authority. + """ + + __slots__ = () + + def __new__( + cls, + *, + tenant_record_id: UUID, + person_record_id: UUID, + identity_issuer: str, + identity_subject: str, + ) -> ExternalIdentityBindingCandidate: + """Validate and detach candidate evidence into immutable tuple storage.""" + tenant_identity = _validate_operational_uuid("tenant_record_id", tenant_record_id) + person_identity = _validate_operational_uuid("person_record_id", person_record_id) + issuer = _validate_canonical_text("identity_issuer", identity_issuer) + subject = _validate_canonical_text("identity_subject", identity_subject) + return tuple.__new__( + cls, + ( + tenant_identity, + person_identity, + issuer, + subject, + ), + ) + + @property + def tenant_record_id(self) -> UUID: + """Return a fresh UUID view of the retained tenant identity scalar.""" + return UUID(int=self[0]) + + @property + def person_record_id(self) -> UUID: + """Return a fresh UUID view of the retained Person identity scalar.""" + return UUID(int=self[1]) + + @property + def identity_issuer(self) -> str: + """Return the canonical issuer text retained at validation.""" + return self[2] + + @property + def identity_subject(self) -> str: + """Return the canonical opaque subject retained at validation.""" + return self[3] + + @property + def persistence_authorized(self) -> bool: + """Expose the invariant that locally validated candidate data never authorizes persistence.""" + return False + + +def _reject_missing_or_credential_input( + *, + identity_issuer: object, + identity_subject: object, + extra_claims: object, +) -> None: + """Reject absent identity and credential-shaped claim names before candidate construction.""" + if type(identity_issuer) is not str: + raise ValueError("identity_issuer must be exact text.") + if type(identity_subject) is not str: + raise ValueError("identity_subject must be exact text.") if not identity_issuer.strip() or not identity_subject.strip(): raise CredentialRejectedError( "Identity issuer and subject are required.", - next_action="Send the Keyverse issuer and opaque subject, then retry the bind.", + next_action="Send the Keyverse issuer and opaque subject, then retry validation.", ) - claims = extra_claims or {} - forbidden = _FORBIDDEN_FIELD_NAMES.intersection(name.lower() for name in claims) + claim_names = _validate_extra_claim_names(extra_claims) + forbidden = _FORBIDDEN_FIELD_NAMES.intersection(name.lower() for name in claim_names) if forbidden: raise CredentialRejectedError( - "Identity binding cannot store credentials or tokens.", + "Identity validation cannot accept credentials or tokens.", next_action="Keep secrets in Keyverse and send only the opaque subject.", ) - return ExternalIdentityBinding( + + +def validate_identity_subject_candidate( + *, + tenant_record_id: UUID, + person_record_id: UUID, + identity_issuer: str, + identity_subject: str, + extra_claims: dict[str, str] | None = None, +) -> ExternalIdentityBindingCandidate: + """Validate raw identity input as non-authorizing candidate data. + + This function deliberately does not accept a trust flag or caller-created + authentication receipt. A future positive binding path must consume the exact + released/versioned Keyverse subject-assertion contract through an Orgmetra ACL. + """ + _reject_missing_or_credential_input( + identity_issuer=identity_issuer, + identity_subject=identity_subject, + extra_claims=extra_claims, + ) + return ExternalIdentityBindingCandidate( + tenant_record_id=tenant_record_id, + person_record_id=person_record_id, + identity_issuer=identity_issuer, + identity_subject=identity_subject, + ) + + +def bind_identity_subject( + *, + tenant_record_id: UUID, + person_record_id: UUID, + identity_issuer: str, + identity_subject: str, + extra_claims: dict[str, str] | None = None, +) -> Never: + """Fail closed until Keyverse publishes immutable subject-assertion trust evidence. + + Raw issuer/subject text is validated first so malformed or credential-bearing + input still fails at the narrowest boundary. Successful syntax validation is + not authentication evidence and therefore cannot produce a durable binding. + """ + validate_identity_subject_candidate( tenant_record_id=tenant_record_id, person_record_id=person_record_id, - identity_issuer=identity_issuer.strip(), - identity_subject=identity_subject.strip(), + identity_issuer=identity_issuer, + identity_subject=identity_subject, + extra_claims=extra_claims, ) + raise IdentityBindingTrustUnavailableError() diff --git a/packages/keyverse-adapter/tests/test_binding.py b/packages/keyverse-adapter/tests/test_binding.py index 48cb269af..c1ce0c1e6 100644 --- a/packages/keyverse-adapter/tests/test_binding.py +++ b/packages/keyverse-adapter/tests/test_binding.py @@ -1,4 +1,4 @@ -"""Keyverse adapter tests: bind subjects, reject credentials.""" +"""Keyverse adapter tests: validate candidates and reject untrusted binding authority.""" from uuid import UUID @@ -6,60 +6,255 @@ from orgmetra_keyverse_adapter import ( CredentialRejectedError, + ExternalIdentityBindingCandidate, + IdentityBindingTrustUnavailableError, bind_identity_subject, + validate_identity_subject_candidate, ) TENANT = UUID("10000000-0000-7000-8000-000000000401") PERSON = UUID("10000000-0000-7000-8000-000000000402") +ISSUER = "https://keyverse.example/issuer" +SUBJECT = "sub_jordan_hale" -def test_bind_identity_subject_keeps_only_opaque_subject() -> None: - """After login, store the Keyverse subject and continue the HR action.""" - binding = bind_identity_subject( +def _candidate(**overrides: object) -> ExternalIdentityBindingCandidate: + """Build one valid non-authorizing candidate and override one field per test.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "person_record_id": PERSON, + "identity_issuer": ISSUER, + "identity_subject": SUBJECT, + } + values.update(overrides) + return ExternalIdentityBindingCandidate(**values) # type: ignore[arg-type] + + +def test_validate_identity_subject_candidate_keeps_only_opaque_subject() -> None: + """Raw identity input may become validation data, never persistence authority.""" + candidate = validate_identity_subject_candidate( tenant_record_id=TENANT, person_record_id=PERSON, - identity_issuer="https://keyverse.example/issuer", - identity_subject="sub_jordan_hale", + identity_issuer=ISSUER, + identity_subject=SUBJECT, extra_claims={"purpose": "hr_operations"}, ) - assert binding.identity_subject == "sub_jordan_hale" - assert binding.identity_issuer == "https://keyverse.example/issuer" - assert binding.person_record_id == PERSON + assert candidate.identity_subject == SUBJECT + assert candidate.identity_issuer == ISSUER + assert candidate.person_record_id == PERSON + assert candidate.persistence_authorized is False -def test_bind_identity_subject_rejects_blank_issuer_or_subject() -> None: - """Ask Keyverse for a real subject before creating the person link.""" - with pytest.raises(CredentialRejectedError, match="required"): +def test_bind_identity_subject_fails_closed_without_released_keyverse_trust() -> None: + """Syntactically valid raw identity cannot manufacture a durable trusted binding.""" + with pytest.raises(IdentityBindingTrustUnavailableError) as caught: bind_identity_subject( + tenant_record_id=TENANT, + person_record_id=PERSON, + identity_issuer=ISSUER, + identity_subject=SUBJECT, + extra_claims={"purpose": "hr_operations"}, + ) + assert caught.value.next_action == "integrate_released_keyverse_subject_assertion_contract" + + +def test_candidate_validation_rejects_blank_issuer_or_subject() -> None: + """Ask Keyverse for a real subject before creating even candidate identity data.""" + with pytest.raises(CredentialRejectedError, match="required"): + validate_identity_subject_candidate( tenant_record_id=TENANT, person_record_id=PERSON, identity_issuer=" ", - identity_subject="sub_jordan_hale", + identity_subject=SUBJECT, ) with pytest.raises(CredentialRejectedError, match="required"): - bind_identity_subject( + validate_identity_subject_candidate( tenant_record_id=TENANT, person_record_id=PERSON, - identity_issuer="https://keyverse.example/issuer", + identity_issuer=ISSUER, identity_subject="", ) -def test_bind_identity_subject_rejects_credential_claim_names() -> None: - """Never copy a password, passkey, or token onto the person record.""" +def test_bind_identity_subject_rejects_credential_claim_names_before_trust_gate() -> None: + """Never copy a password, passkey, or token while evaluating identity input.""" with pytest.raises(CredentialRejectedError, match="credentials"): bind_identity_subject( tenant_record_id=TENANT, person_record_id=PERSON, - identity_issuer="https://keyverse.example/issuer", - identity_subject="sub_jordan_hale", + identity_issuer=ISSUER, + identity_subject=SUBJECT, extra_claims={"password": "not-a-secret-we-will-store"}, ) with pytest.raises(CredentialRejectedError, match="credentials"): bind_identity_subject( tenant_record_id=TENANT, person_record_id=PERSON, - identity_issuer="https://keyverse.example/issuer", - identity_subject="sub_jordan_hale", + identity_issuer=ISSUER, + identity_subject=SUBJECT, extra_claims={"Access_Token": "header.payload.sig"}, ) + + +def test_candidate_rejects_non_uuid_tenant_or_person_identity() -> None: + """Never retain candidate person linkage under a non-UUID identifier.""" + with pytest.raises(ValueError, match="tenant_record_id must be an operational UUID"): + _candidate(tenant_record_id="not-a-uuid") + with pytest.raises(ValueError, match="person_record_id must be an operational UUID"): + _candidate(person_record_id=123) + + +def test_candidate_rejects_reserved_uuid_sentinels() -> None: + """Never retain the protocol-reserved Nil or Max identity sentinels.""" + with pytest.raises(ValueError, match="tenant_record_id must be an operational UUID"): + _candidate(tenant_record_id=UUID(int=0)) + with pytest.raises(ValueError, match="person_record_id must be an operational UUID"): + _candidate(person_record_id=UUID(int=(1 << 128) - 1)) + + +def test_candidate_rejects_forged_uuid_internal_integer_payload() -> None: + """Reject an exact UUID whose internal integer was rewritten off-range.""" + forged = UUID("10000000-0000-7000-8000-000000000401") + object.__setattr__(forged, "int", -1) + with pytest.raises(ValueError, match="tenant_record_id must be an operational UUID"): + _candidate(tenant_record_id=forged) + + out_of_range = UUID("10000000-0000-7000-8000-000000000402") + object.__setattr__(out_of_range, "int", 1 << 128) + with pytest.raises(ValueError, match="person_record_id must be an operational UUID"): + _candidate(person_record_id=out_of_range) + + +def test_candidate_detaches_caller_owned_uuid_before_return() -> None: + """Caller mutation after validation cannot rewrite retained candidate identity.""" + tenant = UUID("10000000-0000-7000-8000-000000000411") + person = UUID("10000000-0000-7000-8000-000000000412") + expected_tenant = UUID(int=tenant.int) + expected_person = UUID(int=person.int) + + candidate = ExternalIdentityBindingCandidate( + tenant_record_id=tenant, + person_record_id=person, + identity_issuer=ISSUER, + identity_subject=SUBJECT, + ) + + assert candidate.tenant_record_id is not tenant + assert candidate.person_record_id is not person + object.__setattr__(tenant, "int", -1) + object.__setattr__(person, "int", 1 << 128) + assert candidate.tenant_record_id == expected_tenant + assert candidate.person_record_id == expected_person + + +def test_candidate_rejects_text_subtypes_and_non_text_issuer_or_subject() -> None: + """Require exact built-in text for issuer and subject candidate data.""" + + class TextSubtype(str): + pass + + with pytest.raises(ValueError, match="identity_issuer must be exact text"): + _candidate(identity_issuer=TextSubtype(ISSUER)) + with pytest.raises(ValueError, match="identity_subject must be exact text"): + _candidate(identity_subject=TextSubtype(SUBJECT)) + with pytest.raises(ValueError, match="identity_issuer must be exact text"): + _candidate(identity_issuer=object()) + with pytest.raises(ValueError, match="identity_subject must be exact text"): + _candidate(identity_subject=object()) + + +def test_candidate_rejects_trimmed_empty_or_padded_issuer_or_subject() -> None: + """Reject missing identity and preserve the bounded canonical text exactly.""" + with pytest.raises(ValueError, match="identity_issuer must be non-blank text"): + _candidate(identity_issuer=" ") + with pytest.raises(ValueError, match="identity_subject must be non-blank text"): + _candidate(identity_subject="\t") + with pytest.raises(ValueError, match="identity_issuer must be canonical text"): + _candidate(identity_issuer=f" {ISSUER} ") + with pytest.raises(ValueError, match="identity_subject must be canonical text"): + _candidate(identity_subject=f"{SUBJECT}\n") + + +def test_bind_identity_subject_rejects_untrusted_runtime_types_before_trust_gate() -> None: + """Reject forged identity inputs before reporting missing owner trust evidence.""" + + class TextSubtype(str): + pass + + class DuckText: + def __init__(self, value: str) -> None: + self._value = value + + def strip(self) -> str: + return self._value + + with pytest.raises(ValueError, match="tenant_record_id must be an operational UUID"): + bind_identity_subject( + tenant_record_id=UUID(int=0), + person_record_id=PERSON, + identity_issuer=ISSUER, + identity_subject=SUBJECT, + ) + with pytest.raises(ValueError, match="identity_issuer must be exact text"): + bind_identity_subject( + tenant_record_id=TENANT, + person_record_id=PERSON, + identity_issuer=DuckText(ISSUER), + identity_subject=SUBJECT, + ) + with pytest.raises(ValueError, match="identity_subject must be exact text"): + bind_identity_subject( + tenant_record_id=TENANT, + person_record_id=PERSON, + identity_issuer=ISSUER, + identity_subject=TextSubtype(SUBJECT), + ) + with pytest.raises(ValueError, match="identity_subject must be canonical text"): + bind_identity_subject( + tenant_record_id=TENANT, + person_record_id=PERSON, + identity_issuer=ISSUER, + identity_subject=f" {SUBJECT} ", + ) + + +def test_extra_claims_reject_executable_container_or_key_before_use() -> None: + """Do not execute caller-defined claim container or key behavior while rejecting credentials.""" + + class ClaimsDict(dict[str, str]): + def __len__(self) -> int: + raise AssertionError("claim-container truthiness executed") + + class ClaimName(str): + def lower(self) -> str: + raise AssertionError("claim-name lower executed") + + with pytest.raises(ValueError, match="extra_claims must be an exact dict"): + validate_identity_subject_candidate( + tenant_record_id=TENANT, + person_record_id=PERSON, + identity_issuer=ISSUER, + identity_subject=SUBJECT, + extra_claims=ClaimsDict({"purpose": "hr_operations"}), + ) + + with pytest.raises(ValueError, match="extra claim names must be exact text"): + validate_identity_subject_candidate( + tenant_record_id=TENANT, + person_record_id=PERSON, + identity_issuer=ISSUER, + identity_subject=SUBJECT, + extra_claims={ClaimName("password"): "not-retained"}, + ) + + +def test_extra_claims_reject_non_text_claim_name_with_bounded_error() -> None: + """Malformed claim names fail closed without leaking an incidental AttributeError.""" + with pytest.raises(ValueError, match="extra claim names must be exact text"): + validate_identity_subject_candidate( + tenant_record_id=TENANT, + person_record_id=PERSON, + identity_issuer=ISSUER, + identity_subject=SUBJECT, + extra_claims={object(): "not-retained"}, # type: ignore[dict-item] + ) diff --git a/packages/keyverse-adapter/tests/test_binding_claim_name_canonicality.py b/packages/keyverse-adapter/tests/test_binding_claim_name_canonicality.py new file mode 100644 index 000000000..d6442b609 --- /dev/null +++ b/packages/keyverse-adapter/tests/test_binding_claim_name_canonicality.py @@ -0,0 +1,49 @@ +"""Regression contracts for inert, canonical Keyverse claim names.""" + +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import CredentialRejectedError, bind_identity_subject + +TENANT = UUID("10000000-0000-7000-8000-000000000421") +PERSON = UUID("10000000-0000-7000-8000-000000000422") +ISSUER = "https://keyverse.example/issuer" +SUBJECT = "sub_claim_name_boundary" + + +def test_padded_credential_claim_name_fails_before_trust_gate() -> None: + """Whitespace must not let a credential-shaped claim name evade screening.""" + with pytest.raises(ValueError, match="extra claim name must be canonical text"): + bind_identity_subject( + tenant_record_id=TENANT, + person_record_id=PERSON, + identity_issuer=ISSUER, + identity_subject=SUBJECT, + extra_claims={" Access_Token ": "header.payload.sig"}, + ) + + +def test_blank_claim_name_fails_before_trust_gate() -> None: + """A blank claim name is malformed input rather than trusted candidate metadata.""" + with pytest.raises(ValueError, match="extra claim name must be non-blank text"): + bind_identity_subject( + tenant_record_id=TENANT, + person_record_id=PERSON, + identity_issuer=ISSUER, + identity_subject=SUBJECT, + extra_claims={" ": "ignored"}, + ) + + +@pytest.mark.parametrize("claim_name", ["id_token", "client_secret", "api_key"]) +def test_standard_credential_claim_names_fail_before_trust_gate(claim_name: str) -> None: + """Raw security-token and client-credential fields never reach the owner trust gate.""" + with pytest.raises(CredentialRejectedError, match="credentials"): + bind_identity_subject( + tenant_record_id=TENANT, + person_record_id=PERSON, + identity_issuer=ISSUER, + identity_subject=SUBJECT, + extra_claims={claim_name: "not-retained"}, + ) diff --git a/packages/keyverse-adapter/tests/test_binding_structural_immutability.py b/packages/keyverse-adapter/tests/test_binding_structural_immutability.py new file mode 100644 index 000000000..b9a1d182f --- /dev/null +++ b/packages/keyverse-adapter/tests/test_binding_structural_immutability.py @@ -0,0 +1,47 @@ +"""Regression contract for post-validation Keyverse candidate integrity.""" + +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import validate_identity_subject_candidate + +TENANT = UUID("10000000-0000-7000-8000-000000000431") +PERSON = UUID("10000000-0000-7000-8000-000000000432") +ISSUER = "https://keyverse.example/issuer" +SUBJECT = "sub_structural_integrity" + + +def _candidate(): + """Build one validated candidate for structural-integrity regressions.""" + return validate_identity_subject_candidate( + tenant_record_id=TENANT, + person_record_id=PERSON, + identity_issuer=ISSUER, + identity_subject=SUBJECT, + ) + + +def test_validated_candidate_cannot_be_rewritten_after_validation() -> None: + """Validated candidate fields remain unchanged after low-level attribute replacement attempts.""" + candidate = _candidate() + + with pytest.raises((AttributeError, TypeError)): + object.__setattr__(candidate, "identity_subject", "sub_rewritten_after_validation") + + assert candidate.identity_subject == SUBJECT + + +def test_returned_uuid_views_cannot_mutate_candidate_identity() -> None: + """Caller mutation of returned UUID views cannot rewrite retained tenant or Person identity.""" + candidate = _candidate() + tenant_view = candidate.tenant_record_id + person_view = candidate.person_record_id + + object.__setattr__(tenant_view, "int", 0) + object.__setattr__(person_view, "int", (1 << 128) - 1) + + assert candidate.tenant_record_id == TENANT + assert candidate.person_record_id == PERSON + assert candidate.tenant_record_id is not tenant_view + assert candidate.person_record_id is not person_view