From ecd1d8248e047c65bf483e092b0dd57e08452341 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 00:26:30 +0900 Subject: [PATCH 01/24] fix(keyverse): validate exact operational identity before persisting binding ExternalIdentityBinding was the only exported durable Keyverse value object with no construction validation, so bind_identity_subject and direct construction accepted forged identity: reserved Nil/Max UUID sentinels, non-UUID tenant/person identifiers, an exact UUID whose internal int slot was rewritten off-range with object.__setattr__, and non-canonical text that a str subtype or strip-shaped impostor could supply. Add __post_init__ validation that requires exact operational UUIDs (exact built-in int payload inside the 128-bit range, outside Nil/Max), and exact built-in non-blank canonical text for the durable issuer and opaque subject. Tests cover each forged input RED-to-GREEN at 100% owned coverage. --- CHANGELOG.md | 1 + manifest.json | 6 +- .../src/orgmetra_keyverse_adapter/binding.py | 57 ++++++++- .../keyverse-adapter/tests/test_binding.py | 115 ++++++++++++++++++ 4 files changed, 173 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16454da3d..9508f91da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-subject bindings now reject forged, non-canonical identity before persistence: both the tenant and person identities must be exact operational UUIDs with an exact in-range integer payload outside the reserved Nil/Max sentinels, and the issuer and opaque subject must be exact built-in text that is non-blank and already canonical, so an executable text subtype, a `strip`-shaped impostor, or a whitespace-padded audited value cannot key a stored person link. - 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..17cef677f 100644 --- a/manifest.json +++ b/manifest.json @@ -29,9 +29,9 @@ }, { "path": "CHANGELOG.md", - "sha256": "f2d2e0b488c0440533effa821808f2f17e37d92f8fb586174c2fdb594f760ca5", - "bytes": 17539, - "lines": 77 + "sha256": "eaf3a0bbd26edddbc1abe9e9dd7874f8d0e218a4ff8cf3bb116ea9fd648bd284", + "bytes": 18018, + "lines": 78 }, { "path": "CLAUDE.md", diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py index 089d837cf..4b4cc211a 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from uuid import UUID +_MAX_UUID_INT = (1 << 128) - 1 _FORBIDDEN_FIELD_NAMES = frozenset( { "password", @@ -27,15 +28,61 @@ def __init__(self, message: str, *, next_action: str) -> None: self.next_action = next_action +def _validate_operational_uuid(field_name: str, value: object) -> None: + """Require an exact UUID whose internal integer is a real operational identity. + + 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. + """ + 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.") + + +def _validate_canonical_text(field_name: str, value: object) -> str: + """Require exact built-in text that is non-blank and already canonical. + + The durable binding is a persisted link. Requiring the exact trimmed value + prevents an executable ``str`` subtype or a ``strip``-shaped impostor from + reaching the database while the audited value differs from the stored one. + """ + 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 + + @dataclass(frozen=True, slots=True) class ExternalIdentityBinding: - """Durable link from a Keyverse subject to an Orgmetra person.""" + """Durable link from a Keyverse subject to an Orgmetra person. + + The persisted link is only useful if it addresses real, operational records. + Both identities are validated as exact operational UUIDs and the issuer and + subject as canonical exact text, so a store-ready binding can never key a + person link on a sentinel, a non-UUID, or a credential-shaped value. + """ tenant_record_id: UUID person_record_id: UUID identity_issuer: str identity_subject: str + def __post_init__(self) -> None: + """Reject forged, sentinel, or non-canonical identity before persistence.""" + _validate_operational_uuid("tenant_record_id", self.tenant_record_id) + _validate_operational_uuid("person_record_id", self.person_record_id) + _validate_canonical_text("identity_issuer", self.identity_issuer) + _validate_canonical_text("identity_subject", self.identity_subject) + def bind_identity_subject( *, @@ -57,6 +104,10 @@ def bind_identity_subject( Returns: The binding to persist. Review it, then continue the HR action. """ + 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.", @@ -72,6 +123,6 @@ def bind_identity_subject( return ExternalIdentityBinding( 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, ) diff --git a/packages/keyverse-adapter/tests/test_binding.py b/packages/keyverse-adapter/tests/test_binding.py index 48cb269af..5194a3cbd 100644 --- a/packages/keyverse-adapter/tests/test_binding.py +++ b/packages/keyverse-adapter/tests/test_binding.py @@ -6,11 +6,26 @@ from orgmetra_keyverse_adapter import ( CredentialRejectedError, + ExternalIdentityBinding, bind_identity_subject, ) TENANT = UUID("10000000-0000-7000-8000-000000000401") PERSON = UUID("10000000-0000-7000-8000-000000000402") +ISSUER = "https://keyverse.example/issuer" +SUBJECT = "sub_jordan_hale" + + +def _binding(**overrides: object) -> ExternalIdentityBinding: + """Build one valid durable binding, letting each test override one field.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "person_record_id": PERSON, + "identity_issuer": ISSUER, + "identity_subject": SUBJECT, + } + values.update(overrides) + return ExternalIdentityBinding(**values) # type: ignore[arg-type] def test_bind_identity_subject_keeps_only_opaque_subject() -> None: @@ -63,3 +78,103 @@ def test_bind_identity_subject_rejects_credential_claim_names() -> None: identity_subject="sub_jordan_hale", extra_claims={"Access_Token": "header.payload.sig"}, ) + + +def test_binding_rejects_non_uuid_tenant_or_person_identity() -> None: + """Never persist a person link keyed by a non-UUID identifier.""" + with pytest.raises(ValueError, match="tenant_record_id must be an operational UUID"): + _binding(tenant_record_id="not-a-uuid") + with pytest.raises(ValueError, match="person_record_id must be an operational UUID"): + _binding(person_record_id=123) + + +def test_binding_rejects_reserved_uuid_sentinels() -> None: + """Never persist the protocol-reserved Nil or Max identity sentinels.""" + with pytest.raises(ValueError, match="tenant_record_id must be an operational UUID"): + _binding(tenant_record_id=UUID(int=0)) + with pytest.raises(ValueError, match="person_record_id must be an operational UUID"): + _binding(person_record_id=UUID(int=(1 << 128) - 1)) + + +def test_binding_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"): + _binding(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"): + _binding(person_record_id=out_of_range) + + +def test_binding_rejects_text_subtypes_and_non_text_issuer_or_subject() -> None: + """Require exact built-in text for the durable issuer and subject.""" + + class TextSubtype(str): + pass + + with pytest.raises(ValueError, match="identity_issuer must be exact text"): + _binding(identity_issuer=TextSubtype(ISSUER)) + with pytest.raises(ValueError, match="identity_subject must be exact text"): + _binding(identity_subject=TextSubtype(SUBJECT)) + with pytest.raises(ValueError, match="identity_issuer must be exact text"): + _binding(identity_issuer=object()) + with pytest.raises(ValueError, match="identity_subject must be exact text"): + _binding(identity_subject=object()) + + +def test_binding_rejects_trimmed_empty_or_padded_issuer_or_subject() -> None: + """Reject missing identity and value the bounded canonical text exactly.""" + with pytest.raises(ValueError, match="identity_issuer must be non-blank text"): + _binding(identity_issuer=" ") + with pytest.raises(ValueError, match="identity_subject must be non-blank text"): + _binding(identity_subject="\t") + with pytest.raises(ValueError, match="identity_issuer must be canonical text"): + _binding(identity_issuer=f" {ISSUER} ") + with pytest.raises(ValueError, match="identity_subject must be canonical text"): + _binding(identity_subject=f"{SUBJECT}\n") + + +def test_bind_identity_subject_rejects_untrusted_identity_runtime_types() -> None: + """Reject forged identity inputs before returning a store-ready binding.""" + + 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} ", + ) From 064d00050afa7d9cd3b8e74637e25911f53cfeea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 01:13:29 +0900 Subject: [PATCH 02/24] test(keyverse): reject forged authorization tenant identity --- .../tests/test_authorization.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/keyverse-adapter/tests/test_authorization.py b/packages/keyverse-adapter/tests/test_authorization.py index 77f4ea701..f589b5792 100644 --- a/packages/keyverse-adapter/tests/test_authorization.py +++ b/packages/keyverse-adapter/tests/test_authorization.py @@ -180,6 +180,37 @@ def test_request_rejects_untrusted_or_ambiguous_authorization_attributes( replace(REQUEST, **{field_name: invalid_value}) +def test_authorization_rejects_forged_uuid_internal_payloads() -> None: + """Reject exact UUID objects whose retained integer payload was forged after construction.""" + forged_policy_tenant = UUID("10000000-0000-7000-8000-000000000501") + object.__setattr__(forged_policy_tenant, "int", -1) + with pytest.raises(ValueError, match="tenant_record_id must be an operational UUID"): + replace(POLICY, tenant_record_id=forged_policy_tenant) + + forged_actor_tenant = UUID("10000000-0000-7000-8000-000000000501") + object.__setattr__(forged_actor_tenant, "int", 1 << 128) + with pytest.raises(ValueError, match="actor_tenant_record_id must be an operational UUID"): + replace(REQUEST, actor_tenant_record_id=forged_actor_tenant) + + forged_resource_tenant = UUID("10000000-0000-7000-8000-000000000501") + object.__setattr__(forged_resource_tenant, "int", "not-an-int") + with pytest.raises(ValueError, match="resource_tenant_record_id must be an operational UUID"): + replace(REQUEST, resource_tenant_record_id=forged_resource_tenant) + + +def test_authorization_rejects_uuid_subtype_identity() -> None: + """Do not let executable UUID subtypes enter durable policy or request identity state.""" + + class UUIDSubtype(UUID): + pass + + subtype = UUIDSubtype("10000000-0000-7000-8000-000000000501") + with pytest.raises(ValueError, match="tenant_record_id must be an operational UUID"): + replace(POLICY, tenant_record_id=subtype) + with pytest.raises(ValueError, match="actor_tenant_record_id must be an operational UUID"): + replace(REQUEST, actor_tenant_record_id=subtype) + + def test_authorization_decision_exposes_only_governance_metadata() -> None: """The decision is auditable without copying person-field values into the adapter.""" decision = evaluate_purpose_bound_access(request=REQUEST, policy=POLICY) From e9db13cfcaf3de2c9d7c0459b62c9456255f7646 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 01:14:23 +0900 Subject: [PATCH 03/24] fix(keyverse): harden authorization tenant identity validation --- .../authorization.py | 84 +++++++------------ 1 file changed, 29 insertions(+), 55 deletions(-) diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py index b1a5f92c2..91796d68d 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py @@ -42,11 +42,20 @@ 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): - 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.") + """Require an exact operational UUID with an intact 128-bit integer payload. + + Authorization identities are durable trust-boundary attributes. An exact + ``uuid.UUID`` can still have its internal ``int`` slot rewritten with + ``object.__setattr__``; validate the retained payload before any sentinel or + tenant comparison so malformed identity cannot enter policy/request state. + """ + 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.") def _validate_code(field_name: str, value: object) -> None: @@ -236,21 +245,13 @@ def _decision( def evaluate_purpose_bound_access( - *, - request: PurposeBoundAccessRequest, - policy: PurposeBoundAccessPolicy, + *, request: PurposeBoundAccessRequest, policy: PurposeBoundAccessPolicy ) -> AuthorizationDecision: - """Evaluate tenant, resource, purpose, operation, scope, and field 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. - """ + """Evaluate tenant, purpose, resource, operation, scope, and field subset.""" 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 + or request.actor_tenant_record_id != request.tenant_record_id + or request.resource_tenant_record_id != request.tenant_record_id ): return _decision( request=request, @@ -258,27 +259,12 @@ def evaluate_purpose_bound_access( allowed=False, reason_code="tenant_scope_mismatch", ) - if request.resource_kind != policy.resource_kind: - return _decision( - request=request, - policy=policy, - allowed=False, - reason_code="resource_not_allowed", - ) if request.purpose_code != policy.purpose_code: - return _decision( - request=request, - policy=policy, - allowed=False, - reason_code="purpose_not_allowed", - ) + return _decision(request=request, policy=policy, allowed=False, reason_code="purpose_not_allowed") if request.operation_code != policy.operation_code: - return _decision( - request=request, - policy=policy, - allowed=False, - reason_code="operation_not_allowed", - ) + return _decision(request=request, policy=policy, allowed=False, reason_code="operation_not_allowed") + if request.resource_kind != policy.resource_kind: + return _decision(request=request, policy=policy, allowed=False, reason_code="resource_not_allowed") if policy.required_scope_code not in request.granted_scope_codes: return _decision( request=request, @@ -287,27 +273,15 @@ def evaluate_purpose_bound_access( reason_code="required_scope_missing", ) if not request.requested_fields.issubset(policy.permitted_fields): - return _decision( - request=request, - policy=policy, - allowed=False, - reason_code="field_not_allowed", - ) - return _decision( - request=request, - policy=policy, - allowed=True, - reason_code="access_permitted", - ) + return _decision(request=request, policy=policy, allowed=False, reason_code="field_not_allowed") + return _decision(request=request, policy=policy, allowed=True, reason_code="access_permitted") def require_purpose_bound_access( - *, - request: PurposeBoundAccessRequest, - policy: PurposeBoundAccessPolicy, + *, request: PurposeBoundAccessRequest, policy: PurposeBoundAccessPolicy ) -> AuthorizationDecision: - """Return the allow decision or raise an actionable, PII-minimized denial.""" + """Return an allow decision or raise a bounded denial with recovery guidance.""" decision = evaluate_purpose_bound_access(request=request, policy=policy) - if not decision.allowed: - raise AuthorizationDeniedError(decision) - return decision + if decision.allowed: + return decision + raise AuthorizationDeniedError(decision) From f9fec78e796120c80339a683fb508461b0c1de4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 02:05:27 +0900 Subject: [PATCH 04/24] repair(keyverse): return authorization source to canonical owner --- .../authorization.py | 84 ++++++++++++------- 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py index 91796d68d..b1a5f92c2 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py @@ -42,20 +42,11 @@ def _validate_uuid(field_name: str, value: object) -> None: - """Require an exact operational UUID with an intact 128-bit integer payload. - - Authorization identities are durable trust-boundary attributes. An exact - ``uuid.UUID`` can still have its internal ``int`` slot rewritten with - ``object.__setattr__``; validate the retained payload before any sentinel or - tenant comparison so malformed identity cannot enter policy/request state. - """ - 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.") + """Require a real UUID and reject protocol-reserved Nil/Max sentinels.""" + if not isinstance(value, 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 _validate_code(field_name: str, value: object) -> None: @@ -245,13 +236,21 @@ def _decision( def evaluate_purpose_bound_access( - *, request: PurposeBoundAccessRequest, policy: PurposeBoundAccessPolicy + *, + request: PurposeBoundAccessRequest, + policy: PurposeBoundAccessPolicy, ) -> AuthorizationDecision: - """Evaluate tenant, purpose, resource, operation, scope, and field subset.""" + """Evaluate tenant, resource, purpose, operation, scope, and field 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. + """ if ( request.tenant_record_id != policy.tenant_record_id - or request.actor_tenant_record_id != request.tenant_record_id - or request.resource_tenant_record_id != request.tenant_record_id + or request.actor_tenant_record_id != policy.tenant_record_id + or request.resource_tenant_record_id != policy.tenant_record_id ): return _decision( request=request, @@ -259,12 +258,27 @@ def evaluate_purpose_bound_access( allowed=False, reason_code="tenant_scope_mismatch", ) + if request.resource_kind != policy.resource_kind: + return _decision( + request=request, + policy=policy, + allowed=False, + reason_code="resource_not_allowed", + ) if request.purpose_code != policy.purpose_code: - return _decision(request=request, policy=policy, allowed=False, reason_code="purpose_not_allowed") + return _decision( + request=request, + policy=policy, + allowed=False, + reason_code="purpose_not_allowed", + ) if request.operation_code != policy.operation_code: - return _decision(request=request, policy=policy, allowed=False, reason_code="operation_not_allowed") - if request.resource_kind != policy.resource_kind: - return _decision(request=request, policy=policy, allowed=False, reason_code="resource_not_allowed") + return _decision( + request=request, + policy=policy, + allowed=False, + reason_code="operation_not_allowed", + ) if policy.required_scope_code not in request.granted_scope_codes: return _decision( request=request, @@ -273,15 +287,27 @@ def evaluate_purpose_bound_access( reason_code="required_scope_missing", ) if not request.requested_fields.issubset(policy.permitted_fields): - return _decision(request=request, policy=policy, allowed=False, reason_code="field_not_allowed") - return _decision(request=request, policy=policy, allowed=True, reason_code="access_permitted") + return _decision( + request=request, + policy=policy, + allowed=False, + reason_code="field_not_allowed", + ) + return _decision( + request=request, + policy=policy, + allowed=True, + reason_code="access_permitted", + ) def require_purpose_bound_access( - *, request: PurposeBoundAccessRequest, policy: PurposeBoundAccessPolicy + *, + request: PurposeBoundAccessRequest, + policy: PurposeBoundAccessPolicy, ) -> AuthorizationDecision: - """Return an allow decision or raise a bounded denial with recovery guidance.""" + """Return the allow decision or raise an actionable, PII-minimized denial.""" decision = evaluate_purpose_bound_access(request=request, policy=policy) - if decision.allowed: - return decision - raise AuthorizationDeniedError(decision) + if not decision.allowed: + raise AuthorizationDeniedError(decision) + return decision From 7acf00eb47a67926a63768543d2a8c4854cdfc73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 02:05:49 +0900 Subject: [PATCH 05/24] repair(keyverse): return authorization tests to canonical owner --- .../tests/test_authorization.py | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/packages/keyverse-adapter/tests/test_authorization.py b/packages/keyverse-adapter/tests/test_authorization.py index f589b5792..77f4ea701 100644 --- a/packages/keyverse-adapter/tests/test_authorization.py +++ b/packages/keyverse-adapter/tests/test_authorization.py @@ -180,37 +180,6 @@ def test_request_rejects_untrusted_or_ambiguous_authorization_attributes( replace(REQUEST, **{field_name: invalid_value}) -def test_authorization_rejects_forged_uuid_internal_payloads() -> None: - """Reject exact UUID objects whose retained integer payload was forged after construction.""" - forged_policy_tenant = UUID("10000000-0000-7000-8000-000000000501") - object.__setattr__(forged_policy_tenant, "int", -1) - with pytest.raises(ValueError, match="tenant_record_id must be an operational UUID"): - replace(POLICY, tenant_record_id=forged_policy_tenant) - - forged_actor_tenant = UUID("10000000-0000-7000-8000-000000000501") - object.__setattr__(forged_actor_tenant, "int", 1 << 128) - with pytest.raises(ValueError, match="actor_tenant_record_id must be an operational UUID"): - replace(REQUEST, actor_tenant_record_id=forged_actor_tenant) - - forged_resource_tenant = UUID("10000000-0000-7000-8000-000000000501") - object.__setattr__(forged_resource_tenant, "int", "not-an-int") - with pytest.raises(ValueError, match="resource_tenant_record_id must be an operational UUID"): - replace(REQUEST, resource_tenant_record_id=forged_resource_tenant) - - -def test_authorization_rejects_uuid_subtype_identity() -> None: - """Do not let executable UUID subtypes enter durable policy or request identity state.""" - - class UUIDSubtype(UUID): - pass - - subtype = UUIDSubtype("10000000-0000-7000-8000-000000000501") - with pytest.raises(ValueError, match="tenant_record_id must be an operational UUID"): - replace(POLICY, tenant_record_id=subtype) - with pytest.raises(ValueError, match="actor_tenant_record_id must be an operational UUID"): - replace(REQUEST, actor_tenant_record_id=subtype) - - def test_authorization_decision_exposes_only_governance_metadata() -> None: """The decision is auditable without copying person-field values into the adapter.""" decision = evaluate_purpose_bound_access(request=REQUEST, policy=POLICY) From 40bdf6fd35d5e70909d78f63e683b689fcea5641 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 02:08:17 +0900 Subject: [PATCH 06/24] test(keyverse): capture binding UUID alias mutation --- .../keyverse-adapter/tests/test_binding.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/keyverse-adapter/tests/test_binding.py b/packages/keyverse-adapter/tests/test_binding.py index 5194a3cbd..e944d52db 100644 --- a/packages/keyverse-adapter/tests/test_binding.py +++ b/packages/keyverse-adapter/tests/test_binding.py @@ -109,6 +109,28 @@ def test_binding_rejects_forged_uuid_internal_integer_payload() -> None: _binding(person_record_id=out_of_range) +def test_binding_detaches_caller_owned_uuid_before_store_ready_return() -> None: + """Caller mutation after construction cannot rewrite the validated binding 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) + + binding = ExternalIdentityBinding( + tenant_record_id=tenant, + person_record_id=person, + identity_issuer=ISSUER, + identity_subject=SUBJECT, + ) + + assert binding.tenant_record_id is not tenant + assert binding.person_record_id is not person + object.__setattr__(tenant, "int", -1) + object.__setattr__(person, "int", 1 << 128) + assert binding.tenant_record_id == expected_tenant + assert binding.person_record_id == expected_person + + def test_binding_rejects_text_subtypes_and_non_text_issuer_or_subject() -> None: """Require exact built-in text for the durable issuer and subject.""" From 63a1103135f7f98a55cf284e6e2ff706c60deea9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 02:08:40 +0900 Subject: [PATCH 07/24] fix(keyverse): detach validated binding UUID identity --- .../src/orgmetra_keyverse_adapter/binding.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py index 4b4cc211a..245370245 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py @@ -28,13 +28,14 @@ def __init__(self, message: str, *, next_action: str) -> None: self.next_action = next_action -def _validate_operational_uuid(field_name: str, value: object) -> None: - """Require an exact UUID whose internal integer is a real operational identity. +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. + Nil/Max sentinels are compared. Returning only the checked scalar lets the + binding 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.") @@ -43,6 +44,7 @@ def _validate_operational_uuid(field_name: str, value: object) -> None: 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: @@ -66,9 +68,9 @@ class ExternalIdentityBinding: """Durable link from a Keyverse subject to an Orgmetra person. The persisted link is only useful if it addresses real, operational records. - Both identities are validated as exact operational UUIDs and the issuer and - subject as canonical exact text, so a store-ready binding can never key a - person link on a sentinel, a non-UUID, or a credential-shaped value. + Both identities are validated as exact operational UUIDs and detached from + caller-owned UUID objects; issuer and subject are canonical exact text. The + value object is validation data, not an unforgeable same-process capability. """ tenant_record_id: UUID @@ -77,11 +79,13 @@ class ExternalIdentityBinding: identity_subject: str def __post_init__(self) -> None: - """Reject forged, sentinel, or non-canonical identity before persistence.""" - _validate_operational_uuid("tenant_record_id", self.tenant_record_id) - _validate_operational_uuid("person_record_id", self.person_record_id) + """Reject invalid identity and detach caller-owned UUIDs before persistence.""" + tenant_identity = _validate_operational_uuid("tenant_record_id", self.tenant_record_id) + person_identity = _validate_operational_uuid("person_record_id", self.person_record_id) _validate_canonical_text("identity_issuer", self.identity_issuer) _validate_canonical_text("identity_subject", self.identity_subject) + object.__setattr__(self, "tenant_record_id", UUID(int=tenant_identity)) + object.__setattr__(self, "person_record_id", UUID(int=person_identity)) def bind_identity_subject( From 97e12be2f3131e9ecbdeec99218c12f45705385b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 03:14:16 +0900 Subject: [PATCH 08/24] test(keyverse): require authenticated authority for durable binding --- .../keyverse-adapter/tests/test_binding.py | 130 ++++++++++-------- 1 file changed, 73 insertions(+), 57 deletions(-) diff --git a/packages/keyverse-adapter/tests/test_binding.py b/packages/keyverse-adapter/tests/test_binding.py index e944d52db..e16b6e711 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,8 +6,10 @@ from orgmetra_keyverse_adapter import ( CredentialRejectedError, - ExternalIdentityBinding, + ExternalIdentityBindingCandidate, + IdentityBindingTrustUnavailableError, bind_identity_subject, + validate_identity_subject_candidate, ) TENANT = UUID("10000000-0000-7000-8000-000000000401") @@ -16,8 +18,8 @@ SUBJECT = "sub_jordan_hale" -def _binding(**overrides: object) -> ExternalIdentityBinding: - """Build one valid durable binding, letting each test override one field.""" +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, @@ -25,142 +27,156 @@ def _binding(**overrides: object) -> ExternalIdentityBinding: "identity_subject": SUBJECT, } values.update(overrides) - return ExternalIdentityBinding(**values) # type: ignore[arg-type] + return ExternalIdentityBindingCandidate(**values) # type: ignore[arg-type] -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 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_binding_rejects_non_uuid_tenant_or_person_identity() -> None: - """Never persist a person link keyed by a non-UUID identifier.""" +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"): - _binding(tenant_record_id="not-a-uuid") + _candidate(tenant_record_id="not-a-uuid") with pytest.raises(ValueError, match="person_record_id must be an operational UUID"): - _binding(person_record_id=123) + _candidate(person_record_id=123) -def test_binding_rejects_reserved_uuid_sentinels() -> None: - """Never persist the protocol-reserved Nil or Max identity sentinels.""" +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"): - _binding(tenant_record_id=UUID(int=0)) + _candidate(tenant_record_id=UUID(int=0)) with pytest.raises(ValueError, match="person_record_id must be an operational UUID"): - _binding(person_record_id=UUID(int=(1 << 128) - 1)) + _candidate(person_record_id=UUID(int=(1 << 128) - 1)) -def test_binding_rejects_forged_uuid_internal_integer_payload() -> None: +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"): - _binding(tenant_record_id=forged) + _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"): - _binding(person_record_id=out_of_range) + _candidate(person_record_id=out_of_range) -def test_binding_detaches_caller_owned_uuid_before_store_ready_return() -> None: - """Caller mutation after construction cannot rewrite the validated binding identity.""" +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) - binding = ExternalIdentityBinding( + candidate = ExternalIdentityBindingCandidate( tenant_record_id=tenant, person_record_id=person, identity_issuer=ISSUER, identity_subject=SUBJECT, ) - assert binding.tenant_record_id is not tenant - assert binding.person_record_id is not person + 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 binding.tenant_record_id == expected_tenant - assert binding.person_record_id == expected_person + assert candidate.tenant_record_id == expected_tenant + assert candidate.person_record_id == expected_person -def test_binding_rejects_text_subtypes_and_non_text_issuer_or_subject() -> None: - """Require exact built-in text for the durable issuer and subject.""" +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"): - _binding(identity_issuer=TextSubtype(ISSUER)) + _candidate(identity_issuer=TextSubtype(ISSUER)) with pytest.raises(ValueError, match="identity_subject must be exact text"): - _binding(identity_subject=TextSubtype(SUBJECT)) + _candidate(identity_subject=TextSubtype(SUBJECT)) with pytest.raises(ValueError, match="identity_issuer must be exact text"): - _binding(identity_issuer=object()) + _candidate(identity_issuer=object()) with pytest.raises(ValueError, match="identity_subject must be exact text"): - _binding(identity_subject=object()) + _candidate(identity_subject=object()) -def test_binding_rejects_trimmed_empty_or_padded_issuer_or_subject() -> None: - """Reject missing identity and value the bounded canonical text exactly.""" +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"): - _binding(identity_issuer=" ") + _candidate(identity_issuer=" ") with pytest.raises(ValueError, match="identity_subject must be non-blank text"): - _binding(identity_subject="\t") + _candidate(identity_subject="\t") with pytest.raises(ValueError, match="identity_issuer must be canonical text"): - _binding(identity_issuer=f" {ISSUER} ") + _candidate(identity_issuer=f" {ISSUER} ") with pytest.raises(ValueError, match="identity_subject must be canonical text"): - _binding(identity_subject=f"{SUBJECT}\n") + _candidate(identity_subject=f"{SUBJECT}\n") -def test_bind_identity_subject_rejects_untrusted_identity_runtime_types() -> None: - """Reject forged identity inputs before returning a store-ready binding.""" +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 From c2ccf2d03ab3a1b63d858802d5680cb00373b347 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 03:14:54 +0900 Subject: [PATCH 09/24] fix(keyverse): fail closed without released identity trust --- .../src/orgmetra_keyverse_adapter/binding.py | 119 ++++++++++++------ 1 file changed, 83 insertions(+), 36 deletions(-) diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py index 245370245..3a5a8e080 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py @@ -1,8 +1,9 @@ -"""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 @@ -20,14 +21,23 @@ 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 +class IdentityBindingTrustUnavailableError(RuntimeError): + """A durable bind was requested without released Keyverse trust evidence.""" + + 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 _validate_operational_uuid(field_name: str, value: object) -> int: """Return the detached integer of one exact operational UUID. @@ -35,7 +45,7 @@ def _validate_operational_uuid(field_name: str, value: object) -> int: ``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 - binding reconstruct its own UUID instead of retaining a caller-owned alias. + 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.") @@ -50,9 +60,9 @@ def _validate_operational_uuid(field_name: str, value: object) -> int: def _validate_canonical_text(field_name: str, value: object) -> str: """Require exact built-in text that is non-blank and already canonical. - The durable binding is a persisted link. Requiring the exact trimmed value - prevents an executable ``str`` subtype or a ``strip``-shaped impostor from - reaching the database while the audited value differs from the stored one. + 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.") @@ -64,13 +74,13 @@ def _validate_canonical_text(field_name: str, value: object) -> str: @dataclass(frozen=True, slots=True) -class ExternalIdentityBinding: - """Durable link from a Keyverse subject to an Orgmetra person. +class ExternalIdentityBindingCandidate: + """Validated identity candidate that carries no persistence authority. - The persisted link is only useful if it addresses real, operational records. - Both identities are validated as exact operational UUIDs and detached from - caller-owned UUID objects; issuer and subject are canonical exact text. The - value object is validation data, not an unforgeable same-process capability. + Tenant and person identities are exact operational UUIDs detached from + caller-owned objects. Issuer and subject are canonical exact text. This value + proves only local input integrity: it does not prove that Keyverse authenticated + the subject, verified the issuer, or authorized a durable Orgmetra binding. """ tenant_record_id: UUID @@ -79,7 +89,7 @@ class ExternalIdentityBinding: identity_subject: str def __post_init__(self) -> None: - """Reject invalid identity and detach caller-owned UUIDs before persistence.""" + """Reject invalid identity and detach caller-owned UUIDs before returning candidate data.""" tenant_identity = _validate_operational_uuid("tenant_record_id", self.tenant_record_id) person_identity = _validate_operational_uuid("person_record_id", self.person_record_id) _validate_canonical_text("identity_issuer", self.identity_issuer) @@ -87,27 +97,19 @@ def __post_init__(self) -> None: object.__setattr__(self, "tenant_record_id", UUID(int=tenant_identity)) object.__setattr__(self, "person_record_id", UUID(int=person_identity)) + @property + def persistence_authorized(self) -> bool: + """Expose the invariant that locally validated candidate data never authorizes persistence.""" + return False -def bind_identity_subject( + +def _reject_missing_or_credential_input( *, - 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. - """ + identity_issuer: object, + identity_subject: object, + extra_claims: dict[str, str] | None, +) -> 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: @@ -115,18 +117,63 @@ def bind_identity_subject( 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) 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, identity_subject=identity_subject, + extra_claims=extra_claims, ) + raise IdentityBindingTrustUnavailableError() From d7b7f531e216027a99efe31e36dc1aab60fe23af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 03:15:06 +0900 Subject: [PATCH 10/24] fix(keyverse): expose non-authorizing identity candidate boundary --- .../src/orgmetra_keyverse_adapter/__init__.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) 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", ] From 72589b8c9e523942425eb9f59abd846f3156d891 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 03:23:28 +0900 Subject: [PATCH 11/24] docs(keyverse): distinguish identity candidate from trusted binding --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9508f91da..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,7 +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-subject bindings now reject forged, non-canonical identity before persistence: both the tenant and person identities must be exact operational UUIDs with an exact in-range integer payload outside the reserved Nil/Max sentinels, and the issuer and opaque subject must be exact built-in text that is non-blank and already canonical, so an executable text subtype, a `strip`-shaped impostor, or a whitespace-padded audited value cannot key a stored person link. +- 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. From cf9d6d1727b542e545d1e34f7222bcbe1dec8139 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 03:26:06 +0900 Subject: [PATCH 12/24] chore(manifest): reseal Keyverse trust-boundary changelog --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 17cef677f..982b4b0c7 100644 --- a/manifest.json +++ b/manifest.json @@ -29,8 +29,8 @@ }, { "path": "CHANGELOG.md", - "sha256": "eaf3a0bbd26edddbc1abe9e9dd7874f8d0e218a4ff8cf3bb116ea9fd648bd284", - "bytes": 18018, + "sha256": "a1c283260fe0194aaeb5e806fc6e325262e19062b6c229792dfd405aae596d6c", + "bytes": 18331, "lines": 78 }, { @@ -83,7 +83,7 @@ }, { "path": "database/migrations/0005_outbox_delivery_finalization.sql", - "sha256": "b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961", + "sha256": "b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5ef9e8a92abba5c3cf182", "bytes": 6125, "lines": 170 }, From d15a741c10685208b124477e9d7820cdf17339e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 03:33:25 +0900 Subject: [PATCH 13/24] fix(manifest): restore canonical outbox finalization seal --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index 982b4b0c7..68acba4ff 100644 --- a/manifest.json +++ b/manifest.json @@ -83,7 +83,7 @@ }, { "path": "database/migrations/0005_outbox_delivery_finalization.sql", - "sha256": "b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5ef9e8a92abba5c3cf182", + "sha256": "b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961", "bytes": 6125, "lines": 170 }, @@ -472,4 +472,4 @@ "lines": 637 } ] -} +} \ No newline at end of file From 85c08ac7e991877171d44dd523aaad585c599259 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 04:05:17 +0900 Subject: [PATCH 14/24] test(keyverse): reject executable extra-claim shapes --- .../keyverse-adapter/tests/test_binding.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/keyverse-adapter/tests/test_binding.py b/packages/keyverse-adapter/tests/test_binding.py index e16b6e711..c1ce0c1e6 100644 --- a/packages/keyverse-adapter/tests/test_binding.py +++ b/packages/keyverse-adapter/tests/test_binding.py @@ -216,3 +216,45 @@ def strip(self) -> str: 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] + ) From a3fa3470b6b4b489889b574afcc1f672a7fdedcc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 04:05:57 +0900 Subject: [PATCH 15/24] fix(keyverse): validate extra claim names inertly --- .../src/orgmetra_keyverse_adapter/binding.py | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py index 3a5a8e080..bd0621e37 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py @@ -73,6 +73,27 @@ def _validate_canonical_text(field_name: str, value: object) -> str: return value +def _validate_extra_claim_names(extra_claims: object) -> tuple[str, ...]: + """Detach only inert claim names needed for credential-field screening. + + Claim values are intentionally ignored because candidate validation does not + retain or interpret them. Requiring an exact built-in ``dict`` and exact + built-in string keys avoids invoking caller-defined container truthiness, + iteration, or ``str.lower`` behavior before the trust gate. + """ + 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(name) + return tuple(names) + + @dataclass(frozen=True, slots=True) class ExternalIdentityBindingCandidate: """Validated identity candidate that carries no persistence authority. @@ -107,7 +128,7 @@ def _reject_missing_or_credential_input( *, identity_issuer: object, identity_subject: object, - extra_claims: dict[str, str] | None, + extra_claims: object, ) -> None: """Reject absent identity and credential-shaped claim names before candidate construction.""" if type(identity_issuer) is not str: @@ -119,8 +140,8 @@ def _reject_missing_or_credential_input( "Identity issuer and subject are required.", 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 validation cannot accept credentials or tokens.", From b63c50a88f50dbeb1ea1a23eb58e781692f8427a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 06:07:07 +0900 Subject: [PATCH 16/24] test(keyverse): reject noncanonical credential claim names --- .../test_binding_claim_name_canonicality.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/keyverse-adapter/tests/test_binding_claim_name_canonicality.py 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..1184aead6 --- /dev/null +++ b/packages/keyverse-adapter/tests/test_binding_claim_name_canonicality.py @@ -0,0 +1,36 @@ +"""Regression contracts for inert, canonical Keyverse claim names.""" + +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import 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"}, + ) From 694585a64d038d30e851e156b8d5423e567c14f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 06:07:33 +0900 Subject: [PATCH 17/24] fix(keyverse): require canonical claim names --- .../src/orgmetra_keyverse_adapter/binding.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py index bd0621e37..e0d9a04dc 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py @@ -74,12 +74,12 @@ def _validate_canonical_text(field_name: str, value: object) -> str: def _validate_extra_claim_names(extra_claims: object) -> tuple[str, ...]: - """Detach only inert claim names needed for credential-field screening. + """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 exact - built-in string keys avoids invoking caller-defined container truthiness, - iteration, or ``str.lower`` behavior before the trust gate. + 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 () @@ -88,9 +88,7 @@ def _validate_extra_claim_names(extra_claims: object) -> tuple[str, ...]: 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(name) + names.append(_validate_canonical_text("extra claim name", name)) return tuple(names) From 625649e1770e395e3e6f44b36235d363ebf168ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 06:12:48 +0900 Subject: [PATCH 18/24] fix(keyverse): preserve bounded claim-type errors --- .../keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py index e0d9a04dc..50a7cf747 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py @@ -88,6 +88,8 @@ def _validate_extra_claim_names(extra_claims: object) -> tuple[str, ...]: 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) From b15a3511c8b4eb9ad8acf8841d43a868ef253370 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 07:03:44 +0900 Subject: [PATCH 19/24] test(keyverse): reject standard credential claim names --- .../tests/test_binding_claim_name_canonicality.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/keyverse-adapter/tests/test_binding_claim_name_canonicality.py b/packages/keyverse-adapter/tests/test_binding_claim_name_canonicality.py index 1184aead6..d6442b609 100644 --- a/packages/keyverse-adapter/tests/test_binding_claim_name_canonicality.py +++ b/packages/keyverse-adapter/tests/test_binding_claim_name_canonicality.py @@ -4,7 +4,7 @@ import pytest -from orgmetra_keyverse_adapter import bind_identity_subject +from orgmetra_keyverse_adapter import CredentialRejectedError, bind_identity_subject TENANT = UUID("10000000-0000-7000-8000-000000000421") PERSON = UUID("10000000-0000-7000-8000-000000000422") @@ -34,3 +34,16 @@ def test_blank_claim_name_fails_before_trust_gate() -> None: 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"}, + ) From da7d005e87c906f0ed1f0e8bd91c0677b3fff5d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 07:05:24 +0900 Subject: [PATCH 20/24] fix(keyverse): reject standard credential claim fields --- .../keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py index 50a7cf747..5115d6864 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py @@ -16,6 +16,9 @@ "credential", "refresh_token", "access_token", + "id_token", + "client_secret", + "api_key", } ) From 634daefaad125b6de49bac4e0905dec1f822ccf3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 08:03:17 +0900 Subject: [PATCH 21/24] test(keyverse): require structural candidate immutability --- .../test_binding_structural_immutability.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 packages/keyverse-adapter/tests/test_binding_structural_immutability.py 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..26abaa05e --- /dev/null +++ b/packages/keyverse-adapter/tests/test_binding_structural_immutability.py @@ -0,0 +1,27 @@ +"""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 test_validated_candidate_cannot_be_rewritten_after_validation() -> None: + """Validated candidate fields remain unchanged after low-level attribute replacement attempts.""" + candidate = validate_identity_subject_candidate( + tenant_record_id=TENANT, + person_record_id=PERSON, + identity_issuer=ISSUER, + identity_subject=SUBJECT, + ) + + with pytest.raises((AttributeError, TypeError)): + object.__setattr__(candidate, "identity_subject", "sub_rewritten_after_validation") + + assert candidate.identity_subject == SUBJECT From 086fe88607b1e30f72633b5222fbcaf8f846e9ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 08:03:51 +0900 Subject: [PATCH 22/24] fix(keyverse): make validated candidate structurally immutable --- .../src/orgmetra_keyverse_adapter/binding.py | 72 +++++++++++++------ 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py index 5115d6864..d4a71001c 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Never from uuid import UUID @@ -97,29 +96,62 @@ def _validate_extra_claim_names(extra_claims: object) -> tuple[str, ...]: return tuple(names) -@dataclass(frozen=True, slots=True) -class ExternalIdentityBindingCandidate: - """Validated identity candidate that carries no persistence authority. +class ExternalIdentityBindingCandidate(tuple): + """Validated, structurally immutable identity candidate with no persistence authority. Tenant and person identities are exact operational UUIDs detached from - caller-owned objects. Issuer and subject are canonical exact text. This value - proves only local input integrity: it does not prove that Keyverse authenticated - the subject, verified the issuer, or authorized a durable Orgmetra binding. + caller-owned objects. Issuer and subject are canonical exact text. Tuple-backed + storage prevents post-validation attribute replacement from changing the live + candidate. 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. """ - tenant_record_id: UUID - person_record_id: UUID - identity_issuer: str - identity_subject: str - - def __post_init__(self) -> None: - """Reject invalid identity and detach caller-owned UUIDs before returning candidate data.""" - tenant_identity = _validate_operational_uuid("tenant_record_id", self.tenant_record_id) - person_identity = _validate_operational_uuid("person_record_id", self.person_record_id) - _validate_canonical_text("identity_issuer", self.identity_issuer) - _validate_canonical_text("identity_subject", self.identity_subject) - object.__setattr__(self, "tenant_record_id", UUID(int=tenant_identity)) - object.__setattr__(self, "person_record_id", UUID(int=person_identity)) + __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, + ( + UUID(int=tenant_identity), + UUID(int=person_identity), + issuer, + subject, + ), + ) + + @property + def tenant_record_id(self) -> UUID: + """Return the detached tenant identity.""" + return self[0] + + @property + def person_record_id(self) -> UUID: + """Return the detached Person identity.""" + return 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: From 7d44147ef005508c3e071d6dec10c13f68846e45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 10:05:14 +0900 Subject: [PATCH 23/24] test(keyverse): isolate returned candidate UUID views --- .../test_binding_structural_immutability.py | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/keyverse-adapter/tests/test_binding_structural_immutability.py b/packages/keyverse-adapter/tests/test_binding_structural_immutability.py index 26abaa05e..b9a1d182f 100644 --- a/packages/keyverse-adapter/tests/test_binding_structural_immutability.py +++ b/packages/keyverse-adapter/tests/test_binding_structural_immutability.py @@ -12,16 +12,36 @@ SUBJECT = "sub_structural_integrity" -def test_validated_candidate_cannot_be_rewritten_after_validation() -> None: - """Validated candidate fields remain unchanged after low-level attribute replacement attempts.""" - candidate = validate_identity_subject_candidate( +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 From c7d39a6702ed2149c79dc2aa7c2c4be983679f9c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 10:06:16 +0900 Subject: [PATCH 24/24] fix(keyverse): detach returned candidate UUID views --- .../src/orgmetra_keyverse_adapter/binding.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py index d4a71001c..b3856921c 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py @@ -99,13 +99,13 @@ def _validate_extra_claim_names(extra_claims: object) -> tuple[str, ...]: class ExternalIdentityBindingCandidate(tuple): """Validated, structurally immutable identity candidate with no persistence authority. - Tenant and person identities are exact operational UUIDs detached from - caller-owned objects. Issuer and subject are canonical exact text. Tuple-backed - storage prevents post-validation attribute replacement from changing the live - candidate. 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. + 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__ = () @@ -126,8 +126,8 @@ def __new__( return tuple.__new__( cls, ( - UUID(int=tenant_identity), - UUID(int=person_identity), + tenant_identity, + person_identity, issuer, subject, ), @@ -135,13 +135,13 @@ def __new__( @property def tenant_record_id(self) -> UUID: - """Return the detached tenant identity.""" - return self[0] + """Return a fresh UUID view of the retained tenant identity scalar.""" + return UUID(int=self[0]) @property def person_record_id(self) -> UUID: - """Return the detached Person identity.""" - return self[1] + """Return a fresh UUID view of the retained Person identity scalar.""" + return UUID(int=self[1]) @property def identity_issuer(self) -> str: