Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ecd1d82
fix(keyverse): validate exact operational identity before persisting …
seonghobae Sep 10, 2026
064d000
test(keyverse): reject forged authorization tenant identity
seonghobae Sep 10, 2026
e9db13c
fix(keyverse): harden authorization tenant identity validation
seonghobae Sep 10, 2026
f9fec78
repair(keyverse): return authorization source to canonical owner
seonghobae Sep 10, 2026
7acf00e
repair(keyverse): return authorization tests to canonical owner
seonghobae Sep 10, 2026
40bdf6f
test(keyverse): capture binding UUID alias mutation
seonghobae Sep 10, 2026
63a1103
fix(keyverse): detach validated binding UUID identity
seonghobae Sep 10, 2026
97e12be
test(keyverse): require authenticated authority for durable binding
seonghobae Sep 10, 2026
c2ccf2d
fix(keyverse): fail closed without released identity trust
seonghobae Sep 10, 2026
d7b7f53
fix(keyverse): expose non-authorizing identity candidate boundary
seonghobae Sep 10, 2026
72589b8
docs(keyverse): distinguish identity candidate from trusted binding
seonghobae Sep 10, 2026
cf9d6d1
chore(manifest): reseal Keyverse trust-boundary changelog
seonghobae Sep 10, 2026
d15a741
fix(manifest): restore canonical outbox finalization seal
seonghobae Sep 10, 2026
85c08ac
test(keyverse): reject executable extra-claim shapes
seonghobae Sep 10, 2026
a3fa347
fix(keyverse): validate extra claim names inertly
seonghobae Sep 10, 2026
b63c50a
test(keyverse): reject noncanonical credential claim names
seonghobae Sep 10, 2026
694585a
fix(keyverse): require canonical claim names
seonghobae Sep 10, 2026
625649e
fix(keyverse): preserve bounded claim-type errors
seonghobae Sep 10, 2026
b15a351
test(keyverse): reject standard credential claim names
seonghobae Sep 10, 2026
da7d005
fix(keyverse): reject standard credential claim fields
seonghobae Sep 10, 2026
634daef
test(keyverse): require structural candidate immutability
seonghobae Sep 10, 2026
086fe88
fix(keyverse): make validated candidate structurally immutable
seonghobae Sep 10, 2026
7d44147
test(keyverse): isolate returned candidate UUID views
seonghobae Sep 11, 2026
c7d39a6
fix(keyverse): detach returned candidate UUID views
seonghobae Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@
},
{
"path": "CHANGELOG.md",
"sha256": "f2d2e0b488c0440533effa821808f2f17e37d92f8fb586174c2fdb594f760ca5",
"bytes": 17539,
"lines": 77
"sha256": "a1c283260fe0194aaeb5e806fc6e325262e19062b6c229792dfd405aae596d6c",
"bytes": 18331,
"lines": 78
},
{
"path": "CLAUDE.md",
Expand Down Expand Up @@ -472,4 +472,4 @@
"lines": 637
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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",
]
232 changes: 195 additions & 37 deletions packages/keyverse-adapter/src/orgmetra_keyverse_adapter/binding.py
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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()
Loading
Loading