diff --git a/services/workforce-validation-api/README.md b/services/workforce-validation-api/README.md index d3d84736..27823dc8 100644 --- a/services/workforce-validation-api/README.md +++ b/services/workforce-validation-api/README.md @@ -1,10 +1,10 @@ # Orgmetra Workforce Validation API -This package is the application boundary for the `workforce_validation` bounded context. The current slice exposes one purpose-bound read use case for the existing validity-study registry header and establishes the context-local PostgreSQL ownership bootstrap. +This package is the application and persistence boundary for the `workforce_validation` bounded context. The current stack exposes a purpose-bound validity-study registry read and adopts the existing registry table into the context-owned PostgreSQL schema without copying authoritative HR evidence. -It does **not** query People, Talent Acquisition, Performance Management, Job Architecture, Psychometrics Commons, fast-mlsirm, or TEPP tables. Those contexts remain separate owners. Exact foreign identifiers and immutable specialist result references cross the boundary only through published contracts. +It does **not** query People, Talent Acquisition, Performance Management, Job Architecture, Psychometrics Commons, fast-mlsirm, or TEPP tables. Those contexts remain separate owners. Exact foreign identifiers and immutable specialist result references cross the boundary only through published contracts or explicitly approved database constraints inside the modular deployment. -## Current slice +## Current read boundary `read_validity_study(...)`: @@ -22,15 +22,21 @@ It does **not** query People, Talent Acquisition, Performance Management, Job Ar `ValidityStudyView` is a data projection, not a durable authorization credential or cryptographic capability. Downstream consequential actions must perform their own purpose-bound authorization and authoritative re-resolution rather than treating the Python runtime type as reusable authority. Low-level interpreter construction is outside the supported public API and is not accepted as proof that authorization occurred. -`services/workforce-validation-api/database/migrations/0001_owner_schema.sql` starts this bounded context's own migration history. It creates the `workforce_validation` schema and deny-default `workforce_validation_role`, revokes public schema access, and intentionally creates or moves no application table yet. The role is a **NOLOGIN migration/schema owner only**; runtime principals must not be granted that owner role. PostgreSQL applies role-level configuration defaults at login and does not re-apply them on `SET ROLE`, so an `ALTER ROLE ... SET search_path` entry on this NOLOGIN role is not treated as a runtime isolation control. The later durable adapter must use a distinct least-privilege runtime role, schema-qualified `workforce_validation` relations, and explicit function-level `search_path` where `SECURITY DEFINER` code is introduced. +## PostgreSQL ownership -Protected foundation migrations still create validity-study tables in the legacy foundation schema, so the next forward-only persistence increment must adopt those records without normalizing `public.validity_study` as a long-lived service contract or breaking existing linkage evidence. +`database/migrations/0001_owner_schema.sql` creates the deny-default `workforce_validation` schema and `workforce_validation_role`. That role remains a **NOLOGIN migration/schema owner only**; runtime principals must not be granted it. PostgreSQL role-level configuration defaults are not treated as runtime isolation because `SET ROLE` does not re-apply login-time defaults. -Issue #234 owns the remaining order: durable owner-schema adoption and PostgreSQL adapter, idempotent registration, explicit predictor/sample/decision-policy/analysis-protocol versions, scientific adapters, OpenAPI/gateway exposure, and realistic p95 measurement. Issues #236–#244 retain the current bootstrap trust-boundary findings through exact-head acceptance and protected integration: persisted-record immutability, principal immutability and constructor revalidation, owner-role/runtime-role separation, inert repository-capability validation, immutable minimized output, non-public issuance of that output, detached UUID storage/target snapshots, and exact validation of UUID internal payloads before comparison. +`database/migrations/0002_registry_adoption.sql` is a forward-only adoption migration. It uses `ALTER TABLE public.validity_study SET SCHEMA workforce_validation`, so the existing relation OID, rows, indexes, foreign-key dependencies, forced tenant RLS policy, and bitemporal mutation trigger stay attached to the same table object. It creates a separate deny-default `workforce_validation_runtime_role`, grants only schema `USAGE`, registry `SELECT`, and the tenant-context helper required by the preserved RLS policy, and grants no registry mutation privilege. No `public.validity_study` compatibility view or second mutable registry is created. + +`PostgresValidityStudyReadPort` uses only `workforce_validation.validity_study` and `pg_catalog.set_config(...)`. It snapshots tenant/study UUID authority into immutable integer payloads before executable connection acquisition, reconstructs fresh UUID parameters, opens a read-only transaction, binds `orgmetra.tenant_record_id` transaction-locally, fetches at most two rows, and fails closed on duplicate, malformed, non-canonical, or foreign-target persistence results. Deployment code owns the actual login, pooling, TLS and assumption/grant of the runtime role; the adapter does not elevate itself with `SET ROLE`. + +The legacy decision/evidence/outcome links remain separate relations for now. Their existing foreign keys continue to reference the moved registry by relation identity, which PostgreSQL preserves across `SET SCHEMA`. Later increments must adopt the remaining `workforce_validation` relations deliberately rather than create cross-service SQL or duplicate the registry. + +Issue #234 owns the broader FR-007 order. Issue #247 owns this durable registry adoption/read-port slice. After its exact-head acceptance and the parent #235 protected integration, the next buyer/scientific work is idempotent validity-study registration, explicit predictor/sample/decision-policy/analysis-protocol versions, scientific adapters, versioned OpenAPI/gateway exposure, and realistic PostgreSQL-backed p95 evidence. ## Test -The Draft branch is admitted to the canonical Foundation quality workflow with the same hash-locked test toolchain and direct source-tree dependency policy used by the existing owner services: +The service remains in the canonical Foundation unit gate with the repository's hash-locked test toolchain: ```bash PYTHONPATH=services/workforce-validation-api/src:packages/keyverse-adapter/src \ @@ -39,6 +45,6 @@ PYTHONPATH=services/workforce-validation-api/src:packages/keyverse-adapter/src \ services/workforce-validation-api/tests ``` -The same Foundation job also runs `tests/test_workforce_validation_owner_schema_postgres.sh` in its own pinned PostgreSQL 16.14 container. That contract executes the service-local owner migration and checks the exact deny-default role flags, schema owner, absence of ineffective login-only `rolconfig`, actual `SET ROLE` search-path behavior, absence of inherited PUBLIC `USAGE`/`CREATE`, and absence of application relations in the bootstrap schema. The test intentionally demonstrates that `SET ROLE` retains the caller's existing `search_path`; runtime isolation therefore cannot be inferred from owner-role metadata. +`tests/test_workforce_validation_owner_schema_postgres.sh`, already admitted to the pinned PostgreSQL 16.14 Foundation lane, first proves the empty owner-schema bootstrap and then applies the foundation schema plus `0002_registry_adoption.sql`. It verifies preserved relation OID/FK dependencies/RLS/bitemporal guard, absence of `public.validity_study`, deny-default runtime-role flags, read-only privileges, no-row behavior without tenant context, and tenant-scoped owner reads. -Those source contracts are not terminal acceptance by themselves. The slice remains Draft until the exact current head actually executes with 100% owned statement/branch coverage, the PostgreSQL owner-schema contract is GREEN, applicable security workflows are terminal, and the normal review/governance requirements are satisfied. Only then may the next forward-only owner-table adoption and durable adapter be treated as eligible for integration. +Source contracts are not terminal acceptance by themselves. This child remains Draft while #235 is mutable and until, after parent integration/retarget, its exact head executes with 100% owned statement/branch coverage, the isolated PostgreSQL contract is GREEN, applicable security workflows are terminal, and normal review/governance requirements are satisfied. diff --git a/services/workforce-validation-api/database/migrations/0002_registry_adoption.sql b/services/workforce-validation-api/database/migrations/0002_registry_adoption.sql new file mode 100644 index 00000000..cb87fadc --- /dev/null +++ b/services/workforce-validation-api/database/migrations/0002_registry_adoption.sql @@ -0,0 +1,201 @@ +-- Adopt the existing validity-study registry into its canonical bounded-context schema. +-- ALTER TABLE ... SET SCHEMA preserves the relation OID, rows, constraints, indexes, +-- RLS policy and bitemporal trigger instead of copying authoritative HR evidence. + +BEGIN; + +CREATE ROLE workforce_validation_runtime_role NOLOGIN + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOREPLICATION + NOBYPASSRLS; + +ALTER TABLE public.validity_study SET SCHEMA workforce_validation; +ALTER TABLE workforce_validation.validity_study OWNER TO workforce_validation_role; + +-- The protected validity-study case trigger predates owner-schema adoption and its +-- PL/pgSQL body names public.validity_study explicitly. ALTER TABLE ... SET SCHEMA +-- preserves the trigger/function objects but cannot rewrite relation names embedded +-- in function source. Replace the existing function in place so normalized case +-- governance continues to read the same registry relation after ownership moves. +CREATE OR REPLACE FUNCTION public.validate_validity_study_case() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public, pg_temp +AS $$ +DECLARE + study_criterion_id uuid; + study_recorded_from timestamptz; + study_recorded_to timestamptz; + criterion_job_id uuid; + decision_candidate_id uuid; + decision_job_id uuid; + decision_evidence_id uuid; + decision_recorded_at timestamptz; + evidence_sealed_at timestamptz; + evidence_sealed_decision_id uuid; + outcome_criterion_id uuid; + outcome_person_id uuid; + outcome_recorded_from timestamptz; + outcome_recorded_to timestamptz; + conversion_candidate_id uuid; + conversion_person_id uuid; + conversion_decision_id uuid; + conversion_recorded_from timestamptz; + conversion_recorded_to timestamptz; +BEGIN + SELECT + study.criterion_blueprint_id, + study.recorded_from, + study.recorded_to, + criterion.job_profile_id + INTO + study_criterion_id, + study_recorded_from, + study_recorded_to, + criterion_job_id + FROM workforce_validation.validity_study AS study + JOIN public.criterion_blueprint AS criterion + ON criterion.tenant_record_id = study.tenant_record_id + AND criterion.criterion_blueprint_id = study.criterion_blueprint_id + WHERE study.tenant_record_id = NEW.tenant_record_id + AND study.validity_study_id = NEW.validity_study_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'validity-study case requires a tenant-local study and criterion' + USING ERRCODE = '23503'; + END IF; + + IF NEW.linked_at < study_recorded_from + OR (study_recorded_to IS NOT NULL AND NEW.linked_at >= study_recorded_to) THEN + RAISE EXCEPTION 'validity-study case must bind a study version visible at linked_at' + USING ERRCODE = '23514'; + END IF; + + SELECT + decision.candidate_profile_id, + decision.job_profile_id, + decision.decision_evidence_set_id, + decision.recorded_at + INTO + decision_candidate_id, + decision_job_id, + decision_evidence_id, + decision_recorded_at + FROM public.selection_decision AS decision + WHERE decision.tenant_record_id = NEW.tenant_record_id + AND decision.selection_decision_id = NEW.selection_decision_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'validity-study case requires a tenant-local selection decision' + USING ERRCODE = '23503'; + END IF; + + IF decision_job_id IS DISTINCT FROM criterion_job_id THEN + RAISE EXCEPTION 'validity-study case decision belongs to a different Job' + USING ERRCODE = '23514'; + END IF; + + IF decision_evidence_id IS DISTINCT FROM NEW.decision_evidence_set_id THEN + RAISE EXCEPTION 'validity-study case requires the selection decision''s exact evidence set' + USING ERRCODE = '23514'; + END IF; + + SELECT evidence.sealed_at, evidence.sealed_selection_decision_id + INTO evidence_sealed_at, evidence_sealed_decision_id + FROM public.decision_evidence_set AS evidence + WHERE evidence.tenant_record_id = NEW.tenant_record_id + AND evidence.decision_evidence_set_id = NEW.decision_evidence_set_id; + + IF NOT FOUND + OR evidence_sealed_at IS NULL + OR evidence_sealed_decision_id IS DISTINCT FROM NEW.selection_decision_id THEN + RAISE EXCEPTION 'validity-study case requires evidence sealed by the exact selection decision' + USING ERRCODE = '23514'; + END IF; + + SELECT + observation.criterion_blueprint_id, + observation.person_record_id, + observation.recorded_from, + observation.recorded_to + INTO + outcome_criterion_id, + outcome_person_id, + outcome_recorded_from, + outcome_recorded_to + FROM public.criterion_observation AS observation + WHERE observation.tenant_record_id = NEW.tenant_record_id + AND observation.criterion_observation_id = NEW.criterion_observation_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'validity-study case requires a tenant-local criterion observation' + USING ERRCODE = '23503'; + END IF; + + IF outcome_criterion_id IS DISTINCT FROM study_criterion_id THEN + RAISE EXCEPTION 'validity-study case outcome uses a different criterion' + USING ERRCODE = '23514'; + END IF; + + SELECT + conversion.candidate_profile_id, + conversion.person_record_id, + conversion.selection_decision_id, + conversion.recorded_from, + conversion.recorded_to + INTO + conversion_candidate_id, + conversion_person_id, + conversion_decision_id, + conversion_recorded_from, + conversion_recorded_to + FROM public.candidate_worker_conversion_record AS conversion + WHERE conversion.tenant_record_id = NEW.tenant_record_id + AND conversion.candidate_worker_conversion_record_id = + NEW.candidate_worker_conversion_record_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'validity-study case requires a governed candidate-worker conversion' + USING ERRCODE = '23503'; + END IF; + + IF conversion_decision_id IS DISTINCT FROM NEW.selection_decision_id + OR conversion_candidate_id IS DISTINCT FROM decision_candidate_id THEN + RAISE EXCEPTION 'validity-study case conversion does not bind the selected candidate' + USING ERRCODE = '23514'; + END IF; + + IF conversion_person_id IS DISTINCT FROM outcome_person_id THEN + RAISE EXCEPTION 'validity-study case outcome belongs to a different worker' + USING ERRCODE = '23514'; + END IF; + + IF NEW.linked_at < decision_recorded_at + OR NEW.linked_at < evidence_sealed_at + OR NEW.linked_at < outcome_recorded_from + OR ( + outcome_recorded_to IS NOT NULL + AND NEW.linked_at >= outcome_recorded_to + ) + OR NEW.linked_at < conversion_recorded_from + OR ( + conversion_recorded_to IS NOT NULL + AND NEW.linked_at >= conversion_recorded_to + ) THEN + RAISE EXCEPTION 'validity-study case may use only evidence visible at linked_at' + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON TABLE workforce_validation.validity_study FROM PUBLIC; +GRANT USAGE ON SCHEMA workforce_validation TO workforce_validation_runtime_role; +GRANT SELECT ON TABLE workforce_validation.validity_study TO workforce_validation_runtime_role; +GRANT EXECUTE ON FUNCTION public.current_tenant_record_id() TO workforce_validation_runtime_role; + +COMMIT; diff --git a/services/workforce-validation-api/src/orgmetra_workforce_validation_api/__init__.py b/services/workforce-validation-api/src/orgmetra_workforce_validation_api/__init__.py index 48570a97..e592a98c 100644 --- a/services/workforce-validation-api/src/orgmetra_workforce_validation_api/__init__.py +++ b/services/workforce-validation-api/src/orgmetra_workforce_validation_api/__init__.py @@ -1,5 +1,6 @@ """Canonical workforce-validation application contracts for Orgmetra.""" +from orgmetra_workforce_validation_api.postgres_registry import PostgresValidityStudyReadPort from orgmetra_workforce_validation_api.registry import ( ValidationPrincipal, ValidityStudyIntegrityError, @@ -11,6 +12,7 @@ ) __all__ = [ + "PostgresValidityStudyReadPort", "ValidationPrincipal", "ValidityStudyIntegrityError", "ValidityStudyNotFound", diff --git a/services/workforce-validation-api/src/orgmetra_workforce_validation_api/postgres_registry.py b/services/workforce-validation-api/src/orgmetra_workforce_validation_api/postgres_registry.py new file mode 100644 index 00000000..5dc604ae --- /dev/null +++ b/services/workforce-validation-api/src/orgmetra_workforce_validation_api/postgres_registry.py @@ -0,0 +1,119 @@ +"""Tenant-bound PostgreSQL read adapter for the workforce-validation registry. + +The adapter reads only the canonical ``workforce_validation.validity_study`` +relation. Deployment code owns pooling, TLS, credentials and assumption of the +least-privilege runtime role; this boundary owns transaction-local tenant RLS, +parameterized SQL, target snapshots and fail-closed row reconstruction. +""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +from typing import Any, Callable +from uuid import UUID + +from orgmetra_workforce_validation_api.registry import ( + ValidityStudyIntegrityError, + ValidityStudyRecord, + _restore_operational_uuid, + _store_operational_uuid, +) + +PostgresConnectionFactory = Callable[[], AbstractContextManager[Any]] + +_READ_ONLY_SQL = "SET TRANSACTION READ ONLY" +_TENANT_CONTEXT_SQL = "SELECT pg_catalog.set_config('orgmetra.tenant_record_id', %s, true)" +_REGISTRY_READ_SQL = """ +SELECT + tenant_record_id, + validity_study_id, + criterion_blueprint_id, + study_status_code, + recorded_from, + recorded_to +FROM workforce_validation.validity_study +WHERE tenant_record_id = %s + AND validity_study_id = %s + AND recorded_to IS NULL +LIMIT 2 +""".strip() + + +class PostgresValidityStudyReadPort(tuple): + """Read one current validity-study header under forced tenant RLS. + + ``connection_factory`` must return a DB-API-compatible connection context + manager configured by deployment code. Tuple-backed storage prevents a + retained port reference from replacing that accepted dependency after + validation. The adapter snapshots UUID identity before invoking the factory so + retained caller UUID aliases cannot change the authorized SQL target during + connection acquisition. + """ + + __slots__ = () + + def __new__( + cls, + *, + connection_factory: PostgresConnectionFactory, + ) -> PostgresValidityStudyReadPort: + """Validate and structurally bind the executable connection dependency.""" + if not callable(connection_factory): + raise TypeError("connection_factory must be callable") + return tuple.__new__(cls, (connection_factory,)) + + @property + def connection_factory(self) -> PostgresConnectionFactory: + """Return the exact callable stored by the validating base constructor.""" + return tuple.__getitem__(self, 0) + + def read_validity_study( + self, + *, + tenant_record_id: UUID, + validity_study_id: UUID, + ) -> ValidityStudyRecord | None: + """Return one exact current owner record or ``None`` for the tenant target.""" + connection_factory = tuple.__getitem__(self, 0) + tenant_identity = _store_operational_uuid("tenant_record_id", tenant_record_id) + study_identity = _store_operational_uuid("validity_study_id", validity_study_id) + + sql_tenant_id = _restore_operational_uuid("tenant_record_id", tenant_identity) + sql_study_id = _restore_operational_uuid("validity_study_id", study_identity) + + with connection_factory() as connection: + with connection.cursor() as cursor: + cursor.execute(_READ_ONLY_SQL) + cursor.execute(_TENANT_CONTEXT_SQL, (str(sql_tenant_id),)) + cursor.execute(_REGISTRY_READ_SQL, (sql_tenant_id, sql_study_id)) + rows = cursor.fetchmany(2) + + if not rows: + return None + if len(rows) != 1: + raise ValidityStudyIntegrityError("multiple current validity-study rows match the target") + + row = rows[0] + if type(row) is not tuple or len(row) != 6: + raise ValidityStudyIntegrityError("repository returned a non-canonical validity-study row") + + try: + record = ValidityStudyRecord( + tenant_record_id=row[0], + validity_study_id=row[1], + criterion_blueprint_id=row[2], + study_status_code=row[3], + recorded_from=row[4], + recorded_to=row[5], + ) + except (TypeError, ValueError) as exc: + raise ValidityStudyIntegrityError("repository returned an invalid validity-study row") from exc + + if ( + _store_operational_uuid("record tenant_record_id", record.tenant_record_id) + != tenant_identity + or _store_operational_uuid("record validity_study_id", record.validity_study_id) + != study_identity + ): + raise ValidityStudyIntegrityError("repository returned a validity-study row for another target") + return record diff --git a/services/workforce-validation-api/tests/test_postgres_registry.py b/services/workforce-validation-api/tests/test_postgres_registry.py new file mode 100644 index 00000000..0a2678ba --- /dev/null +++ b/services/workforce-validation-api/tests/test_postgres_registry.py @@ -0,0 +1,226 @@ +"""PostgreSQL adapter contract for the workforce-validation registry owner.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +import pytest + +from orgmetra_workforce_validation_api.postgres_registry import PostgresValidityStudyReadPort +from orgmetra_workforce_validation_api.registry import ValidityStudyIntegrityError, ValidityStudyRecord + +TENANT = UUID("10000000-0000-7000-8000-000000000001") +STUDY = UUID("00000000-0000-7000-8000-0000000000c1") +CRITERION = UUID("00000000-0000-7000-8000-0000000000b1") +RECORDED_FROM = datetime(2026, 7, 1, tzinfo=timezone.utc) + + +def _row( + *, + tenant_record_id: UUID = TENANT, + validity_study_id: UUID = STUDY, + study_status_code: str = "active", +) -> tuple[object, ...]: + return ( + tenant_record_id, + validity_study_id, + CRITERION, + study_status_code, + RECORDED_FROM, + None, + ) + + +class _Cursor: + """Record exact DB-API calls and return a bounded row set.""" + + def __init__(self, rows: list[object]) -> None: + self.rows = rows + self.calls: list[tuple[str, object]] = [] + self.fetch_size: int | None = None + + def __enter__(self) -> _Cursor: + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + del exc_type, exc, traceback + + def execute(self, sql: str, params: object = None) -> None: + self.calls.append((sql, params)) + + def fetchmany(self, size: int) -> list[object]: + self.fetch_size = size + return self.rows[:size] + + +class _Connection: + """Provide one deterministic cursor through a context-manager connection.""" + + def __init__(self, cursor: _Cursor) -> None: + self.cursor_instance = cursor + + def __enter__(self) -> _Connection: + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + del exc_type, exc, traceback + + def cursor(self) -> _Cursor: + return self.cursor_instance + + +class _Factory: + """Expose whether executable connection acquisition was reached.""" + + def __init__(self, rows: list[object], callback=None) -> None: + self.cursor = _Cursor(rows) + self.callback = callback + self.calls = 0 + + def __call__(self) -> _Connection: + self.calls += 1 + if self.callback is not None: + self.callback() + return _Connection(self.cursor) + + +def test_constructor_rejects_non_callable_factory() -> None: + with pytest.raises(TypeError, match="connection_factory must be callable"): + PostgresValidityStudyReadPort(connection_factory=object()) # type: ignore[arg-type] + + +def test_connection_factory_cannot_be_replaced_after_port_validation() -> None: + original_factory = _Factory([]) + replacement_factory = _Factory([]) + port = PostgresValidityStudyReadPort(connection_factory=original_factory) + + with pytest.raises(AttributeError): + object.__setattr__(port, "connection_factory", replacement_factory) + + assert port.connection_factory is original_factory + assert port.read_validity_study(tenant_record_id=TENANT, validity_study_id=STUDY) is None + assert original_factory.calls == 1 + assert replacement_factory.calls == 0 + + +def test_invalid_target_is_rejected_before_connection_acquisition() -> None: + factory = _Factory([]) + port = PostgresValidityStudyReadPort(connection_factory=factory) + + with pytest.raises(ValueError, match="tenant_record_id must be an exact operational UUID"): + port.read_validity_study(tenant_record_id="not-a-uuid", validity_study_id=STUDY) # type: ignore[arg-type] + + assert factory.calls == 0 + + +def test_empty_read_is_tenant_bound_read_only_and_schema_qualified() -> None: + factory = _Factory([]) + port = PostgresValidityStudyReadPort(connection_factory=factory) + + assert port.read_validity_study(tenant_record_id=TENANT, validity_study_id=STUDY) is None + + assert factory.cursor.fetch_size == 2 + assert len(factory.cursor.calls) == 3 + read_only_sql, read_only_params = factory.cursor.calls[0] + tenant_sql, tenant_params = factory.cursor.calls[1] + registry_sql, registry_params = factory.cursor.calls[2] + assert read_only_sql == "SET TRANSACTION READ ONLY" + assert read_only_params is None + assert tenant_sql == "SELECT pg_catalog.set_config('orgmetra.tenant_record_id', %s, true)" + assert tenant_params == (str(TENANT),) + assert "FROM workforce_validation.validity_study" in registry_sql + assert "public.validity_study" not in registry_sql + assert registry_params == (TENANT, STUDY) + + +def test_multiple_rows_fail_closed() -> None: + port = PostgresValidityStudyReadPort(connection_factory=_Factory([_row(), _row()])) + + with pytest.raises(ValidityStudyIntegrityError, match="multiple current validity-study rows"): + port.read_validity_study(tenant_record_id=TENANT, validity_study_id=STUDY) + + +@pytest.mark.parametrize( + "row", + [ + list(_row()), + _row()[:-1], + ], +) +def test_noncanonical_row_container_fails_closed(row: object) -> None: + port = PostgresValidityStudyReadPort(connection_factory=_Factory([row])) + + with pytest.raises(ValidityStudyIntegrityError, match="non-canonical validity-study row"): + port.read_validity_study(tenant_record_id=TENANT, validity_study_id=STUDY) + + +def test_invalid_row_scalar_is_reported_as_persistence_integrity_failure() -> None: + port = PostgresValidityStudyReadPort( + connection_factory=_Factory([_row(study_status_code="NOT_CANONICAL")]) + ) + + with pytest.raises(ValidityStudyIntegrityError, match="invalid validity-study row"): + port.read_validity_study(tenant_record_id=TENANT, validity_study_id=STUDY) + + +@pytest.mark.parametrize( + "row", + [ + _row(tenant_record_id=UUID("10000000-0000-7000-8000-000000000002")), + _row(validity_study_id=UUID("00000000-0000-7000-8000-0000000000c2")), + ], +) +def test_foreign_row_target_fails_closed(row: tuple[object, ...]) -> None: + port = PostgresValidityStudyReadPort(connection_factory=_Factory([row])) + + with pytest.raises(ValidityStudyIntegrityError, match="another target"): + port.read_validity_study(tenant_record_id=TENANT, validity_study_id=STUDY) + + +def test_valid_row_reconstructs_owner_record() -> None: + port = PostgresValidityStudyReadPort(connection_factory=_Factory([_row()])) + + record = port.read_validity_study(tenant_record_id=TENANT, validity_study_id=STUDY) + + assert type(record) is ValidityStudyRecord + assert record.tenant_record_id == TENANT + assert record.validity_study_id == STUDY + assert record.criterion_blueprint_id == CRITERION + assert record.study_status_code == "active" + assert record.recorded_from == RECORDED_FROM + assert record.recorded_to is None + + +def test_connection_callback_cannot_switch_snapshotted_target() -> None: + tenant = UUID("10000000-0000-7000-8000-000000000001") + study = UUID("00000000-0000-7000-8000-0000000000c1") + original_tenant_int = tenant.int + original_study_int = study.int + other_tenant = UUID("10000000-0000-7000-8000-000000000002") + other_study = UUID("00000000-0000-7000-8000-0000000000c2") + + def mutate_retained_inputs() -> None: + object.__setattr__(tenant, "int", other_tenant.int) + object.__setattr__(study, "int", other_study.int) + + factory = _Factory( + [ + _row( + tenant_record_id=UUID(int=original_tenant_int), + validity_study_id=UUID(int=original_study_int), + ) + ], + callback=mutate_retained_inputs, + ) + port = PostgresValidityStudyReadPort(connection_factory=factory) + + record = port.read_validity_study(tenant_record_id=tenant, validity_study_id=study) + + _, tenant_params = factory.cursor.calls[1] + _, registry_params = factory.cursor.calls[2] + assert tenant_params == (str(UUID(int=original_tenant_int)),) + assert registry_params == (UUID(int=original_tenant_int), UUID(int=original_study_int)) + assert record is not None + assert record.tenant_record_id == UUID(int=original_tenant_int) + assert record.validity_study_id == UUID(int=original_study_int) diff --git a/services/workforce-validation-api/tests/test_postgres_registry_bound_capability.py b/services/workforce-validation-api/tests/test_postgres_registry_bound_capability.py new file mode 100644 index 00000000..b4c711af --- /dev/null +++ b/services/workforce-validation-api/tests/test_postgres_registry_bound_capability.py @@ -0,0 +1,74 @@ +"""Regression contract for the bound PostgreSQL connection capability.""" + +from __future__ import annotations + +from uuid import UUID + +from orgmetra_workforce_validation_api.postgres_registry import PostgresValidityStudyReadPort + +TENANT = UUID("10000000-0000-7000-8000-000000000001") +STUDY = UUID("00000000-0000-7000-8000-0000000000c1") + + +class _Cursor: + """Provide the smallest DB-API cursor needed by an empty registry read.""" + + def __enter__(self) -> _Cursor: + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + del exc_type, exc, traceback + + def execute(self, sql: str, params: object = None) -> None: + del sql, params + + def fetchmany(self, size: int) -> list[object]: + del size + return [] + + +class _Connection: + """Expose one deterministic cursor through the connection context protocol.""" + + def __enter__(self) -> _Connection: + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + del exc_type, exc, traceback + + def cursor(self) -> _Cursor: + return _Cursor() + + +class _Factory: + """Count connection acquisition without performing external I/O.""" + + def __init__(self) -> None: + self.calls = 0 + + def __call__(self) -> _Connection: + self.calls += 1 + return _Connection() + + +ORIGINAL_FACTORY = _Factory() +REPLACEMENT_FACTORY = _Factory() + + +class _SwitchingPort(PostgresValidityStudyReadPort): + """Present a different dynamic property than the callable stored at construction.""" + + @property + def connection_factory(self) -> _Factory: + return REPLACEMENT_FACTORY + + +def test_inherited_read_uses_exact_factory_stored_by_base_constructor() -> None: + """A subclass property must not replace the already-validated executable dependency.""" + ORIGINAL_FACTORY.calls = 0 + REPLACEMENT_FACTORY.calls = 0 + port = _SwitchingPort(connection_factory=ORIGINAL_FACTORY) + + assert port.read_validity_study(tenant_record_id=TENANT, validity_study_id=STUDY) is None + assert ORIGINAL_FACTORY.calls == 1 + assert REPLACEMENT_FACTORY.calls == 0 diff --git a/tests/test_workforce_validation_owner_schema_postgres.sh b/tests/test_workforce_validation_owner_schema_postgres.sh index c79b659c..8642d294 100644 --- a/tests/test_workforce_validation_owner_schema_postgres.sh +++ b/tests/test_workforce_validation_owner_schema_postgres.sh @@ -3,8 +3,9 @@ set -euo pipefail : "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" -migration="services/workforce-validation-api/database/migrations/0001_owner_schema.sql" -psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" +owner_migration="services/workforce-validation-api/database/migrations/0001_owner_schema.sql" +adoption_migration="services/workforce-validation-api/database/migrations/0002_registry_adoption.sql" +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${owner_migration}" role_flags="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " SELECT rolcanlogin, rolsuper, rolcreatedb, rolcreaterole, rolinherit, rolreplication, rolbypassrls @@ -74,3 +75,288 @@ fi psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -qc "DROP ROLE workforce_validation_public_probe;" trap - EXIT + +# Adoption must be proven against the protected migration state that already owns +# the normalized validity-study case trigger. A table move is not complete if +# schema-qualified SQL inside that trigger still names the legacy relation. +for migration in \ + database/migrations/0001_foundation_schema.sql \ + database/migrations/0002_sealed_evidence_digest.sql \ + database/migrations/0003_audit_outbox_persistence.sql \ + database/migrations/0004_outbox_delivery_claim.sql \ + database/migrations/0005_outbox_delivery_finalization.sql \ + database/migrations/0006_outbox_delivery_dead_letter.sql \ + database/migrations/0007_outbox_retry_exhaustion.sql \ + database/migrations/0008_audit_outbox_review_hardening.sql \ + database/migrations/0009_candidate_worker_conversion_governance.sql \ + database/migrations/0010_validity_study_case_integrity.sql; do + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" +done + +TENANT_ID="10000000-0000-7000-8000-000000000001" +PERSON_ID="00000000-0000-7000-8000-000000000001" +EMPLOYMENT_ID="00000000-0000-7000-8000-000000000011" +JOB_ID="00000000-0000-7000-8000-000000000021" +CANDIDATE_ID="00000000-0000-7000-8000-000000000031" +EVIDENCE_ID="00000000-0000-7000-8000-000000000041" +DECISION_ID="00000000-0000-7000-8000-000000000051" +AUDIT_ID="00000000-0000-7000-8000-000000000062" +OUTBOX_ID="00000000-0000-7000-8000-000000000072" +CONVERSION_ID="00000000-0000-7000-8000-000000000081" +CYCLE_ID="00000000-0000-7000-8000-000000000091" +CRITERION_ID="00000000-0000-7000-8000-0000000000a1" +OBSERVATION_ID="00000000-0000-7000-8000-0000000000b1" +STUDY_ID="00000000-0000-7000-8000-0000000000c1" +CASE_ID="00000000-0000-7000-8000-0000000000e4" + +tenant_psql() { + PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" command psql "$@" +} + +tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <&2 + exit 1 +fi + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${adoption_migration}" + +if [[ "$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc "SELECT to_regclass('public.validity_study') IS NULL;")" != "t" ]]; then + echo "legacy public.validity_study relation still exists after owner adoption" >&2 + exit 1 +fi + +owner_oid="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc "SELECT 'workforce_validation.validity_study'::regclass::oid;")" +if [[ "${owner_oid}" != "${legacy_oid}" ]]; then + echo "registry adoption copied/recreated the table instead of preserving relation identity" >&2 + exit 1 +fi + +registry_owner="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT pg_get_userbyid(relowner) +FROM pg_class +WHERE oid = ${owner_oid}; +")" +if [[ "${registry_owner}" != "workforce_validation_role" ]]; then + echo "registry table has unexpected owner: ${registry_owner}" >&2 + exit 1 +fi + +owner_fk_count="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT count(*) FROM pg_constraint WHERE contype = 'f' AND confrelid = ${owner_oid}; +")" +if [[ "${owner_fk_count}" != "${legacy_fk_count}" ]]; then + echo "registry adoption broke existing FK dependencies: before=${legacy_fk_count} after=${owner_fk_count}" >&2 + exit 1 +fi + +rls_flags="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT relrowsecurity, relforcerowsecurity +FROM pg_class +WHERE oid = ${owner_oid}; +")" +if [[ "${rls_flags}" != "t|t" ]]; then + echo "registry adoption did not preserve forced tenant RLS: ${rls_flags}" >&2 + exit 1 +fi + +bitemporal_trigger="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT count(*) +FROM pg_trigger +WHERE tgrelid = ${owner_oid} + AND tgname = 'validity_study_bitemporal_guard' + AND NOT tgisinternal; +")" +if [[ "${bitemporal_trigger}" != "1" ]]; then + echo "registry adoption lost the bitemporal mutation guard" >&2 + exit 1 +fi + +# Regression for #251: the normalized case-governance trigger must remain +# executable after the registry table moves out of public. The predecessor +# function body names public.validity_study and therefore fails this insert. +tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <&2 + exit 1 +fi + +runtime_flags="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT rolcanlogin, rolsuper, rolcreatedb, rolcreaterole, rolinherit, rolreplication, rolbypassrls +FROM pg_roles +WHERE rolname = 'workforce_validation_runtime_role'; +")" +if [[ "${runtime_flags}" != "f|f|f|f|f|f|f" ]]; then + echo "workforce_validation_runtime_role flags are not deny-default: ${runtime_flags}" >&2 + exit 1 +fi + +runtime_privileges="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT + has_schema_privilege('workforce_validation_runtime_role', 'workforce_validation', 'USAGE'), + has_schema_privilege('workforce_validation_runtime_role', 'workforce_validation', 'CREATE'), + has_table_privilege('workforce_validation_runtime_role', 'workforce_validation.validity_study', 'SELECT'), + has_table_privilege('workforce_validation_runtime_role', 'workforce_validation.validity_study', 'INSERT'), + has_table_privilege('workforce_validation_runtime_role', 'workforce_validation.validity_study', 'UPDATE'), + has_table_privilege('workforce_validation_runtime_role', 'workforce_validation.validity_study', 'DELETE'), + has_table_privilege('workforce_validation_runtime_role', 'workforce_validation.validity_study', 'TRUNCATE'); +")" +if [[ "${runtime_privileges}" != "t|f|t|f|f|f|f" ]]; then + echo "runtime role privileges are not least-privilege read-only: ${runtime_privileges}" >&2 + exit 1 +fi + +missing_tenant_count="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SET ROLE workforce_validation_runtime_role; +SELECT count(*) FROM workforce_validation.validity_study; +RESET ROLE; +")" +if [[ "${missing_tenant_count}" != "0" ]]; then + echo "runtime read returned rows without tenant context: ${missing_tenant_count}" >&2 + exit 1 +fi + +tenant_read="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SET orgmetra.tenant_record_id = '${TENANT_ID}'; +SET ROLE workforce_validation_runtime_role; +SELECT validity_study_id::text FROM workforce_validation.validity_study; +RESET ROLE; +RESET orgmetra.tenant_record_id; +")" +if [[ "${tenant_read}" != "${STUDY_ID}" ]]; then + echo "runtime role did not read the tenant-scoped owner registry: ${tenant_read}" >&2 + exit 1 +fi