diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r0/postimage/identity-provisioner/tests/test_group_postgres.py b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r0/postimage/identity-provisioner/tests/test_group_postgres.py new file mode 100644 index 00000000..e6ecf00a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r0/postimage/identity-provisioner/tests/test_group_postgres.py @@ -0,0 +1,160 @@ +"""Binding, CAS and ambiguous-commit refusal tests; native SQL proof is separate.""" +from dataclasses import replace +import json +from unittest.mock import Mock +import unittest + +from group_provenance import GroupProvenanceUnavailable +from group_provenance_postgres import GroupJournalBinding, PostgresGroupProvenance +from server import configured_group_provenance +from unittest.mock import patch +import os + +EPOCH = '41fbb0ed-ea3a-4682-996b-e9c94a6aa9c6' +BINDING = GroupJournalBinding('tenant:fixture:one', 'synthetic-directory', 'sha256:' + '1' * 64, + 'sha256:' + '2' * 64, 'synthetic-owner', EPOCH) +REQUEST = '3' * 64 +META = (EPOCH, 'synthetic-directory', 7, False) + + +def fixture(rows=(), rowcount=1, commit_error=None): + cursor = Mock() + cursor.__enter__ = Mock(return_value=cursor) + cursor.__exit__ = Mock(return_value=False) + cursor.fetchone.side_effect = rows + cursor.rowcount = rowcount + connection = Mock() + connection.__enter__ = Mock(return_value=connection) + connection.__exit__ = Mock(return_value=False, side_effect=commit_error) + connection.cursor.return_value = cursor + connector = Mock(return_value=connection) + continuity = Mock() + continuity.check.return_value = True + continuity.advance.return_value = True + store = PostgresGroupProvenance(connect=connector, binding=BINDING, continuity=continuity) + return store, connector, connection, cursor, continuity + + +def receipt(version=4): + return {'key': 'synthetic-operation-key', 'binding': REQUEST, 'state': 'pending', 'intent': None, + 'groups': {}, 'created_groups': [], 'created_memberships': [], + '_pg_version': version, '_pg_context': BINDING.digest} + + +class PostgresSourceTests(unittest.TestCase): + def test_explicit_binding_and_continuity_required_without_connecting(self): + connector = Mock() + for binding, continuity in ((None, Mock()), (BINDING, None), ({'tenant_scope': 'caller-json'}, Mock())): + with self.assertRaises(ValueError): + PostgresGroupProvenance(connect=connector, binding=binding, continuity=continuity) + connector.assert_not_called() + for change in ({'tenant_scope': ''}, {'principal': ''}, {'case_digest': 'unsigned'}, {'epoch': 'default'}): + with self.assertRaises(ValueError): + replace(BINDING, **change) + self.assertNotEqual(BINDING.digest, replace(BINDING, principal='another-owner').digest) + self.assertNotEqual(BINDING.digest, replace(BINDING, tenant_scope='another-explicit-scope').digest) + + def test_missing_environment_callback_path_keeps_production_disabled(self): + with patch.dict(os.environ, {'GROUP_POSTGRES_DSN': 'synthetic-only', 'GROUP_PROVENANCE_POSTGRES': 'true'}, clear=True): + self.assertIsNone(configured_group_provenance()) + + def test_checkpoint_mismatch_poisoned_without_receipt_query_or_effect(self): + store, connect, _, cursor, checkpoint = fixture([META]) + checkpoint.check.return_value = False + with self.assertRaises(GroupProvenanceUnavailable): + store.reserve('synthetic-operation-key', REQUEST) + self.assertFalse(any('group_receipts' in c.args[0] for c in cursor.execute.call_args_list)) + with self.assertRaises(GroupProvenanceUnavailable): + store.check() + self.assertEqual(1, connect.call_count) + checkpoint.advance.assert_not_called() + + def test_restored_epoch_or_quarantine_refused(self): + for meta in ((EPOCH, 'other-directory', 7, False), (EPOCH, 'synthetic-directory', 7, True), + ('98aa2b6d-a32a-44a5-a89f-b2480befc00e', 'synthetic-directory', 7, False), None): + store, _, _, _, checkpoint = fixture([meta]) + with self.assertRaises(GroupProvenanceUnavailable): + store.check() + checkpoint.check.assert_not_called() + + def test_exactly_one_version_update_required_before_checkpoint_advance(self): + store, _, _, cursor, checkpoint = fixture([META], rowcount=0) + original = receipt() + with self.assertRaises(GroupProvenanceUnavailable): + store.save(original) + query, params = next(c.args for c in cursor.execute.call_args_list if c.args[0].startswith('UPDATE identity_provisioner.group_receipts')) + self.assertIn('AND version = %s', query) + self.assertEqual(4, params[-1]) + self.assertEqual(4, original['_pg_version']) + checkpoint.advance.assert_not_called() + + def test_success_acknowledges_commit_and_updates_receipt_version(self): + store, _, connection, _, checkpoint = fixture([META]) + original = receipt() + store.save(original) + self.assertEqual(5, original['_pg_version']) + connection.__exit__.assert_called_once() + checkpoint.advance.assert_called_once_with(EPOCH, 'synthetic-directory', 7, 8) + connection.close.assert_called_once() + + def test_connection_close_failure_is_sanitized_and_stops_continuation(self): + store, _, connection, _, _ = fixture([META]) + connection.close.side_effect = OSError('synthetic-private-diagnostic') + with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): + store.check() + self.assertTrue(store._poisoned) + + def test_commit_failure_never_advances_owner_checkpoint_or_receipt(self): + store, _, _, _, checkpoint = fixture([META], commit_error=OSError('synthetic-secret-diagnostic')) + original = receipt() + with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): + store.save(original) + self.assertEqual(4, original['_pg_version']) + checkpoint.advance.assert_not_called() + + def test_lost_checkpoint_acknowledgment_refuses_then_poisoned(self): + store, connect, _, _, checkpoint = fixture([META]) + checkpoint.advance.side_effect = OSError('synthetic-secret-diagnostic') + original = receipt() + with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): + store.save(original) + self.assertEqual(4, original['_pg_version']) + with self.assertRaises(GroupProvenanceUnavailable): + store.save(original) + self.assertEqual(1, connect.call_count) + + def test_cross_case_body_or_version_cannot_be_imported(self): + for body in (dict(receipt(), _pg_context='0' * 64), dict(receipt(), _pg_version=6)): + store, _, _, _, _ = fixture([META, (REQUEST, 4, json.dumps(body), json.dumps(BINDING.__dict__, sort_keys=True))]) + with self.assertRaises(GroupProvenanceUnavailable): + store.get('synthetic-operation-key', REQUEST) + + def test_existing_request_mismatch_is_refused_not_rebound(self): + store, _, _, _, checkpoint = fixture([META, ('0' * 64, 4, '{}', '{}')]) + with self.assertRaisesRegex(ValueError, 'binding mismatch'): + store.get('synthetic-operation-key', REQUEST) + checkpoint.advance.assert_not_called() + self.assertFalse(store._poisoned) + + def test_active_fence_capacity_refusal_and_no_insert(self): + for rows in ([META, None, (1,)], [META, None, None, (256,)]): + store, _, _, cursor, checkpoint = fixture(rows) + with self.assertRaises(GroupProvenanceUnavailable): + store.reserve('synthetic-operation-key', REQUEST) + self.assertFalse(any('INSERT' in c.args[0] for c in cursor.execute.call_args_list)) + checkpoint.advance.assert_not_called() + + def test_new_reservation_retains_bound_context_and_write_intent(self): + store, _, _, cursor, checkpoint = fixture([META, None, None, (0,)]) + created, fresh = store.reserve('synthetic-operation-key', REQUEST) + self.assertTrue(fresh) + self.assertEqual('pending', created['state']) + self.assertEqual(BINDING.digest, created['_pg_context']) + insert = next(c for c in cursor.execute.call_args_list if c.args[0].startswith('INSERT')) + self.assertIn('context_json', insert.args[0]) + self.assertEqual(BINDING.tenant_scope, json.loads(insert.args[1][1])['tenant_scope']) + checkpoint.advance.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r0/review-finding.json b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r0/review-finding.json new file mode 100644 index 00000000..3baabc22 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r0/review-finding.json @@ -0,0 +1,7 @@ +{ + "reviewer": "custodian-coordinator", + "source_tests": "78 passed in11.51s; no real PostgreSQL fixture execution", + "finding": "head counter alone admits divergent clone that reaches same number with different bodies after one externaladvance wins", + "disposition": "superseded; bind SQL meta and external check/advance to exact canonical transition hash chain, source/context/key/version and prior marker; add same-counter divergent clone refusal across new object", + "native_execution": "not executed and forbidden pending successor review" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r0/source-manifest.json b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r0/source-manifest.json new file mode 100644 index 00000000..99ba785c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r0/source-manifest.json @@ -0,0 +1,47 @@ +{ + "base_revision": "6cb43c0cc55212363a5406b02db046e2384a7c40", + "scope": "pre-review counter-only candidate, superseded/not admissible", + "files": [ + { + "path": "docs/service-group-provenance.md", + "preimage_sha256": "cd6b45640983b11e29a6bc6278bcb45de8fd8a5ddcad53eb77ab1a1f3f1f1280", + "postimage_sha256": "d0071c2356d95aad7a9b95dc3c646e13665fd7cc6deb9b1d735be67f0eb20f72" + }, + { + "path": "identity-provisioner/Containerfile", + "preimage_sha256": "c744f39ffb4c0dc2686c3e94d8233bc772b55faae9581557bee17f91c19d9ca6", + "postimage_sha256": "31e7d948d4f40efd6950764a3dbb8c410d50ed7a1c13c18e0830a1186f2abafc" + }, + { + "path": "identity-provisioner/group_provenance.py", + "preimage_sha256": "fbd4c88d7682ba3d46c5b325dad1bc4f4a7faac2cd3f6ce9e0f7a89283bf46d4", + "postimage_sha256": "405e08eaaab7c21d618dff4db5970607806f32ab6c461effb81c737e32182b10" + }, + { + "path": "identity-provisioner/group_provenance_postgres.py", + "preimage_sha256": null, + "postimage_sha256": "7a8a0437fc4cb7672b5dd690048da66d34a498f93e9447d71eeac1eba65f055f" + }, + { + "path": "identity-provisioner/migrations/group_journal_postgres_v1.sql", + "preimage_sha256": null, + "postimage_sha256": "f88eab60b0a07f1fa311effc9feb80dd0926962c6c765c9f68b831928e9cf336" + }, + { + "path": "identity-provisioner/provisioner.py", + "preimage_sha256": "2f8e4368e7155465bbc4070b1019c7105edf43bdc2ad3bbc5aa9bc19dc6ed7a5", + "postimage_sha256": "19d1b837cbafeeacb1aa815856cd68091e6702281d72b1d860230fa320e1725b" + }, + { + "path": "identity-provisioner/requirements-postgres.txt", + "preimage_sha256": null, + "postimage_sha256": "14f1beae54a789d2c880654ca0ce4094c9aa54645e79314b4297c12938ba8877" + }, + { + "path": "identity-provisioner/tests/test_group_postgres.py", + "preimage_sha256": null, + "postimage_sha256": "529986a3fd1602369a6c3cc0aa2ae01b78a6528a953650de208efee678e4a790" + } + ], + "patch_sha256": "c1ca16be7a34bea71aa1eef9474bea617ac0af0a187e32d4c22b848c89171810" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r0/source.patch b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r0/source.patch new file mode 100644 index 00000000..ae82dd58 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r0/source.patch @@ -0,0 +1,571 @@ +diff --git a/docs/service-group-provenance.md b/docs/service-group-provenance.md +--- a/docs/service-group-provenance.md ++++ b/docs/service-group-provenance.md +@@ -80,6 +80,60 @@ + and replay. No arbitrary JSON import, receipt expiry or automatic uncertainty + clearing is introduced. Owner reconciliation needs independent evidence. + ++## PostgreSQL source seam (not native admission) ++ ++`PostgresGroupProvenance` implements the same reserve/get/claim-revoke/save/check ++journal contract. It is injection-only: the ordinary HTTP server has no ++PostgreSQL environment switch and remains without a group store unless the ++existing local SQLite configuration is explicitly supplied. `GROUP_POSTGRES_DSN` ++or a caller JSON object cannot enable the PostgreSQL path. Production group ++operations remain unadmitted and disabled pending the owner callbacks and gates ++above; a backend constructor is not an access decision. ++ ++A trusted owner integration must provide an exact `GroupJournalBinding` with ++opaque authoritative tenant scope, directory instance, accepted case digest, ++source digest, represented principal and journal epoch. There is no guessed ++platform-root scope or request-derived tenant fallback. The owner also injects a ++renewable private connection-info callback using the existing `rapp-postgres` ++consumer/lease contract, and independently durable `JournalContinuity` callbacks. ++These callbacks retain/check/advance exact epoch+directory+head outside the ++PostgreSQL state that might be restored. Neither callback authenticates a caller ++or grants a protected side effect. Their native custody, source binding, ++revocation and execution bounds still require independent admission. Only the ++disposable fixture supplies a test checkpoint implementation here. ++ ++The v1 migration under `identity-provisioner/migrations/` creates tables within ++the existing consumer schema; it creates no database/schema/roles, inserts no ++epoch and clears no quarantine. Startup does not run migrations or initialize ++missing state. The owner must separately admit an epoch/checkpoint after ++independent continuity/recovery review; absent or quarantined state refuses. ++A restored database with an older head than the independently retained checkpoint ++also refuses rather than treating missing later intents as authority to replay. ++ ++PostgreSQL transactions lock the single journal metadata row, commit bounded ++receipts/intents and advance the head with an exactly-one conditional update. ++Every receipt update also requires its exact context/key/request/version and ++increments its version. The active slot spans all contexts in the journal; ++it serializes cooperating producer operations, not external LDAP writers. ++SQL commit precedes independently durable checkpoint acknowledgment. A lost ++commit/acknowledgment, stale version, missing row or continuity mismatch refuses ++and poisons that backend object; exception handling cannot automatically rewrite ++its journal into success. Known request-binding mismatches remain domain ++refusals. Definite before-mutation refusal records can release the active slot ++while the original key remains terminal. ++ ++The container source includes a hash-pinned Linux amd64 CPython3.12 psycopg3 ++closure selected from User Engine's existing lockfile. No image build or package ++installation is part of this source proof; build/publication, artifact scanning ++and native runtime/dependency admission remain owner work. SQLite is still ++local/disposable only. The separate PostgreSQL fixture proposal uses a cached ++pinned PostgreSQL image and a cached pinned User Engine Python image, real SQL, ++separate-process reopen, concurrency, stale-CAS and actual pg_dump/pg_restore ++behind an independently retained test checkpoint. That fixture needs exact ++review before Docker execution and does not prove live CNPG HA, TLS/lease ++custody, tenant authority, external writer fencing, production retention or ++protected session/evidence integration. ++ + ## Safety and remaining native acceptance + + Before each directory mutation, durable intent is committed. A successful +diff --git a/identity-provisioner/Containerfile b/identity-provisioner/Containerfile +--- a/identity-provisioner/Containerfile ++++ b/identity-provisioner/Containerfile +@@ -3,7 +3,10 @@ + RUN useradd --create-home --uid 10001 provisioner + WORKDIR /app + COPY --from=lldap /app/lldap_set_password /app/lldap_set_password +-COPY provisioner.py group_provenance.py password_setup.py server.py /app/ ++COPY requirements-postgres.txt /app/ ++RUN python -m pip install --no-cache-dir --require-hashes -r /app/requirements-postgres.txt ++COPY provisioner.py group_provenance.py group_provenance_postgres.py password_setup.py server.py /app/ ++COPY migrations/ /app/migrations/ + USER 10001:10001 + EXPOSE 8080 + CMD ["python", "server.py"] +diff --git a/identity-provisioner/group_provenance.py b/identity-provisioner/group_provenance.py +--- a/identity-provisioner/group_provenance.py ++++ b/identity-provisioner/group_provenance.py +@@ -11,8 +11,17 @@ + from pathlib import Path + import sqlite3 + import stat +-from typing import Any ++from typing import Any, Protocol + from urllib.parse import quote ++ ++ ++class GroupJournalStore(Protocol): ++ """Owner-injected persistence only; grants no caller or native authority.""" ++ def check(self) -> None: ... ++ def reserve(self, key: str, binding: str) -> tuple[dict[str, Any], bool]: ... ++ def get(self, key: str, binding: str) -> dict[str, Any]: ... ++ def claim_revoke(self, key: str, binding: str, delete_if_empty: bool) -> dict[str, Any]: ... ++ def save(self, receipt: dict[str, Any]) -> None: ... + + + class GroupProvenanceUnavailable(RuntimeError): +diff --git a/identity-provisioner/group_provenance_postgres.py b/identity-provisioner/group_provenance_postgres.py +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/group_provenance_postgres.py +@@ -0,0 +1,233 @@ ++"""Injection-only PostgreSQL implementation of the existing owner journal seam. ++ ++Binding and continuity callbacks are supplied by a trusted owner integration, ++never constructed from an HTTP payload. Neither is an authorization decision. ++No server environment switch, automatic schema creation or recovery admission. ++""" ++from __future__ import annotations ++ ++from contextlib import contextmanager ++from dataclasses import asdict, dataclass ++import hashlib ++import json ++import re ++from typing import Any, Callable, Protocol ++from uuid import UUID ++ ++from group_provenance import GroupProvenance, GroupProvenanceUnavailable ++ ++ ++@dataclass(frozen=True) ++class GroupJournalBinding: ++ tenant_scope: str ++ directory: str ++ case_digest: str ++ source_digest: str ++ principal: str ++ epoch: str ++ ++ def __post_init__(self): ++ for value in (self.tenant_scope, self.directory, self.principal): ++ if not isinstance(value, str) or not 1 <= len(value) <= 256 or any(ord(c) < 32 for c in value): ++ raise ValueError("explicit owner journal binding required") ++ for value in (self.case_digest, self.source_digest): ++ if not isinstance(value, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", value): ++ raise ValueError("exact owner source and case digests required") ++ if not isinstance(self.epoch, str) or str(UUID(self.epoch)) != self.epoch: ++ raise ValueError("explicit canonical journal epoch required") ++ ++ @property ++ def digest(self): ++ return hashlib.sha256(json.dumps(asdict(self), sort_keys=True, separators=(",", ":")).encode()).hexdigest() ++ ++ ++class JournalContinuity(Protocol): ++ """Existing independently retained owner checkpoint, outside restored SQL state. ++ ++ Implementations compare/advance exact epoch+directory+head atomically and ++ durably. An in-memory implementation is a fixture only. Missing/stale or ++ ambiguous checkpoints must refuse; no callback grants mutation authority. ++ """ ++ def check(self, epoch: str, directory: str, head: int) -> bool: ... ++ def advance(self, epoch: str, directory: str, previous: int, current: int) -> bool: ... ++ ++ ++class PostgresGroupProvenance: ++ def __init__(self, *, connect: Callable, binding: GroupJournalBinding, ++ continuity: JournalContinuity, capacity: int = 256): ++ if not callable(connect) or not isinstance(binding, GroupJournalBinding) or not 1 <= capacity <= 4096: ++ raise ValueError("explicit owner connector, binding and bounded capacity required") ++ if not callable(getattr(continuity, "check", None)) or not callable(getattr(continuity, "advance", None)): ++ raise ValueError("independent owner continuity callbacks required") ++ self.connect, self.binding, self.continuity, self.capacity = connect, binding, continuity, capacity ++ self._poisoned = False ++ ++ def _continuity(self, method, *args): ++ try: ++ if getattr(self.continuity, method)(self.binding.epoch, self.binding.directory, *args) is not True: ++ raise GroupProvenanceUnavailable() ++ except Exception: ++ raise GroupProvenanceUnavailable() from None ++ ++ @contextmanager ++ def _transaction(self): ++ if self._poisoned: ++ raise GroupProvenanceUnavailable() ++ connection = None ++ tx = None ++ try: ++ connection = self.connect() ++ with connection: ++ with connection.cursor() as cursor: ++ cursor.execute("SET LOCAL statement_timeout = '5s'") ++ cursor.execute("SET LOCAL idle_in_transaction_session_timeout = '15s'") ++ cursor.execute("SELECT epoch::text, directory, head, quarantined FROM identity_provisioner.group_journal_meta WHERE singleton = true FOR UPDATE") ++ meta = cursor.fetchone() ++ if not meta or meta[0] != self.binding.epoch or meta[1] != self.binding.directory or meta[3] is not False or type(meta[2]) is not int or meta[2] < 0: ++ raise GroupProvenanceUnavailable() ++ self._continuity("check", meta[2]) ++ tx = {"cursor": cursor, "head": meta[2], "dirty": False} ++ yield tx ++ if tx["dirty"]: ++ cursor.execute("UPDATE identity_provisioner.group_journal_meta SET head = head + 1 WHERE singleton = true AND epoch = %s AND directory = %s AND head = %s AND quarantined = false", ++ (self.binding.epoch, self.binding.directory, tx["head"])) ++ if cursor.rowcount != 1: ++ raise GroupProvenanceUnavailable() ++ # SQL commit precedes the independent checkpoint acknowledgment. ++ # A lost acknowledgment refuses before the next directory effect. ++ if tx and tx["dirty"]: ++ self._continuity("advance", tx["head"], tx["head"] + 1) ++ except ValueError: ++ raise ++ except GroupProvenanceUnavailable: ++ # Caller mismatches are domain refusals. Storage/continuity failures ++ # poison this object; durable pending records are never rewritten ++ # automatically by an exception handler after ambiguous commit. ++ self._poisoned = True ++ raise ++ except Exception: ++ self._poisoned = True ++ raise GroupProvenanceUnavailable() from None ++ finally: ++ if connection is not None: ++ try: ++ connection.close() ++ except Exception: ++ self._poisoned = True ++ raise GroupProvenanceUnavailable() from None ++ ++ def check(self): ++ with self._transaction(): ++ pass ++ ++ def _read(self, cursor, key, request_binding): ++ cursor.execute("SELECT binding, version, body, context_json FROM identity_provisioner.group_receipts WHERE context_digest = %s AND key = %s", ++ (self.binding.digest, key)) ++ row = cursor.fetchone() ++ if row is None: ++ return None ++ if row[0] != request_binding: ++ raise ValueError("ensure receipt request binding mismatch") ++ if row[3] != json.dumps(asdict(self.binding), sort_keys=True): ++ raise GroupProvenanceUnavailable() ++ receipt = GroupProvenance._load(row[2], key, request_binding) ++ if type(row[1]) is not int or row[1] < 1 or type(receipt.get("_pg_version")) is not int or receipt.get("_pg_version") != row[1] or receipt.get("_pg_context") != self.binding.digest: ++ raise GroupProvenanceUnavailable() ++ return receipt ++ ++ @staticmethod ++ def _inputs(key, binding): ++ if not isinstance(key, str) or not 1 <= len(key) <= 256 or not isinstance(binding, str) or not re.fullmatch(r"[0-9a-f]{64}", binding): ++ raise ValueError("bounded exact journal request binding required") ++ ++ def _active(self, cursor): ++ cursor.execute("SELECT 1 FROM identity_provisioner.group_receipts WHERE active = true LIMIT 1") ++ if cursor.fetchone(): ++ raise GroupProvenanceUnavailable() ++ ++ def reserve(self, key, binding): ++ self._inputs(key, binding) ++ with self._transaction() as tx: ++ cursor = tx["cursor"] ++ receipt = self._read(cursor, key, binding) ++ if receipt is not None: ++ return receipt, False ++ self._active(cursor) ++ cursor.execute("SELECT COUNT(*) FROM identity_provisioner.group_receipts") ++ if cursor.fetchone()[0] >= self.capacity: ++ raise GroupProvenanceUnavailable() ++ receipt = {"key": key, "binding": binding, "state": "pending", "intent": None, "groups": {}, ++ "created_groups": [], "created_memberships": [], "_pg_version": 1, "_pg_context": self.binding.digest} ++ cursor.execute("INSERT INTO identity_provisioner.group_receipts (context_digest, context_json, key, binding, version, state, active, body) VALUES (%s, %s, %s, %s, 1, 'pending', true, %s)", ++ (self.binding.digest, json.dumps(asdict(self.binding), sort_keys=True), key, binding, json.dumps(receipt, sort_keys=True))) ++ if cursor.rowcount != 1: ++ raise GroupProvenanceUnavailable() ++ tx["dirty"] = True ++ return receipt, True ++ ++ def get(self, key, binding): ++ self._inputs(key, binding) ++ with self._transaction() as tx: ++ receipt = self._read(tx["cursor"], key, binding) ++ if receipt is None: ++ raise ValueError("unknown ensure receipt or request binding mismatch") ++ return receipt ++ ++ def _write(self, tx, receipt): ++ self._inputs(receipt.get("key"), receipt.get("binding")) ++ if receipt.get("_pg_context") != self.binding.digest or type(receipt.get("_pg_version")) is not int or receipt["_pg_version"] < 1: ++ raise GroupProvenanceUnavailable() ++ encoded = json.dumps(receipt, sort_keys=True) ++ GroupProvenance._load(encoded, receipt["key"], receipt["binding"]) ++ updated = json.loads(encoded) ++ updated["_pg_version"] += 1 ++ active = True if receipt["state"] in {"pending", "revoking", "uncertain"} else None ++ tx["cursor"].execute("UPDATE identity_provisioner.group_receipts SET state = %s, active = %s, body = %s, version = version + 1 WHERE context_digest = %s AND key = %s AND binding = %s AND version = %s", ++ (receipt["state"], active, json.dumps(updated, sort_keys=True), self.binding.digest, receipt["key"], receipt["binding"], receipt["_pg_version"])) ++ if tx["cursor"].rowcount != 1: ++ raise GroupProvenanceUnavailable() ++ tx["dirty"] = True ++ return updated ++ ++ def save(self, receipt): ++ with self._transaction() as tx: ++ updated = self._write(tx, receipt) ++ receipt.clear() ++ receipt.update(updated) ++ ++ def claim_revoke(self, key, binding, delete_if_empty): ++ self._inputs(key, binding) ++ if not isinstance(delete_if_empty, bool): ++ raise ValueError("delete_if_empty must be boolean") ++ with self._transaction() as tx: ++ receipt = self._read(tx["cursor"], key, binding) ++ if receipt is None: ++ raise ValueError("unknown ensure receipt or request binding mismatch") ++ if receipt["state"] == "revoked": ++ if receipt["delete_if_empty"] != delete_if_empty: ++ raise ValueError("completed rollback options cannot change") ++ return receipt ++ if receipt["state"] != "ensured": ++ raise GroupProvenanceUnavailable() ++ self._active(tx["cursor"]) ++ receipt.update(state="revoking", delete_if_empty=delete_if_empty) ++ updated = self._write(tx, receipt) ++ return updated ++ ++ ++def psycopg_connector(*, connection_info: Callable[[], dict[str, Any]]): ++ """Owner-provided renewable lease values stay in memory; never env/argv/logs. ++ ++ No connection happens here. Lazy import keeps disabled group endpoints free ++ of a runtime dependency. The admitted owner supplies TLS/host/db/role facts. ++ """ ++ if not callable(connection_info): ++ raise ValueError("owner connection-info callback required") ++ def connect(): ++ try: ++ import psycopg ++ values = dict(connection_info()) ++ return psycopg.connect(**values, connect_timeout=5) ++ except Exception: ++ raise GroupProvenanceUnavailable() from None ++ return connect +diff --git a/identity-provisioner/migrations/group_journal_postgres_v1.sql b/identity-provisioner/migrations/group_journal_postgres_v1.sql +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/migrations/group_journal_postgres_v1.sql +@@ -0,0 +1,22 @@ ++-- Run only by the admitted migration role in its existing owner schema. ++-- Does not create a database/schema/credential, admit an epoch or clear quarantine. ++CREATE TABLE identity_provisioner.group_journal_meta ( ++ singleton boolean PRIMARY KEY CHECK (singleton = true), ++ epoch uuid NOT NULL, ++ directory text NOT NULL CHECK (octet_length(directory) BETWEEN 1 AND 1024), ++ head bigint NOT NULL CHECK (head >= 0), ++ quarantined boolean NOT NULL DEFAULT true ++); ++CREATE TABLE identity_provisioner.group_receipts ( ++ context_digest text NOT NULL CHECK (context_digest ~ '^[0-9a-f]{64}$'), ++ context_json text NOT NULL CHECK (octet_length(context_json) <= 16384), ++ key text NOT NULL CHECK (char_length(key) BETWEEN 1 AND 256), ++ binding text NOT NULL CHECK (binding ~ '^[0-9a-f]{64}$'), ++ version bigint NOT NULL CHECK (version >= 1), ++ state text NOT NULL CHECK (state IN ('pending','ensured','revoking','revoked','uncertain','refused')), ++ active boolean UNIQUE CHECK (active IS NULL OR active = true), ++ body text NOT NULL CHECK (octet_length(body) <= 1048576), ++ PRIMARY KEY (context_digest, key) ++); ++-- Epoch insertion, initial continuity checkpoint and admission are separate ++-- owner actions; this migration leaves all operations refused without them. +diff --git a/identity-provisioner/provisioner.py b/identity-provisioner/provisioner.py +--- a/identity-provisioner/provisioner.py ++++ b/identity-provisioner/provisioner.py +@@ -11,7 +11,7 @@ + from urllib.error import HTTPError, URLError + from urllib.request import Request, urlopen + +-from group_provenance import GroupProvenance, GroupProvenanceUnavailable ++from group_provenance import GroupJournalStore, GroupProvenanceUnavailable + + + @dataclass(frozen=True) +@@ -64,7 +64,7 @@ + opener: Callable = urlopen, + auth_rejected_cooldown: float = 30.0, + clock: Callable[[], float] = time.monotonic, +- group_provenance: GroupProvenance | None = None, ++ group_provenance: GroupJournalStore | None = None, + ) -> None: + self.group_provenance = group_provenance + self.base_url = base_url.rstrip("/") +@@ -243,7 +243,7 @@ + timeout=timeout, + ) + +- def _group_store(self) -> GroupProvenance: ++ def _group_store(self) -> GroupJournalStore: + store = self.group_provenance + if store is None: + raise GroupProvenanceUnavailable() +diff --git a/identity-provisioner/requirements-postgres.txt b/identity-provisioner/requirements-postgres.txt +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/requirements-postgres.txt +@@ -0,0 +1,5 @@ ++# Linux amd64 CPython3.12 runtime closure from user-engine/uv.lock. ++# Build-only dependency resolution; no packages are installed at service startup. ++psycopg==3.3.5 --hash=sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e ++psycopg-binary==3.3.5 --hash=sha256:682a17a57415c3ca1731eec018ed031f012ffcb81ba74806eb219cb396065672 ++typing-extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 +diff --git a/identity-provisioner/tests/test_group_postgres.py b/identity-provisioner/tests/test_group_postgres.py +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/tests/test_group_postgres.py +@@ -0,0 +1,160 @@ ++"""Binding, CAS and ambiguous-commit refusal tests; native SQL proof is separate.""" ++from dataclasses import replace ++import json ++from unittest.mock import Mock ++import unittest ++ ++from group_provenance import GroupProvenanceUnavailable ++from group_provenance_postgres import GroupJournalBinding, PostgresGroupProvenance ++from server import configured_group_provenance ++from unittest.mock import patch ++import os ++ ++EPOCH = '41fbb0ed-ea3a-4682-996b-e9c94a6aa9c6' ++BINDING = GroupJournalBinding('tenant:fixture:one', 'synthetic-directory', 'sha256:' + '1' * 64, ++ 'sha256:' + '2' * 64, 'synthetic-owner', EPOCH) ++REQUEST = '3' * 64 ++META = (EPOCH, 'synthetic-directory', 7, False) ++ ++ ++def fixture(rows=(), rowcount=1, commit_error=None): ++ cursor = Mock() ++ cursor.__enter__ = Mock(return_value=cursor) ++ cursor.__exit__ = Mock(return_value=False) ++ cursor.fetchone.side_effect = rows ++ cursor.rowcount = rowcount ++ connection = Mock() ++ connection.__enter__ = Mock(return_value=connection) ++ connection.__exit__ = Mock(return_value=False, side_effect=commit_error) ++ connection.cursor.return_value = cursor ++ connector = Mock(return_value=connection) ++ continuity = Mock() ++ continuity.check.return_value = True ++ continuity.advance.return_value = True ++ store = PostgresGroupProvenance(connect=connector, binding=BINDING, continuity=continuity) ++ return store, connector, connection, cursor, continuity ++ ++ ++def receipt(version=4): ++ return {'key': 'synthetic-operation-key', 'binding': REQUEST, 'state': 'pending', 'intent': None, ++ 'groups': {}, 'created_groups': [], 'created_memberships': [], ++ '_pg_version': version, '_pg_context': BINDING.digest} ++ ++ ++class PostgresSourceTests(unittest.TestCase): ++ def test_explicit_binding_and_continuity_required_without_connecting(self): ++ connector = Mock() ++ for binding, continuity in ((None, Mock()), (BINDING, None), ({'tenant_scope': 'caller-json'}, Mock())): ++ with self.assertRaises(ValueError): ++ PostgresGroupProvenance(connect=connector, binding=binding, continuity=continuity) ++ connector.assert_not_called() ++ for change in ({'tenant_scope': ''}, {'principal': ''}, {'case_digest': 'unsigned'}, {'epoch': 'default'}): ++ with self.assertRaises(ValueError): ++ replace(BINDING, **change) ++ self.assertNotEqual(BINDING.digest, replace(BINDING, principal='another-owner').digest) ++ self.assertNotEqual(BINDING.digest, replace(BINDING, tenant_scope='another-explicit-scope').digest) ++ ++ def test_missing_environment_callback_path_keeps_production_disabled(self): ++ with patch.dict(os.environ, {'GROUP_POSTGRES_DSN': 'synthetic-only', 'GROUP_PROVENANCE_POSTGRES': 'true'}, clear=True): ++ self.assertIsNone(configured_group_provenance()) ++ ++ def test_checkpoint_mismatch_poisoned_without_receipt_query_or_effect(self): ++ store, connect, _, cursor, checkpoint = fixture([META]) ++ checkpoint.check.return_value = False ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.reserve('synthetic-operation-key', REQUEST) ++ self.assertFalse(any('group_receipts' in c.args[0] for c in cursor.execute.call_args_list)) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.check() ++ self.assertEqual(1, connect.call_count) ++ checkpoint.advance.assert_not_called() ++ ++ def test_restored_epoch_or_quarantine_refused(self): ++ for meta in ((EPOCH, 'other-directory', 7, False), (EPOCH, 'synthetic-directory', 7, True), ++ ('98aa2b6d-a32a-44a5-a89f-b2480befc00e', 'synthetic-directory', 7, False), None): ++ store, _, _, _, checkpoint = fixture([meta]) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.check() ++ checkpoint.check.assert_not_called() ++ ++ def test_exactly_one_version_update_required_before_checkpoint_advance(self): ++ store, _, _, cursor, checkpoint = fixture([META], rowcount=0) ++ original = receipt() ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.save(original) ++ query, params = next(c.args for c in cursor.execute.call_args_list if c.args[0].startswith('UPDATE identity_provisioner.group_receipts')) ++ self.assertIn('AND version = %s', query) ++ self.assertEqual(4, params[-1]) ++ self.assertEqual(4, original['_pg_version']) ++ checkpoint.advance.assert_not_called() ++ ++ def test_success_acknowledges_commit_and_updates_receipt_version(self): ++ store, _, connection, _, checkpoint = fixture([META]) ++ original = receipt() ++ store.save(original) ++ self.assertEqual(5, original['_pg_version']) ++ connection.__exit__.assert_called_once() ++ checkpoint.advance.assert_called_once_with(EPOCH, 'synthetic-directory', 7, 8) ++ connection.close.assert_called_once() ++ ++ def test_connection_close_failure_is_sanitized_and_stops_continuation(self): ++ store, _, connection, _, _ = fixture([META]) ++ connection.close.side_effect = OSError('synthetic-private-diagnostic') ++ with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): ++ store.check() ++ self.assertTrue(store._poisoned) ++ ++ def test_commit_failure_never_advances_owner_checkpoint_or_receipt(self): ++ store, _, _, _, checkpoint = fixture([META], commit_error=OSError('synthetic-secret-diagnostic')) ++ original = receipt() ++ with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): ++ store.save(original) ++ self.assertEqual(4, original['_pg_version']) ++ checkpoint.advance.assert_not_called() ++ ++ def test_lost_checkpoint_acknowledgment_refuses_then_poisoned(self): ++ store, connect, _, _, checkpoint = fixture([META]) ++ checkpoint.advance.side_effect = OSError('synthetic-secret-diagnostic') ++ original = receipt() ++ with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): ++ store.save(original) ++ self.assertEqual(4, original['_pg_version']) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.save(original) ++ self.assertEqual(1, connect.call_count) ++ ++ def test_cross_case_body_or_version_cannot_be_imported(self): ++ for body in (dict(receipt(), _pg_context='0' * 64), dict(receipt(), _pg_version=6)): ++ store, _, _, _, _ = fixture([META, (REQUEST, 4, json.dumps(body), json.dumps(BINDING.__dict__, sort_keys=True))]) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.get('synthetic-operation-key', REQUEST) ++ ++ def test_existing_request_mismatch_is_refused_not_rebound(self): ++ store, _, _, _, checkpoint = fixture([META, ('0' * 64, 4, '{}', '{}')]) ++ with self.assertRaisesRegex(ValueError, 'binding mismatch'): ++ store.get('synthetic-operation-key', REQUEST) ++ checkpoint.advance.assert_not_called() ++ self.assertFalse(store._poisoned) ++ ++ def test_active_fence_capacity_refusal_and_no_insert(self): ++ for rows in ([META, None, (1,)], [META, None, None, (256,)]): ++ store, _, _, cursor, checkpoint = fixture(rows) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.reserve('synthetic-operation-key', REQUEST) ++ self.assertFalse(any('INSERT' in c.args[0] for c in cursor.execute.call_args_list)) ++ checkpoint.advance.assert_not_called() ++ ++ def test_new_reservation_retains_bound_context_and_write_intent(self): ++ store, _, _, cursor, checkpoint = fixture([META, None, None, (0,)]) ++ created, fresh = store.reserve('synthetic-operation-key', REQUEST) ++ self.assertTrue(fresh) ++ self.assertEqual('pending', created['state']) ++ self.assertEqual(BINDING.digest, created['_pg_context']) ++ insert = next(c for c in cursor.execute.call_args_list if c.args[0].startswith('INSERT')) ++ self.assertIn('context_json', insert.args[0]) ++ self.assertEqual(BINDING.tenant_scope, json.loads(insert.args[1][1])['tenant_scope']) ++ checkpoint.advance.assert_called_once() ++ ++ ++if __name__ == '__main__': ++ unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/docs/service-group-provenance.md b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/docs/service-group-provenance.md new file mode 100644 index 00000000..8d75c90f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/docs/service-group-provenance.md @@ -0,0 +1,172 @@ +# Durable service group creation provenance + +This source change narrows the existing authenticated identity-provisioner +`/v1/groups/ensure` and `/v1/groups/revoke` endpoints. It introduces no new +principal, credential, policy decision, or rollback authorization. An endpoint's +service token and a journal receipt do not establish an accepted case, caller +session, owner assent, or permission to use a protected execution lane. + +## Request and receipt migration + +Ensure keeps `groups`, `members`, `idempotency_key` and `correlation_id`. +The original correlation is now part of its immutable binding: the original +idempotency key with a different correlation or group/member request is refused. +The response adds `ensure_idempotency_key`, `ensure_correlation_id`, +`created_groups` (name and exact directory id) and `created_memberships` +(name, directory id and username). The owner journal independently retains the +complete prestate and expected postimage. Response inventories are advisory; +caller-supplied inventories are rejected and never authorize undo. + +Revoke retains the original group/member request, its own `idempotency_key` and +`correlation_id`, and optional boolean `delete_if_empty`. It additionally requires +`ensure_idempotency_key` and `ensure_correlation_id` from the original ensure. +Legacy revoke requests fail before directory access. No old deployment inventory +is imported as proof of creation: pre-upgrade groups and memberships cannot be +rolled back automatically without independent owner reconciliation. + +An ensure replay returns its original receipt and never recreates subsequently +removed state. Reusing its key after rollback is refused. Revoke replays return +its original outcome; changing deletion options after completion is refused. +There are at most 16 groups, 256 requested membership entries, 4096 retained +preexisting members per operation, and 256 journal records by default. Records +have no timeout or automatic eviction. Capacity exhaustion stops new operations. + +## Storage policy and local fixture configuration + +The implemented SQLite backend is for local development and disposable tests. +It is not an admitted production storage design. Accepted +[ADR-0015 section3](adr/ADR-0015-netkingdom-railiance-workload-packaging-and-relational-platform.md) +requires production relational storage through CloudNative PG and `rapp-postgres` +using `database-per-consumer`; it explicitly rejects per-workload SQLite-on-a-PVC +as a production pattern. The rail-kubernetes wave-1 contract also does not supply +a generic persistent-storage contract. This source does not authorize adding a +SQLite PVC to identity-provisioner. + +Production group operations remain unadmitted and must stay disabled until the +existing owner paths admit a CNPG-backed implementation of the same journal +semantics, credentials, storage/backup/restore and writer-isolation contracts, +or an explicit governed exception to ADR-0015 identifies the exact scope, +limits, duration, recovery and risk owner. No such backend, exception or native +assent is supplied here. `railiance-platform` owns shared database/custody +governance; `rapp-postgres` owns the package consumer provisioning surface. +The identity-provisioner runtime package/operating assignment must also be +explicitly settled rather than inferred from User Engine's dependency on it. + +For local/disposable tests only, `GROUP_PROVENANCE_DB` selects an absolute, +non-symlink SQLite path in a private test directory owned by the process UID, +with mode0700 for the directory and mode0600 for the database. The local fixture +explicitly initializes a genuinely new empty store with +`GroupProvenance(absolute_path, initialize=True)`; initialization refuses an +existing file. Ordinary opening never recreates a missing store. The completed +disposable fixture uses private container tmpfs and preserves the file across +separate Python processes; that proves process-restart semantics, not production +durability. `/tmp`, `emptyDir`, tmpfs and an image layer do not satisfy native +retention or recovery duties. + +With no configuration, ordinary directory readiness reports +`service_groups: disabled` and group endpoints refuse work. Missing/untrusted or +unavailable configured local storage prevents startup or returns a fixed +`group_provenance` dependency failure without path/database diagnostics. +`service_groups: available` checks capability only: the implementation does not +infer or enforce production admission, policy exceptions, accepted cases or +writer isolation from a filesystem path. + +The existing reference Deployment remains one `Recreate` replica at UID/GID10001 +with `readOnlyRootFilesystem: true`, no provenance volume and an older immutable +image. Image rebuild, native owner review and credential delivery remain separate +acts. Preserve its nonroot/read-only boundaries. Native retention and recovery +must preserve original idempotency bindings, prestate, confirmed creations and +unresolved intents; losing a journal never authorizes replacement initialization +and replay. No arbitrary JSON import, receipt expiry or automatic uncertainty +clearing is introduced. Owner reconciliation needs independent evidence. + +## PostgreSQL source seam (not native admission) + +`PostgresGroupProvenance` implements the same reserve/get/claim-revoke/save/check +journal contract. It is injection-only: the ordinary HTTP server has no +PostgreSQL environment switch and remains without a group store unless the +existing local SQLite configuration is explicitly supplied. `GROUP_POSTGRES_DSN` +or a caller JSON object cannot enable the PostgreSQL path. Production group +operations remain unadmitted and disabled pending the owner callbacks and gates +above; a backend constructor is not an access decision. + +A trusted owner integration must provide an exact `GroupJournalBinding` with +opaque authoritative tenant scope, directory instance, accepted case digest, +source digest, represented principal and journal epoch. There is no guessed +platform-root scope or request-derived tenant fallback. The owner also injects a +renewable private connection-info callback using the existing `rapp-postgres` +consumer/lease contract, and independently durable `JournalContinuity` callbacks. +These callbacks retain/check/advance exact epoch+directory+head+commit digest outside the +PostgreSQL state that might be restored. Neither callback authenticates a caller +or grants a protected side effect. Their native custody, source binding, +revocation and execution bounds still require independent admission. Only the +disposable fixture supplies a test checkpoint implementation here. + +The v1 migration under `identity-provisioner/migrations/` creates tables within +the existing consumer schema; it creates no database/schema/roles, inserts no +epoch and clears no quarantine. Startup does not run migrations or initialize +missing state. The owner must separately admit an epoch/checkpoint after +independent continuity/recovery review; absent or quarantined state refuses. +A restored database with an older head than the independently retained checkpoint +also refuses rather than treating missing later intents as authority to replay. + +PostgreSQL transactions lock the single journal metadata row, commit bounded +receipts/intents and advance the head and exact transition digest with an exactly-one conditional +update. The digest binds the prior marker, canonical body, context, request key +and receipt version; the independent checkpoint must compare/CAS both content +and sequence, never accept a counter-only callback. Divergent restored clones +that commit different transitions at the same counter cannot both be accepted. +Every receipt update also requires its exact context/key/request/version and +increments its version. The active slot spans all contexts in the journal; +it serializes cooperating producer operations, not external LDAP writers. +SQL commit precedes independently durable checkpoint acknowledgment. A lost +commit/acknowledgment, stale version, missing row or continuity mismatch refuses +and poisons that backend object; exception handling cannot automatically rewrite +its journal into success. Known request-binding mismatches remain domain +refusals. Definite before-mutation refusal records can release the active slot +while the original key remains terminal. + +The container source includes a hash-pinned Linux amd64 CPython3.12 psycopg3 +closure selected from User Engine's existing lockfile. No image build or package +installation is part of this source proof; build/publication, artifact scanning +and native runtime/dependency admission remain owner work. SQLite is still +local/disposable only. The separate PostgreSQL fixture proposal uses a cached +pinned PostgreSQL image and a cached pinned User Engine Python image, real SQL, +separate-process reopen, concurrency, stale-CAS and actual pg_dump/pg_restore +behind an independently retained test checkpoint. That fixture needs exact +review before Docker execution and does not prove live CNPG HA, TLS/lease +custody, tenant authority, external writer fencing, production retention or +protected session/evidence integration. + +## Safety and remaining native acceptance + +Before each directory mutation, durable intent is committed. A successful +response and exact readback are required before recording a confirmed creation. +A definite failure before any mutation attempt retains a `refused` record and +releases the active fence; its original key still cannot retry. Transport loss, +readback failure after an attempted mutation or a process exit leaves pending/uncertain state; +it survives reopening and blocks new mutations instead of becoming retry success. +Every database connection rechecks the private path and original device/inode; +missing/replaced storage is refused. Every intent update must affect exactly one +matching journal row before execution can continue. +The journal records mutation intent and confirmed outcomes, not an atomic LDAP +transaction or a complete audit emission guarantee. + +Rollback validates every targeted group id and complete member postimage before +its first destructive operation, and verifies each subsequent change. It removes +only memberships confirmed newly added by that ensure. It deletes only groups +confirmed newly created by that ensure, and only while empty. Preexisting groups, +including empty groups, are never deleted. Existing members are never included +in the removal inventory. Detected shared-member drift or group replacement +refuses the operation before any destructive call and retains uncertainty. + +LLDAP exposes no membership-generation token or conditional mutation/CAS here. +Readback cannot detect a remove/re-add ABA sequence or close a read/write race +with an external writer. Native rollback therefore still requires an owner-proven +writer isolation/fencing contract or a provider-side versioned/CAS operation; +these synthetic fixtures do not prove that boundary. A durable SQLite file is +trusted local owner-held state, not a production storage admission, tamper-evident +remote evidence or an authorization broker. Existing caller authentication is unchanged; case/source/principal binding, +per-attempt protected session revocation, independently admitted evidence callbacks, +native storage/backup recovery and native endpoint acceptance remain outstanding +under NK-WP-0048-T14/T16. This source change alone does not finish those tasks. diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/Containerfile b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/Containerfile new file mode 100644 index 00000000..00f96e6c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/Containerfile @@ -0,0 +1,12 @@ +FROM lldap/lldap:stable@sha256:2a8454b668c1aba7157e832eab0e242e1e7eb5fb7591d7e7774ba05286511ca8 AS lldap +FROM python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9 +RUN useradd --create-home --uid 10001 provisioner +WORKDIR /app +COPY --from=lldap /app/lldap_set_password /app/lldap_set_password +COPY requirements-postgres.txt /app/ +RUN python -m pip install --no-cache-dir --require-hashes -r /app/requirements-postgres.txt +COPY provisioner.py group_provenance.py group_provenance_postgres.py password_setup.py server.py /app/ +COPY migrations/ /app/migrations/ +USER 10001:10001 +EXPOSE 8080 +CMD ["python", "server.py"] diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/group_provenance.py b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/group_provenance.py new file mode 100644 index 00000000..4e9e809b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/group_provenance.py @@ -0,0 +1,179 @@ +"""Bounded, owner-private recovery journal; never an authorization source. + +An unresolved write intent is retained indefinitely and blocks further writes. +Only the directory owner may reconcile it using independently obtained evidence. +""" +from __future__ import annotations + +import json +from contextlib import contextmanager +import os +from pathlib import Path +import sqlite3 +import stat +from typing import Any, Protocol +from urllib.parse import quote + + +class GroupJournalStore(Protocol): + """Owner-injected persistence only; grants no caller or native authority.""" + def check(self) -> None: ... + def reserve(self, key: str, binding: str) -> tuple[dict[str, Any], bool]: ... + def get(self, key: str, binding: str) -> dict[str, Any]: ... + def claim_revoke(self, key: str, binding: str, delete_if_empty: bool) -> dict[str, Any]: ... + def save(self, receipt: dict[str, Any]) -> None: ... + + +class GroupProvenanceUnavailable(RuntimeError): + def __init__(self): + super().__init__("group_provenance_unavailable") + + +class GroupProvenance: + def __init__(self, path: str, *, capacity: int = 256, initialize: bool = False) -> None: + target = Path(path) + if not target.is_absolute() or target.parent.resolve() != target.parent or capacity < 1 or capacity > 4096: + raise ValueError("private absolute provenance path and bounded capacity required") + parent = target.parent.lstat() + if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() or parent.st_mode & 0o077: + raise ValueError("provenance directory must be owner-private") + # Service startup never creates a replacement for missing durable state. + flags = os.O_RDWR | os.O_NOFOLLOW + if initialize: + flags |= os.O_CREAT | os.O_EXCL + fd = os.open(target, flags, 0o600) + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077 or info.st_nlink != 1: + raise ValueError("provenance file must be owner-private and regular") + finally: + os.close(fd) + self.path = str(target) + self._device, self._inode = info.st_dev, info.st_ino + self.capacity = capacity + with self._db() as db: + if initialize: + db.execute("CREATE TABLE group_receipts (key TEXT PRIMARY KEY, binding TEXT NOT NULL, state TEXT NOT NULL, active INTEGER UNIQUE, body TEXT NOT NULL)") + db.execute("PRAGMA user_version=1") + if db.execute("PRAGMA user_version").fetchone()[0] != 1: + raise RuntimeError("unrecognized group provenance schema") + db.execute("SELECT key, binding, state, active, body FROM group_receipts LIMIT 0") + + def _check_file(self): + try: + target = Path(self.path) + parent = target.parent.lstat() + info = target.lstat() + if target.parent.resolve() != target.parent or not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() or parent.st_mode & 0o077: + raise GroupProvenanceUnavailable() + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077 or info.st_nlink != 1: + raise GroupProvenanceUnavailable() + if (info.st_dev, info.st_ino) != (self._device, self._inode): + raise GroupProvenanceUnavailable() + except OSError: + raise GroupProvenanceUnavailable() from None + + def check(self): + with self._db() as db: + if db.execute("PRAGMA user_version").fetchone()[0] != 1: + raise GroupProvenanceUnavailable() + db.execute("SELECT key FROM group_receipts LIMIT 0") + + @staticmethod + def _load(body, key, binding): + try: + if not isinstance(body, str) or len(body) > 1048576: + raise GroupProvenanceUnavailable() + value = json.loads(body) + if not isinstance(value, dict) or value.get("key") != key or value.get("binding") != binding: + raise GroupProvenanceUnavailable() + if value.get("state") not in {"pending", "ensured", "revoking", "revoked", "uncertain", "refused"}: + raise GroupProvenanceUnavailable() + if not isinstance(value.get("groups"), dict) or len(value["groups"]) > 16: + raise GroupProvenanceUnavailable() + for field, limit in (("created_groups", 16), ("created_memberships", 256)): + if not isinstance(value.get(field), list) or len(value[field]) > limit or any(not isinstance(item, dict) for item in value[field]): + raise GroupProvenanceUnavailable() + if "intent" not in value or value["intent"] is not None and not isinstance(value["intent"], dict): + raise GroupProvenanceUnavailable() + for group in value["groups"].values(): + if not isinstance(group, dict) or "id" not in group or not all(isinstance(group.get(f), list) and all(isinstance(u, str) for u in group[f]) for f in ("before", "after")): + raise GroupProvenanceUnavailable() + if value["state"] in {"ensured", "revoking", "revoked"} and ( + not isinstance(value.get("requested_groups"), list) or not isinstance(value.get("ensure_correlation_id"), str) + ): + raise GroupProvenanceUnavailable() + return value + except (TypeError, ValueError): + raise GroupProvenanceUnavailable() from None + + @contextmanager + def _db(self): + db = None + try: + self._check_file() + db = sqlite3.connect("file:" + quote(self.path, safe="/") + "?mode=rw", uri=True, timeout=5) + self._check_file() + db.execute("PRAGMA synchronous=FULL") + with db: + yield db + except sqlite3.Error: + raise GroupProvenanceUnavailable() from None + finally: + if db is not None: + db.close() + + def reserve(self, key: str, binding: str) -> tuple[dict[str, Any], bool]: + with self._db() as db: + db.execute("BEGIN IMMEDIATE") + row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone() + if row: + if row[0] != binding: + raise ValueError("ensure idempotency key is bound to another request") + return self._load(row[1], key, binding), False + if db.execute("SELECT COUNT(*) FROM group_receipts").fetchone()[0] >= self.capacity: + raise RuntimeError("provenance capacity reached; retained records require owner disposition") + receipt = {"key": key, "binding": binding, "state": "pending", "intent": None, + "groups": {}, "created_groups": [], "created_memberships": []} + try: + db.execute("INSERT INTO group_receipts VALUES (?, ?, ?, 1, ?)", + (key, binding, "pending", json.dumps(receipt, sort_keys=True))) + except sqlite3.IntegrityError: + raise RuntimeError("another group operation is unresolved") from None + return receipt, True + + def get(self, key: str, binding: str) -> dict[str, Any]: + with self._db() as db: + row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone() + if not row or row[0] != binding: + raise ValueError("unknown ensure receipt or request binding mismatch") + return self._load(row[1], key, binding) + + def claim_revoke(self, key: str, binding: str, delete_if_empty: bool) -> dict[str, Any]: + with self._db() as db: + db.execute("BEGIN IMMEDIATE") + row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone() + if not row or row[0] != binding: + raise ValueError("unknown ensure receipt or request binding mismatch") + receipt = self._load(row[1], key, binding) + if receipt["state"] == "revoked": + if receipt["delete_if_empty"] != delete_if_empty: + raise ValueError("completed rollback options cannot change") + return receipt + if receipt["state"] != "ensured": + raise RuntimeError("group provenance unresolved; independent owner recovery required") + receipt.update(state="revoking", delete_if_empty=delete_if_empty) + try: + db.execute("UPDATE group_receipts SET state='revoking', active=1, body=? WHERE key=?", + (json.dumps(receipt, sort_keys=True), key)) + except sqlite3.IntegrityError: + raise RuntimeError("another group operation is unresolved") from None + return receipt + + def save(self, receipt: dict[str, Any]) -> None: + active = 1 if receipt["state"] in {"pending", "revoking", "uncertain"} else None + with self._db() as db: + written = db.execute("UPDATE group_receipts SET state=?, active=?, body=? WHERE key=? AND binding=?", + (receipt["state"], active, json.dumps(receipt, sort_keys=True), receipt["key"], receipt["binding"])) + if written.rowcount != 1: + raise GroupProvenanceUnavailable() diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/group_provenance_postgres.py b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/group_provenance_postgres.py new file mode 100644 index 00000000..7aaffd92 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/group_provenance_postgres.py @@ -0,0 +1,251 @@ +"""Injection-only PostgreSQL implementation of the existing owner journal seam. + +Binding and continuity callbacks are supplied by a trusted owner integration, +never constructed from an HTTP payload. Neither is an authorization decision. +No server environment switch, automatic schema creation or recovery admission. +""" +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import asdict, dataclass +import hashlib +import json +import re +from typing import Any, Callable, Protocol +from uuid import UUID + +from group_provenance import GroupProvenance, GroupProvenanceUnavailable + + +@dataclass(frozen=True) +class GroupJournalBinding: + tenant_scope: str + directory: str + case_digest: str + source_digest: str + principal: str + epoch: str + + def __post_init__(self): + for value in (self.tenant_scope, self.directory, self.principal): + if not isinstance(value, str) or not 1 <= len(value) <= 256 or any(ord(c) < 32 for c in value): + raise ValueError("explicit owner journal binding required") + for value in (self.case_digest, self.source_digest): + if not isinstance(value, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", value): + raise ValueError("exact owner source and case digests required") + if not isinstance(self.epoch, str) or str(UUID(self.epoch)) != self.epoch: + raise ValueError("explicit canonical journal epoch required") + + @property + def digest(self): + return hashlib.sha256(json.dumps(asdict(self), sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def journal_genesis(epoch: str, directory: str) -> str: + """Deterministic initial marker for explicit owner migration/admission only.""" + return hashlib.sha256(json.dumps({"schema": "nk-group-journal-genesis.v1", "epoch": epoch, + "directory": directory}, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +class JournalContinuity(Protocol): + """Existing independently retained owner checkpoint, outside restored SQL state. + + Implementations compare/advance exact epoch+directory+head+commit digest atomically and + durably. An in-memory implementation is a fixture only. Missing/stale or + ambiguous checkpoints must refuse; no callback grants mutation authority. + """ + def check(self, epoch: str, directory: str, head: int, digest: str) -> bool: ... + def advance(self, epoch: str, directory: str, previous: int, previous_digest: str, current: int, current_digest: str) -> bool: ... + + +class PostgresGroupProvenance: + def __init__(self, *, connect: Callable, binding: GroupJournalBinding, + continuity: JournalContinuity, capacity: int = 256): + if not callable(connect) or not isinstance(binding, GroupJournalBinding) or not 1 <= capacity <= 4096: + raise ValueError("explicit owner connector, binding and bounded capacity required") + if not callable(getattr(continuity, "check", None)) or not callable(getattr(continuity, "advance", None)): + raise ValueError("independent owner continuity callbacks required") + self.connect, self.binding, self.continuity, self.capacity = connect, binding, continuity, capacity + self._poisoned = False + + def _continuity(self, method, *args): + try: + if getattr(self.continuity, method)(self.binding.epoch, self.binding.directory, *args) is not True: + raise GroupProvenanceUnavailable() + except Exception: + raise GroupProvenanceUnavailable() from None + + @contextmanager + def _transaction(self): + if self._poisoned: + raise GroupProvenanceUnavailable() + connection = None + tx = None + try: + connection = self.connect() + with connection: + with connection.cursor() as cursor: + cursor.execute("SET LOCAL statement_timeout = '5s'") + cursor.execute("SET LOCAL idle_in_transaction_session_timeout = '15s'") + cursor.execute("SELECT epoch::text, directory, head, head_digest, quarantined FROM identity_provisioner.group_journal_meta WHERE singleton = true FOR UPDATE") + meta = cursor.fetchone() + if not meta or meta[0] != self.binding.epoch or meta[1] != self.binding.directory or meta[4] is not False or type(meta[2]) is not int or meta[2] < 0 or not isinstance(meta[3], str) or not re.fullmatch(r"[0-9a-f]{64}", meta[3]): + raise GroupProvenanceUnavailable() + self._continuity("check", meta[2], meta[3]) + tx = {"cursor": cursor, "head": meta[2], "digest": meta[3], "dirty": False} + yield tx + if tx["dirty"]: + cursor.execute("UPDATE identity_provisioner.group_journal_meta SET head = head + 1, head_digest = %s WHERE singleton = true AND epoch = %s AND directory = %s AND head = %s AND head_digest = %s AND quarantined = false", + (tx["next_digest"], self.binding.epoch, self.binding.directory, tx["head"], tx["digest"])) + if cursor.rowcount != 1: + raise GroupProvenanceUnavailable() + # SQL commit precedes the independent checkpoint acknowledgment. + # A lost acknowledgment refuses before the next directory effect. + if tx and tx["dirty"]: + self._continuity("advance", tx["head"], tx["digest"], tx["head"] + 1, tx["next_digest"]) + except ValueError: + raise + except GroupProvenanceUnavailable: + # Caller mismatches are domain refusals. Storage/continuity failures + # poison this object; durable pending records are never rewritten + # automatically by an exception handler after ambiguous commit. + self._poisoned = True + raise + except Exception: + self._poisoned = True + raise GroupProvenanceUnavailable() from None + finally: + if connection is not None: + try: + connection.close() + except Exception: + self._poisoned = True + raise GroupProvenanceUnavailable() from None + + def check(self): + with self._transaction(): + pass + + def _read(self, cursor, key, request_binding): + cursor.execute("SELECT binding, version, body, context_json FROM identity_provisioner.group_receipts WHERE context_digest = %s AND key = %s", + (self.binding.digest, key)) + row = cursor.fetchone() + if row is None: + return None + if row[0] != request_binding: + raise ValueError("ensure receipt request binding mismatch") + if row[3] != json.dumps(asdict(self.binding), sort_keys=True): + raise GroupProvenanceUnavailable() + receipt = GroupProvenance._load(row[2], key, request_binding) + if type(row[1]) is not int or row[1] < 1 or type(receipt.get("_pg_version")) is not int or receipt.get("_pg_version") != row[1] or receipt.get("_pg_context") != self.binding.digest: + raise GroupProvenanceUnavailable() + return receipt + + @staticmethod + def _inputs(key, binding): + if not isinstance(key, str) or not 1 <= len(key) <= 256 or not isinstance(binding, str) or not re.fullmatch(r"[0-9a-f]{64}", binding): + raise ValueError("bounded exact journal request binding required") + + def _active(self, cursor): + cursor.execute("SELECT 1 FROM identity_provisioner.group_receipts WHERE active = true LIMIT 1") + if cursor.fetchone(): + raise GroupProvenanceUnavailable() + + def _transition(self, tx, receipt, operation): + # Bind the exact transition, not merely a counter. Two restored/forked + # copies that commit different bodies at the same next head cannot both + # match an independently retained checkpoint after reopening. + transition = {"schema": "nk-group-journal-transition.v1", "epoch": self.binding.epoch, + "directory": self.binding.directory, "prior_head": tx["head"], "prior_digest": tx["digest"], + "head": tx["head"] + 1, "operation": operation, "context": asdict(self.binding), + "key": receipt["key"], "request_binding": receipt["binding"], + "version": receipt["_pg_version"], "body": receipt} + tx["next_digest"] = hashlib.sha256(json.dumps(transition, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + tx["dirty"] = True + + def reserve(self, key, binding): + self._inputs(key, binding) + with self._transaction() as tx: + cursor = tx["cursor"] + receipt = self._read(cursor, key, binding) + if receipt is not None: + return receipt, False + self._active(cursor) + cursor.execute("SELECT COUNT(*) FROM identity_provisioner.group_receipts") + if cursor.fetchone()[0] >= self.capacity: + raise GroupProvenanceUnavailable() + receipt = {"key": key, "binding": binding, "state": "pending", "intent": None, "groups": {}, + "created_groups": [], "created_memberships": [], "_pg_version": 1, "_pg_context": self.binding.digest} + cursor.execute("INSERT INTO identity_provisioner.group_receipts (context_digest, context_json, key, binding, version, state, active, body) VALUES (%s, %s, %s, %s, 1, 'pending', true, %s)", + (self.binding.digest, json.dumps(asdict(self.binding), sort_keys=True), key, binding, json.dumps(receipt, sort_keys=True))) + if cursor.rowcount != 1: + raise GroupProvenanceUnavailable() + self._transition(tx, receipt, "reserve") + return receipt, True + + def get(self, key, binding): + self._inputs(key, binding) + with self._transaction() as tx: + receipt = self._read(tx["cursor"], key, binding) + if receipt is None: + raise ValueError("unknown ensure receipt or request binding mismatch") + return receipt + + def _write(self, tx, receipt, operation="save"): + self._inputs(receipt.get("key"), receipt.get("binding")) + if receipt.get("_pg_context") != self.binding.digest or type(receipt.get("_pg_version")) is not int or receipt["_pg_version"] < 1: + raise GroupProvenanceUnavailable() + encoded = json.dumps(receipt, sort_keys=True) + GroupProvenance._load(encoded, receipt["key"], receipt["binding"]) + updated = json.loads(encoded) + updated["_pg_version"] += 1 + active = True if receipt["state"] in {"pending", "revoking", "uncertain"} else None + tx["cursor"].execute("UPDATE identity_provisioner.group_receipts SET state = %s, active = %s, body = %s, version = version + 1 WHERE context_digest = %s AND key = %s AND binding = %s AND version = %s", + (receipt["state"], active, json.dumps(updated, sort_keys=True), self.binding.digest, receipt["key"], receipt["binding"], receipt["_pg_version"])) + if tx["cursor"].rowcount != 1: + raise GroupProvenanceUnavailable() + self._transition(tx, updated, operation) + return updated + + def save(self, receipt): + with self._transaction() as tx: + updated = self._write(tx, receipt) + receipt.clear() + receipt.update(updated) + + def claim_revoke(self, key, binding, delete_if_empty): + self._inputs(key, binding) + if not isinstance(delete_if_empty, bool): + raise ValueError("delete_if_empty must be boolean") + with self._transaction() as tx: + receipt = self._read(tx["cursor"], key, binding) + if receipt is None: + raise ValueError("unknown ensure receipt or request binding mismatch") + if receipt["state"] == "revoked": + if receipt["delete_if_empty"] != delete_if_empty: + raise ValueError("completed rollback options cannot change") + return receipt + if receipt["state"] != "ensured": + raise GroupProvenanceUnavailable() + self._active(tx["cursor"]) + receipt.update(state="revoking", delete_if_empty=delete_if_empty) + updated = self._write(tx, receipt, "claim_revoke") + return updated + + +def psycopg_connector(*, connection_info: Callable[[], dict[str, Any]]): + """Owner-provided renewable lease values stay in memory; never env/argv/logs. + + No connection happens here. Lazy import keeps disabled group endpoints free + of a runtime dependency. The admitted owner supplies TLS/host/db/role facts. + """ + if not callable(connection_info): + raise ValueError("owner connection-info callback required") + def connect(): + try: + import psycopg + values = dict(connection_info()) + return psycopg.connect(**values, connect_timeout=5) + except Exception: + raise GroupProvenanceUnavailable() from None + return connect diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/migrations/group_journal_postgres_v1.sql b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/migrations/group_journal_postgres_v1.sql new file mode 100644 index 00000000..caf1d757 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/migrations/group_journal_postgres_v1.sql @@ -0,0 +1,23 @@ +-- Run only by the admitted migration role in its existing owner schema. +-- Does not create a database/schema/credential, admit an epoch or clear quarantine. +CREATE TABLE identity_provisioner.group_journal_meta ( + singleton boolean PRIMARY KEY CHECK (singleton = true), + epoch uuid NOT NULL, + directory text NOT NULL CHECK (octet_length(directory) BETWEEN 1 AND 1024), + head bigint NOT NULL CHECK (head >= 0), + head_digest text NOT NULL CHECK (head_digest ~ '^[0-9a-f]{64}$'), + quarantined boolean NOT NULL DEFAULT true +); +CREATE TABLE identity_provisioner.group_receipts ( + context_digest text NOT NULL CHECK (context_digest ~ '^[0-9a-f]{64}$'), + context_json text NOT NULL CHECK (octet_length(context_json) <= 16384), + key text NOT NULL CHECK (char_length(key) BETWEEN 1 AND 256), + binding text NOT NULL CHECK (binding ~ '^[0-9a-f]{64}$'), + version bigint NOT NULL CHECK (version >= 1), + state text NOT NULL CHECK (state IN ('pending','ensured','revoking','revoked','uncertain','refused')), + active boolean UNIQUE CHECK (active IS NULL OR active = true), + body text NOT NULL CHECK (octet_length(body) <= 1048576), + PRIMARY KEY (context_digest, key) +); +-- Epoch insertion, initial continuity checkpoint and admission are separate +-- owner actions; this migration leaves all operations refused without them. diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/provisioner.py b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/provisioner.py new file mode 100644 index 00000000..28a3e5ae --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/provisioner.py @@ -0,0 +1,674 @@ +"""NetKingdom's idempotent LLDAP lifecycle adapter.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import hashlib +import re +import time +from typing import Any, Callable +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from group_provenance import GroupJournalStore, GroupProvenanceUnavailable + + +@dataclass(frozen=True) +class Result: + provider: str + external_subject: str + status: str + resumed: bool + + +@dataclass(frozen=True) +class GroupResult(Result): + ensure_idempotency_key: str + ensure_correlation_id: str + created_groups: tuple[dict[str, Any], ...] + created_memberships: tuple[dict[str, Any], ...] + + +@dataclass(frozen=True) +class DriftResult: + provider: str + external_subject: str + status: str + drift: tuple[str, ...] + changed: tuple[str, ...] = () + + +class DependencyFailure(RuntimeError): + """Directory dependency failed. Messages never carry credentials or upstream bodies.""" + + def __init__(self, code: str, *, dependency: str = "directory") -> None: + self.code = code + self.dependency = dependency + super().__init__("dependency_unavailable") + + def payload(self) -> dict[str, str]: + return { + "error": "dependency_unavailable", + "dependency": self.dependency, + "reason": self.code, + } + + +class LLDAPProvisioner: + def __init__( + self, + *, + base_url: str, + admin_password: str, + opener: Callable = urlopen, + auth_rejected_cooldown: float = 30.0, + clock: Callable[[], float] = time.monotonic, + group_provenance: GroupJournalStore | None = None, + ) -> None: + self.group_provenance = group_provenance + self.base_url = base_url.rstrip("/") + self.admin_password = admin_password + self.opener = opener + self.auth_rejected_cooldown = auth_rejected_cooldown + self.clock = clock + self._auth_rejected_until = 0.0 + + def provision(self, payload: dict[str, Any]) -> Result: + _required(payload, "user_id", "tenant", "primary_email", "idempotency_key", "correlation_id") + email = str(payload["primary_email"]).strip().lower() + username = _username(email, payload.get("preferred_username")) + token = self._login() + users, groups = self._directory(token) + existing = next((user for user in users if user.get("id") == username), None) + resumed = existing is not None + created = existing is None + if created: + self._gql(token, """ +mutation CreateUser($id: String!, $email: String!, $display: String!) { + createUser(user: {id: $id, email: $email, displayName: $display}) { id } +}""", { + "id": username, + "email": email, + "display": str(payload.get("display_name") or username), + }) + elif str(existing.get("email", "")).lower() != email: + raise ValueError("directory username collision") + roles = {str(role) for role in payload.get("roles", ())} + group_names = [f"{payload['tenant']}:users"] + if "tenant-admin" in roles: + group_names.append(f"{payload['tenant']}:admins") + try: + for name in group_names: + group_id = self._ensure_group(token, groups, name) + self._add_group(token, username, group_id) + except Exception: + if created: + try: + self._delete(token, username) + except Exception: + pass + raise + return Result("netkingdom-lldap", _oidc_subject(username), "password_setup_required", resumed) + + def tenant_access(self, payload: dict[str, Any]) -> Result: + """Change only the two role groups owned by the specified tenant.""" + _required(payload, "external_subject", "tenant", "idempotency_key", "correlation_id") + tenant = str(payload["tenant"]) + if not re.fullmatch(r"tenant:[a-z0-9][a-z0-9._-]*(?::[a-z0-9][a-z0-9._-]*)*", tenant): + raise ValueError("invalid tenant identifier") + if tenant == "tenant:platform:root": + raise ValueError("platform root is not a tenant access target") + if not isinstance(payload.get("enabled"), bool): + raise ValueError("enabled must be boolean") + roles = payload.get("roles", []) + if not isinstance(roles, (list, tuple)) or any(r not in {"user", "tenant-admin"} for r in roles): + raise ValueError("unsupported tenant role") + subject = _directory_username(str(payload["external_subject"])) + if not re.fullmatch(r"[A-Za-z0-9._-]+", subject): + raise ValueError("invalid directory subject") + token = self._login() + user = self._user(token, subject) + if user is None: + raise ValueError("login identity not found; create it before changing access") + managed = {f"{tenant}:users", f"{tenant}:admins"} + desired = {f"{tenant}:users"} if payload["enabled"] else set() + if payload["enabled"] and "tenant-admin" in roles: + desired.add(f"{tenant}:admins") + current = {str(g["displayName"]): int(g["id"]) for g in user.get("groups", ())} + groups = list(self._directory(token)[1]) + for name in sorted(desired - current.keys()): + self._add_group(token, subject, self._ensure_group(token, groups, name)) + for name in sorted((managed & current.keys()) - desired): + self._remove_group(token, subject, current[name]) + checked = self._user(token, subject) + if checked is None or ({g["displayName"] for g in checked.get("groups", ())} & managed) != desired: + raise RuntimeError("tenant access readback did not confirm the requested state") + return Result("netkingdom-lldap", _oidc_subject(subject), + "tenant_active" if payload["enabled"] else "tenant_disabled", False) + + def suspend(self, subject: str) -> Result: + subject = _directory_username(subject) + token = self._login() + _, groups = self._directory(token) + group_id = self._ensure_group(token, groups, "netkingdom-suspended") + self._add_group(token, subject, group_id) + return Result("netkingdom-lldap", subject, "suspended", False) + + def reactivate(self, subject: str) -> Result: + subject = _directory_username(subject) + token = self._login() + _, groups = self._directory(token) + group = next((item for item in groups if item.get("displayName") == "netkingdom-suspended"), None) + if group: + self._gql(token, """ +mutation Remove($userId: String!, $groupId: Int!) { + removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } +}""", {"userId": subject, "groupId": int(group["id"])}) + return Result("netkingdom-lldap", subject, "active", False) + + def deprovision(self, subject: str) -> Result: + subject = _directory_username(subject) + token = self._login() + if self._user(token, subject) is None: + return Result("netkingdom-lldap", subject, "deprovisioned", True) + self._delete(token, subject) + return Result("netkingdom-lldap", subject, "deprovisioned", False) + + def drift(self, payload: dict[str, Any]) -> DriftResult: + subject, email, desired_groups, desired_status = _desired(payload) + token = self._login() + user = self._user(token, subject) + drift = self._drift(user, email, desired_groups, desired_status, str(payload["tenant"])) + status = "in_sync" if not drift else "drifted" + return DriftResult("netkingdom-lldap", subject, status, tuple(drift)) + + def reconcile(self, payload: dict[str, Any]) -> DriftResult: + subject, email, desired_groups, desired_status = _desired(payload) + token = self._login() + user = self._user(token, subject) + changed: list[str] = [] + if user is None: + result = self.provision(payload) + changed.append("user:created") + subject = result.external_subject + token = self._login() + user = self._user(token, subject) + if user is None: + raise RuntimeError("directory reconciliation did not create the identity") + if str(user.get("email", "")).lower() != email: + raise ValueError("directory email drift requires explicit identity repair") + + groups = list(self._directory(token)[1]) + current = {str(item["displayName"]): int(item["id"]) for item in user.get("groups", ())} + tenant = str(payload["tenant"]) + managed = { + name for name in current + if name in {f"{tenant}:users", f"{tenant}:admins", "netkingdom-suspended"} + } + for name in sorted(desired_groups - managed): + self._add_group(token, subject, self._ensure_group(token, groups, name)) + changed.append(f"group:added:{name}") + for name in sorted(managed - desired_groups): + self._remove_group(token, subject, current[name]) + changed.append(f"group:removed:{name}") + + user = self._user(token, subject) + remaining = self._drift(user, email, desired_groups, desired_status, tenant) + status = "reconciled" if not remaining else "drifted" + return DriftResult("netkingdom-lldap", subject, status, tuple(remaining), tuple(changed)) + + def preflight(self) -> dict[str, str]: + """One login plus one directory read. Auth rejection is not retried during cooldown.""" + now = self.clock() + if now < self._auth_rejected_until: + raise DependencyFailure("auth_rejected") + try: + token = self._login(timeout=3) + self._gql(token, "query { groups { id } }", {}, timeout=3) + except DependencyFailure as exc: + if exc.code == "auth_rejected": + self._auth_rejected_until = now + self.auth_rejected_cooldown + raise + if self.group_provenance is not None: + self.group_provenance.check() + return {"status": "ready", "dependency": "directory", + "service_groups": "available" if self.group_provenance is not None else "disabled"} + + def _login(self, timeout: float = 10) -> str: + return directory_login( + base_url=self.base_url, + admin_password=self.admin_password, + opener=self.opener, + timeout=timeout, + ) + + def _group_store(self) -> GroupJournalStore: + store = self.group_provenance + if store is None: + raise GroupProvenanceUnavailable() + store.check() + return store + + @staticmethod + def _group_binding(groups, members, correlation_id) -> str: + # Original correlation is part of the exact ensure binding. + canonical = {"ensure_correlation_id": correlation_id, "groups": sorted(groups), "members": {g: sorted(members.get(g, [])) for g in sorted(groups)}} + return hashlib.sha256(json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + @staticmethod + def _group_result(receipt, *, resumed=False) -> GroupResult: + status = "groups_ensured" if receipt["state"] == "ensured" else ( + "groups_revoked_and_deleted" if receipt.get("deleted") else "groups_revoked") + return GroupResult("netkingdom-lldap", ",".join(receipt["requested_groups"]), status, resumed, + receipt["key"], receipt["ensure_correlation_id"], tuple(receipt["created_groups"]), tuple(receipt["created_memberships"])) + + def ensure_service_groups(self, payload: dict[str, Any]) -> GroupResult: + """Additive ensure with durable creation provenance, not new authority. + + Replays return the original result, never recreate removed state. Pending + or ambiguous calls survive restart and require independent owner recovery. + """ + groups, members = _service_group_request(payload) + store = self._group_store() + receipt, fresh = store.reserve(payload["idempotency_key"], self._group_binding(groups, members, payload["correlation_id"])) + if not fresh: + if receipt["state"] != "ensured": + raise RuntimeError("group provenance unresolved or already rolled back") + return self._group_result(receipt, resumed=True) + attempted = False + try: + token = self._login() + users, directory_groups = self._directory(token) + known = {str(u.get("id")) for u in users} + if {u for us in members.values() for u in us} - known: + raise ValueError("unknown directory users") + ids = {str(g["displayName"]): int(g["id"]) for g in directory_groups} + receipt["requested_groups"] = sorted(groups) + receipt["ensure_correlation_id"] = payload["correlation_id"] + # Complete prestate is persisted before the first directory mutation. + for name in groups: + gid = ids.get(name) + before = sorted(self._group_members(token, gid)) if gid is not None else [] + if len(before) + sum(len(g["before"]) for g in receipt["groups"].values()) > 4096: + raise ValueError("group prestate exceeds bounded provenance capacity") + receipt["groups"][name] = {"id": gid, "before": before, "after": sorted(set(before) | set(members.get(name, [])))} + store.save(receipt) + for name in groups: + state = receipt["groups"][name] + if state["id"] is None: + receipt["intent"] = {"operation": "create_group", "name": name} + store.save(receipt) + attempted = True + gid = self._ensure_group(token, directory_groups, name) + state["id"] = gid + # A successful creation response plus readback is needed. + checked = {str(g["displayName"]): int(g["id"]) for g in self._directory(token)[1]} + if checked.get(name) != gid or self._group_members(token, gid): + raise RuntimeError("created group readback differs") + receipt["created_groups"].append({"name": name, "id": gid}) + receipt["intent"] = None + store.save(receipt) + for username in members.get(name, []): + if username in state["before"]: + continue + receipt["intent"] = {"operation": "add_member", "name": name, "id": state["id"], "username": username} + store.save(receipt) + # Unlike the lifecycle helper, duplicate/racing additions are + # ambiguous, never accepted as proof that this attempt created it. + attempted = True + result = self._gql(token, "mutation Add($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }", + {"userId": username, "groupId": state["id"]}) + if result.get("addUserToGroup", {}).get("ok") is not True or username not in self._group_members(token, state["id"]): + raise RuntimeError("membership creation did not read back") + receipt["created_memberships"].append({"name": name, "id": state["id"], "username": username}) + receipt["intent"] = None + store.save(receipt) + self._check_group_postimage(token, receipt) + receipt["state"] = "ensured" + store.save(receipt) + except Exception: + receipt["state"] = "uncertain" if attempted else "refused" + receipt["failure_phase"] = "mutation_attempted" if attempted else "before_mutation" + store.save(receipt) + raise + return self._group_result(receipt) + + def _check_group_postimage(self, token, receipt) -> None: + ids = {str(g["displayName"]): int(g["id"]) for g in self._directory(token)[1]} + for name, state in receipt["groups"].items(): + if ids.get(name) != state["id"] or sorted(self._group_members(token, state["id"])) != state["after"]: + raise RuntimeError("group postimage changed; independent owner reconciliation required") + + def revoke_service_groups(self, payload: dict[str, Any]) -> GroupResult: + """Reverse confirmed creations from the owner journal only. + + Caller-supplied creation inventories confer no rights. This protects + preexisting state and refuses detected drift; native LLDAP has no CAS or + membership-generation token, so external writer isolation is still an + owner acceptance prerequisite (readback alone cannot exclude ABA). + """ + groups, members = _service_group_request(payload) + _required(payload, "ensure_idempotency_key", "ensure_correlation_id") + delete_if_empty = payload.get("delete_if_empty", False) + if not isinstance(delete_if_empty, bool): + raise ValueError("delete_if_empty must be true or false") + store = self._group_store() + receipt = store.claim_revoke(payload["ensure_idempotency_key"], self._group_binding(groups, members, payload["ensure_correlation_id"]), delete_if_empty) + if receipt["state"] == "revoked": + return self._group_result(receipt, resumed=True) + try: + token = self._login() + # Validate the entire operation before the first destructive call. + self._check_group_postimage(token, receipt) + for edge in receipt["created_memberships"]: + receipt["intent"] = {"operation": "remove_member", **edge} + store.save(receipt) + self._remove_group(token, edge["username"], edge["id"]) + expected = receipt["groups"][edge["name"]]["after"] + expected.remove(edge["username"]) + self._check_group_postimage(token, receipt) + receipt["intent"] = None + store.save(receipt) + receipt["deleted"] = [] + if delete_if_empty: + for group in receipt["created_groups"]: + # Only this attempt's groups, never a preexisting empty group. + self._check_group_postimage(token, receipt) + if self._group_members(token, group["id"]): + raise RuntimeError("created group is no longer empty") + receipt["intent"] = {"operation": "delete_group", **group} + store.save(receipt) + self._gql(token, "mutation Del($id: Int!) { deleteGroup(groupId: $id) { ok } }", {"id": group["id"]}) + if any(int(g["id"]) == group["id"] or g["displayName"] == group["name"] for g in self._directory(token)[1]): + raise RuntimeError("group deletion did not read back") + receipt["deleted"].append(group["name"]) + del receipt["groups"][group["name"]] + receipt["intent"] = None + store.save(receipt) + receipt["state"] = "revoked" + store.save(receipt) + except Exception: + receipt["state"] = "uncertain" + store.save(receipt) + raise + return self._group_result(receipt) + + def _group_members(self, token: str, group_id: int) -> list[str]: + value = self._gql(token, "query G($id: Int!) { group(groupId: $id) { users { id } } }", {"id": group_id}) + return [str(u["id"]) for u in (value.get("group") or {}).get("users", ())] + + def _directory(self, token: str) -> tuple[list[dict], list[dict]]: + value = self._gql(token, "query { users { id email displayName } groups { id displayName } }", {}) + return list(value["users"]), list(value["groups"]) + + def _user(self, token: str, subject: str) -> dict[str, Any] | None: + try: + value = self._gql(token, """ +query User($id: String!) { + user(userId: $id) { id email displayName groups { id displayName } } +}""", {"id": subject}) + except ValueError as exc: + if "not found" in str(exc).lower(): + return None + raise + user = value.get("user") + return dict(user) if user else None + + def _ensure_group(self, token: str, groups: list[dict], name: str) -> int: + existing = next((group for group in groups if group.get("displayName") == name), None) + if existing: + return int(existing["id"]) + created = self._gql( + token, + "mutation CreateGroup($name: String!) { createGroup(name: $name) { id displayName } }", + {"name": name}, + )["createGroup"] + groups.append(created) + return int(created["id"]) + + def _add_group(self, token: str, username: str, group_id: int) -> None: + try: + self._gql(token, """ +mutation Add($userId: String!, $groupId: Int!) { + addUserToGroup(userId: $userId, groupId: $groupId) { ok } +}""", {"userId": username, "groupId": group_id}) + except ValueError as exc: + if "already" not in str(exc).lower() and "unique" not in str(exc).lower(): + raise + + def _remove_group(self, token: str, username: str, group_id: int) -> None: + self._gql(token, """ +mutation Remove($userId: String!, $groupId: Int!) { + removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } +}""", {"userId": username, "groupId": group_id}) + + def _delete(self, token: str, subject: str) -> None: + self._gql( + token, + "mutation Delete($id: String!) { deleteUser(userId: $id) { ok } }", + {"id": subject}, + ) + + @staticmethod + def _drift( + user: dict[str, Any] | None, + email: str, + desired_groups: set[str], + desired_status: str, + tenant: str, + ) -> list[str]: + if user is None: + return ["user:missing"] + drift: list[str] = [] + if str(user.get("email", "")).lower() != email: + drift.append("email:mismatch") + current = {str(item["displayName"]) for item in user.get("groups", ())} + managed = { + name for name in current + if name in {f"{tenant}:users", f"{tenant}:admins", "netkingdom-suspended"} + } + drift.extend(f"group:missing:{name}" for name in sorted(desired_groups - managed)) + drift.extend(f"group:unexpected:{name}" for name in sorted(managed - desired_groups)) + suspended = "netkingdom-suspended" in current + if suspended != (desired_status == "suspended"): + drift.append("status:mismatch") + return drift + + def _gql(self, token: str, query: str, variables: dict[str, Any], timeout: float = 15) -> dict: + request = Request( + self.base_url + "/api/graphql", + data=json.dumps({"query": query, "variables": variables}).encode(), + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with self.opener(request, timeout=timeout) as response: + payload = json.loads(response.read()) + except HTTPError as exc: + status = int(getattr(exc, "code", 0) or 0) + _discard(exc) + if status in {401, 403}: + raise DependencyFailure("auth_rejected") from None + raise DependencyFailure("protocol_error") from None + except (TimeoutError, URLError, OSError): + raise DependencyFailure("unreachable") from None + except (json.JSONDecodeError, TypeError, ValueError, UnicodeDecodeError): + raise DependencyFailure("protocol_error") from None + if payload.get("errors"): + raise ValueError(str(payload["errors"][0].get("message", "LLDAP GraphQL error"))) + return dict(payload.get("data") or {}) + + +def directory_login( + *, + base_url: str, + admin_password: str, + opener: Callable = urlopen, + timeout: float = 10, +) -> str: + request = Request( + base_url.rstrip("/") + "/auth/simple/login", + data=json.dumps({"username": "admin", "password": admin_password}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with opener(request, timeout=timeout) as response: + payload = json.loads(response.read()) + except HTTPError as exc: + status = int(getattr(exc, "code", 0) or 0) + _discard(exc) + if status in {401, 403}: + raise DependencyFailure("auth_rejected") from None + raise DependencyFailure("protocol_error") from None + except (TimeoutError, URLError, OSError): + raise DependencyFailure("unreachable") from None + except (json.JSONDecodeError, TypeError, ValueError, UnicodeDecodeError): + raise DependencyFailure("protocol_error") from None + if not isinstance(payload, dict): + raise DependencyFailure("protocol_error") + token = str(payload.get("token") or "") + if not token: + raise DependencyFailure("protocol_error") + return token + + +def _discard(exc: BaseException) -> None: + read = getattr(exc, "read", None) + if callable(read): + try: + read(65536) + except Exception: + pass + + +SERVICE_GROUP = re.compile(r"[a-z][a-z0-9-]{1,62}") +RESERVED_GROUP_PREFIXES = ("tenant", "netkingdom-", "net-kingdom-", "lldap_") +DIRECTORY_USERNAME = re.compile(r"[a-z][a-z0-9._-]{0,31}") + + +def _service_group_request(payload: dict[str, Any]) -> tuple[list[str], dict[str, list[str]]]: + """Validate a service-access group request before anything is changed. + + Service-access groups gate sign-in to an internal service (SecurityCaseType + oidc-browser-relying-party). Tenant, platform identity/administration, + platform-suspension and LLDAP admin groups are reserved and can never be + touched through this path. + """ + if not isinstance(payload, dict): + raise ValueError("service group request must be an object") + _required(payload, "groups", "idempotency_key", "correlation_id") + allowed = {"groups", "members", "idempotency_key", "correlation_id", "delete_if_empty", "ensure_idempotency_key", "ensure_correlation_id"} + if set(payload) - allowed: + raise ValueError("unsupported service group request fields") + if not isinstance(payload["idempotency_key"], str) or len(payload["idempotency_key"]) > 256: + raise ValueError("invalid idempotency key") + if "ensure_idempotency_key" in payload and (not isinstance(payload["ensure_idempotency_key"], str) or not 16 <= len(payload["ensure_idempotency_key"]) <= 256): + raise ValueError("invalid ensure idempotency key") + for key in ("correlation_id", "ensure_correlation_id"): + if key in payload and (not isinstance(payload[key], str) or not 1 <= len(payload[key]) <= 256): + raise ValueError("invalid correlation identifier") + groups = payload["groups"] + if not isinstance(groups, list) or not groups or len(groups) > 16: + raise ValueError("groups must be a list of 1 to 16 names") + for name in groups: + if not isinstance(name, str) or not SERVICE_GROUP.fullmatch(name): + raise ValueError(f"invalid service group name {name!r}") + if name.startswith(RESERVED_GROUP_PREFIXES): + raise ValueError(f"group {name!r} is reserved") + members = payload.get("members", {}) + if not isinstance(members, dict): + raise ValueError("members must map group names to usernames") + total_members = 0 + for group, users in members.items(): + if group not in groups: + raise ValueError(f"members names group {group!r}, which is not in groups") + if not isinstance(users, list) or not all(isinstance(u, str) and DIRECTORY_USERNAME.fullmatch(u) for u in users): + raise ValueError(f"members of {group!r} must be directory usernames") + total_members += len(users) + if total_members > 256: + raise ValueError("at most 256 service memberships per request") + return list(dict.fromkeys(groups)), {g: list(dict.fromkeys(u)) for g, u in members.items()} + + +def dispatch( + provisioner: LLDAPProvisioner, path: str, payload: dict[str, Any] +) -> Result | DriftResult: + if path == "/v1/groups/ensure": + return provisioner.ensure_service_groups(payload) + if path == "/v1/groups/revoke": + return provisioner.revoke_service_groups(payload) + if path == "/v1/identities/tenant-access": + return provisioner.tenant_access(payload) + if path == "/v1/identities/provision": + return provisioner.provision(payload) + if path == "/v1/identities/drift": + return provisioner.drift(payload) + if path == "/v1/identities/reconcile": + return provisioner.reconcile(payload) + _required(payload, "external_subject", "idempotency_key", "correlation_id") + subject = _directory_username(str(payload["external_subject"])) + if path == "/v1/identities/suspend": + return provisioner.suspend(subject) + if path == "/v1/identities/reactivate": + return provisioner.reactivate(subject) + if path == "/v1/identities/deprovision": + return provisioner.deprovision(subject) + raise KeyError(path) + + +def _username(email: str, preferred: object = None) -> str: + if preferred is not None: + value = str(preferred).strip().lower() + if not re.fullmatch(r"[a-z][a-z0-9._-]{2,31}", value): + raise ValueError("preferred_username is invalid") + if value in {"admin", "administrator", "platform-root", "root", "system"}: + raise ValueError("preferred_username is reserved") + return value + local = email.partition("@")[0].lower() + value = re.sub(r"[^a-z0-9._-]+", "-", local).strip("-") + if not value or "@" not in email: + raise ValueError("valid primary_email is required") + return value[:64] + + +def _required(payload: dict[str, Any], *fields: str) -> None: + missing = [field for field in fields if not payload.get(field)] + if missing: + raise ValueError("missing required fields: " + ", ".join(missing)) + if len(str(payload.get("idempotency_key", ""))) < 16: + raise ValueError("idempotency_key must contain at least 16 characters") + + +def _desired(payload: dict[str, Any]) -> tuple[str, str, set[str], str]: + _required( + payload, + "external_subject", + "tenant", + "primary_email", + "idempotency_key", + "correlation_id", + ) + subject = str(payload["external_subject"]) + email = str(payload["primary_email"]).strip().lower() + if _username(email) != subject: + raise ValueError("external_subject does not match canonical email username") + desired_status = str(payload.get("desired_status", "active")) + if desired_status not in {"active", "suspended"}: + raise ValueError("desired_status must be active or suspended") + tenant = str(payload["tenant"]) + groups = {f"{tenant}:users"} + if "tenant-admin" in {str(role) for role in payload.get("roles", ())}: + groups.add(f"{tenant}:admins") + if desired_status == "suspended": + groups.add("netkingdom-suspended") + return subject, email, groups, desired_status + + +def _directory_username(subject: str) -> str: + return subject[4:].split(",", 1)[0] if subject.startswith("uid=") else subject + + +def _oidc_subject(username: str) -> str: + return f"uid={username},ou=people,dc=netkingdom,dc=local" diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/requirements-postgres.txt b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/requirements-postgres.txt new file mode 100644 index 00000000..9e614f64 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/requirements-postgres.txt @@ -0,0 +1,5 @@ +# Linux amd64 CPython3.12 runtime closure from user-engine/uv.lock. +# Build-only dependency resolution; no packages are installed at service startup. +psycopg==3.3.5 --hash=sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e +psycopg-binary==3.3.5 --hash=sha256:682a17a57415c3ca1731eec018ed031f012ffcb81ba74806eb219cb396065672 +typing-extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/tests/test_group_postgres.py b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/tests/test_group_postgres.py new file mode 100644 index 00000000..f9cd76ff --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/postimage/identity-provisioner/tests/test_group_postgres.py @@ -0,0 +1,192 @@ +"""Binding, CAS and ambiguous-commit refusal tests; native SQL proof is separate.""" +from dataclasses import replace +import json +from unittest.mock import Mock +import unittest + +from group_provenance import GroupProvenanceUnavailable +from group_provenance_postgres import GroupJournalBinding, PostgresGroupProvenance +from server import configured_group_provenance +from unittest.mock import patch +import os + +EPOCH = '41fbb0ed-ea3a-4682-996b-e9c94a6aa9c6' +BINDING = GroupJournalBinding('tenant:fixture:one', 'synthetic-directory', 'sha256:' + '1' * 64, + 'sha256:' + '2' * 64, 'synthetic-owner', EPOCH) +REQUEST = '3' * 64 +HEAD_DIGEST = 'a' * 64 +META = (EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, False) + + +def fixture(rows=(), rowcount=1, commit_error=None): + cursor = Mock() + cursor.__enter__ = Mock(return_value=cursor) + cursor.__exit__ = Mock(return_value=False) + cursor.fetchone.side_effect = rows + cursor.rowcount = rowcount + connection = Mock() + connection.__enter__ = Mock(return_value=connection) + connection.__exit__ = Mock(return_value=False, side_effect=commit_error) + connection.cursor.return_value = cursor + connector = Mock(return_value=connection) + continuity = Mock() + continuity.check.return_value = True + continuity.advance.return_value = True + store = PostgresGroupProvenance(connect=connector, binding=BINDING, continuity=continuity) + return store, connector, connection, cursor, continuity + + +def receipt(version=4): + return {'key': 'synthetic-operation-key', 'binding': REQUEST, 'state': 'pending', 'intent': None, + 'groups': {}, 'created_groups': [], 'created_memberships': [], + '_pg_version': version, '_pg_context': BINDING.digest} + + +class PostgresSourceTests(unittest.TestCase): + def test_explicit_binding_and_continuity_required_without_connecting(self): + connector = Mock() + for binding, continuity in ((None, Mock()), (BINDING, None), ({'tenant_scope': 'caller-json'}, Mock())): + with self.assertRaises(ValueError): + PostgresGroupProvenance(connect=connector, binding=binding, continuity=continuity) + connector.assert_not_called() + for change in ({'tenant_scope': ''}, {'principal': ''}, {'case_digest': 'unsigned'}, {'epoch': 'default'}): + with self.assertRaises(ValueError): + replace(BINDING, **change) + self.assertNotEqual(BINDING.digest, replace(BINDING, principal='another-owner').digest) + self.assertNotEqual(BINDING.digest, replace(BINDING, tenant_scope='another-explicit-scope').digest) + + def test_missing_environment_callback_path_keeps_production_disabled(self): + with patch.dict(os.environ, {'GROUP_POSTGRES_DSN': 'synthetic-only', 'GROUP_PROVENANCE_POSTGRES': 'true'}, clear=True): + self.assertIsNone(configured_group_provenance()) + + def test_checkpoint_mismatch_poisoned_without_receipt_query_or_effect(self): + store, connect, _, cursor, checkpoint = fixture([META]) + checkpoint.check.return_value = False + with self.assertRaises(GroupProvenanceUnavailable): + store.reserve('synthetic-operation-key', REQUEST) + self.assertFalse(any('group_receipts' in c.args[0] for c in cursor.execute.call_args_list)) + with self.assertRaises(GroupProvenanceUnavailable): + store.check() + self.assertEqual(1, connect.call_count) + checkpoint.advance.assert_not_called() + + def test_restored_epoch_or_quarantine_refused(self): + for meta in ((EPOCH, 'other-directory', 7, HEAD_DIGEST, False), (EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, True), + ('98aa2b6d-a32a-44a5-a89f-b2480befc00e', 'synthetic-directory', 7, HEAD_DIGEST, False), None): + store, _, _, _, checkpoint = fixture([meta]) + with self.assertRaises(GroupProvenanceUnavailable): + store.check() + checkpoint.check.assert_not_called() + + def test_exactly_one_version_update_required_before_checkpoint_advance(self): + store, _, _, cursor, checkpoint = fixture([META], rowcount=0) + original = receipt() + with self.assertRaises(GroupProvenanceUnavailable): + store.save(original) + query, params = next(c.args for c in cursor.execute.call_args_list if c.args[0].startswith('UPDATE identity_provisioner.group_receipts')) + self.assertIn('AND version = %s', query) + self.assertEqual(4, params[-1]) + self.assertEqual(4, original['_pg_version']) + checkpoint.advance.assert_not_called() + + def test_success_acknowledges_commit_and_updates_receipt_version(self): + store, _, connection, _, checkpoint = fixture([META]) + original = receipt() + store.save(original) + self.assertEqual(5, original['_pg_version']) + connection.__exit__.assert_called_once() + args = checkpoint.advance.call_args.args + self.assertEqual((EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, 8), args[:5]) + self.assertRegex(args[5], r'^[0-9a-f]{64}$') + self.assertNotEqual(HEAD_DIGEST, args[5]) + connection.close.assert_called_once() + + def test_connection_close_failure_is_sanitized_and_stops_continuation(self): + store, _, connection, _, _ = fixture([META]) + connection.close.side_effect = OSError('synthetic-private-diagnostic') + with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): + store.check() + self.assertTrue(store._poisoned) + + def test_commit_failure_never_advances_owner_checkpoint_or_receipt(self): + store, _, _, _, checkpoint = fixture([META], commit_error=OSError('synthetic-secret-diagnostic')) + original = receipt() + with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): + store.save(original) + self.assertEqual(4, original['_pg_version']) + checkpoint.advance.assert_not_called() + + def test_lost_checkpoint_acknowledgment_refuses_then_poisoned(self): + store, connect, _, _, checkpoint = fixture([META]) + checkpoint.advance.side_effect = OSError('synthetic-secret-diagnostic') + original = receipt() + with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): + store.save(original) + self.assertEqual(4, original['_pg_version']) + with self.assertRaises(GroupProvenanceUnavailable): + store.save(original) + self.assertEqual(1, connect.call_count) + + def test_cross_case_body_or_version_cannot_be_imported(self): + for body in (dict(receipt(), _pg_context='0' * 64), dict(receipt(), _pg_version=6)): + store, _, _, _, _ = fixture([META, (REQUEST, 4, json.dumps(body), json.dumps(BINDING.__dict__, sort_keys=True))]) + with self.assertRaises(GroupProvenanceUnavailable): + store.get('synthetic-operation-key', REQUEST) + + def test_same_counter_divergent_body_or_context_yields_distinct_marker(self): + store, *_ = fixture() + markers = [] + for change in ({}, {'intent': {'operation': 'different'}}, {'key': 'another-exact-key'}, {'_pg_version': 5}): + tx = {'head': 7, 'digest': HEAD_DIGEST} + store._transition(tx, dict(receipt(), **change), 'save') + markers.append(tx['next_digest']) + self.assertEqual(4, len(set(markers))) + tx = {'head': 7, 'digest': 'b' * 64} + store._transition(tx, receipt(), 'save') + self.assertNotIn(tx['next_digest'], markers) + foreign, *_ = fixture() + foreign.binding = replace(BINDING, principal='another-owner') + tx = {'head': 7, 'digest': HEAD_DIGEST} + foreign._transition(tx, receipt(), 'save') + self.assertNotIn(tx['next_digest'], markers) + + def test_reopened_loser_clone_at_same_head_refuses_different_marker(self): + # Both DBs report head8. The independent callback accepts only winner + # digest; loser must be refused before loading any receipt or effect. + loser_digest = 'b' * 64 + store, _, _, cursor, checkpoint = fixture([(EPOCH, 'synthetic-directory', 8, loser_digest, False)]) + checkpoint.check.side_effect = lambda epoch, directory, head, digest: (head, digest) == (8, 'c' * 64) + with self.assertRaises(GroupProvenanceUnavailable): + store.check() + checkpoint.check.assert_called_once_with(EPOCH, 'synthetic-directory', 8, loser_digest) + self.assertFalse(any('group_receipts' in c.args[0] for c in cursor.execute.call_args_list)) + + def test_existing_request_mismatch_is_refused_not_rebound(self): + store, _, _, _, checkpoint = fixture([META, ('0' * 64, 4, '{}', '{}')]) + with self.assertRaisesRegex(ValueError, 'binding mismatch'): + store.get('synthetic-operation-key', REQUEST) + checkpoint.advance.assert_not_called() + self.assertFalse(store._poisoned) + + def test_active_fence_capacity_refusal_and_no_insert(self): + for rows in ([META, None, (1,)], [META, None, None, (256,)]): + store, _, _, cursor, checkpoint = fixture(rows) + with self.assertRaises(GroupProvenanceUnavailable): + store.reserve('synthetic-operation-key', REQUEST) + self.assertFalse(any('INSERT' in c.args[0] for c in cursor.execute.call_args_list)) + checkpoint.advance.assert_not_called() + + def test_new_reservation_retains_bound_context_and_write_intent(self): + store, _, _, cursor, checkpoint = fixture([META, None, None, (0,)]) + created, fresh = store.reserve('synthetic-operation-key', REQUEST) + self.assertTrue(fresh) + self.assertEqual('pending', created['state']) + self.assertEqual(BINDING.digest, created['_pg_context']) + insert = next(c for c in cursor.execute.call_args_list if c.args[0].startswith('INSERT')) + self.assertIn('context_json', insert.args[0]) + self.assertEqual(BINDING.tenant_scope, json.loads(insert.args[1][1])['tenant_scope']) + checkpoint.advance.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/source-manifest.json b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/source-manifest.json new file mode 100644 index 00000000..3c80043f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/source-manifest.json @@ -0,0 +1,65 @@ +{ + "base_revision": "6cb43c0cc55212363a5406b02db046e2384a7c40", + "base_tree": "615095596348b23ad34da79cd0e8597cdb815c5d", + "scope": "source successor with content-bound continuity; no native/canonical admission", + "files": [ + { + "path": "docs/service-group-provenance.md", + "preimage_sha256": "cd6b45640983b11e29a6bc6278bcb45de8fd8a5ddcad53eb77ab1a1f3f1f1280", + "postimage_sha256": "b287e7a85aa2146f35d2bc8473dfd05239ea0179b7b8b186d2e85bc07c7b650c", + "preimage_bytes": 7455, + "postimage_bytes": 11401 + }, + { + "path": "identity-provisioner/Containerfile", + "preimage_sha256": "c744f39ffb4c0dc2686c3e94d8233bc772b55faae9581557bee17f91c19d9ca6", + "postimage_sha256": "31e7d948d4f40efd6950764a3dbb8c410d50ed7a1c13c18e0830a1186f2abafc", + "preimage_bytes": 459, + "postimage_bytes": 651 + }, + { + "path": "identity-provisioner/group_provenance.py", + "preimage_sha256": "fbd4c88d7682ba3d46c5b325dad1bc4f4a7faac2cd3f6ce9e0f7a89283bf46d4", + "postimage_sha256": "405e08eaaab7c21d618dff4db5970607806f32ab6c461effb81c737e32182b10", + "preimage_bytes": 8947, + "postimage_bytes": 9409 + }, + { + "path": "identity-provisioner/group_provenance_postgres.py", + "preimage_sha256": null, + "postimage_sha256": "55282af26f95af66ff8d444d38d0bcbcf3ee58a615adedfff8da5db2b0716889", + "preimage_bytes": 0, + "postimage_bytes": 13176 + }, + { + "path": "identity-provisioner/migrations/group_journal_postgres_v1.sql", + "preimage_sha256": null, + "postimage_sha256": "3cdf82194d6f1e2dd865b0c7e7b8a14447fda8c49151148134a0d40a80fb46ac", + "preimage_bytes": 0, + "postimage_bytes": 1353 + }, + { + "path": "identity-provisioner/provisioner.py", + "preimage_sha256": "2f8e4368e7155465bbc4070b1019c7105edf43bdc2ad3bbc5aa9bc19dc6ed7a5", + "postimage_sha256": "19d1b837cbafeeacb1aa815856cd68091e6702281d72b1d860230fa320e1725b", + "preimage_bytes": 31721, + "postimage_bytes": 31727 + }, + { + "path": "identity-provisioner/requirements-postgres.txt", + "preimage_sha256": null, + "postimage_sha256": "14f1beae54a789d2c880654ca0ce4094c9aa54645e79314b4297c12938ba8877", + "preimage_bytes": 0, + "postimage_bytes": 450 + }, + { + "path": "identity-provisioner/tests/test_group_postgres.py", + "preimage_sha256": null, + "postimage_sha256": "6fdf86b7158ce263b141265346d2910c074a8ae5483417c961053c7792db96c0", + "preimage_bytes": 0, + "postimage_bytes": 10173 + } + ], + "patch_sha256": "503e3d0217e6be25df8a58f3968cab7c00cdd9933d0fee2a66ef951fc4aae2b1", + "superseded_counter_only": "review-r0" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/source.patch b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/source.patch new file mode 100644 index 00000000..81698fb8 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r3/source.patch @@ -0,0 +1,626 @@ +diff --git a/docs/service-group-provenance.md b/docs/service-group-provenance.md +--- a/docs/service-group-provenance.md ++++ b/docs/service-group-provenance.md +@@ -80,6 +80,64 @@ + and replay. No arbitrary JSON import, receipt expiry or automatic uncertainty + clearing is introduced. Owner reconciliation needs independent evidence. + ++## PostgreSQL source seam (not native admission) ++ ++`PostgresGroupProvenance` implements the same reserve/get/claim-revoke/save/check ++journal contract. It is injection-only: the ordinary HTTP server has no ++PostgreSQL environment switch and remains without a group store unless the ++existing local SQLite configuration is explicitly supplied. `GROUP_POSTGRES_DSN` ++or a caller JSON object cannot enable the PostgreSQL path. Production group ++operations remain unadmitted and disabled pending the owner callbacks and gates ++above; a backend constructor is not an access decision. ++ ++A trusted owner integration must provide an exact `GroupJournalBinding` with ++opaque authoritative tenant scope, directory instance, accepted case digest, ++source digest, represented principal and journal epoch. There is no guessed ++platform-root scope or request-derived tenant fallback. The owner also injects a ++renewable private connection-info callback using the existing `rapp-postgres` ++consumer/lease contract, and independently durable `JournalContinuity` callbacks. ++These callbacks retain/check/advance exact epoch+directory+head+commit digest outside the ++PostgreSQL state that might be restored. Neither callback authenticates a caller ++or grants a protected side effect. Their native custody, source binding, ++revocation and execution bounds still require independent admission. Only the ++disposable fixture supplies a test checkpoint implementation here. ++ ++The v1 migration under `identity-provisioner/migrations/` creates tables within ++the existing consumer schema; it creates no database/schema/roles, inserts no ++epoch and clears no quarantine. Startup does not run migrations or initialize ++missing state. The owner must separately admit an epoch/checkpoint after ++independent continuity/recovery review; absent or quarantined state refuses. ++A restored database with an older head than the independently retained checkpoint ++also refuses rather than treating missing later intents as authority to replay. ++ ++PostgreSQL transactions lock the single journal metadata row, commit bounded ++receipts/intents and advance the head and exact transition digest with an exactly-one conditional ++update. The digest binds the prior marker, canonical body, context, request key ++and receipt version; the independent checkpoint must compare/CAS both content ++and sequence, never accept a counter-only callback. Divergent restored clones ++that commit different transitions at the same counter cannot both be accepted. ++Every receipt update also requires its exact context/key/request/version and ++increments its version. The active slot spans all contexts in the journal; ++it serializes cooperating producer operations, not external LDAP writers. ++SQL commit precedes independently durable checkpoint acknowledgment. A lost ++commit/acknowledgment, stale version, missing row or continuity mismatch refuses ++and poisons that backend object; exception handling cannot automatically rewrite ++its journal into success. Known request-binding mismatches remain domain ++refusals. Definite before-mutation refusal records can release the active slot ++while the original key remains terminal. ++ ++The container source includes a hash-pinned Linux amd64 CPython3.12 psycopg3 ++closure selected from User Engine's existing lockfile. No image build or package ++installation is part of this source proof; build/publication, artifact scanning ++and native runtime/dependency admission remain owner work. SQLite is still ++local/disposable only. The separate PostgreSQL fixture proposal uses a cached ++pinned PostgreSQL image and a cached pinned User Engine Python image, real SQL, ++separate-process reopen, concurrency, stale-CAS and actual pg_dump/pg_restore ++behind an independently retained test checkpoint. That fixture needs exact ++review before Docker execution and does not prove live CNPG HA, TLS/lease ++custody, tenant authority, external writer fencing, production retention or ++protected session/evidence integration. ++ + ## Safety and remaining native acceptance + + Before each directory mutation, durable intent is committed. A successful +diff --git a/identity-provisioner/Containerfile b/identity-provisioner/Containerfile +--- a/identity-provisioner/Containerfile ++++ b/identity-provisioner/Containerfile +@@ -3,7 +3,10 @@ + RUN useradd --create-home --uid 10001 provisioner + WORKDIR /app + COPY --from=lldap /app/lldap_set_password /app/lldap_set_password +-COPY provisioner.py group_provenance.py password_setup.py server.py /app/ ++COPY requirements-postgres.txt /app/ ++RUN python -m pip install --no-cache-dir --require-hashes -r /app/requirements-postgres.txt ++COPY provisioner.py group_provenance.py group_provenance_postgres.py password_setup.py server.py /app/ ++COPY migrations/ /app/migrations/ + USER 10001:10001 + EXPOSE 8080 + CMD ["python", "server.py"] +diff --git a/identity-provisioner/group_provenance.py b/identity-provisioner/group_provenance.py +--- a/identity-provisioner/group_provenance.py ++++ b/identity-provisioner/group_provenance.py +@@ -11,8 +11,17 @@ + from pathlib import Path + import sqlite3 + import stat +-from typing import Any ++from typing import Any, Protocol + from urllib.parse import quote ++ ++ ++class GroupJournalStore(Protocol): ++ """Owner-injected persistence only; grants no caller or native authority.""" ++ def check(self) -> None: ... ++ def reserve(self, key: str, binding: str) -> tuple[dict[str, Any], bool]: ... ++ def get(self, key: str, binding: str) -> dict[str, Any]: ... ++ def claim_revoke(self, key: str, binding: str, delete_if_empty: bool) -> dict[str, Any]: ... ++ def save(self, receipt: dict[str, Any]) -> None: ... + + + class GroupProvenanceUnavailable(RuntimeError): +diff --git a/identity-provisioner/group_provenance_postgres.py b/identity-provisioner/group_provenance_postgres.py +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/group_provenance_postgres.py +@@ -0,0 +1,251 @@ ++"""Injection-only PostgreSQL implementation of the existing owner journal seam. ++ ++Binding and continuity callbacks are supplied by a trusted owner integration, ++never constructed from an HTTP payload. Neither is an authorization decision. ++No server environment switch, automatic schema creation or recovery admission. ++""" ++from __future__ import annotations ++ ++from contextlib import contextmanager ++from dataclasses import asdict, dataclass ++import hashlib ++import json ++import re ++from typing import Any, Callable, Protocol ++from uuid import UUID ++ ++from group_provenance import GroupProvenance, GroupProvenanceUnavailable ++ ++ ++@dataclass(frozen=True) ++class GroupJournalBinding: ++ tenant_scope: str ++ directory: str ++ case_digest: str ++ source_digest: str ++ principal: str ++ epoch: str ++ ++ def __post_init__(self): ++ for value in (self.tenant_scope, self.directory, self.principal): ++ if not isinstance(value, str) or not 1 <= len(value) <= 256 or any(ord(c) < 32 for c in value): ++ raise ValueError("explicit owner journal binding required") ++ for value in (self.case_digest, self.source_digest): ++ if not isinstance(value, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", value): ++ raise ValueError("exact owner source and case digests required") ++ if not isinstance(self.epoch, str) or str(UUID(self.epoch)) != self.epoch: ++ raise ValueError("explicit canonical journal epoch required") ++ ++ @property ++ def digest(self): ++ return hashlib.sha256(json.dumps(asdict(self), sort_keys=True, separators=(",", ":")).encode()).hexdigest() ++ ++ ++def journal_genesis(epoch: str, directory: str) -> str: ++ """Deterministic initial marker for explicit owner migration/admission only.""" ++ return hashlib.sha256(json.dumps({"schema": "nk-group-journal-genesis.v1", "epoch": epoch, ++ "directory": directory}, sort_keys=True, separators=(",", ":")).encode()).hexdigest() ++ ++ ++class JournalContinuity(Protocol): ++ """Existing independently retained owner checkpoint, outside restored SQL state. ++ ++ Implementations compare/advance exact epoch+directory+head+commit digest atomically and ++ durably. An in-memory implementation is a fixture only. Missing/stale or ++ ambiguous checkpoints must refuse; no callback grants mutation authority. ++ """ ++ def check(self, epoch: str, directory: str, head: int, digest: str) -> bool: ... ++ def advance(self, epoch: str, directory: str, previous: int, previous_digest: str, current: int, current_digest: str) -> bool: ... ++ ++ ++class PostgresGroupProvenance: ++ def __init__(self, *, connect: Callable, binding: GroupJournalBinding, ++ continuity: JournalContinuity, capacity: int = 256): ++ if not callable(connect) or not isinstance(binding, GroupJournalBinding) or not 1 <= capacity <= 4096: ++ raise ValueError("explicit owner connector, binding and bounded capacity required") ++ if not callable(getattr(continuity, "check", None)) or not callable(getattr(continuity, "advance", None)): ++ raise ValueError("independent owner continuity callbacks required") ++ self.connect, self.binding, self.continuity, self.capacity = connect, binding, continuity, capacity ++ self._poisoned = False ++ ++ def _continuity(self, method, *args): ++ try: ++ if getattr(self.continuity, method)(self.binding.epoch, self.binding.directory, *args) is not True: ++ raise GroupProvenanceUnavailable() ++ except Exception: ++ raise GroupProvenanceUnavailable() from None ++ ++ @contextmanager ++ def _transaction(self): ++ if self._poisoned: ++ raise GroupProvenanceUnavailable() ++ connection = None ++ tx = None ++ try: ++ connection = self.connect() ++ with connection: ++ with connection.cursor() as cursor: ++ cursor.execute("SET LOCAL statement_timeout = '5s'") ++ cursor.execute("SET LOCAL idle_in_transaction_session_timeout = '15s'") ++ cursor.execute("SELECT epoch::text, directory, head, head_digest, quarantined FROM identity_provisioner.group_journal_meta WHERE singleton = true FOR UPDATE") ++ meta = cursor.fetchone() ++ if not meta or meta[0] != self.binding.epoch or meta[1] != self.binding.directory or meta[4] is not False or type(meta[2]) is not int or meta[2] < 0 or not isinstance(meta[3], str) or not re.fullmatch(r"[0-9a-f]{64}", meta[3]): ++ raise GroupProvenanceUnavailable() ++ self._continuity("check", meta[2], meta[3]) ++ tx = {"cursor": cursor, "head": meta[2], "digest": meta[3], "dirty": False} ++ yield tx ++ if tx["dirty"]: ++ cursor.execute("UPDATE identity_provisioner.group_journal_meta SET head = head + 1, head_digest = %s WHERE singleton = true AND epoch = %s AND directory = %s AND head = %s AND head_digest = %s AND quarantined = false", ++ (tx["next_digest"], self.binding.epoch, self.binding.directory, tx["head"], tx["digest"])) ++ if cursor.rowcount != 1: ++ raise GroupProvenanceUnavailable() ++ # SQL commit precedes the independent checkpoint acknowledgment. ++ # A lost acknowledgment refuses before the next directory effect. ++ if tx and tx["dirty"]: ++ self._continuity("advance", tx["head"], tx["digest"], tx["head"] + 1, tx["next_digest"]) ++ except ValueError: ++ raise ++ except GroupProvenanceUnavailable: ++ # Caller mismatches are domain refusals. Storage/continuity failures ++ # poison this object; durable pending records are never rewritten ++ # automatically by an exception handler after ambiguous commit. ++ self._poisoned = True ++ raise ++ except Exception: ++ self._poisoned = True ++ raise GroupProvenanceUnavailable() from None ++ finally: ++ if connection is not None: ++ try: ++ connection.close() ++ except Exception: ++ self._poisoned = True ++ raise GroupProvenanceUnavailable() from None ++ ++ def check(self): ++ with self._transaction(): ++ pass ++ ++ def _read(self, cursor, key, request_binding): ++ cursor.execute("SELECT binding, version, body, context_json FROM identity_provisioner.group_receipts WHERE context_digest = %s AND key = %s", ++ (self.binding.digest, key)) ++ row = cursor.fetchone() ++ if row is None: ++ return None ++ if row[0] != request_binding: ++ raise ValueError("ensure receipt request binding mismatch") ++ if row[3] != json.dumps(asdict(self.binding), sort_keys=True): ++ raise GroupProvenanceUnavailable() ++ receipt = GroupProvenance._load(row[2], key, request_binding) ++ if type(row[1]) is not int or row[1] < 1 or type(receipt.get("_pg_version")) is not int or receipt.get("_pg_version") != row[1] or receipt.get("_pg_context") != self.binding.digest: ++ raise GroupProvenanceUnavailable() ++ return receipt ++ ++ @staticmethod ++ def _inputs(key, binding): ++ if not isinstance(key, str) or not 1 <= len(key) <= 256 or not isinstance(binding, str) or not re.fullmatch(r"[0-9a-f]{64}", binding): ++ raise ValueError("bounded exact journal request binding required") ++ ++ def _active(self, cursor): ++ cursor.execute("SELECT 1 FROM identity_provisioner.group_receipts WHERE active = true LIMIT 1") ++ if cursor.fetchone(): ++ raise GroupProvenanceUnavailable() ++ ++ def _transition(self, tx, receipt, operation): ++ # Bind the exact transition, not merely a counter. Two restored/forked ++ # copies that commit different bodies at the same next head cannot both ++ # match an independently retained checkpoint after reopening. ++ transition = {"schema": "nk-group-journal-transition.v1", "epoch": self.binding.epoch, ++ "directory": self.binding.directory, "prior_head": tx["head"], "prior_digest": tx["digest"], ++ "head": tx["head"] + 1, "operation": operation, "context": asdict(self.binding), ++ "key": receipt["key"], "request_binding": receipt["binding"], ++ "version": receipt["_pg_version"], "body": receipt} ++ tx["next_digest"] = hashlib.sha256(json.dumps(transition, sort_keys=True, separators=(",", ":")).encode()).hexdigest() ++ tx["dirty"] = True ++ ++ def reserve(self, key, binding): ++ self._inputs(key, binding) ++ with self._transaction() as tx: ++ cursor = tx["cursor"] ++ receipt = self._read(cursor, key, binding) ++ if receipt is not None: ++ return receipt, False ++ self._active(cursor) ++ cursor.execute("SELECT COUNT(*) FROM identity_provisioner.group_receipts") ++ if cursor.fetchone()[0] >= self.capacity: ++ raise GroupProvenanceUnavailable() ++ receipt = {"key": key, "binding": binding, "state": "pending", "intent": None, "groups": {}, ++ "created_groups": [], "created_memberships": [], "_pg_version": 1, "_pg_context": self.binding.digest} ++ cursor.execute("INSERT INTO identity_provisioner.group_receipts (context_digest, context_json, key, binding, version, state, active, body) VALUES (%s, %s, %s, %s, 1, 'pending', true, %s)", ++ (self.binding.digest, json.dumps(asdict(self.binding), sort_keys=True), key, binding, json.dumps(receipt, sort_keys=True))) ++ if cursor.rowcount != 1: ++ raise GroupProvenanceUnavailable() ++ self._transition(tx, receipt, "reserve") ++ return receipt, True ++ ++ def get(self, key, binding): ++ self._inputs(key, binding) ++ with self._transaction() as tx: ++ receipt = self._read(tx["cursor"], key, binding) ++ if receipt is None: ++ raise ValueError("unknown ensure receipt or request binding mismatch") ++ return receipt ++ ++ def _write(self, tx, receipt, operation="save"): ++ self._inputs(receipt.get("key"), receipt.get("binding")) ++ if receipt.get("_pg_context") != self.binding.digest or type(receipt.get("_pg_version")) is not int or receipt["_pg_version"] < 1: ++ raise GroupProvenanceUnavailable() ++ encoded = json.dumps(receipt, sort_keys=True) ++ GroupProvenance._load(encoded, receipt["key"], receipt["binding"]) ++ updated = json.loads(encoded) ++ updated["_pg_version"] += 1 ++ active = True if receipt["state"] in {"pending", "revoking", "uncertain"} else None ++ tx["cursor"].execute("UPDATE identity_provisioner.group_receipts SET state = %s, active = %s, body = %s, version = version + 1 WHERE context_digest = %s AND key = %s AND binding = %s AND version = %s", ++ (receipt["state"], active, json.dumps(updated, sort_keys=True), self.binding.digest, receipt["key"], receipt["binding"], receipt["_pg_version"])) ++ if tx["cursor"].rowcount != 1: ++ raise GroupProvenanceUnavailable() ++ self._transition(tx, updated, operation) ++ return updated ++ ++ def save(self, receipt): ++ with self._transaction() as tx: ++ updated = self._write(tx, receipt) ++ receipt.clear() ++ receipt.update(updated) ++ ++ def claim_revoke(self, key, binding, delete_if_empty): ++ self._inputs(key, binding) ++ if not isinstance(delete_if_empty, bool): ++ raise ValueError("delete_if_empty must be boolean") ++ with self._transaction() as tx: ++ receipt = self._read(tx["cursor"], key, binding) ++ if receipt is None: ++ raise ValueError("unknown ensure receipt or request binding mismatch") ++ if receipt["state"] == "revoked": ++ if receipt["delete_if_empty"] != delete_if_empty: ++ raise ValueError("completed rollback options cannot change") ++ return receipt ++ if receipt["state"] != "ensured": ++ raise GroupProvenanceUnavailable() ++ self._active(tx["cursor"]) ++ receipt.update(state="revoking", delete_if_empty=delete_if_empty) ++ updated = self._write(tx, receipt, "claim_revoke") ++ return updated ++ ++ ++def psycopg_connector(*, connection_info: Callable[[], dict[str, Any]]): ++ """Owner-provided renewable lease values stay in memory; never env/argv/logs. ++ ++ No connection happens here. Lazy import keeps disabled group endpoints free ++ of a runtime dependency. The admitted owner supplies TLS/host/db/role facts. ++ """ ++ if not callable(connection_info): ++ raise ValueError("owner connection-info callback required") ++ def connect(): ++ try: ++ import psycopg ++ values = dict(connection_info()) ++ return psycopg.connect(**values, connect_timeout=5) ++ except Exception: ++ raise GroupProvenanceUnavailable() from None ++ return connect +diff --git a/identity-provisioner/migrations/group_journal_postgres_v1.sql b/identity-provisioner/migrations/group_journal_postgres_v1.sql +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/migrations/group_journal_postgres_v1.sql +@@ -0,0 +1,23 @@ ++-- Run only by the admitted migration role in its existing owner schema. ++-- Does not create a database/schema/credential, admit an epoch or clear quarantine. ++CREATE TABLE identity_provisioner.group_journal_meta ( ++ singleton boolean PRIMARY KEY CHECK (singleton = true), ++ epoch uuid NOT NULL, ++ directory text NOT NULL CHECK (octet_length(directory) BETWEEN 1 AND 1024), ++ head bigint NOT NULL CHECK (head >= 0), ++ head_digest text NOT NULL CHECK (head_digest ~ '^[0-9a-f]{64}$'), ++ quarantined boolean NOT NULL DEFAULT true ++); ++CREATE TABLE identity_provisioner.group_receipts ( ++ context_digest text NOT NULL CHECK (context_digest ~ '^[0-9a-f]{64}$'), ++ context_json text NOT NULL CHECK (octet_length(context_json) <= 16384), ++ key text NOT NULL CHECK (char_length(key) BETWEEN 1 AND 256), ++ binding text NOT NULL CHECK (binding ~ '^[0-9a-f]{64}$'), ++ version bigint NOT NULL CHECK (version >= 1), ++ state text NOT NULL CHECK (state IN ('pending','ensured','revoking','revoked','uncertain','refused')), ++ active boolean UNIQUE CHECK (active IS NULL OR active = true), ++ body text NOT NULL CHECK (octet_length(body) <= 1048576), ++ PRIMARY KEY (context_digest, key) ++); ++-- Epoch insertion, initial continuity checkpoint and admission are separate ++-- owner actions; this migration leaves all operations refused without them. +diff --git a/identity-provisioner/provisioner.py b/identity-provisioner/provisioner.py +--- a/identity-provisioner/provisioner.py ++++ b/identity-provisioner/provisioner.py +@@ -11,7 +11,7 @@ + from urllib.error import HTTPError, URLError + from urllib.request import Request, urlopen + +-from group_provenance import GroupProvenance, GroupProvenanceUnavailable ++from group_provenance import GroupJournalStore, GroupProvenanceUnavailable + + + @dataclass(frozen=True) +@@ -64,7 +64,7 @@ + opener: Callable = urlopen, + auth_rejected_cooldown: float = 30.0, + clock: Callable[[], float] = time.monotonic, +- group_provenance: GroupProvenance | None = None, ++ group_provenance: GroupJournalStore | None = None, + ) -> None: + self.group_provenance = group_provenance + self.base_url = base_url.rstrip("/") +@@ -243,7 +243,7 @@ + timeout=timeout, + ) + +- def _group_store(self) -> GroupProvenance: ++ def _group_store(self) -> GroupJournalStore: + store = self.group_provenance + if store is None: + raise GroupProvenanceUnavailable() +diff --git a/identity-provisioner/requirements-postgres.txt b/identity-provisioner/requirements-postgres.txt +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/requirements-postgres.txt +@@ -0,0 +1,5 @@ ++# Linux amd64 CPython3.12 runtime closure from user-engine/uv.lock. ++# Build-only dependency resolution; no packages are installed at service startup. ++psycopg==3.3.5 --hash=sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e ++psycopg-binary==3.3.5 --hash=sha256:682a17a57415c3ca1731eec018ed031f012ffcb81ba74806eb219cb396065672 ++typing-extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 +diff --git a/identity-provisioner/tests/test_group_postgres.py b/identity-provisioner/tests/test_group_postgres.py +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/tests/test_group_postgres.py +@@ -0,0 +1,192 @@ ++"""Binding, CAS and ambiguous-commit refusal tests; native SQL proof is separate.""" ++from dataclasses import replace ++import json ++from unittest.mock import Mock ++import unittest ++ ++from group_provenance import GroupProvenanceUnavailable ++from group_provenance_postgres import GroupJournalBinding, PostgresGroupProvenance ++from server import configured_group_provenance ++from unittest.mock import patch ++import os ++ ++EPOCH = '41fbb0ed-ea3a-4682-996b-e9c94a6aa9c6' ++BINDING = GroupJournalBinding('tenant:fixture:one', 'synthetic-directory', 'sha256:' + '1' * 64, ++ 'sha256:' + '2' * 64, 'synthetic-owner', EPOCH) ++REQUEST = '3' * 64 ++HEAD_DIGEST = 'a' * 64 ++META = (EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, False) ++ ++ ++def fixture(rows=(), rowcount=1, commit_error=None): ++ cursor = Mock() ++ cursor.__enter__ = Mock(return_value=cursor) ++ cursor.__exit__ = Mock(return_value=False) ++ cursor.fetchone.side_effect = rows ++ cursor.rowcount = rowcount ++ connection = Mock() ++ connection.__enter__ = Mock(return_value=connection) ++ connection.__exit__ = Mock(return_value=False, side_effect=commit_error) ++ connection.cursor.return_value = cursor ++ connector = Mock(return_value=connection) ++ continuity = Mock() ++ continuity.check.return_value = True ++ continuity.advance.return_value = True ++ store = PostgresGroupProvenance(connect=connector, binding=BINDING, continuity=continuity) ++ return store, connector, connection, cursor, continuity ++ ++ ++def receipt(version=4): ++ return {'key': 'synthetic-operation-key', 'binding': REQUEST, 'state': 'pending', 'intent': None, ++ 'groups': {}, 'created_groups': [], 'created_memberships': [], ++ '_pg_version': version, '_pg_context': BINDING.digest} ++ ++ ++class PostgresSourceTests(unittest.TestCase): ++ def test_explicit_binding_and_continuity_required_without_connecting(self): ++ connector = Mock() ++ for binding, continuity in ((None, Mock()), (BINDING, None), ({'tenant_scope': 'caller-json'}, Mock())): ++ with self.assertRaises(ValueError): ++ PostgresGroupProvenance(connect=connector, binding=binding, continuity=continuity) ++ connector.assert_not_called() ++ for change in ({'tenant_scope': ''}, {'principal': ''}, {'case_digest': 'unsigned'}, {'epoch': 'default'}): ++ with self.assertRaises(ValueError): ++ replace(BINDING, **change) ++ self.assertNotEqual(BINDING.digest, replace(BINDING, principal='another-owner').digest) ++ self.assertNotEqual(BINDING.digest, replace(BINDING, tenant_scope='another-explicit-scope').digest) ++ ++ def test_missing_environment_callback_path_keeps_production_disabled(self): ++ with patch.dict(os.environ, {'GROUP_POSTGRES_DSN': 'synthetic-only', 'GROUP_PROVENANCE_POSTGRES': 'true'}, clear=True): ++ self.assertIsNone(configured_group_provenance()) ++ ++ def test_checkpoint_mismatch_poisoned_without_receipt_query_or_effect(self): ++ store, connect, _, cursor, checkpoint = fixture([META]) ++ checkpoint.check.return_value = False ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.reserve('synthetic-operation-key', REQUEST) ++ self.assertFalse(any('group_receipts' in c.args[0] for c in cursor.execute.call_args_list)) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.check() ++ self.assertEqual(1, connect.call_count) ++ checkpoint.advance.assert_not_called() ++ ++ def test_restored_epoch_or_quarantine_refused(self): ++ for meta in ((EPOCH, 'other-directory', 7, HEAD_DIGEST, False), (EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, True), ++ ('98aa2b6d-a32a-44a5-a89f-b2480befc00e', 'synthetic-directory', 7, HEAD_DIGEST, False), None): ++ store, _, _, _, checkpoint = fixture([meta]) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.check() ++ checkpoint.check.assert_not_called() ++ ++ def test_exactly_one_version_update_required_before_checkpoint_advance(self): ++ store, _, _, cursor, checkpoint = fixture([META], rowcount=0) ++ original = receipt() ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.save(original) ++ query, params = next(c.args for c in cursor.execute.call_args_list if c.args[0].startswith('UPDATE identity_provisioner.group_receipts')) ++ self.assertIn('AND version = %s', query) ++ self.assertEqual(4, params[-1]) ++ self.assertEqual(4, original['_pg_version']) ++ checkpoint.advance.assert_not_called() ++ ++ def test_success_acknowledges_commit_and_updates_receipt_version(self): ++ store, _, connection, _, checkpoint = fixture([META]) ++ original = receipt() ++ store.save(original) ++ self.assertEqual(5, original['_pg_version']) ++ connection.__exit__.assert_called_once() ++ args = checkpoint.advance.call_args.args ++ self.assertEqual((EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, 8), args[:5]) ++ self.assertRegex(args[5], r'^[0-9a-f]{64}$') ++ self.assertNotEqual(HEAD_DIGEST, args[5]) ++ connection.close.assert_called_once() ++ ++ def test_connection_close_failure_is_sanitized_and_stops_continuation(self): ++ store, _, connection, _, _ = fixture([META]) ++ connection.close.side_effect = OSError('synthetic-private-diagnostic') ++ with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): ++ store.check() ++ self.assertTrue(store._poisoned) ++ ++ def test_commit_failure_never_advances_owner_checkpoint_or_receipt(self): ++ store, _, _, _, checkpoint = fixture([META], commit_error=OSError('synthetic-secret-diagnostic')) ++ original = receipt() ++ with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): ++ store.save(original) ++ self.assertEqual(4, original['_pg_version']) ++ checkpoint.advance.assert_not_called() ++ ++ def test_lost_checkpoint_acknowledgment_refuses_then_poisoned(self): ++ store, connect, _, _, checkpoint = fixture([META]) ++ checkpoint.advance.side_effect = OSError('synthetic-secret-diagnostic') ++ original = receipt() ++ with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): ++ store.save(original) ++ self.assertEqual(4, original['_pg_version']) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.save(original) ++ self.assertEqual(1, connect.call_count) ++ ++ def test_cross_case_body_or_version_cannot_be_imported(self): ++ for body in (dict(receipt(), _pg_context='0' * 64), dict(receipt(), _pg_version=6)): ++ store, _, _, _, _ = fixture([META, (REQUEST, 4, json.dumps(body), json.dumps(BINDING.__dict__, sort_keys=True))]) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.get('synthetic-operation-key', REQUEST) ++ ++ def test_same_counter_divergent_body_or_context_yields_distinct_marker(self): ++ store, *_ = fixture() ++ markers = [] ++ for change in ({}, {'intent': {'operation': 'different'}}, {'key': 'another-exact-key'}, {'_pg_version': 5}): ++ tx = {'head': 7, 'digest': HEAD_DIGEST} ++ store._transition(tx, dict(receipt(), **change), 'save') ++ markers.append(tx['next_digest']) ++ self.assertEqual(4, len(set(markers))) ++ tx = {'head': 7, 'digest': 'b' * 64} ++ store._transition(tx, receipt(), 'save') ++ self.assertNotIn(tx['next_digest'], markers) ++ foreign, *_ = fixture() ++ foreign.binding = replace(BINDING, principal='another-owner') ++ tx = {'head': 7, 'digest': HEAD_DIGEST} ++ foreign._transition(tx, receipt(), 'save') ++ self.assertNotIn(tx['next_digest'], markers) ++ ++ def test_reopened_loser_clone_at_same_head_refuses_different_marker(self): ++ # Both DBs report head8. The independent callback accepts only winner ++ # digest; loser must be refused before loading any receipt or effect. ++ loser_digest = 'b' * 64 ++ store, _, _, cursor, checkpoint = fixture([(EPOCH, 'synthetic-directory', 8, loser_digest, False)]) ++ checkpoint.check.side_effect = lambda epoch, directory, head, digest: (head, digest) == (8, 'c' * 64) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.check() ++ checkpoint.check.assert_called_once_with(EPOCH, 'synthetic-directory', 8, loser_digest) ++ self.assertFalse(any('group_receipts' in c.args[0] for c in cursor.execute.call_args_list)) ++ ++ def test_existing_request_mismatch_is_refused_not_rebound(self): ++ store, _, _, _, checkpoint = fixture([META, ('0' * 64, 4, '{}', '{}')]) ++ with self.assertRaisesRegex(ValueError, 'binding mismatch'): ++ store.get('synthetic-operation-key', REQUEST) ++ checkpoint.advance.assert_not_called() ++ self.assertFalse(store._poisoned) ++ ++ def test_active_fence_capacity_refusal_and_no_insert(self): ++ for rows in ([META, None, (1,)], [META, None, None, (256,)]): ++ store, _, _, cursor, checkpoint = fixture(rows) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.reserve('synthetic-operation-key', REQUEST) ++ self.assertFalse(any('INSERT' in c.args[0] for c in cursor.execute.call_args_list)) ++ checkpoint.advance.assert_not_called() ++ ++ def test_new_reservation_retains_bound_context_and_write_intent(self): ++ store, _, _, cursor, checkpoint = fixture([META, None, None, (0,)]) ++ created, fresh = store.reserve('synthetic-operation-key', REQUEST) ++ self.assertTrue(fresh) ++ self.assertEqual('pending', created['state']) ++ self.assertEqual(BINDING.digest, created['_pg_context']) ++ insert = next(c for c in cursor.execute.call_args_list if c.args[0].startswith('INSERT')) ++ self.assertIn('context_json', insert.args[0]) ++ self.assertEqual(BINDING.tenant_scope, json.loads(insert.args[1][1])['tenant_scope']) ++ checkpoint.advance.assert_called_once() ++ ++ ++if __name__ == '__main__': ++ unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/docs/service-group-provenance.md b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/docs/service-group-provenance.md new file mode 100644 index 00000000..8d75c90f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/docs/service-group-provenance.md @@ -0,0 +1,172 @@ +# Durable service group creation provenance + +This source change narrows the existing authenticated identity-provisioner +`/v1/groups/ensure` and `/v1/groups/revoke` endpoints. It introduces no new +principal, credential, policy decision, or rollback authorization. An endpoint's +service token and a journal receipt do not establish an accepted case, caller +session, owner assent, or permission to use a protected execution lane. + +## Request and receipt migration + +Ensure keeps `groups`, `members`, `idempotency_key` and `correlation_id`. +The original correlation is now part of its immutable binding: the original +idempotency key with a different correlation or group/member request is refused. +The response adds `ensure_idempotency_key`, `ensure_correlation_id`, +`created_groups` (name and exact directory id) and `created_memberships` +(name, directory id and username). The owner journal independently retains the +complete prestate and expected postimage. Response inventories are advisory; +caller-supplied inventories are rejected and never authorize undo. + +Revoke retains the original group/member request, its own `idempotency_key` and +`correlation_id`, and optional boolean `delete_if_empty`. It additionally requires +`ensure_idempotency_key` and `ensure_correlation_id` from the original ensure. +Legacy revoke requests fail before directory access. No old deployment inventory +is imported as proof of creation: pre-upgrade groups and memberships cannot be +rolled back automatically without independent owner reconciliation. + +An ensure replay returns its original receipt and never recreates subsequently +removed state. Reusing its key after rollback is refused. Revoke replays return +its original outcome; changing deletion options after completion is refused. +There are at most 16 groups, 256 requested membership entries, 4096 retained +preexisting members per operation, and 256 journal records by default. Records +have no timeout or automatic eviction. Capacity exhaustion stops new operations. + +## Storage policy and local fixture configuration + +The implemented SQLite backend is for local development and disposable tests. +It is not an admitted production storage design. Accepted +[ADR-0015 section3](adr/ADR-0015-netkingdom-railiance-workload-packaging-and-relational-platform.md) +requires production relational storage through CloudNative PG and `rapp-postgres` +using `database-per-consumer`; it explicitly rejects per-workload SQLite-on-a-PVC +as a production pattern. The rail-kubernetes wave-1 contract also does not supply +a generic persistent-storage contract. This source does not authorize adding a +SQLite PVC to identity-provisioner. + +Production group operations remain unadmitted and must stay disabled until the +existing owner paths admit a CNPG-backed implementation of the same journal +semantics, credentials, storage/backup/restore and writer-isolation contracts, +or an explicit governed exception to ADR-0015 identifies the exact scope, +limits, duration, recovery and risk owner. No such backend, exception or native +assent is supplied here. `railiance-platform` owns shared database/custody +governance; `rapp-postgres` owns the package consumer provisioning surface. +The identity-provisioner runtime package/operating assignment must also be +explicitly settled rather than inferred from User Engine's dependency on it. + +For local/disposable tests only, `GROUP_PROVENANCE_DB` selects an absolute, +non-symlink SQLite path in a private test directory owned by the process UID, +with mode0700 for the directory and mode0600 for the database. The local fixture +explicitly initializes a genuinely new empty store with +`GroupProvenance(absolute_path, initialize=True)`; initialization refuses an +existing file. Ordinary opening never recreates a missing store. The completed +disposable fixture uses private container tmpfs and preserves the file across +separate Python processes; that proves process-restart semantics, not production +durability. `/tmp`, `emptyDir`, tmpfs and an image layer do not satisfy native +retention or recovery duties. + +With no configuration, ordinary directory readiness reports +`service_groups: disabled` and group endpoints refuse work. Missing/untrusted or +unavailable configured local storage prevents startup or returns a fixed +`group_provenance` dependency failure without path/database diagnostics. +`service_groups: available` checks capability only: the implementation does not +infer or enforce production admission, policy exceptions, accepted cases or +writer isolation from a filesystem path. + +The existing reference Deployment remains one `Recreate` replica at UID/GID10001 +with `readOnlyRootFilesystem: true`, no provenance volume and an older immutable +image. Image rebuild, native owner review and credential delivery remain separate +acts. Preserve its nonroot/read-only boundaries. Native retention and recovery +must preserve original idempotency bindings, prestate, confirmed creations and +unresolved intents; losing a journal never authorizes replacement initialization +and replay. No arbitrary JSON import, receipt expiry or automatic uncertainty +clearing is introduced. Owner reconciliation needs independent evidence. + +## PostgreSQL source seam (not native admission) + +`PostgresGroupProvenance` implements the same reserve/get/claim-revoke/save/check +journal contract. It is injection-only: the ordinary HTTP server has no +PostgreSQL environment switch and remains without a group store unless the +existing local SQLite configuration is explicitly supplied. `GROUP_POSTGRES_DSN` +or a caller JSON object cannot enable the PostgreSQL path. Production group +operations remain unadmitted and disabled pending the owner callbacks and gates +above; a backend constructor is not an access decision. + +A trusted owner integration must provide an exact `GroupJournalBinding` with +opaque authoritative tenant scope, directory instance, accepted case digest, +source digest, represented principal and journal epoch. There is no guessed +platform-root scope or request-derived tenant fallback. The owner also injects a +renewable private connection-info callback using the existing `rapp-postgres` +consumer/lease contract, and independently durable `JournalContinuity` callbacks. +These callbacks retain/check/advance exact epoch+directory+head+commit digest outside the +PostgreSQL state that might be restored. Neither callback authenticates a caller +or grants a protected side effect. Their native custody, source binding, +revocation and execution bounds still require independent admission. Only the +disposable fixture supplies a test checkpoint implementation here. + +The v1 migration under `identity-provisioner/migrations/` creates tables within +the existing consumer schema; it creates no database/schema/roles, inserts no +epoch and clears no quarantine. Startup does not run migrations or initialize +missing state. The owner must separately admit an epoch/checkpoint after +independent continuity/recovery review; absent or quarantined state refuses. +A restored database with an older head than the independently retained checkpoint +also refuses rather than treating missing later intents as authority to replay. + +PostgreSQL transactions lock the single journal metadata row, commit bounded +receipts/intents and advance the head and exact transition digest with an exactly-one conditional +update. The digest binds the prior marker, canonical body, context, request key +and receipt version; the independent checkpoint must compare/CAS both content +and sequence, never accept a counter-only callback. Divergent restored clones +that commit different transitions at the same counter cannot both be accepted. +Every receipt update also requires its exact context/key/request/version and +increments its version. The active slot spans all contexts in the journal; +it serializes cooperating producer operations, not external LDAP writers. +SQL commit precedes independently durable checkpoint acknowledgment. A lost +commit/acknowledgment, stale version, missing row or continuity mismatch refuses +and poisons that backend object; exception handling cannot automatically rewrite +its journal into success. Known request-binding mismatches remain domain +refusals. Definite before-mutation refusal records can release the active slot +while the original key remains terminal. + +The container source includes a hash-pinned Linux amd64 CPython3.12 psycopg3 +closure selected from User Engine's existing lockfile. No image build or package +installation is part of this source proof; build/publication, artifact scanning +and native runtime/dependency admission remain owner work. SQLite is still +local/disposable only. The separate PostgreSQL fixture proposal uses a cached +pinned PostgreSQL image and a cached pinned User Engine Python image, real SQL, +separate-process reopen, concurrency, stale-CAS and actual pg_dump/pg_restore +behind an independently retained test checkpoint. That fixture needs exact +review before Docker execution and does not prove live CNPG HA, TLS/lease +custody, tenant authority, external writer fencing, production retention or +protected session/evidence integration. + +## Safety and remaining native acceptance + +Before each directory mutation, durable intent is committed. A successful +response and exact readback are required before recording a confirmed creation. +A definite failure before any mutation attempt retains a `refused` record and +releases the active fence; its original key still cannot retry. Transport loss, +readback failure after an attempted mutation or a process exit leaves pending/uncertain state; +it survives reopening and blocks new mutations instead of becoming retry success. +Every database connection rechecks the private path and original device/inode; +missing/replaced storage is refused. Every intent update must affect exactly one +matching journal row before execution can continue. +The journal records mutation intent and confirmed outcomes, not an atomic LDAP +transaction or a complete audit emission guarantee. + +Rollback validates every targeted group id and complete member postimage before +its first destructive operation, and verifies each subsequent change. It removes +only memberships confirmed newly added by that ensure. It deletes only groups +confirmed newly created by that ensure, and only while empty. Preexisting groups, +including empty groups, are never deleted. Existing members are never included +in the removal inventory. Detected shared-member drift or group replacement +refuses the operation before any destructive call and retains uncertainty. + +LLDAP exposes no membership-generation token or conditional mutation/CAS here. +Readback cannot detect a remove/re-add ABA sequence or close a read/write race +with an external writer. Native rollback therefore still requires an owner-proven +writer isolation/fencing contract or a provider-side versioned/CAS operation; +these synthetic fixtures do not prove that boundary. A durable SQLite file is +trusted local owner-held state, not a production storage admission, tamper-evident +remote evidence or an authorization broker. Existing caller authentication is unchanged; case/source/principal binding, +per-attempt protected session revocation, independently admitted evidence callbacks, +native storage/backup recovery and native endpoint acceptance remain outstanding +under NK-WP-0048-T14/T16. This source change alone does not finish those tasks. diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/Containerfile b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/Containerfile new file mode 100644 index 00000000..00f96e6c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/Containerfile @@ -0,0 +1,12 @@ +FROM lldap/lldap:stable@sha256:2a8454b668c1aba7157e832eab0e242e1e7eb5fb7591d7e7774ba05286511ca8 AS lldap +FROM python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9 +RUN useradd --create-home --uid 10001 provisioner +WORKDIR /app +COPY --from=lldap /app/lldap_set_password /app/lldap_set_password +COPY requirements-postgres.txt /app/ +RUN python -m pip install --no-cache-dir --require-hashes -r /app/requirements-postgres.txt +COPY provisioner.py group_provenance.py group_provenance_postgres.py password_setup.py server.py /app/ +COPY migrations/ /app/migrations/ +USER 10001:10001 +EXPOSE 8080 +CMD ["python", "server.py"] diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/group_provenance.py b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/group_provenance.py new file mode 100644 index 00000000..4e9e809b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/group_provenance.py @@ -0,0 +1,179 @@ +"""Bounded, owner-private recovery journal; never an authorization source. + +An unresolved write intent is retained indefinitely and blocks further writes. +Only the directory owner may reconcile it using independently obtained evidence. +""" +from __future__ import annotations + +import json +from contextlib import contextmanager +import os +from pathlib import Path +import sqlite3 +import stat +from typing import Any, Protocol +from urllib.parse import quote + + +class GroupJournalStore(Protocol): + """Owner-injected persistence only; grants no caller or native authority.""" + def check(self) -> None: ... + def reserve(self, key: str, binding: str) -> tuple[dict[str, Any], bool]: ... + def get(self, key: str, binding: str) -> dict[str, Any]: ... + def claim_revoke(self, key: str, binding: str, delete_if_empty: bool) -> dict[str, Any]: ... + def save(self, receipt: dict[str, Any]) -> None: ... + + +class GroupProvenanceUnavailable(RuntimeError): + def __init__(self): + super().__init__("group_provenance_unavailable") + + +class GroupProvenance: + def __init__(self, path: str, *, capacity: int = 256, initialize: bool = False) -> None: + target = Path(path) + if not target.is_absolute() or target.parent.resolve() != target.parent or capacity < 1 or capacity > 4096: + raise ValueError("private absolute provenance path and bounded capacity required") + parent = target.parent.lstat() + if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() or parent.st_mode & 0o077: + raise ValueError("provenance directory must be owner-private") + # Service startup never creates a replacement for missing durable state. + flags = os.O_RDWR | os.O_NOFOLLOW + if initialize: + flags |= os.O_CREAT | os.O_EXCL + fd = os.open(target, flags, 0o600) + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077 or info.st_nlink != 1: + raise ValueError("provenance file must be owner-private and regular") + finally: + os.close(fd) + self.path = str(target) + self._device, self._inode = info.st_dev, info.st_ino + self.capacity = capacity + with self._db() as db: + if initialize: + db.execute("CREATE TABLE group_receipts (key TEXT PRIMARY KEY, binding TEXT NOT NULL, state TEXT NOT NULL, active INTEGER UNIQUE, body TEXT NOT NULL)") + db.execute("PRAGMA user_version=1") + if db.execute("PRAGMA user_version").fetchone()[0] != 1: + raise RuntimeError("unrecognized group provenance schema") + db.execute("SELECT key, binding, state, active, body FROM group_receipts LIMIT 0") + + def _check_file(self): + try: + target = Path(self.path) + parent = target.parent.lstat() + info = target.lstat() + if target.parent.resolve() != target.parent or not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() or parent.st_mode & 0o077: + raise GroupProvenanceUnavailable() + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077 or info.st_nlink != 1: + raise GroupProvenanceUnavailable() + if (info.st_dev, info.st_ino) != (self._device, self._inode): + raise GroupProvenanceUnavailable() + except OSError: + raise GroupProvenanceUnavailable() from None + + def check(self): + with self._db() as db: + if db.execute("PRAGMA user_version").fetchone()[0] != 1: + raise GroupProvenanceUnavailable() + db.execute("SELECT key FROM group_receipts LIMIT 0") + + @staticmethod + def _load(body, key, binding): + try: + if not isinstance(body, str) or len(body) > 1048576: + raise GroupProvenanceUnavailable() + value = json.loads(body) + if not isinstance(value, dict) or value.get("key") != key or value.get("binding") != binding: + raise GroupProvenanceUnavailable() + if value.get("state") not in {"pending", "ensured", "revoking", "revoked", "uncertain", "refused"}: + raise GroupProvenanceUnavailable() + if not isinstance(value.get("groups"), dict) or len(value["groups"]) > 16: + raise GroupProvenanceUnavailable() + for field, limit in (("created_groups", 16), ("created_memberships", 256)): + if not isinstance(value.get(field), list) or len(value[field]) > limit or any(not isinstance(item, dict) for item in value[field]): + raise GroupProvenanceUnavailable() + if "intent" not in value or value["intent"] is not None and not isinstance(value["intent"], dict): + raise GroupProvenanceUnavailable() + for group in value["groups"].values(): + if not isinstance(group, dict) or "id" not in group or not all(isinstance(group.get(f), list) and all(isinstance(u, str) for u in group[f]) for f in ("before", "after")): + raise GroupProvenanceUnavailable() + if value["state"] in {"ensured", "revoking", "revoked"} and ( + not isinstance(value.get("requested_groups"), list) or not isinstance(value.get("ensure_correlation_id"), str) + ): + raise GroupProvenanceUnavailable() + return value + except (TypeError, ValueError): + raise GroupProvenanceUnavailable() from None + + @contextmanager + def _db(self): + db = None + try: + self._check_file() + db = sqlite3.connect("file:" + quote(self.path, safe="/") + "?mode=rw", uri=True, timeout=5) + self._check_file() + db.execute("PRAGMA synchronous=FULL") + with db: + yield db + except sqlite3.Error: + raise GroupProvenanceUnavailable() from None + finally: + if db is not None: + db.close() + + def reserve(self, key: str, binding: str) -> tuple[dict[str, Any], bool]: + with self._db() as db: + db.execute("BEGIN IMMEDIATE") + row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone() + if row: + if row[0] != binding: + raise ValueError("ensure idempotency key is bound to another request") + return self._load(row[1], key, binding), False + if db.execute("SELECT COUNT(*) FROM group_receipts").fetchone()[0] >= self.capacity: + raise RuntimeError("provenance capacity reached; retained records require owner disposition") + receipt = {"key": key, "binding": binding, "state": "pending", "intent": None, + "groups": {}, "created_groups": [], "created_memberships": []} + try: + db.execute("INSERT INTO group_receipts VALUES (?, ?, ?, 1, ?)", + (key, binding, "pending", json.dumps(receipt, sort_keys=True))) + except sqlite3.IntegrityError: + raise RuntimeError("another group operation is unresolved") from None + return receipt, True + + def get(self, key: str, binding: str) -> dict[str, Any]: + with self._db() as db: + row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone() + if not row or row[0] != binding: + raise ValueError("unknown ensure receipt or request binding mismatch") + return self._load(row[1], key, binding) + + def claim_revoke(self, key: str, binding: str, delete_if_empty: bool) -> dict[str, Any]: + with self._db() as db: + db.execute("BEGIN IMMEDIATE") + row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone() + if not row or row[0] != binding: + raise ValueError("unknown ensure receipt or request binding mismatch") + receipt = self._load(row[1], key, binding) + if receipt["state"] == "revoked": + if receipt["delete_if_empty"] != delete_if_empty: + raise ValueError("completed rollback options cannot change") + return receipt + if receipt["state"] != "ensured": + raise RuntimeError("group provenance unresolved; independent owner recovery required") + receipt.update(state="revoking", delete_if_empty=delete_if_empty) + try: + db.execute("UPDATE group_receipts SET state='revoking', active=1, body=? WHERE key=?", + (json.dumps(receipt, sort_keys=True), key)) + except sqlite3.IntegrityError: + raise RuntimeError("another group operation is unresolved") from None + return receipt + + def save(self, receipt: dict[str, Any]) -> None: + active = 1 if receipt["state"] in {"pending", "revoking", "uncertain"} else None + with self._db() as db: + written = db.execute("UPDATE group_receipts SET state=?, active=?, body=? WHERE key=? AND binding=?", + (receipt["state"], active, json.dumps(receipt, sort_keys=True), receipt["key"], receipt["binding"])) + if written.rowcount != 1: + raise GroupProvenanceUnavailable() diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/group_provenance_postgres.py b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/group_provenance_postgres.py new file mode 100644 index 00000000..f176dead --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/group_provenance_postgres.py @@ -0,0 +1,253 @@ +"""Injection-only PostgreSQL implementation of the existing owner journal seam. + +Binding and continuity callbacks are supplied by a trusted owner integration, +never constructed from an HTTP payload. Neither is an authorization decision. +No server environment switch, automatic schema creation or recovery admission. +""" +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import asdict, dataclass +import hashlib +import json +import re +from typing import Any, Callable, Protocol +from uuid import UUID + +from group_provenance import GroupProvenance, GroupProvenanceUnavailable + + +@dataclass(frozen=True) +class GroupJournalBinding: + tenant_scope: str + directory: str + case_digest: str + source_digest: str + principal: str + epoch: str + + def __post_init__(self): + for value in (self.tenant_scope, self.directory, self.principal): + if not isinstance(value, str) or not 1 <= len(value) <= 256 or any(ord(c) < 32 for c in value): + raise ValueError("explicit owner journal binding required") + for value in (self.case_digest, self.source_digest): + if not isinstance(value, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", value): + raise ValueError("exact owner source and case digests required") + if not isinstance(self.epoch, str) or str(UUID(self.epoch)) != self.epoch: + raise ValueError("explicit canonical journal epoch required") + + @property + def digest(self): + return hashlib.sha256(json.dumps(asdict(self), sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def journal_genesis(epoch: str, directory: str) -> str: + """Deterministic initial marker for explicit owner migration/admission only.""" + return hashlib.sha256(json.dumps({"schema": "nk-group-journal-genesis.v1", "epoch": epoch, + "directory": directory}, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +class JournalContinuity(Protocol): + """Existing independently retained owner checkpoint, outside restored SQL state. + + Implementations compare/advance exact epoch+directory+head+commit digest atomically and + durably. An in-memory implementation is a fixture only. Missing/stale or + ambiguous checkpoints must refuse; no callback grants mutation authority. + """ + def check(self, epoch: str, directory: str, head: int, digest: str) -> bool: ... + def advance(self, epoch: str, directory: str, previous: int, previous_digest: str, current: int, current_digest: str) -> bool: ... + + +class PostgresGroupProvenance: + def __init__(self, *, connect: Callable, binding: GroupJournalBinding, + continuity: JournalContinuity, capacity: int = 256): + if not callable(connect) or not isinstance(binding, GroupJournalBinding) or not 1 <= capacity <= 4096: + raise ValueError("explicit owner connector, binding and bounded capacity required") + if not callable(getattr(continuity, "check", None)) or not callable(getattr(continuity, "advance", None)): + raise ValueError("independent owner continuity callbacks required") + self.connect, self.binding, self.continuity, self.capacity = connect, binding, continuity, capacity + self._poisoned = False + + def _continuity(self, method, *args): + try: + if getattr(self.continuity, method)(self.binding.epoch, self.binding.directory, *args) is not True: + raise GroupProvenanceUnavailable() + except Exception: + raise GroupProvenanceUnavailable() from None + + @contextmanager + def _transaction(self): + if self._poisoned: + raise GroupProvenanceUnavailable() + connection = None + tx = None + try: + connection = self.connect() + with connection: + with connection.cursor() as cursor: + cursor.execute("SET LOCAL statement_timeout = '5s'") + cursor.execute("SET LOCAL idle_in_transaction_session_timeout = '15s'") + cursor.execute("SELECT epoch::text, directory, head, head_digest, quarantined FROM identity_provisioner.group_journal_meta WHERE singleton = true FOR UPDATE") + meta = cursor.fetchone() + if not meta or meta[0] != self.binding.epoch or meta[1] != self.binding.directory or meta[4] is not False or type(meta[2]) is not int or meta[2] < 0 or not isinstance(meta[3], str) or not re.fullmatch(r"[0-9a-f]{64}", meta[3]): + raise GroupProvenanceUnavailable() + self._continuity("check", meta[2], meta[3]) + tx = {"cursor": cursor, "head": meta[2], "digest": meta[3], "dirty": False} + yield tx + if tx["dirty"]: + cursor.execute("UPDATE identity_provisioner.group_journal_meta SET head = head + 1, head_digest = %s WHERE singleton = true AND epoch = %s AND directory = %s AND head = %s AND head_digest = %s AND quarantined = false", + (tx["next_digest"], self.binding.epoch, self.binding.directory, tx["head"], tx["digest"])) + if cursor.rowcount != 1: + raise GroupProvenanceUnavailable() + # SQL commit precedes the independent checkpoint acknowledgment. + # A lost acknowledgment refuses before the next directory effect. + if tx and tx["dirty"]: + self._continuity("advance", tx["head"], tx["digest"], tx["head"] + 1, tx["next_digest"]) + except ValueError: + raise + except GroupProvenanceUnavailable: + # Caller mismatches are domain refusals. Storage/continuity failures + # poison this object; durable pending records are never rewritten + # automatically by an exception handler after ambiguous commit. + self._poisoned = True + raise + except Exception: + self._poisoned = True + raise GroupProvenanceUnavailable() from None + finally: + if connection is not None: + try: + connection.close() + except Exception: + self._poisoned = True + raise GroupProvenanceUnavailable() from None + + def check(self): + with self._transaction(): + pass + + def _read(self, cursor, key, request_binding): + cursor.execute("SELECT binding, version, body, context_json FROM identity_provisioner.group_receipts WHERE context_digest = %s AND key = %s", + (self.binding.digest, key)) + row = cursor.fetchone() + if row is None: + return None + if row[0] != request_binding: + raise ValueError("ensure receipt request binding mismatch") + if row[3] != json.dumps(asdict(self.binding), sort_keys=True): + raise GroupProvenanceUnavailable() + receipt = GroupProvenance._load(row[2], key, request_binding) + if type(row[1]) is not int or row[1] < 1 or type(receipt.get("_pg_version")) is not int or receipt.get("_pg_version") != row[1] or receipt.get("_pg_context") != self.binding.digest: + raise GroupProvenanceUnavailable() + return receipt + + @staticmethod + def _inputs(key, binding): + if not isinstance(key, str) or not 1 <= len(key) <= 256 or not isinstance(binding, str) or not re.fullmatch(r"[0-9a-f]{64}", binding): + raise ValueError("bounded exact journal request binding required") + + def _active(self, cursor): + cursor.execute("SELECT 1 FROM identity_provisioner.group_receipts WHERE active = true LIMIT 1") + if cursor.fetchone(): + raise GroupProvenanceUnavailable() + + def _transition(self, tx, receipt, operation): + # Bind the exact transition, not merely a counter. Two restored/forked + # copies that commit different bodies at the same next head cannot both + # match an independently retained checkpoint after reopening. + transition = {"schema": "nk-group-journal-transition.v1", "epoch": self.binding.epoch, + "directory": self.binding.directory, "prior_head": tx["head"], "prior_digest": tx["digest"], + "head": tx["head"] + 1, "operation": operation, "context": asdict(self.binding), + "key": receipt["key"], "request_binding": receipt["binding"], + "version": receipt["_pg_version"], "body": receipt} + tx["next_digest"] = hashlib.sha256(json.dumps(transition, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + tx["dirty"] = True + + def reserve(self, key, binding): + self._inputs(key, binding) + with self._transaction() as tx: + cursor = tx["cursor"] + receipt = self._read(cursor, key, binding) + if receipt is not None: + return receipt, False + self._active(cursor) + cursor.execute("SELECT COUNT(*) FROM identity_provisioner.group_receipts") + if cursor.fetchone()[0] >= self.capacity: + raise GroupProvenanceUnavailable() + receipt = {"key": key, "binding": binding, "state": "pending", "intent": None, "groups": {}, + "created_groups": [], "created_memberships": [], "_pg_version": 1, "_pg_context": self.binding.digest} + cursor.execute("INSERT INTO identity_provisioner.group_receipts (context_digest, context_json, key, binding, version, state, active, body) VALUES (%s, %s, %s, %s, 1, 'pending', true, %s)", + (self.binding.digest, json.dumps(asdict(self.binding), sort_keys=True), key, binding, json.dumps(receipt, sort_keys=True))) + if cursor.rowcount != 1: + raise GroupProvenanceUnavailable() + self._transition(tx, receipt, "reserve") + return receipt, True + + def get(self, key, binding): + self._inputs(key, binding) + with self._transaction() as tx: + receipt = self._read(tx["cursor"], key, binding) + if receipt is None: + raise ValueError("unknown ensure receipt or request binding mismatch") + return receipt + + def _write(self, tx, receipt, operation="save"): + self._inputs(receipt.get("key"), receipt.get("binding")) + if receipt.get("_pg_context") != self.binding.digest or type(receipt.get("_pg_version")) is not int or receipt["_pg_version"] < 1: + raise GroupProvenanceUnavailable() + encoded = json.dumps(receipt, sort_keys=True) + GroupProvenance._load(encoded, receipt["key"], receipt["binding"]) + updated = json.loads(encoded) + updated["_pg_version"] += 1 + active = True if receipt["state"] in {"pending", "revoking", "uncertain"} else None + tx["cursor"].execute("UPDATE identity_provisioner.group_receipts SET state = %s, active = %s, body = %s, version = version + 1 WHERE context_digest = %s AND key = %s AND binding = %s AND version = %s", + (receipt["state"], active, json.dumps(updated, sort_keys=True), self.binding.digest, receipt["key"], receipt["binding"], receipt["_pg_version"])) + if tx["cursor"].rowcount != 1: + raise GroupProvenanceUnavailable() + self._transition(tx, updated, operation) + return updated + + def save(self, receipt): + with self._transaction() as tx: + updated = self._write(tx, receipt) + # The provisioner keeps references to nested group pre/poststate across + # intent persistence. Replace only this backend's acknowledged version; + # _write serializes without changing the caller's mutation body. + receipt["_pg_version"] = updated["_pg_version"] + + def claim_revoke(self, key, binding, delete_if_empty): + self._inputs(key, binding) + if not isinstance(delete_if_empty, bool): + raise ValueError("delete_if_empty must be boolean") + with self._transaction() as tx: + receipt = self._read(tx["cursor"], key, binding) + if receipt is None: + raise ValueError("unknown ensure receipt or request binding mismatch") + if receipt["state"] == "revoked": + if receipt["delete_if_empty"] != delete_if_empty: + raise ValueError("completed rollback options cannot change") + return receipt + if receipt["state"] != "ensured": + raise GroupProvenanceUnavailable() + self._active(tx["cursor"]) + receipt.update(state="revoking", delete_if_empty=delete_if_empty) + updated = self._write(tx, receipt, "claim_revoke") + return updated + + +def psycopg_connector(*, connection_info: Callable[[], dict[str, Any]]): + """Owner-provided renewable lease values stay in memory; never env/argv/logs. + + No connection happens here. Lazy import keeps disabled group endpoints free + of a runtime dependency. The admitted owner supplies TLS/host/db/role facts. + """ + if not callable(connection_info): + raise ValueError("owner connection-info callback required") + def connect(): + try: + import psycopg + values = dict(connection_info()) + return psycopg.connect(**values, connect_timeout=5) + except Exception: + raise GroupProvenanceUnavailable() from None + return connect diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/migrations/group_journal_postgres_v1.sql b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/migrations/group_journal_postgres_v1.sql new file mode 100644 index 00000000..caf1d757 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/migrations/group_journal_postgres_v1.sql @@ -0,0 +1,23 @@ +-- Run only by the admitted migration role in its existing owner schema. +-- Does not create a database/schema/credential, admit an epoch or clear quarantine. +CREATE TABLE identity_provisioner.group_journal_meta ( + singleton boolean PRIMARY KEY CHECK (singleton = true), + epoch uuid NOT NULL, + directory text NOT NULL CHECK (octet_length(directory) BETWEEN 1 AND 1024), + head bigint NOT NULL CHECK (head >= 0), + head_digest text NOT NULL CHECK (head_digest ~ '^[0-9a-f]{64}$'), + quarantined boolean NOT NULL DEFAULT true +); +CREATE TABLE identity_provisioner.group_receipts ( + context_digest text NOT NULL CHECK (context_digest ~ '^[0-9a-f]{64}$'), + context_json text NOT NULL CHECK (octet_length(context_json) <= 16384), + key text NOT NULL CHECK (char_length(key) BETWEEN 1 AND 256), + binding text NOT NULL CHECK (binding ~ '^[0-9a-f]{64}$'), + version bigint NOT NULL CHECK (version >= 1), + state text NOT NULL CHECK (state IN ('pending','ensured','revoking','revoked','uncertain','refused')), + active boolean UNIQUE CHECK (active IS NULL OR active = true), + body text NOT NULL CHECK (octet_length(body) <= 1048576), + PRIMARY KEY (context_digest, key) +); +-- Epoch insertion, initial continuity checkpoint and admission are separate +-- owner actions; this migration leaves all operations refused without them. diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/provisioner.py b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/provisioner.py new file mode 100644 index 00000000..28a3e5ae --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/provisioner.py @@ -0,0 +1,674 @@ +"""NetKingdom's idempotent LLDAP lifecycle adapter.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import hashlib +import re +import time +from typing import Any, Callable +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from group_provenance import GroupJournalStore, GroupProvenanceUnavailable + + +@dataclass(frozen=True) +class Result: + provider: str + external_subject: str + status: str + resumed: bool + + +@dataclass(frozen=True) +class GroupResult(Result): + ensure_idempotency_key: str + ensure_correlation_id: str + created_groups: tuple[dict[str, Any], ...] + created_memberships: tuple[dict[str, Any], ...] + + +@dataclass(frozen=True) +class DriftResult: + provider: str + external_subject: str + status: str + drift: tuple[str, ...] + changed: tuple[str, ...] = () + + +class DependencyFailure(RuntimeError): + """Directory dependency failed. Messages never carry credentials or upstream bodies.""" + + def __init__(self, code: str, *, dependency: str = "directory") -> None: + self.code = code + self.dependency = dependency + super().__init__("dependency_unavailable") + + def payload(self) -> dict[str, str]: + return { + "error": "dependency_unavailable", + "dependency": self.dependency, + "reason": self.code, + } + + +class LLDAPProvisioner: + def __init__( + self, + *, + base_url: str, + admin_password: str, + opener: Callable = urlopen, + auth_rejected_cooldown: float = 30.0, + clock: Callable[[], float] = time.monotonic, + group_provenance: GroupJournalStore | None = None, + ) -> None: + self.group_provenance = group_provenance + self.base_url = base_url.rstrip("/") + self.admin_password = admin_password + self.opener = opener + self.auth_rejected_cooldown = auth_rejected_cooldown + self.clock = clock + self._auth_rejected_until = 0.0 + + def provision(self, payload: dict[str, Any]) -> Result: + _required(payload, "user_id", "tenant", "primary_email", "idempotency_key", "correlation_id") + email = str(payload["primary_email"]).strip().lower() + username = _username(email, payload.get("preferred_username")) + token = self._login() + users, groups = self._directory(token) + existing = next((user for user in users if user.get("id") == username), None) + resumed = existing is not None + created = existing is None + if created: + self._gql(token, """ +mutation CreateUser($id: String!, $email: String!, $display: String!) { + createUser(user: {id: $id, email: $email, displayName: $display}) { id } +}""", { + "id": username, + "email": email, + "display": str(payload.get("display_name") or username), + }) + elif str(existing.get("email", "")).lower() != email: + raise ValueError("directory username collision") + roles = {str(role) for role in payload.get("roles", ())} + group_names = [f"{payload['tenant']}:users"] + if "tenant-admin" in roles: + group_names.append(f"{payload['tenant']}:admins") + try: + for name in group_names: + group_id = self._ensure_group(token, groups, name) + self._add_group(token, username, group_id) + except Exception: + if created: + try: + self._delete(token, username) + except Exception: + pass + raise + return Result("netkingdom-lldap", _oidc_subject(username), "password_setup_required", resumed) + + def tenant_access(self, payload: dict[str, Any]) -> Result: + """Change only the two role groups owned by the specified tenant.""" + _required(payload, "external_subject", "tenant", "idempotency_key", "correlation_id") + tenant = str(payload["tenant"]) + if not re.fullmatch(r"tenant:[a-z0-9][a-z0-9._-]*(?::[a-z0-9][a-z0-9._-]*)*", tenant): + raise ValueError("invalid tenant identifier") + if tenant == "tenant:platform:root": + raise ValueError("platform root is not a tenant access target") + if not isinstance(payload.get("enabled"), bool): + raise ValueError("enabled must be boolean") + roles = payload.get("roles", []) + if not isinstance(roles, (list, tuple)) or any(r not in {"user", "tenant-admin"} for r in roles): + raise ValueError("unsupported tenant role") + subject = _directory_username(str(payload["external_subject"])) + if not re.fullmatch(r"[A-Za-z0-9._-]+", subject): + raise ValueError("invalid directory subject") + token = self._login() + user = self._user(token, subject) + if user is None: + raise ValueError("login identity not found; create it before changing access") + managed = {f"{tenant}:users", f"{tenant}:admins"} + desired = {f"{tenant}:users"} if payload["enabled"] else set() + if payload["enabled"] and "tenant-admin" in roles: + desired.add(f"{tenant}:admins") + current = {str(g["displayName"]): int(g["id"]) for g in user.get("groups", ())} + groups = list(self._directory(token)[1]) + for name in sorted(desired - current.keys()): + self._add_group(token, subject, self._ensure_group(token, groups, name)) + for name in sorted((managed & current.keys()) - desired): + self._remove_group(token, subject, current[name]) + checked = self._user(token, subject) + if checked is None or ({g["displayName"] for g in checked.get("groups", ())} & managed) != desired: + raise RuntimeError("tenant access readback did not confirm the requested state") + return Result("netkingdom-lldap", _oidc_subject(subject), + "tenant_active" if payload["enabled"] else "tenant_disabled", False) + + def suspend(self, subject: str) -> Result: + subject = _directory_username(subject) + token = self._login() + _, groups = self._directory(token) + group_id = self._ensure_group(token, groups, "netkingdom-suspended") + self._add_group(token, subject, group_id) + return Result("netkingdom-lldap", subject, "suspended", False) + + def reactivate(self, subject: str) -> Result: + subject = _directory_username(subject) + token = self._login() + _, groups = self._directory(token) + group = next((item for item in groups if item.get("displayName") == "netkingdom-suspended"), None) + if group: + self._gql(token, """ +mutation Remove($userId: String!, $groupId: Int!) { + removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } +}""", {"userId": subject, "groupId": int(group["id"])}) + return Result("netkingdom-lldap", subject, "active", False) + + def deprovision(self, subject: str) -> Result: + subject = _directory_username(subject) + token = self._login() + if self._user(token, subject) is None: + return Result("netkingdom-lldap", subject, "deprovisioned", True) + self._delete(token, subject) + return Result("netkingdom-lldap", subject, "deprovisioned", False) + + def drift(self, payload: dict[str, Any]) -> DriftResult: + subject, email, desired_groups, desired_status = _desired(payload) + token = self._login() + user = self._user(token, subject) + drift = self._drift(user, email, desired_groups, desired_status, str(payload["tenant"])) + status = "in_sync" if not drift else "drifted" + return DriftResult("netkingdom-lldap", subject, status, tuple(drift)) + + def reconcile(self, payload: dict[str, Any]) -> DriftResult: + subject, email, desired_groups, desired_status = _desired(payload) + token = self._login() + user = self._user(token, subject) + changed: list[str] = [] + if user is None: + result = self.provision(payload) + changed.append("user:created") + subject = result.external_subject + token = self._login() + user = self._user(token, subject) + if user is None: + raise RuntimeError("directory reconciliation did not create the identity") + if str(user.get("email", "")).lower() != email: + raise ValueError("directory email drift requires explicit identity repair") + + groups = list(self._directory(token)[1]) + current = {str(item["displayName"]): int(item["id"]) for item in user.get("groups", ())} + tenant = str(payload["tenant"]) + managed = { + name for name in current + if name in {f"{tenant}:users", f"{tenant}:admins", "netkingdom-suspended"} + } + for name in sorted(desired_groups - managed): + self._add_group(token, subject, self._ensure_group(token, groups, name)) + changed.append(f"group:added:{name}") + for name in sorted(managed - desired_groups): + self._remove_group(token, subject, current[name]) + changed.append(f"group:removed:{name}") + + user = self._user(token, subject) + remaining = self._drift(user, email, desired_groups, desired_status, tenant) + status = "reconciled" if not remaining else "drifted" + return DriftResult("netkingdom-lldap", subject, status, tuple(remaining), tuple(changed)) + + def preflight(self) -> dict[str, str]: + """One login plus one directory read. Auth rejection is not retried during cooldown.""" + now = self.clock() + if now < self._auth_rejected_until: + raise DependencyFailure("auth_rejected") + try: + token = self._login(timeout=3) + self._gql(token, "query { groups { id } }", {}, timeout=3) + except DependencyFailure as exc: + if exc.code == "auth_rejected": + self._auth_rejected_until = now + self.auth_rejected_cooldown + raise + if self.group_provenance is not None: + self.group_provenance.check() + return {"status": "ready", "dependency": "directory", + "service_groups": "available" if self.group_provenance is not None else "disabled"} + + def _login(self, timeout: float = 10) -> str: + return directory_login( + base_url=self.base_url, + admin_password=self.admin_password, + opener=self.opener, + timeout=timeout, + ) + + def _group_store(self) -> GroupJournalStore: + store = self.group_provenance + if store is None: + raise GroupProvenanceUnavailable() + store.check() + return store + + @staticmethod + def _group_binding(groups, members, correlation_id) -> str: + # Original correlation is part of the exact ensure binding. + canonical = {"ensure_correlation_id": correlation_id, "groups": sorted(groups), "members": {g: sorted(members.get(g, [])) for g in sorted(groups)}} + return hashlib.sha256(json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + @staticmethod + def _group_result(receipt, *, resumed=False) -> GroupResult: + status = "groups_ensured" if receipt["state"] == "ensured" else ( + "groups_revoked_and_deleted" if receipt.get("deleted") else "groups_revoked") + return GroupResult("netkingdom-lldap", ",".join(receipt["requested_groups"]), status, resumed, + receipt["key"], receipt["ensure_correlation_id"], tuple(receipt["created_groups"]), tuple(receipt["created_memberships"])) + + def ensure_service_groups(self, payload: dict[str, Any]) -> GroupResult: + """Additive ensure with durable creation provenance, not new authority. + + Replays return the original result, never recreate removed state. Pending + or ambiguous calls survive restart and require independent owner recovery. + """ + groups, members = _service_group_request(payload) + store = self._group_store() + receipt, fresh = store.reserve(payload["idempotency_key"], self._group_binding(groups, members, payload["correlation_id"])) + if not fresh: + if receipt["state"] != "ensured": + raise RuntimeError("group provenance unresolved or already rolled back") + return self._group_result(receipt, resumed=True) + attempted = False + try: + token = self._login() + users, directory_groups = self._directory(token) + known = {str(u.get("id")) for u in users} + if {u for us in members.values() for u in us} - known: + raise ValueError("unknown directory users") + ids = {str(g["displayName"]): int(g["id"]) for g in directory_groups} + receipt["requested_groups"] = sorted(groups) + receipt["ensure_correlation_id"] = payload["correlation_id"] + # Complete prestate is persisted before the first directory mutation. + for name in groups: + gid = ids.get(name) + before = sorted(self._group_members(token, gid)) if gid is not None else [] + if len(before) + sum(len(g["before"]) for g in receipt["groups"].values()) > 4096: + raise ValueError("group prestate exceeds bounded provenance capacity") + receipt["groups"][name] = {"id": gid, "before": before, "after": sorted(set(before) | set(members.get(name, [])))} + store.save(receipt) + for name in groups: + state = receipt["groups"][name] + if state["id"] is None: + receipt["intent"] = {"operation": "create_group", "name": name} + store.save(receipt) + attempted = True + gid = self._ensure_group(token, directory_groups, name) + state["id"] = gid + # A successful creation response plus readback is needed. + checked = {str(g["displayName"]): int(g["id"]) for g in self._directory(token)[1]} + if checked.get(name) != gid or self._group_members(token, gid): + raise RuntimeError("created group readback differs") + receipt["created_groups"].append({"name": name, "id": gid}) + receipt["intent"] = None + store.save(receipt) + for username in members.get(name, []): + if username in state["before"]: + continue + receipt["intent"] = {"operation": "add_member", "name": name, "id": state["id"], "username": username} + store.save(receipt) + # Unlike the lifecycle helper, duplicate/racing additions are + # ambiguous, never accepted as proof that this attempt created it. + attempted = True + result = self._gql(token, "mutation Add($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }", + {"userId": username, "groupId": state["id"]}) + if result.get("addUserToGroup", {}).get("ok") is not True or username not in self._group_members(token, state["id"]): + raise RuntimeError("membership creation did not read back") + receipt["created_memberships"].append({"name": name, "id": state["id"], "username": username}) + receipt["intent"] = None + store.save(receipt) + self._check_group_postimage(token, receipt) + receipt["state"] = "ensured" + store.save(receipt) + except Exception: + receipt["state"] = "uncertain" if attempted else "refused" + receipt["failure_phase"] = "mutation_attempted" if attempted else "before_mutation" + store.save(receipt) + raise + return self._group_result(receipt) + + def _check_group_postimage(self, token, receipt) -> None: + ids = {str(g["displayName"]): int(g["id"]) for g in self._directory(token)[1]} + for name, state in receipt["groups"].items(): + if ids.get(name) != state["id"] or sorted(self._group_members(token, state["id"])) != state["after"]: + raise RuntimeError("group postimage changed; independent owner reconciliation required") + + def revoke_service_groups(self, payload: dict[str, Any]) -> GroupResult: + """Reverse confirmed creations from the owner journal only. + + Caller-supplied creation inventories confer no rights. This protects + preexisting state and refuses detected drift; native LLDAP has no CAS or + membership-generation token, so external writer isolation is still an + owner acceptance prerequisite (readback alone cannot exclude ABA). + """ + groups, members = _service_group_request(payload) + _required(payload, "ensure_idempotency_key", "ensure_correlation_id") + delete_if_empty = payload.get("delete_if_empty", False) + if not isinstance(delete_if_empty, bool): + raise ValueError("delete_if_empty must be true or false") + store = self._group_store() + receipt = store.claim_revoke(payload["ensure_idempotency_key"], self._group_binding(groups, members, payload["ensure_correlation_id"]), delete_if_empty) + if receipt["state"] == "revoked": + return self._group_result(receipt, resumed=True) + try: + token = self._login() + # Validate the entire operation before the first destructive call. + self._check_group_postimage(token, receipt) + for edge in receipt["created_memberships"]: + receipt["intent"] = {"operation": "remove_member", **edge} + store.save(receipt) + self._remove_group(token, edge["username"], edge["id"]) + expected = receipt["groups"][edge["name"]]["after"] + expected.remove(edge["username"]) + self._check_group_postimage(token, receipt) + receipt["intent"] = None + store.save(receipt) + receipt["deleted"] = [] + if delete_if_empty: + for group in receipt["created_groups"]: + # Only this attempt's groups, never a preexisting empty group. + self._check_group_postimage(token, receipt) + if self._group_members(token, group["id"]): + raise RuntimeError("created group is no longer empty") + receipt["intent"] = {"operation": "delete_group", **group} + store.save(receipt) + self._gql(token, "mutation Del($id: Int!) { deleteGroup(groupId: $id) { ok } }", {"id": group["id"]}) + if any(int(g["id"]) == group["id"] or g["displayName"] == group["name"] for g in self._directory(token)[1]): + raise RuntimeError("group deletion did not read back") + receipt["deleted"].append(group["name"]) + del receipt["groups"][group["name"]] + receipt["intent"] = None + store.save(receipt) + receipt["state"] = "revoked" + store.save(receipt) + except Exception: + receipt["state"] = "uncertain" + store.save(receipt) + raise + return self._group_result(receipt) + + def _group_members(self, token: str, group_id: int) -> list[str]: + value = self._gql(token, "query G($id: Int!) { group(groupId: $id) { users { id } } }", {"id": group_id}) + return [str(u["id"]) for u in (value.get("group") or {}).get("users", ())] + + def _directory(self, token: str) -> tuple[list[dict], list[dict]]: + value = self._gql(token, "query { users { id email displayName } groups { id displayName } }", {}) + return list(value["users"]), list(value["groups"]) + + def _user(self, token: str, subject: str) -> dict[str, Any] | None: + try: + value = self._gql(token, """ +query User($id: String!) { + user(userId: $id) { id email displayName groups { id displayName } } +}""", {"id": subject}) + except ValueError as exc: + if "not found" in str(exc).lower(): + return None + raise + user = value.get("user") + return dict(user) if user else None + + def _ensure_group(self, token: str, groups: list[dict], name: str) -> int: + existing = next((group for group in groups if group.get("displayName") == name), None) + if existing: + return int(existing["id"]) + created = self._gql( + token, + "mutation CreateGroup($name: String!) { createGroup(name: $name) { id displayName } }", + {"name": name}, + )["createGroup"] + groups.append(created) + return int(created["id"]) + + def _add_group(self, token: str, username: str, group_id: int) -> None: + try: + self._gql(token, """ +mutation Add($userId: String!, $groupId: Int!) { + addUserToGroup(userId: $userId, groupId: $groupId) { ok } +}""", {"userId": username, "groupId": group_id}) + except ValueError as exc: + if "already" not in str(exc).lower() and "unique" not in str(exc).lower(): + raise + + def _remove_group(self, token: str, username: str, group_id: int) -> None: + self._gql(token, """ +mutation Remove($userId: String!, $groupId: Int!) { + removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } +}""", {"userId": username, "groupId": group_id}) + + def _delete(self, token: str, subject: str) -> None: + self._gql( + token, + "mutation Delete($id: String!) { deleteUser(userId: $id) { ok } }", + {"id": subject}, + ) + + @staticmethod + def _drift( + user: dict[str, Any] | None, + email: str, + desired_groups: set[str], + desired_status: str, + tenant: str, + ) -> list[str]: + if user is None: + return ["user:missing"] + drift: list[str] = [] + if str(user.get("email", "")).lower() != email: + drift.append("email:mismatch") + current = {str(item["displayName"]) for item in user.get("groups", ())} + managed = { + name for name in current + if name in {f"{tenant}:users", f"{tenant}:admins", "netkingdom-suspended"} + } + drift.extend(f"group:missing:{name}" for name in sorted(desired_groups - managed)) + drift.extend(f"group:unexpected:{name}" for name in sorted(managed - desired_groups)) + suspended = "netkingdom-suspended" in current + if suspended != (desired_status == "suspended"): + drift.append("status:mismatch") + return drift + + def _gql(self, token: str, query: str, variables: dict[str, Any], timeout: float = 15) -> dict: + request = Request( + self.base_url + "/api/graphql", + data=json.dumps({"query": query, "variables": variables}).encode(), + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with self.opener(request, timeout=timeout) as response: + payload = json.loads(response.read()) + except HTTPError as exc: + status = int(getattr(exc, "code", 0) or 0) + _discard(exc) + if status in {401, 403}: + raise DependencyFailure("auth_rejected") from None + raise DependencyFailure("protocol_error") from None + except (TimeoutError, URLError, OSError): + raise DependencyFailure("unreachable") from None + except (json.JSONDecodeError, TypeError, ValueError, UnicodeDecodeError): + raise DependencyFailure("protocol_error") from None + if payload.get("errors"): + raise ValueError(str(payload["errors"][0].get("message", "LLDAP GraphQL error"))) + return dict(payload.get("data") or {}) + + +def directory_login( + *, + base_url: str, + admin_password: str, + opener: Callable = urlopen, + timeout: float = 10, +) -> str: + request = Request( + base_url.rstrip("/") + "/auth/simple/login", + data=json.dumps({"username": "admin", "password": admin_password}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with opener(request, timeout=timeout) as response: + payload = json.loads(response.read()) + except HTTPError as exc: + status = int(getattr(exc, "code", 0) or 0) + _discard(exc) + if status in {401, 403}: + raise DependencyFailure("auth_rejected") from None + raise DependencyFailure("protocol_error") from None + except (TimeoutError, URLError, OSError): + raise DependencyFailure("unreachable") from None + except (json.JSONDecodeError, TypeError, ValueError, UnicodeDecodeError): + raise DependencyFailure("protocol_error") from None + if not isinstance(payload, dict): + raise DependencyFailure("protocol_error") + token = str(payload.get("token") or "") + if not token: + raise DependencyFailure("protocol_error") + return token + + +def _discard(exc: BaseException) -> None: + read = getattr(exc, "read", None) + if callable(read): + try: + read(65536) + except Exception: + pass + + +SERVICE_GROUP = re.compile(r"[a-z][a-z0-9-]{1,62}") +RESERVED_GROUP_PREFIXES = ("tenant", "netkingdom-", "net-kingdom-", "lldap_") +DIRECTORY_USERNAME = re.compile(r"[a-z][a-z0-9._-]{0,31}") + + +def _service_group_request(payload: dict[str, Any]) -> tuple[list[str], dict[str, list[str]]]: + """Validate a service-access group request before anything is changed. + + Service-access groups gate sign-in to an internal service (SecurityCaseType + oidc-browser-relying-party). Tenant, platform identity/administration, + platform-suspension and LLDAP admin groups are reserved and can never be + touched through this path. + """ + if not isinstance(payload, dict): + raise ValueError("service group request must be an object") + _required(payload, "groups", "idempotency_key", "correlation_id") + allowed = {"groups", "members", "idempotency_key", "correlation_id", "delete_if_empty", "ensure_idempotency_key", "ensure_correlation_id"} + if set(payload) - allowed: + raise ValueError("unsupported service group request fields") + if not isinstance(payload["idempotency_key"], str) or len(payload["idempotency_key"]) > 256: + raise ValueError("invalid idempotency key") + if "ensure_idempotency_key" in payload and (not isinstance(payload["ensure_idempotency_key"], str) or not 16 <= len(payload["ensure_idempotency_key"]) <= 256): + raise ValueError("invalid ensure idempotency key") + for key in ("correlation_id", "ensure_correlation_id"): + if key in payload and (not isinstance(payload[key], str) or not 1 <= len(payload[key]) <= 256): + raise ValueError("invalid correlation identifier") + groups = payload["groups"] + if not isinstance(groups, list) or not groups or len(groups) > 16: + raise ValueError("groups must be a list of 1 to 16 names") + for name in groups: + if not isinstance(name, str) or not SERVICE_GROUP.fullmatch(name): + raise ValueError(f"invalid service group name {name!r}") + if name.startswith(RESERVED_GROUP_PREFIXES): + raise ValueError(f"group {name!r} is reserved") + members = payload.get("members", {}) + if not isinstance(members, dict): + raise ValueError("members must map group names to usernames") + total_members = 0 + for group, users in members.items(): + if group not in groups: + raise ValueError(f"members names group {group!r}, which is not in groups") + if not isinstance(users, list) or not all(isinstance(u, str) and DIRECTORY_USERNAME.fullmatch(u) for u in users): + raise ValueError(f"members of {group!r} must be directory usernames") + total_members += len(users) + if total_members > 256: + raise ValueError("at most 256 service memberships per request") + return list(dict.fromkeys(groups)), {g: list(dict.fromkeys(u)) for g, u in members.items()} + + +def dispatch( + provisioner: LLDAPProvisioner, path: str, payload: dict[str, Any] +) -> Result | DriftResult: + if path == "/v1/groups/ensure": + return provisioner.ensure_service_groups(payload) + if path == "/v1/groups/revoke": + return provisioner.revoke_service_groups(payload) + if path == "/v1/identities/tenant-access": + return provisioner.tenant_access(payload) + if path == "/v1/identities/provision": + return provisioner.provision(payload) + if path == "/v1/identities/drift": + return provisioner.drift(payload) + if path == "/v1/identities/reconcile": + return provisioner.reconcile(payload) + _required(payload, "external_subject", "idempotency_key", "correlation_id") + subject = _directory_username(str(payload["external_subject"])) + if path == "/v1/identities/suspend": + return provisioner.suspend(subject) + if path == "/v1/identities/reactivate": + return provisioner.reactivate(subject) + if path == "/v1/identities/deprovision": + return provisioner.deprovision(subject) + raise KeyError(path) + + +def _username(email: str, preferred: object = None) -> str: + if preferred is not None: + value = str(preferred).strip().lower() + if not re.fullmatch(r"[a-z][a-z0-9._-]{2,31}", value): + raise ValueError("preferred_username is invalid") + if value in {"admin", "administrator", "platform-root", "root", "system"}: + raise ValueError("preferred_username is reserved") + return value + local = email.partition("@")[0].lower() + value = re.sub(r"[^a-z0-9._-]+", "-", local).strip("-") + if not value or "@" not in email: + raise ValueError("valid primary_email is required") + return value[:64] + + +def _required(payload: dict[str, Any], *fields: str) -> None: + missing = [field for field in fields if not payload.get(field)] + if missing: + raise ValueError("missing required fields: " + ", ".join(missing)) + if len(str(payload.get("idempotency_key", ""))) < 16: + raise ValueError("idempotency_key must contain at least 16 characters") + + +def _desired(payload: dict[str, Any]) -> tuple[str, str, set[str], str]: + _required( + payload, + "external_subject", + "tenant", + "primary_email", + "idempotency_key", + "correlation_id", + ) + subject = str(payload["external_subject"]) + email = str(payload["primary_email"]).strip().lower() + if _username(email) != subject: + raise ValueError("external_subject does not match canonical email username") + desired_status = str(payload.get("desired_status", "active")) + if desired_status not in {"active", "suspended"}: + raise ValueError("desired_status must be active or suspended") + tenant = str(payload["tenant"]) + groups = {f"{tenant}:users"} + if "tenant-admin" in {str(role) for role in payload.get("roles", ())}: + groups.add(f"{tenant}:admins") + if desired_status == "suspended": + groups.add("netkingdom-suspended") + return subject, email, groups, desired_status + + +def _directory_username(subject: str) -> str: + return subject[4:].split(",", 1)[0] if subject.startswith("uid=") else subject + + +def _oidc_subject(username: str) -> str: + return f"uid={username},ou=people,dc=netkingdom,dc=local" diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/requirements-postgres.txt b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/requirements-postgres.txt new file mode 100644 index 00000000..9e614f64 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/requirements-postgres.txt @@ -0,0 +1,5 @@ +# Linux amd64 CPython3.12 runtime closure from user-engine/uv.lock. +# Build-only dependency resolution; no packages are installed at service startup. +psycopg==3.3.5 --hash=sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e +psycopg-binary==3.3.5 --hash=sha256:682a17a57415c3ca1731eec018ed031f012ffcb81ba74806eb219cb396065672 +typing-extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/tests/test_group_postgres.py b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/tests/test_group_postgres.py new file mode 100644 index 00000000..adcd518a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/postimage/identity-provisioner/tests/test_group_postgres.py @@ -0,0 +1,231 @@ +"""Binding, CAS and ambiguous-commit refusal tests; native SQL proof is separate.""" +from dataclasses import replace +import json +from unittest.mock import Mock +import unittest + +from group_provenance import GroupProvenanceUnavailable +from group_provenance_postgres import GroupJournalBinding, PostgresGroupProvenance +from server import configured_group_provenance +from unittest.mock import patch +import os + +EPOCH = '41fbb0ed-ea3a-4682-996b-e9c94a6aa9c6' +BINDING = GroupJournalBinding('tenant:fixture:one', 'synthetic-directory', 'sha256:' + '1' * 64, + 'sha256:' + '2' * 64, 'synthetic-owner', EPOCH) +REQUEST = '3' * 64 +HEAD_DIGEST = 'a' * 64 +META = (EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, False) + + +def fixture(rows=(), rowcount=1, commit_error=None): + cursor = Mock() + cursor.__enter__ = Mock(return_value=cursor) + cursor.__exit__ = Mock(return_value=False) + cursor.fetchone.side_effect = rows + cursor.rowcount = rowcount + connection = Mock() + connection.__enter__ = Mock(return_value=connection) + connection.__exit__ = Mock(return_value=False, side_effect=commit_error) + connection.cursor.return_value = cursor + connector = Mock(return_value=connection) + continuity = Mock() + continuity.check.return_value = True + continuity.advance.return_value = True + store = PostgresGroupProvenance(connect=connector, binding=BINDING, continuity=continuity) + return store, connector, connection, cursor, continuity + + +def receipt(version=4): + return {'key': 'synthetic-operation-key', 'binding': REQUEST, 'state': 'pending', 'intent': None, + 'groups': {}, 'created_groups': [], 'created_memberships': [], + '_pg_version': version, '_pg_context': BINDING.digest} + + +class PostgresSourceTests(unittest.TestCase): + def test_explicit_binding_and_continuity_required_without_connecting(self): + connector = Mock() + for binding, continuity in ((None, Mock()), (BINDING, None), ({'tenant_scope': 'caller-json'}, Mock())): + with self.assertRaises(ValueError): + PostgresGroupProvenance(connect=connector, binding=binding, continuity=continuity) + connector.assert_not_called() + for change in ({'tenant_scope': ''}, {'principal': ''}, {'case_digest': 'unsigned'}, {'epoch': 'default'}): + with self.assertRaises(ValueError): + replace(BINDING, **change) + self.assertNotEqual(BINDING.digest, replace(BINDING, principal='another-owner').digest) + self.assertNotEqual(BINDING.digest, replace(BINDING, tenant_scope='another-explicit-scope').digest) + + def test_missing_environment_callback_path_keeps_production_disabled(self): + with patch.dict(os.environ, {'GROUP_POSTGRES_DSN': 'synthetic-only', 'GROUP_PROVENANCE_POSTGRES': 'true'}, clear=True): + self.assertIsNone(configured_group_provenance()) + + def test_checkpoint_mismatch_poisoned_without_receipt_query_or_effect(self): + store, connect, _, cursor, checkpoint = fixture([META]) + checkpoint.check.return_value = False + with self.assertRaises(GroupProvenanceUnavailable): + store.reserve('synthetic-operation-key', REQUEST) + self.assertFalse(any('group_receipts' in c.args[0] for c in cursor.execute.call_args_list)) + with self.assertRaises(GroupProvenanceUnavailable): + store.check() + self.assertEqual(1, connect.call_count) + checkpoint.advance.assert_not_called() + + def test_restored_epoch_or_quarantine_refused(self): + for meta in ((EPOCH, 'other-directory', 7, HEAD_DIGEST, False), (EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, True), + ('98aa2b6d-a32a-44a5-a89f-b2480befc00e', 'synthetic-directory', 7, HEAD_DIGEST, False), None): + store, _, _, _, checkpoint = fixture([meta]) + with self.assertRaises(GroupProvenanceUnavailable): + store.check() + checkpoint.check.assert_not_called() + + def test_exactly_one_version_update_required_before_checkpoint_advance(self): + store, _, _, cursor, checkpoint = fixture([META], rowcount=0) + original = receipt() + with self.assertRaises(GroupProvenanceUnavailable): + store.save(original) + query, params = next(c.args for c in cursor.execute.call_args_list if c.args[0].startswith('UPDATE identity_provisioner.group_receipts')) + self.assertIn('AND version = %s', query) + self.assertEqual(4, params[-1]) + self.assertEqual(4, original['_pg_version']) + checkpoint.advance.assert_not_called() + + def test_success_acknowledges_commit_and_updates_receipt_version(self): + store, _, connection, _, checkpoint = fixture([META]) + original = receipt() + store.save(original) + self.assertEqual(5, original['_pg_version']) + connection.__exit__.assert_called_once() + args = checkpoint.advance.call_args.args + self.assertEqual((EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, 8), args[:5]) + self.assertRegex(args[5], r'^[0-9a-f]{64}$') + self.assertNotEqual(HEAD_DIGEST, args[5]) + connection.close.assert_called_once() + + def test_save_preserves_nested_receipt_references_held_by_provisioner(self): + store, *_ = fixture([META]) + original = receipt() + original['groups']['forge-coulomb-owners'] = {'id': None, 'before': [], 'after': ['tegwick']} + state = original['groups']['forge-coulomb-owners'] + groups, created, memberships = original['groups'], original['created_groups'], original['created_memberships'] + original['intent'] = {'operation': 'create_group', 'name': 'forge-coulomb-owners'} + store.save(original) + self.assertIs(groups, original['groups']) + self.assertIs(state, original['groups']['forge-coulomb-owners']) + self.assertIs(created, original['created_groups']) + self.assertIs(memberships, original['created_memberships']) + state['id'] = 4 + self.assertEqual(4, original['groups']['forge-coulomb-owners']['id']) + self.assertEqual(5, original['_pg_version']) + + def test_actual_provisioner_ensure_with_postgres_save_retains_created_ids(self): + from test_service_groups import Directory, req + directory = Directory() + directory.members[3].add('tegwick') + store, _, _, cursor, _ = fixture([META] * 32) + original = receipt(version=1) + payload = req() + original['key'] = payload['idempotency_key'] + original['binding'] = directory._group_binding(payload['groups'], payload['members'], payload['correlation_id']) + # Reservation input is already covered by SQL unit/native oracles. Keep + # real save/_write/transaction and the real provisioner creation path. + store.reserve = Mock(return_value=(original, True)) + directory.group_provenance = store + result = directory.ensure_service_groups(payload) + self.assertEqual('groups_ensured', result.status) + self.assertEqual({'forge-coulomb-owners', 'forge-coulomb-readers'}, {g['name'] for g in result.created_groups}) + self.assertEqual(1, len(result.created_memberships)) + self.assertEqual({'tegwick'}, directory.members[3]) + bodies = [json.loads(c.args[1][2]) for c in cursor.execute.call_args_list + if c.args[0].startswith('UPDATE identity_provisioner.group_receipts')] + self.assertTrue(all(g['id'] is not None for g in bodies[-1]['groups'].values())) + self.assertEqual('ensured', bodies[-1]['state']) + + def test_connection_close_failure_is_sanitized_and_stops_continuation(self): + store, _, connection, _, _ = fixture([META]) + connection.close.side_effect = OSError('synthetic-private-diagnostic') + with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): + store.check() + self.assertTrue(store._poisoned) + + def test_commit_failure_never_advances_owner_checkpoint_or_receipt(self): + store, _, _, _, checkpoint = fixture([META], commit_error=OSError('synthetic-secret-diagnostic')) + original = receipt() + with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): + store.save(original) + self.assertEqual(4, original['_pg_version']) + checkpoint.advance.assert_not_called() + + def test_lost_checkpoint_acknowledgment_refuses_then_poisoned(self): + store, connect, _, _, checkpoint = fixture([META]) + checkpoint.advance.side_effect = OSError('synthetic-secret-diagnostic') + original = receipt() + with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): + store.save(original) + self.assertEqual(4, original['_pg_version']) + with self.assertRaises(GroupProvenanceUnavailable): + store.save(original) + self.assertEqual(1, connect.call_count) + + def test_cross_case_body_or_version_cannot_be_imported(self): + for body in (dict(receipt(), _pg_context='0' * 64), dict(receipt(), _pg_version=6)): + store, _, _, _, _ = fixture([META, (REQUEST, 4, json.dumps(body), json.dumps(BINDING.__dict__, sort_keys=True))]) + with self.assertRaises(GroupProvenanceUnavailable): + store.get('synthetic-operation-key', REQUEST) + + def test_same_counter_divergent_body_or_context_yields_distinct_marker(self): + store, *_ = fixture() + markers = [] + for change in ({}, {'intent': {'operation': 'different'}}, {'key': 'another-exact-key'}, {'_pg_version': 5}): + tx = {'head': 7, 'digest': HEAD_DIGEST} + store._transition(tx, dict(receipt(), **change), 'save') + markers.append(tx['next_digest']) + self.assertEqual(4, len(set(markers))) + tx = {'head': 7, 'digest': 'b' * 64} + store._transition(tx, receipt(), 'save') + self.assertNotIn(tx['next_digest'], markers) + foreign, *_ = fixture() + foreign.binding = replace(BINDING, principal='another-owner') + tx = {'head': 7, 'digest': HEAD_DIGEST} + foreign._transition(tx, receipt(), 'save') + self.assertNotIn(tx['next_digest'], markers) + + def test_reopened_loser_clone_at_same_head_refuses_different_marker(self): + # Both DBs report head8. The independent callback accepts only winner + # digest; loser must be refused before loading any receipt or effect. + loser_digest = 'b' * 64 + store, _, _, cursor, checkpoint = fixture([(EPOCH, 'synthetic-directory', 8, loser_digest, False)]) + checkpoint.check.side_effect = lambda epoch, directory, head, digest: (head, digest) == (8, 'c' * 64) + with self.assertRaises(GroupProvenanceUnavailable): + store.check() + checkpoint.check.assert_called_once_with(EPOCH, 'synthetic-directory', 8, loser_digest) + self.assertFalse(any('group_receipts' in c.args[0] for c in cursor.execute.call_args_list)) + + def test_existing_request_mismatch_is_refused_not_rebound(self): + store, _, _, _, checkpoint = fixture([META, ('0' * 64, 4, '{}', '{}')]) + with self.assertRaisesRegex(ValueError, 'binding mismatch'): + store.get('synthetic-operation-key', REQUEST) + checkpoint.advance.assert_not_called() + self.assertFalse(store._poisoned) + + def test_active_fence_capacity_refusal_and_no_insert(self): + for rows in ([META, None, (1,)], [META, None, None, (256,)]): + store, _, _, cursor, checkpoint = fixture(rows) + with self.assertRaises(GroupProvenanceUnavailable): + store.reserve('synthetic-operation-key', REQUEST) + self.assertFalse(any('INSERT' in c.args[0] for c in cursor.execute.call_args_list)) + checkpoint.advance.assert_not_called() + + def test_new_reservation_retains_bound_context_and_write_intent(self): + store, _, _, cursor, checkpoint = fixture([META, None, None, (0,)]) + created, fresh = store.reserve('synthetic-operation-key', REQUEST) + self.assertTrue(fresh) + self.assertEqual('pending', created['state']) + self.assertEqual(BINDING.digest, created['_pg_context']) + insert = next(c for c in cursor.execute.call_args_list if c.args[0].startswith('INSERT')) + self.assertIn('context_json', insert.args[0]) + self.assertEqual(BINDING.tenant_scope, json.loads(insert.args[1][1])['tenant_scope']) + checkpoint.advance.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/source-manifest.json b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/source-manifest.json new file mode 100644 index 00000000..06869051 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/source-manifest.json @@ -0,0 +1,66 @@ +{ + "base_revision": "6cb43c0cc55212363a5406b02db046e2384a7c40", + "base_tree": "615095596348b23ad34da79cd0e8597cdb815c5d", + "scope": "source successor preserving nested receipt aliases after PostgreSQL commit; no native/canonical admission", + "files": [ + { + "path": "docs/service-group-provenance.md", + "preimage_sha256": "cd6b45640983b11e29a6bc6278bcb45de8fd8a5ddcad53eb77ab1a1f3f1f1280", + "postimage_sha256": "b287e7a85aa2146f35d2bc8473dfd05239ea0179b7b8b186d2e85bc07c7b650c", + "preimage_bytes": 7455, + "postimage_bytes": 11401 + }, + { + "path": "identity-provisioner/Containerfile", + "preimage_sha256": "c744f39ffb4c0dc2686c3e94d8233bc772b55faae9581557bee17f91c19d9ca6", + "postimage_sha256": "31e7d948d4f40efd6950764a3dbb8c410d50ed7a1c13c18e0830a1186f2abafc", + "preimage_bytes": 459, + "postimage_bytes": 651 + }, + { + "path": "identity-provisioner/group_provenance.py", + "preimage_sha256": "fbd4c88d7682ba3d46c5b325dad1bc4f4a7faac2cd3f6ce9e0f7a89283bf46d4", + "postimage_sha256": "405e08eaaab7c21d618dff4db5970607806f32ab6c461effb81c737e32182b10", + "preimage_bytes": 8947, + "postimage_bytes": 9409 + }, + { + "path": "identity-provisioner/group_provenance_postgres.py", + "preimage_sha256": null, + "postimage_sha256": "2a0111e83ad7b611076a1b45634e7f698b3d2e9acc473320b4a8ba009ebd0932", + "preimage_bytes": 0, + "postimage_bytes": 13409 + }, + { + "path": "identity-provisioner/migrations/group_journal_postgres_v1.sql", + "preimage_sha256": null, + "postimage_sha256": "3cdf82194d6f1e2dd865b0c7e7b8a14447fda8c49151148134a0d40a80fb46ac", + "preimage_bytes": 0, + "postimage_bytes": 1353 + }, + { + "path": "identity-provisioner/provisioner.py", + "preimage_sha256": "2f8e4368e7155465bbc4070b1019c7105edf43bdc2ad3bbc5aa9bc19dc6ed7a5", + "postimage_sha256": "19d1b837cbafeeacb1aa815856cd68091e6702281d72b1d860230fa320e1725b", + "preimage_bytes": 31721, + "postimage_bytes": 31727 + }, + { + "path": "identity-provisioner/requirements-postgres.txt", + "preimage_sha256": null, + "postimage_sha256": "14f1beae54a789d2c880654ca0ce4094c9aa54645e79314b4297c12938ba8877", + "preimage_bytes": 0, + "postimage_bytes": 450 + }, + { + "path": "identity-provisioner/tests/test_group_postgres.py", + "preimage_sha256": null, + "postimage_sha256": "ff3fd92587c8aeea66018718dd8a76f2504648fb3393b122e91ff35a6f431abc", + "preimage_bytes": 0, + "postimage_bytes": 12568 + } + ], + "patch_sha256": "5549d2982b61ff70aba4b3928fecd5639db0f1a732ab2f8630f678e7acf4d34d", + "superseded_counter_only": "review-r0", + "superseded_alias_replacement": "review-r3" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/source.patch b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/source.patch new file mode 100644 index 00000000..795c7b85 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/review-r4/source.patch @@ -0,0 +1,667 @@ +diff --git a/docs/service-group-provenance.md b/docs/service-group-provenance.md +--- a/docs/service-group-provenance.md ++++ b/docs/service-group-provenance.md +@@ -80,6 +80,64 @@ + and replay. No arbitrary JSON import, receipt expiry or automatic uncertainty + clearing is introduced. Owner reconciliation needs independent evidence. + ++## PostgreSQL source seam (not native admission) ++ ++`PostgresGroupProvenance` implements the same reserve/get/claim-revoke/save/check ++journal contract. It is injection-only: the ordinary HTTP server has no ++PostgreSQL environment switch and remains without a group store unless the ++existing local SQLite configuration is explicitly supplied. `GROUP_POSTGRES_DSN` ++or a caller JSON object cannot enable the PostgreSQL path. Production group ++operations remain unadmitted and disabled pending the owner callbacks and gates ++above; a backend constructor is not an access decision. ++ ++A trusted owner integration must provide an exact `GroupJournalBinding` with ++opaque authoritative tenant scope, directory instance, accepted case digest, ++source digest, represented principal and journal epoch. There is no guessed ++platform-root scope or request-derived tenant fallback. The owner also injects a ++renewable private connection-info callback using the existing `rapp-postgres` ++consumer/lease contract, and independently durable `JournalContinuity` callbacks. ++These callbacks retain/check/advance exact epoch+directory+head+commit digest outside the ++PostgreSQL state that might be restored. Neither callback authenticates a caller ++or grants a protected side effect. Their native custody, source binding, ++revocation and execution bounds still require independent admission. Only the ++disposable fixture supplies a test checkpoint implementation here. ++ ++The v1 migration under `identity-provisioner/migrations/` creates tables within ++the existing consumer schema; it creates no database/schema/roles, inserts no ++epoch and clears no quarantine. Startup does not run migrations or initialize ++missing state. The owner must separately admit an epoch/checkpoint after ++independent continuity/recovery review; absent or quarantined state refuses. ++A restored database with an older head than the independently retained checkpoint ++also refuses rather than treating missing later intents as authority to replay. ++ ++PostgreSQL transactions lock the single journal metadata row, commit bounded ++receipts/intents and advance the head and exact transition digest with an exactly-one conditional ++update. The digest binds the prior marker, canonical body, context, request key ++and receipt version; the independent checkpoint must compare/CAS both content ++and sequence, never accept a counter-only callback. Divergent restored clones ++that commit different transitions at the same counter cannot both be accepted. ++Every receipt update also requires its exact context/key/request/version and ++increments its version. The active slot spans all contexts in the journal; ++it serializes cooperating producer operations, not external LDAP writers. ++SQL commit precedes independently durable checkpoint acknowledgment. A lost ++commit/acknowledgment, stale version, missing row or continuity mismatch refuses ++and poisons that backend object; exception handling cannot automatically rewrite ++its journal into success. Known request-binding mismatches remain domain ++refusals. Definite before-mutation refusal records can release the active slot ++while the original key remains terminal. ++ ++The container source includes a hash-pinned Linux amd64 CPython3.12 psycopg3 ++closure selected from User Engine's existing lockfile. No image build or package ++installation is part of this source proof; build/publication, artifact scanning ++and native runtime/dependency admission remain owner work. SQLite is still ++local/disposable only. The separate PostgreSQL fixture proposal uses a cached ++pinned PostgreSQL image and a cached pinned User Engine Python image, real SQL, ++separate-process reopen, concurrency, stale-CAS and actual pg_dump/pg_restore ++behind an independently retained test checkpoint. That fixture needs exact ++review before Docker execution and does not prove live CNPG HA, TLS/lease ++custody, tenant authority, external writer fencing, production retention or ++protected session/evidence integration. ++ + ## Safety and remaining native acceptance + + Before each directory mutation, durable intent is committed. A successful +diff --git a/identity-provisioner/Containerfile b/identity-provisioner/Containerfile +--- a/identity-provisioner/Containerfile ++++ b/identity-provisioner/Containerfile +@@ -3,7 +3,10 @@ + RUN useradd --create-home --uid 10001 provisioner + WORKDIR /app + COPY --from=lldap /app/lldap_set_password /app/lldap_set_password +-COPY provisioner.py group_provenance.py password_setup.py server.py /app/ ++COPY requirements-postgres.txt /app/ ++RUN python -m pip install --no-cache-dir --require-hashes -r /app/requirements-postgres.txt ++COPY provisioner.py group_provenance.py group_provenance_postgres.py password_setup.py server.py /app/ ++COPY migrations/ /app/migrations/ + USER 10001:10001 + EXPOSE 8080 + CMD ["python", "server.py"] +diff --git a/identity-provisioner/group_provenance.py b/identity-provisioner/group_provenance.py +--- a/identity-provisioner/group_provenance.py ++++ b/identity-provisioner/group_provenance.py +@@ -11,8 +11,17 @@ + from pathlib import Path + import sqlite3 + import stat +-from typing import Any ++from typing import Any, Protocol + from urllib.parse import quote ++ ++ ++class GroupJournalStore(Protocol): ++ """Owner-injected persistence only; grants no caller or native authority.""" ++ def check(self) -> None: ... ++ def reserve(self, key: str, binding: str) -> tuple[dict[str, Any], bool]: ... ++ def get(self, key: str, binding: str) -> dict[str, Any]: ... ++ def claim_revoke(self, key: str, binding: str, delete_if_empty: bool) -> dict[str, Any]: ... ++ def save(self, receipt: dict[str, Any]) -> None: ... + + + class GroupProvenanceUnavailable(RuntimeError): +diff --git a/identity-provisioner/group_provenance_postgres.py b/identity-provisioner/group_provenance_postgres.py +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/group_provenance_postgres.py +@@ -0,0 +1,253 @@ ++"""Injection-only PostgreSQL implementation of the existing owner journal seam. ++ ++Binding and continuity callbacks are supplied by a trusted owner integration, ++never constructed from an HTTP payload. Neither is an authorization decision. ++No server environment switch, automatic schema creation or recovery admission. ++""" ++from __future__ import annotations ++ ++from contextlib import contextmanager ++from dataclasses import asdict, dataclass ++import hashlib ++import json ++import re ++from typing import Any, Callable, Protocol ++from uuid import UUID ++ ++from group_provenance import GroupProvenance, GroupProvenanceUnavailable ++ ++ ++@dataclass(frozen=True) ++class GroupJournalBinding: ++ tenant_scope: str ++ directory: str ++ case_digest: str ++ source_digest: str ++ principal: str ++ epoch: str ++ ++ def __post_init__(self): ++ for value in (self.tenant_scope, self.directory, self.principal): ++ if not isinstance(value, str) or not 1 <= len(value) <= 256 or any(ord(c) < 32 for c in value): ++ raise ValueError("explicit owner journal binding required") ++ for value in (self.case_digest, self.source_digest): ++ if not isinstance(value, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", value): ++ raise ValueError("exact owner source and case digests required") ++ if not isinstance(self.epoch, str) or str(UUID(self.epoch)) != self.epoch: ++ raise ValueError("explicit canonical journal epoch required") ++ ++ @property ++ def digest(self): ++ return hashlib.sha256(json.dumps(asdict(self), sort_keys=True, separators=(",", ":")).encode()).hexdigest() ++ ++ ++def journal_genesis(epoch: str, directory: str) -> str: ++ """Deterministic initial marker for explicit owner migration/admission only.""" ++ return hashlib.sha256(json.dumps({"schema": "nk-group-journal-genesis.v1", "epoch": epoch, ++ "directory": directory}, sort_keys=True, separators=(",", ":")).encode()).hexdigest() ++ ++ ++class JournalContinuity(Protocol): ++ """Existing independently retained owner checkpoint, outside restored SQL state. ++ ++ Implementations compare/advance exact epoch+directory+head+commit digest atomically and ++ durably. An in-memory implementation is a fixture only. Missing/stale or ++ ambiguous checkpoints must refuse; no callback grants mutation authority. ++ """ ++ def check(self, epoch: str, directory: str, head: int, digest: str) -> bool: ... ++ def advance(self, epoch: str, directory: str, previous: int, previous_digest: str, current: int, current_digest: str) -> bool: ... ++ ++ ++class PostgresGroupProvenance: ++ def __init__(self, *, connect: Callable, binding: GroupJournalBinding, ++ continuity: JournalContinuity, capacity: int = 256): ++ if not callable(connect) or not isinstance(binding, GroupJournalBinding) or not 1 <= capacity <= 4096: ++ raise ValueError("explicit owner connector, binding and bounded capacity required") ++ if not callable(getattr(continuity, "check", None)) or not callable(getattr(continuity, "advance", None)): ++ raise ValueError("independent owner continuity callbacks required") ++ self.connect, self.binding, self.continuity, self.capacity = connect, binding, continuity, capacity ++ self._poisoned = False ++ ++ def _continuity(self, method, *args): ++ try: ++ if getattr(self.continuity, method)(self.binding.epoch, self.binding.directory, *args) is not True: ++ raise GroupProvenanceUnavailable() ++ except Exception: ++ raise GroupProvenanceUnavailable() from None ++ ++ @contextmanager ++ def _transaction(self): ++ if self._poisoned: ++ raise GroupProvenanceUnavailable() ++ connection = None ++ tx = None ++ try: ++ connection = self.connect() ++ with connection: ++ with connection.cursor() as cursor: ++ cursor.execute("SET LOCAL statement_timeout = '5s'") ++ cursor.execute("SET LOCAL idle_in_transaction_session_timeout = '15s'") ++ cursor.execute("SELECT epoch::text, directory, head, head_digest, quarantined FROM identity_provisioner.group_journal_meta WHERE singleton = true FOR UPDATE") ++ meta = cursor.fetchone() ++ if not meta or meta[0] != self.binding.epoch or meta[1] != self.binding.directory or meta[4] is not False or type(meta[2]) is not int or meta[2] < 0 or not isinstance(meta[3], str) or not re.fullmatch(r"[0-9a-f]{64}", meta[3]): ++ raise GroupProvenanceUnavailable() ++ self._continuity("check", meta[2], meta[3]) ++ tx = {"cursor": cursor, "head": meta[2], "digest": meta[3], "dirty": False} ++ yield tx ++ if tx["dirty"]: ++ cursor.execute("UPDATE identity_provisioner.group_journal_meta SET head = head + 1, head_digest = %s WHERE singleton = true AND epoch = %s AND directory = %s AND head = %s AND head_digest = %s AND quarantined = false", ++ (tx["next_digest"], self.binding.epoch, self.binding.directory, tx["head"], tx["digest"])) ++ if cursor.rowcount != 1: ++ raise GroupProvenanceUnavailable() ++ # SQL commit precedes the independent checkpoint acknowledgment. ++ # A lost acknowledgment refuses before the next directory effect. ++ if tx and tx["dirty"]: ++ self._continuity("advance", tx["head"], tx["digest"], tx["head"] + 1, tx["next_digest"]) ++ except ValueError: ++ raise ++ except GroupProvenanceUnavailable: ++ # Caller mismatches are domain refusals. Storage/continuity failures ++ # poison this object; durable pending records are never rewritten ++ # automatically by an exception handler after ambiguous commit. ++ self._poisoned = True ++ raise ++ except Exception: ++ self._poisoned = True ++ raise GroupProvenanceUnavailable() from None ++ finally: ++ if connection is not None: ++ try: ++ connection.close() ++ except Exception: ++ self._poisoned = True ++ raise GroupProvenanceUnavailable() from None ++ ++ def check(self): ++ with self._transaction(): ++ pass ++ ++ def _read(self, cursor, key, request_binding): ++ cursor.execute("SELECT binding, version, body, context_json FROM identity_provisioner.group_receipts WHERE context_digest = %s AND key = %s", ++ (self.binding.digest, key)) ++ row = cursor.fetchone() ++ if row is None: ++ return None ++ if row[0] != request_binding: ++ raise ValueError("ensure receipt request binding mismatch") ++ if row[3] != json.dumps(asdict(self.binding), sort_keys=True): ++ raise GroupProvenanceUnavailable() ++ receipt = GroupProvenance._load(row[2], key, request_binding) ++ if type(row[1]) is not int or row[1] < 1 or type(receipt.get("_pg_version")) is not int or receipt.get("_pg_version") != row[1] or receipt.get("_pg_context") != self.binding.digest: ++ raise GroupProvenanceUnavailable() ++ return receipt ++ ++ @staticmethod ++ def _inputs(key, binding): ++ if not isinstance(key, str) or not 1 <= len(key) <= 256 or not isinstance(binding, str) or not re.fullmatch(r"[0-9a-f]{64}", binding): ++ raise ValueError("bounded exact journal request binding required") ++ ++ def _active(self, cursor): ++ cursor.execute("SELECT 1 FROM identity_provisioner.group_receipts WHERE active = true LIMIT 1") ++ if cursor.fetchone(): ++ raise GroupProvenanceUnavailable() ++ ++ def _transition(self, tx, receipt, operation): ++ # Bind the exact transition, not merely a counter. Two restored/forked ++ # copies that commit different bodies at the same next head cannot both ++ # match an independently retained checkpoint after reopening. ++ transition = {"schema": "nk-group-journal-transition.v1", "epoch": self.binding.epoch, ++ "directory": self.binding.directory, "prior_head": tx["head"], "prior_digest": tx["digest"], ++ "head": tx["head"] + 1, "operation": operation, "context": asdict(self.binding), ++ "key": receipt["key"], "request_binding": receipt["binding"], ++ "version": receipt["_pg_version"], "body": receipt} ++ tx["next_digest"] = hashlib.sha256(json.dumps(transition, sort_keys=True, separators=(",", ":")).encode()).hexdigest() ++ tx["dirty"] = True ++ ++ def reserve(self, key, binding): ++ self._inputs(key, binding) ++ with self._transaction() as tx: ++ cursor = tx["cursor"] ++ receipt = self._read(cursor, key, binding) ++ if receipt is not None: ++ return receipt, False ++ self._active(cursor) ++ cursor.execute("SELECT COUNT(*) FROM identity_provisioner.group_receipts") ++ if cursor.fetchone()[0] >= self.capacity: ++ raise GroupProvenanceUnavailable() ++ receipt = {"key": key, "binding": binding, "state": "pending", "intent": None, "groups": {}, ++ "created_groups": [], "created_memberships": [], "_pg_version": 1, "_pg_context": self.binding.digest} ++ cursor.execute("INSERT INTO identity_provisioner.group_receipts (context_digest, context_json, key, binding, version, state, active, body) VALUES (%s, %s, %s, %s, 1, 'pending', true, %s)", ++ (self.binding.digest, json.dumps(asdict(self.binding), sort_keys=True), key, binding, json.dumps(receipt, sort_keys=True))) ++ if cursor.rowcount != 1: ++ raise GroupProvenanceUnavailable() ++ self._transition(tx, receipt, "reserve") ++ return receipt, True ++ ++ def get(self, key, binding): ++ self._inputs(key, binding) ++ with self._transaction() as tx: ++ receipt = self._read(tx["cursor"], key, binding) ++ if receipt is None: ++ raise ValueError("unknown ensure receipt or request binding mismatch") ++ return receipt ++ ++ def _write(self, tx, receipt, operation="save"): ++ self._inputs(receipt.get("key"), receipt.get("binding")) ++ if receipt.get("_pg_context") != self.binding.digest or type(receipt.get("_pg_version")) is not int or receipt["_pg_version"] < 1: ++ raise GroupProvenanceUnavailable() ++ encoded = json.dumps(receipt, sort_keys=True) ++ GroupProvenance._load(encoded, receipt["key"], receipt["binding"]) ++ updated = json.loads(encoded) ++ updated["_pg_version"] += 1 ++ active = True if receipt["state"] in {"pending", "revoking", "uncertain"} else None ++ tx["cursor"].execute("UPDATE identity_provisioner.group_receipts SET state = %s, active = %s, body = %s, version = version + 1 WHERE context_digest = %s AND key = %s AND binding = %s AND version = %s", ++ (receipt["state"], active, json.dumps(updated, sort_keys=True), self.binding.digest, receipt["key"], receipt["binding"], receipt["_pg_version"])) ++ if tx["cursor"].rowcount != 1: ++ raise GroupProvenanceUnavailable() ++ self._transition(tx, updated, operation) ++ return updated ++ ++ def save(self, receipt): ++ with self._transaction() as tx: ++ updated = self._write(tx, receipt) ++ # The provisioner keeps references to nested group pre/poststate across ++ # intent persistence. Replace only this backend's acknowledged version; ++ # _write serializes without changing the caller's mutation body. ++ receipt["_pg_version"] = updated["_pg_version"] ++ ++ def claim_revoke(self, key, binding, delete_if_empty): ++ self._inputs(key, binding) ++ if not isinstance(delete_if_empty, bool): ++ raise ValueError("delete_if_empty must be boolean") ++ with self._transaction() as tx: ++ receipt = self._read(tx["cursor"], key, binding) ++ if receipt is None: ++ raise ValueError("unknown ensure receipt or request binding mismatch") ++ if receipt["state"] == "revoked": ++ if receipt["delete_if_empty"] != delete_if_empty: ++ raise ValueError("completed rollback options cannot change") ++ return receipt ++ if receipt["state"] != "ensured": ++ raise GroupProvenanceUnavailable() ++ self._active(tx["cursor"]) ++ receipt.update(state="revoking", delete_if_empty=delete_if_empty) ++ updated = self._write(tx, receipt, "claim_revoke") ++ return updated ++ ++ ++def psycopg_connector(*, connection_info: Callable[[], dict[str, Any]]): ++ """Owner-provided renewable lease values stay in memory; never env/argv/logs. ++ ++ No connection happens here. Lazy import keeps disabled group endpoints free ++ of a runtime dependency. The admitted owner supplies TLS/host/db/role facts. ++ """ ++ if not callable(connection_info): ++ raise ValueError("owner connection-info callback required") ++ def connect(): ++ try: ++ import psycopg ++ values = dict(connection_info()) ++ return psycopg.connect(**values, connect_timeout=5) ++ except Exception: ++ raise GroupProvenanceUnavailable() from None ++ return connect +diff --git a/identity-provisioner/migrations/group_journal_postgres_v1.sql b/identity-provisioner/migrations/group_journal_postgres_v1.sql +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/migrations/group_journal_postgres_v1.sql +@@ -0,0 +1,23 @@ ++-- Run only by the admitted migration role in its existing owner schema. ++-- Does not create a database/schema/credential, admit an epoch or clear quarantine. ++CREATE TABLE identity_provisioner.group_journal_meta ( ++ singleton boolean PRIMARY KEY CHECK (singleton = true), ++ epoch uuid NOT NULL, ++ directory text NOT NULL CHECK (octet_length(directory) BETWEEN 1 AND 1024), ++ head bigint NOT NULL CHECK (head >= 0), ++ head_digest text NOT NULL CHECK (head_digest ~ '^[0-9a-f]{64}$'), ++ quarantined boolean NOT NULL DEFAULT true ++); ++CREATE TABLE identity_provisioner.group_receipts ( ++ context_digest text NOT NULL CHECK (context_digest ~ '^[0-9a-f]{64}$'), ++ context_json text NOT NULL CHECK (octet_length(context_json) <= 16384), ++ key text NOT NULL CHECK (char_length(key) BETWEEN 1 AND 256), ++ binding text NOT NULL CHECK (binding ~ '^[0-9a-f]{64}$'), ++ version bigint NOT NULL CHECK (version >= 1), ++ state text NOT NULL CHECK (state IN ('pending','ensured','revoking','revoked','uncertain','refused')), ++ active boolean UNIQUE CHECK (active IS NULL OR active = true), ++ body text NOT NULL CHECK (octet_length(body) <= 1048576), ++ PRIMARY KEY (context_digest, key) ++); ++-- Epoch insertion, initial continuity checkpoint and admission are separate ++-- owner actions; this migration leaves all operations refused without them. +diff --git a/identity-provisioner/provisioner.py b/identity-provisioner/provisioner.py +--- a/identity-provisioner/provisioner.py ++++ b/identity-provisioner/provisioner.py +@@ -11,7 +11,7 @@ + from urllib.error import HTTPError, URLError + from urllib.request import Request, urlopen + +-from group_provenance import GroupProvenance, GroupProvenanceUnavailable ++from group_provenance import GroupJournalStore, GroupProvenanceUnavailable + + + @dataclass(frozen=True) +@@ -64,7 +64,7 @@ + opener: Callable = urlopen, + auth_rejected_cooldown: float = 30.0, + clock: Callable[[], float] = time.monotonic, +- group_provenance: GroupProvenance | None = None, ++ group_provenance: GroupJournalStore | None = None, + ) -> None: + self.group_provenance = group_provenance + self.base_url = base_url.rstrip("/") +@@ -243,7 +243,7 @@ + timeout=timeout, + ) + +- def _group_store(self) -> GroupProvenance: ++ def _group_store(self) -> GroupJournalStore: + store = self.group_provenance + if store is None: + raise GroupProvenanceUnavailable() +diff --git a/identity-provisioner/requirements-postgres.txt b/identity-provisioner/requirements-postgres.txt +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/requirements-postgres.txt +@@ -0,0 +1,5 @@ ++# Linux amd64 CPython3.12 runtime closure from user-engine/uv.lock. ++# Build-only dependency resolution; no packages are installed at service startup. ++psycopg==3.3.5 --hash=sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e ++psycopg-binary==3.3.5 --hash=sha256:682a17a57415c3ca1731eec018ed031f012ffcb81ba74806eb219cb396065672 ++typing-extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 +diff --git a/identity-provisioner/tests/test_group_postgres.py b/identity-provisioner/tests/test_group_postgres.py +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/tests/test_group_postgres.py +@@ -0,0 +1,231 @@ ++"""Binding, CAS and ambiguous-commit refusal tests; native SQL proof is separate.""" ++from dataclasses import replace ++import json ++from unittest.mock import Mock ++import unittest ++ ++from group_provenance import GroupProvenanceUnavailable ++from group_provenance_postgres import GroupJournalBinding, PostgresGroupProvenance ++from server import configured_group_provenance ++from unittest.mock import patch ++import os ++ ++EPOCH = '41fbb0ed-ea3a-4682-996b-e9c94a6aa9c6' ++BINDING = GroupJournalBinding('tenant:fixture:one', 'synthetic-directory', 'sha256:' + '1' * 64, ++ 'sha256:' + '2' * 64, 'synthetic-owner', EPOCH) ++REQUEST = '3' * 64 ++HEAD_DIGEST = 'a' * 64 ++META = (EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, False) ++ ++ ++def fixture(rows=(), rowcount=1, commit_error=None): ++ cursor = Mock() ++ cursor.__enter__ = Mock(return_value=cursor) ++ cursor.__exit__ = Mock(return_value=False) ++ cursor.fetchone.side_effect = rows ++ cursor.rowcount = rowcount ++ connection = Mock() ++ connection.__enter__ = Mock(return_value=connection) ++ connection.__exit__ = Mock(return_value=False, side_effect=commit_error) ++ connection.cursor.return_value = cursor ++ connector = Mock(return_value=connection) ++ continuity = Mock() ++ continuity.check.return_value = True ++ continuity.advance.return_value = True ++ store = PostgresGroupProvenance(connect=connector, binding=BINDING, continuity=continuity) ++ return store, connector, connection, cursor, continuity ++ ++ ++def receipt(version=4): ++ return {'key': 'synthetic-operation-key', 'binding': REQUEST, 'state': 'pending', 'intent': None, ++ 'groups': {}, 'created_groups': [], 'created_memberships': [], ++ '_pg_version': version, '_pg_context': BINDING.digest} ++ ++ ++class PostgresSourceTests(unittest.TestCase): ++ def test_explicit_binding_and_continuity_required_without_connecting(self): ++ connector = Mock() ++ for binding, continuity in ((None, Mock()), (BINDING, None), ({'tenant_scope': 'caller-json'}, Mock())): ++ with self.assertRaises(ValueError): ++ PostgresGroupProvenance(connect=connector, binding=binding, continuity=continuity) ++ connector.assert_not_called() ++ for change in ({'tenant_scope': ''}, {'principal': ''}, {'case_digest': 'unsigned'}, {'epoch': 'default'}): ++ with self.assertRaises(ValueError): ++ replace(BINDING, **change) ++ self.assertNotEqual(BINDING.digest, replace(BINDING, principal='another-owner').digest) ++ self.assertNotEqual(BINDING.digest, replace(BINDING, tenant_scope='another-explicit-scope').digest) ++ ++ def test_missing_environment_callback_path_keeps_production_disabled(self): ++ with patch.dict(os.environ, {'GROUP_POSTGRES_DSN': 'synthetic-only', 'GROUP_PROVENANCE_POSTGRES': 'true'}, clear=True): ++ self.assertIsNone(configured_group_provenance()) ++ ++ def test_checkpoint_mismatch_poisoned_without_receipt_query_or_effect(self): ++ store, connect, _, cursor, checkpoint = fixture([META]) ++ checkpoint.check.return_value = False ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.reserve('synthetic-operation-key', REQUEST) ++ self.assertFalse(any('group_receipts' in c.args[0] for c in cursor.execute.call_args_list)) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.check() ++ self.assertEqual(1, connect.call_count) ++ checkpoint.advance.assert_not_called() ++ ++ def test_restored_epoch_or_quarantine_refused(self): ++ for meta in ((EPOCH, 'other-directory', 7, HEAD_DIGEST, False), (EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, True), ++ ('98aa2b6d-a32a-44a5-a89f-b2480befc00e', 'synthetic-directory', 7, HEAD_DIGEST, False), None): ++ store, _, _, _, checkpoint = fixture([meta]) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.check() ++ checkpoint.check.assert_not_called() ++ ++ def test_exactly_one_version_update_required_before_checkpoint_advance(self): ++ store, _, _, cursor, checkpoint = fixture([META], rowcount=0) ++ original = receipt() ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.save(original) ++ query, params = next(c.args for c in cursor.execute.call_args_list if c.args[0].startswith('UPDATE identity_provisioner.group_receipts')) ++ self.assertIn('AND version = %s', query) ++ self.assertEqual(4, params[-1]) ++ self.assertEqual(4, original['_pg_version']) ++ checkpoint.advance.assert_not_called() ++ ++ def test_success_acknowledges_commit_and_updates_receipt_version(self): ++ store, _, connection, _, checkpoint = fixture([META]) ++ original = receipt() ++ store.save(original) ++ self.assertEqual(5, original['_pg_version']) ++ connection.__exit__.assert_called_once() ++ args = checkpoint.advance.call_args.args ++ self.assertEqual((EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, 8), args[:5]) ++ self.assertRegex(args[5], r'^[0-9a-f]{64}$') ++ self.assertNotEqual(HEAD_DIGEST, args[5]) ++ connection.close.assert_called_once() ++ ++ def test_save_preserves_nested_receipt_references_held_by_provisioner(self): ++ store, *_ = fixture([META]) ++ original = receipt() ++ original['groups']['forge-coulomb-owners'] = {'id': None, 'before': [], 'after': ['tegwick']} ++ state = original['groups']['forge-coulomb-owners'] ++ groups, created, memberships = original['groups'], original['created_groups'], original['created_memberships'] ++ original['intent'] = {'operation': 'create_group', 'name': 'forge-coulomb-owners'} ++ store.save(original) ++ self.assertIs(groups, original['groups']) ++ self.assertIs(state, original['groups']['forge-coulomb-owners']) ++ self.assertIs(created, original['created_groups']) ++ self.assertIs(memberships, original['created_memberships']) ++ state['id'] = 4 ++ self.assertEqual(4, original['groups']['forge-coulomb-owners']['id']) ++ self.assertEqual(5, original['_pg_version']) ++ ++ def test_actual_provisioner_ensure_with_postgres_save_retains_created_ids(self): ++ from test_service_groups import Directory, req ++ directory = Directory() ++ directory.members[3].add('tegwick') ++ store, _, _, cursor, _ = fixture([META] * 32) ++ original = receipt(version=1) ++ payload = req() ++ original['key'] = payload['idempotency_key'] ++ original['binding'] = directory._group_binding(payload['groups'], payload['members'], payload['correlation_id']) ++ # Reservation input is already covered by SQL unit/native oracles. Keep ++ # real save/_write/transaction and the real provisioner creation path. ++ store.reserve = Mock(return_value=(original, True)) ++ directory.group_provenance = store ++ result = directory.ensure_service_groups(payload) ++ self.assertEqual('groups_ensured', result.status) ++ self.assertEqual({'forge-coulomb-owners', 'forge-coulomb-readers'}, {g['name'] for g in result.created_groups}) ++ self.assertEqual(1, len(result.created_memberships)) ++ self.assertEqual({'tegwick'}, directory.members[3]) ++ bodies = [json.loads(c.args[1][2]) for c in cursor.execute.call_args_list ++ if c.args[0].startswith('UPDATE identity_provisioner.group_receipts')] ++ self.assertTrue(all(g['id'] is not None for g in bodies[-1]['groups'].values())) ++ self.assertEqual('ensured', bodies[-1]['state']) ++ ++ def test_connection_close_failure_is_sanitized_and_stops_continuation(self): ++ store, _, connection, _, _ = fixture([META]) ++ connection.close.side_effect = OSError('synthetic-private-diagnostic') ++ with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): ++ store.check() ++ self.assertTrue(store._poisoned) ++ ++ def test_commit_failure_never_advances_owner_checkpoint_or_receipt(self): ++ store, _, _, _, checkpoint = fixture([META], commit_error=OSError('synthetic-secret-diagnostic')) ++ original = receipt() ++ with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): ++ store.save(original) ++ self.assertEqual(4, original['_pg_version']) ++ checkpoint.advance.assert_not_called() ++ ++ def test_lost_checkpoint_acknowledgment_refuses_then_poisoned(self): ++ store, connect, _, _, checkpoint = fixture([META]) ++ checkpoint.advance.side_effect = OSError('synthetic-secret-diagnostic') ++ original = receipt() ++ with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): ++ store.save(original) ++ self.assertEqual(4, original['_pg_version']) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.save(original) ++ self.assertEqual(1, connect.call_count) ++ ++ def test_cross_case_body_or_version_cannot_be_imported(self): ++ for body in (dict(receipt(), _pg_context='0' * 64), dict(receipt(), _pg_version=6)): ++ store, _, _, _, _ = fixture([META, (REQUEST, 4, json.dumps(body), json.dumps(BINDING.__dict__, sort_keys=True))]) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.get('synthetic-operation-key', REQUEST) ++ ++ def test_same_counter_divergent_body_or_context_yields_distinct_marker(self): ++ store, *_ = fixture() ++ markers = [] ++ for change in ({}, {'intent': {'operation': 'different'}}, {'key': 'another-exact-key'}, {'_pg_version': 5}): ++ tx = {'head': 7, 'digest': HEAD_DIGEST} ++ store._transition(tx, dict(receipt(), **change), 'save') ++ markers.append(tx['next_digest']) ++ self.assertEqual(4, len(set(markers))) ++ tx = {'head': 7, 'digest': 'b' * 64} ++ store._transition(tx, receipt(), 'save') ++ self.assertNotIn(tx['next_digest'], markers) ++ foreign, *_ = fixture() ++ foreign.binding = replace(BINDING, principal='another-owner') ++ tx = {'head': 7, 'digest': HEAD_DIGEST} ++ foreign._transition(tx, receipt(), 'save') ++ self.assertNotIn(tx['next_digest'], markers) ++ ++ def test_reopened_loser_clone_at_same_head_refuses_different_marker(self): ++ # Both DBs report head8. The independent callback accepts only winner ++ # digest; loser must be refused before loading any receipt or effect. ++ loser_digest = 'b' * 64 ++ store, _, _, cursor, checkpoint = fixture([(EPOCH, 'synthetic-directory', 8, loser_digest, False)]) ++ checkpoint.check.side_effect = lambda epoch, directory, head, digest: (head, digest) == (8, 'c' * 64) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.check() ++ checkpoint.check.assert_called_once_with(EPOCH, 'synthetic-directory', 8, loser_digest) ++ self.assertFalse(any('group_receipts' in c.args[0] for c in cursor.execute.call_args_list)) ++ ++ def test_existing_request_mismatch_is_refused_not_rebound(self): ++ store, _, _, _, checkpoint = fixture([META, ('0' * 64, 4, '{}', '{}')]) ++ with self.assertRaisesRegex(ValueError, 'binding mismatch'): ++ store.get('synthetic-operation-key', REQUEST) ++ checkpoint.advance.assert_not_called() ++ self.assertFalse(store._poisoned) ++ ++ def test_active_fence_capacity_refusal_and_no_insert(self): ++ for rows in ([META, None, (1,)], [META, None, None, (256,)]): ++ store, _, _, cursor, checkpoint = fixture(rows) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.reserve('synthetic-operation-key', REQUEST) ++ self.assertFalse(any('INSERT' in c.args[0] for c in cursor.execute.call_args_list)) ++ checkpoint.advance.assert_not_called() ++ ++ def test_new_reservation_retains_bound_context_and_write_intent(self): ++ store, _, _, cursor, checkpoint = fixture([META, None, None, (0,)]) ++ created, fresh = store.reserve('synthetic-operation-key', REQUEST) ++ self.assertTrue(fresh) ++ self.assertEqual('pending', created['state']) ++ self.assertEqual(BINDING.digest, created['_pg_context']) ++ insert = next(c for c in cursor.execute.call_args_list if c.args[0].startswith('INSERT')) ++ self.assertIn('context_json', insert.args[0]) ++ self.assertEqual(BINDING.tenant_scope, json.loads(insert.args[1][1])['tenant_scope']) ++ checkpoint.advance.assert_called_once() ++ ++ ++if __name__ == '__main__': ++ unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/source-manifest.json b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/source-manifest.json new file mode 100644 index 00000000..06869051 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/source-manifest.json @@ -0,0 +1,66 @@ +{ + "base_revision": "6cb43c0cc55212363a5406b02db046e2384a7c40", + "base_tree": "615095596348b23ad34da79cd0e8597cdb815c5d", + "scope": "source successor preserving nested receipt aliases after PostgreSQL commit; no native/canonical admission", + "files": [ + { + "path": "docs/service-group-provenance.md", + "preimage_sha256": "cd6b45640983b11e29a6bc6278bcb45de8fd8a5ddcad53eb77ab1a1f3f1f1280", + "postimage_sha256": "b287e7a85aa2146f35d2bc8473dfd05239ea0179b7b8b186d2e85bc07c7b650c", + "preimage_bytes": 7455, + "postimage_bytes": 11401 + }, + { + "path": "identity-provisioner/Containerfile", + "preimage_sha256": "c744f39ffb4c0dc2686c3e94d8233bc772b55faae9581557bee17f91c19d9ca6", + "postimage_sha256": "31e7d948d4f40efd6950764a3dbb8c410d50ed7a1c13c18e0830a1186f2abafc", + "preimage_bytes": 459, + "postimage_bytes": 651 + }, + { + "path": "identity-provisioner/group_provenance.py", + "preimage_sha256": "fbd4c88d7682ba3d46c5b325dad1bc4f4a7faac2cd3f6ce9e0f7a89283bf46d4", + "postimage_sha256": "405e08eaaab7c21d618dff4db5970607806f32ab6c461effb81c737e32182b10", + "preimage_bytes": 8947, + "postimage_bytes": 9409 + }, + { + "path": "identity-provisioner/group_provenance_postgres.py", + "preimage_sha256": null, + "postimage_sha256": "2a0111e83ad7b611076a1b45634e7f698b3d2e9acc473320b4a8ba009ebd0932", + "preimage_bytes": 0, + "postimage_bytes": 13409 + }, + { + "path": "identity-provisioner/migrations/group_journal_postgres_v1.sql", + "preimage_sha256": null, + "postimage_sha256": "3cdf82194d6f1e2dd865b0c7e7b8a14447fda8c49151148134a0d40a80fb46ac", + "preimage_bytes": 0, + "postimage_bytes": 1353 + }, + { + "path": "identity-provisioner/provisioner.py", + "preimage_sha256": "2f8e4368e7155465bbc4070b1019c7105edf43bdc2ad3bbc5aa9bc19dc6ed7a5", + "postimage_sha256": "19d1b837cbafeeacb1aa815856cd68091e6702281d72b1d860230fa320e1725b", + "preimage_bytes": 31721, + "postimage_bytes": 31727 + }, + { + "path": "identity-provisioner/requirements-postgres.txt", + "preimage_sha256": null, + "postimage_sha256": "14f1beae54a789d2c880654ca0ce4094c9aa54645e79314b4297c12938ba8877", + "preimage_bytes": 0, + "postimage_bytes": 450 + }, + { + "path": "identity-provisioner/tests/test_group_postgres.py", + "preimage_sha256": null, + "postimage_sha256": "ff3fd92587c8aeea66018718dd8a76f2504648fb3393b122e91ff35a6f431abc", + "preimage_bytes": 0, + "postimage_bytes": 12568 + } + ], + "patch_sha256": "5549d2982b61ff70aba4b3928fecd5639db0f1a732ab2f8630f678e7acf4d34d", + "superseded_counter_only": "review-r0", + "superseded_alias_replacement": "review-r3" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/source.patch b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/source.patch new file mode 100644 index 00000000..795c7b85 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/source.patch @@ -0,0 +1,667 @@ +diff --git a/docs/service-group-provenance.md b/docs/service-group-provenance.md +--- a/docs/service-group-provenance.md ++++ b/docs/service-group-provenance.md +@@ -80,6 +80,64 @@ + and replay. No arbitrary JSON import, receipt expiry or automatic uncertainty + clearing is introduced. Owner reconciliation needs independent evidence. + ++## PostgreSQL source seam (not native admission) ++ ++`PostgresGroupProvenance` implements the same reserve/get/claim-revoke/save/check ++journal contract. It is injection-only: the ordinary HTTP server has no ++PostgreSQL environment switch and remains without a group store unless the ++existing local SQLite configuration is explicitly supplied. `GROUP_POSTGRES_DSN` ++or a caller JSON object cannot enable the PostgreSQL path. Production group ++operations remain unadmitted and disabled pending the owner callbacks and gates ++above; a backend constructor is not an access decision. ++ ++A trusted owner integration must provide an exact `GroupJournalBinding` with ++opaque authoritative tenant scope, directory instance, accepted case digest, ++source digest, represented principal and journal epoch. There is no guessed ++platform-root scope or request-derived tenant fallback. The owner also injects a ++renewable private connection-info callback using the existing `rapp-postgres` ++consumer/lease contract, and independently durable `JournalContinuity` callbacks. ++These callbacks retain/check/advance exact epoch+directory+head+commit digest outside the ++PostgreSQL state that might be restored. Neither callback authenticates a caller ++or grants a protected side effect. Their native custody, source binding, ++revocation and execution bounds still require independent admission. Only the ++disposable fixture supplies a test checkpoint implementation here. ++ ++The v1 migration under `identity-provisioner/migrations/` creates tables within ++the existing consumer schema; it creates no database/schema/roles, inserts no ++epoch and clears no quarantine. Startup does not run migrations or initialize ++missing state. The owner must separately admit an epoch/checkpoint after ++independent continuity/recovery review; absent or quarantined state refuses. ++A restored database with an older head than the independently retained checkpoint ++also refuses rather than treating missing later intents as authority to replay. ++ ++PostgreSQL transactions lock the single journal metadata row, commit bounded ++receipts/intents and advance the head and exact transition digest with an exactly-one conditional ++update. The digest binds the prior marker, canonical body, context, request key ++and receipt version; the independent checkpoint must compare/CAS both content ++and sequence, never accept a counter-only callback. Divergent restored clones ++that commit different transitions at the same counter cannot both be accepted. ++Every receipt update also requires its exact context/key/request/version and ++increments its version. The active slot spans all contexts in the journal; ++it serializes cooperating producer operations, not external LDAP writers. ++SQL commit precedes independently durable checkpoint acknowledgment. A lost ++commit/acknowledgment, stale version, missing row or continuity mismatch refuses ++and poisons that backend object; exception handling cannot automatically rewrite ++its journal into success. Known request-binding mismatches remain domain ++refusals. Definite before-mutation refusal records can release the active slot ++while the original key remains terminal. ++ ++The container source includes a hash-pinned Linux amd64 CPython3.12 psycopg3 ++closure selected from User Engine's existing lockfile. No image build or package ++installation is part of this source proof; build/publication, artifact scanning ++and native runtime/dependency admission remain owner work. SQLite is still ++local/disposable only. The separate PostgreSQL fixture proposal uses a cached ++pinned PostgreSQL image and a cached pinned User Engine Python image, real SQL, ++separate-process reopen, concurrency, stale-CAS and actual pg_dump/pg_restore ++behind an independently retained test checkpoint. That fixture needs exact ++review before Docker execution and does not prove live CNPG HA, TLS/lease ++custody, tenant authority, external writer fencing, production retention or ++protected session/evidence integration. ++ + ## Safety and remaining native acceptance + + Before each directory mutation, durable intent is committed. A successful +diff --git a/identity-provisioner/Containerfile b/identity-provisioner/Containerfile +--- a/identity-provisioner/Containerfile ++++ b/identity-provisioner/Containerfile +@@ -3,7 +3,10 @@ + RUN useradd --create-home --uid 10001 provisioner + WORKDIR /app + COPY --from=lldap /app/lldap_set_password /app/lldap_set_password +-COPY provisioner.py group_provenance.py password_setup.py server.py /app/ ++COPY requirements-postgres.txt /app/ ++RUN python -m pip install --no-cache-dir --require-hashes -r /app/requirements-postgres.txt ++COPY provisioner.py group_provenance.py group_provenance_postgres.py password_setup.py server.py /app/ ++COPY migrations/ /app/migrations/ + USER 10001:10001 + EXPOSE 8080 + CMD ["python", "server.py"] +diff --git a/identity-provisioner/group_provenance.py b/identity-provisioner/group_provenance.py +--- a/identity-provisioner/group_provenance.py ++++ b/identity-provisioner/group_provenance.py +@@ -11,8 +11,17 @@ + from pathlib import Path + import sqlite3 + import stat +-from typing import Any ++from typing import Any, Protocol + from urllib.parse import quote ++ ++ ++class GroupJournalStore(Protocol): ++ """Owner-injected persistence only; grants no caller or native authority.""" ++ def check(self) -> None: ... ++ def reserve(self, key: str, binding: str) -> tuple[dict[str, Any], bool]: ... ++ def get(self, key: str, binding: str) -> dict[str, Any]: ... ++ def claim_revoke(self, key: str, binding: str, delete_if_empty: bool) -> dict[str, Any]: ... ++ def save(self, receipt: dict[str, Any]) -> None: ... + + + class GroupProvenanceUnavailable(RuntimeError): +diff --git a/identity-provisioner/group_provenance_postgres.py b/identity-provisioner/group_provenance_postgres.py +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/group_provenance_postgres.py +@@ -0,0 +1,253 @@ ++"""Injection-only PostgreSQL implementation of the existing owner journal seam. ++ ++Binding and continuity callbacks are supplied by a trusted owner integration, ++never constructed from an HTTP payload. Neither is an authorization decision. ++No server environment switch, automatic schema creation or recovery admission. ++""" ++from __future__ import annotations ++ ++from contextlib import contextmanager ++from dataclasses import asdict, dataclass ++import hashlib ++import json ++import re ++from typing import Any, Callable, Protocol ++from uuid import UUID ++ ++from group_provenance import GroupProvenance, GroupProvenanceUnavailable ++ ++ ++@dataclass(frozen=True) ++class GroupJournalBinding: ++ tenant_scope: str ++ directory: str ++ case_digest: str ++ source_digest: str ++ principal: str ++ epoch: str ++ ++ def __post_init__(self): ++ for value in (self.tenant_scope, self.directory, self.principal): ++ if not isinstance(value, str) or not 1 <= len(value) <= 256 or any(ord(c) < 32 for c in value): ++ raise ValueError("explicit owner journal binding required") ++ for value in (self.case_digest, self.source_digest): ++ if not isinstance(value, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", value): ++ raise ValueError("exact owner source and case digests required") ++ if not isinstance(self.epoch, str) or str(UUID(self.epoch)) != self.epoch: ++ raise ValueError("explicit canonical journal epoch required") ++ ++ @property ++ def digest(self): ++ return hashlib.sha256(json.dumps(asdict(self), sort_keys=True, separators=(",", ":")).encode()).hexdigest() ++ ++ ++def journal_genesis(epoch: str, directory: str) -> str: ++ """Deterministic initial marker for explicit owner migration/admission only.""" ++ return hashlib.sha256(json.dumps({"schema": "nk-group-journal-genesis.v1", "epoch": epoch, ++ "directory": directory}, sort_keys=True, separators=(",", ":")).encode()).hexdigest() ++ ++ ++class JournalContinuity(Protocol): ++ """Existing independently retained owner checkpoint, outside restored SQL state. ++ ++ Implementations compare/advance exact epoch+directory+head+commit digest atomically and ++ durably. An in-memory implementation is a fixture only. Missing/stale or ++ ambiguous checkpoints must refuse; no callback grants mutation authority. ++ """ ++ def check(self, epoch: str, directory: str, head: int, digest: str) -> bool: ... ++ def advance(self, epoch: str, directory: str, previous: int, previous_digest: str, current: int, current_digest: str) -> bool: ... ++ ++ ++class PostgresGroupProvenance: ++ def __init__(self, *, connect: Callable, binding: GroupJournalBinding, ++ continuity: JournalContinuity, capacity: int = 256): ++ if not callable(connect) or not isinstance(binding, GroupJournalBinding) or not 1 <= capacity <= 4096: ++ raise ValueError("explicit owner connector, binding and bounded capacity required") ++ if not callable(getattr(continuity, "check", None)) or not callable(getattr(continuity, "advance", None)): ++ raise ValueError("independent owner continuity callbacks required") ++ self.connect, self.binding, self.continuity, self.capacity = connect, binding, continuity, capacity ++ self._poisoned = False ++ ++ def _continuity(self, method, *args): ++ try: ++ if getattr(self.continuity, method)(self.binding.epoch, self.binding.directory, *args) is not True: ++ raise GroupProvenanceUnavailable() ++ except Exception: ++ raise GroupProvenanceUnavailable() from None ++ ++ @contextmanager ++ def _transaction(self): ++ if self._poisoned: ++ raise GroupProvenanceUnavailable() ++ connection = None ++ tx = None ++ try: ++ connection = self.connect() ++ with connection: ++ with connection.cursor() as cursor: ++ cursor.execute("SET LOCAL statement_timeout = '5s'") ++ cursor.execute("SET LOCAL idle_in_transaction_session_timeout = '15s'") ++ cursor.execute("SELECT epoch::text, directory, head, head_digest, quarantined FROM identity_provisioner.group_journal_meta WHERE singleton = true FOR UPDATE") ++ meta = cursor.fetchone() ++ if not meta or meta[0] != self.binding.epoch or meta[1] != self.binding.directory or meta[4] is not False or type(meta[2]) is not int or meta[2] < 0 or not isinstance(meta[3], str) or not re.fullmatch(r"[0-9a-f]{64}", meta[3]): ++ raise GroupProvenanceUnavailable() ++ self._continuity("check", meta[2], meta[3]) ++ tx = {"cursor": cursor, "head": meta[2], "digest": meta[3], "dirty": False} ++ yield tx ++ if tx["dirty"]: ++ cursor.execute("UPDATE identity_provisioner.group_journal_meta SET head = head + 1, head_digest = %s WHERE singleton = true AND epoch = %s AND directory = %s AND head = %s AND head_digest = %s AND quarantined = false", ++ (tx["next_digest"], self.binding.epoch, self.binding.directory, tx["head"], tx["digest"])) ++ if cursor.rowcount != 1: ++ raise GroupProvenanceUnavailable() ++ # SQL commit precedes the independent checkpoint acknowledgment. ++ # A lost acknowledgment refuses before the next directory effect. ++ if tx and tx["dirty"]: ++ self._continuity("advance", tx["head"], tx["digest"], tx["head"] + 1, tx["next_digest"]) ++ except ValueError: ++ raise ++ except GroupProvenanceUnavailable: ++ # Caller mismatches are domain refusals. Storage/continuity failures ++ # poison this object; durable pending records are never rewritten ++ # automatically by an exception handler after ambiguous commit. ++ self._poisoned = True ++ raise ++ except Exception: ++ self._poisoned = True ++ raise GroupProvenanceUnavailable() from None ++ finally: ++ if connection is not None: ++ try: ++ connection.close() ++ except Exception: ++ self._poisoned = True ++ raise GroupProvenanceUnavailable() from None ++ ++ def check(self): ++ with self._transaction(): ++ pass ++ ++ def _read(self, cursor, key, request_binding): ++ cursor.execute("SELECT binding, version, body, context_json FROM identity_provisioner.group_receipts WHERE context_digest = %s AND key = %s", ++ (self.binding.digest, key)) ++ row = cursor.fetchone() ++ if row is None: ++ return None ++ if row[0] != request_binding: ++ raise ValueError("ensure receipt request binding mismatch") ++ if row[3] != json.dumps(asdict(self.binding), sort_keys=True): ++ raise GroupProvenanceUnavailable() ++ receipt = GroupProvenance._load(row[2], key, request_binding) ++ if type(row[1]) is not int or row[1] < 1 or type(receipt.get("_pg_version")) is not int or receipt.get("_pg_version") != row[1] or receipt.get("_pg_context") != self.binding.digest: ++ raise GroupProvenanceUnavailable() ++ return receipt ++ ++ @staticmethod ++ def _inputs(key, binding): ++ if not isinstance(key, str) or not 1 <= len(key) <= 256 or not isinstance(binding, str) or not re.fullmatch(r"[0-9a-f]{64}", binding): ++ raise ValueError("bounded exact journal request binding required") ++ ++ def _active(self, cursor): ++ cursor.execute("SELECT 1 FROM identity_provisioner.group_receipts WHERE active = true LIMIT 1") ++ if cursor.fetchone(): ++ raise GroupProvenanceUnavailable() ++ ++ def _transition(self, tx, receipt, operation): ++ # Bind the exact transition, not merely a counter. Two restored/forked ++ # copies that commit different bodies at the same next head cannot both ++ # match an independently retained checkpoint after reopening. ++ transition = {"schema": "nk-group-journal-transition.v1", "epoch": self.binding.epoch, ++ "directory": self.binding.directory, "prior_head": tx["head"], "prior_digest": tx["digest"], ++ "head": tx["head"] + 1, "operation": operation, "context": asdict(self.binding), ++ "key": receipt["key"], "request_binding": receipt["binding"], ++ "version": receipt["_pg_version"], "body": receipt} ++ tx["next_digest"] = hashlib.sha256(json.dumps(transition, sort_keys=True, separators=(",", ":")).encode()).hexdigest() ++ tx["dirty"] = True ++ ++ def reserve(self, key, binding): ++ self._inputs(key, binding) ++ with self._transaction() as tx: ++ cursor = tx["cursor"] ++ receipt = self._read(cursor, key, binding) ++ if receipt is not None: ++ return receipt, False ++ self._active(cursor) ++ cursor.execute("SELECT COUNT(*) FROM identity_provisioner.group_receipts") ++ if cursor.fetchone()[0] >= self.capacity: ++ raise GroupProvenanceUnavailable() ++ receipt = {"key": key, "binding": binding, "state": "pending", "intent": None, "groups": {}, ++ "created_groups": [], "created_memberships": [], "_pg_version": 1, "_pg_context": self.binding.digest} ++ cursor.execute("INSERT INTO identity_provisioner.group_receipts (context_digest, context_json, key, binding, version, state, active, body) VALUES (%s, %s, %s, %s, 1, 'pending', true, %s)", ++ (self.binding.digest, json.dumps(asdict(self.binding), sort_keys=True), key, binding, json.dumps(receipt, sort_keys=True))) ++ if cursor.rowcount != 1: ++ raise GroupProvenanceUnavailable() ++ self._transition(tx, receipt, "reserve") ++ return receipt, True ++ ++ def get(self, key, binding): ++ self._inputs(key, binding) ++ with self._transaction() as tx: ++ receipt = self._read(tx["cursor"], key, binding) ++ if receipt is None: ++ raise ValueError("unknown ensure receipt or request binding mismatch") ++ return receipt ++ ++ def _write(self, tx, receipt, operation="save"): ++ self._inputs(receipt.get("key"), receipt.get("binding")) ++ if receipt.get("_pg_context") != self.binding.digest or type(receipt.get("_pg_version")) is not int or receipt["_pg_version"] < 1: ++ raise GroupProvenanceUnavailable() ++ encoded = json.dumps(receipt, sort_keys=True) ++ GroupProvenance._load(encoded, receipt["key"], receipt["binding"]) ++ updated = json.loads(encoded) ++ updated["_pg_version"] += 1 ++ active = True if receipt["state"] in {"pending", "revoking", "uncertain"} else None ++ tx["cursor"].execute("UPDATE identity_provisioner.group_receipts SET state = %s, active = %s, body = %s, version = version + 1 WHERE context_digest = %s AND key = %s AND binding = %s AND version = %s", ++ (receipt["state"], active, json.dumps(updated, sort_keys=True), self.binding.digest, receipt["key"], receipt["binding"], receipt["_pg_version"])) ++ if tx["cursor"].rowcount != 1: ++ raise GroupProvenanceUnavailable() ++ self._transition(tx, updated, operation) ++ return updated ++ ++ def save(self, receipt): ++ with self._transaction() as tx: ++ updated = self._write(tx, receipt) ++ # The provisioner keeps references to nested group pre/poststate across ++ # intent persistence. Replace only this backend's acknowledged version; ++ # _write serializes without changing the caller's mutation body. ++ receipt["_pg_version"] = updated["_pg_version"] ++ ++ def claim_revoke(self, key, binding, delete_if_empty): ++ self._inputs(key, binding) ++ if not isinstance(delete_if_empty, bool): ++ raise ValueError("delete_if_empty must be boolean") ++ with self._transaction() as tx: ++ receipt = self._read(tx["cursor"], key, binding) ++ if receipt is None: ++ raise ValueError("unknown ensure receipt or request binding mismatch") ++ if receipt["state"] == "revoked": ++ if receipt["delete_if_empty"] != delete_if_empty: ++ raise ValueError("completed rollback options cannot change") ++ return receipt ++ if receipt["state"] != "ensured": ++ raise GroupProvenanceUnavailable() ++ self._active(tx["cursor"]) ++ receipt.update(state="revoking", delete_if_empty=delete_if_empty) ++ updated = self._write(tx, receipt, "claim_revoke") ++ return updated ++ ++ ++def psycopg_connector(*, connection_info: Callable[[], dict[str, Any]]): ++ """Owner-provided renewable lease values stay in memory; never env/argv/logs. ++ ++ No connection happens here. Lazy import keeps disabled group endpoints free ++ of a runtime dependency. The admitted owner supplies TLS/host/db/role facts. ++ """ ++ if not callable(connection_info): ++ raise ValueError("owner connection-info callback required") ++ def connect(): ++ try: ++ import psycopg ++ values = dict(connection_info()) ++ return psycopg.connect(**values, connect_timeout=5) ++ except Exception: ++ raise GroupProvenanceUnavailable() from None ++ return connect +diff --git a/identity-provisioner/migrations/group_journal_postgres_v1.sql b/identity-provisioner/migrations/group_journal_postgres_v1.sql +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/migrations/group_journal_postgres_v1.sql +@@ -0,0 +1,23 @@ ++-- Run only by the admitted migration role in its existing owner schema. ++-- Does not create a database/schema/credential, admit an epoch or clear quarantine. ++CREATE TABLE identity_provisioner.group_journal_meta ( ++ singleton boolean PRIMARY KEY CHECK (singleton = true), ++ epoch uuid NOT NULL, ++ directory text NOT NULL CHECK (octet_length(directory) BETWEEN 1 AND 1024), ++ head bigint NOT NULL CHECK (head >= 0), ++ head_digest text NOT NULL CHECK (head_digest ~ '^[0-9a-f]{64}$'), ++ quarantined boolean NOT NULL DEFAULT true ++); ++CREATE TABLE identity_provisioner.group_receipts ( ++ context_digest text NOT NULL CHECK (context_digest ~ '^[0-9a-f]{64}$'), ++ context_json text NOT NULL CHECK (octet_length(context_json) <= 16384), ++ key text NOT NULL CHECK (char_length(key) BETWEEN 1 AND 256), ++ binding text NOT NULL CHECK (binding ~ '^[0-9a-f]{64}$'), ++ version bigint NOT NULL CHECK (version >= 1), ++ state text NOT NULL CHECK (state IN ('pending','ensured','revoking','revoked','uncertain','refused')), ++ active boolean UNIQUE CHECK (active IS NULL OR active = true), ++ body text NOT NULL CHECK (octet_length(body) <= 1048576), ++ PRIMARY KEY (context_digest, key) ++); ++-- Epoch insertion, initial continuity checkpoint and admission are separate ++-- owner actions; this migration leaves all operations refused without them. +diff --git a/identity-provisioner/provisioner.py b/identity-provisioner/provisioner.py +--- a/identity-provisioner/provisioner.py ++++ b/identity-provisioner/provisioner.py +@@ -11,7 +11,7 @@ + from urllib.error import HTTPError, URLError + from urllib.request import Request, urlopen + +-from group_provenance import GroupProvenance, GroupProvenanceUnavailable ++from group_provenance import GroupJournalStore, GroupProvenanceUnavailable + + + @dataclass(frozen=True) +@@ -64,7 +64,7 @@ + opener: Callable = urlopen, + auth_rejected_cooldown: float = 30.0, + clock: Callable[[], float] = time.monotonic, +- group_provenance: GroupProvenance | None = None, ++ group_provenance: GroupJournalStore | None = None, + ) -> None: + self.group_provenance = group_provenance + self.base_url = base_url.rstrip("/") +@@ -243,7 +243,7 @@ + timeout=timeout, + ) + +- def _group_store(self) -> GroupProvenance: ++ def _group_store(self) -> GroupJournalStore: + store = self.group_provenance + if store is None: + raise GroupProvenanceUnavailable() +diff --git a/identity-provisioner/requirements-postgres.txt b/identity-provisioner/requirements-postgres.txt +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/requirements-postgres.txt +@@ -0,0 +1,5 @@ ++# Linux amd64 CPython3.12 runtime closure from user-engine/uv.lock. ++# Build-only dependency resolution; no packages are installed at service startup. ++psycopg==3.3.5 --hash=sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e ++psycopg-binary==3.3.5 --hash=sha256:682a17a57415c3ca1731eec018ed031f012ffcb81ba74806eb219cb396065672 ++typing-extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 +diff --git a/identity-provisioner/tests/test_group_postgres.py b/identity-provisioner/tests/test_group_postgres.py +new file mode 100644 +--- /dev/null ++++ b/identity-provisioner/tests/test_group_postgres.py +@@ -0,0 +1,231 @@ ++"""Binding, CAS and ambiguous-commit refusal tests; native SQL proof is separate.""" ++from dataclasses import replace ++import json ++from unittest.mock import Mock ++import unittest ++ ++from group_provenance import GroupProvenanceUnavailable ++from group_provenance_postgres import GroupJournalBinding, PostgresGroupProvenance ++from server import configured_group_provenance ++from unittest.mock import patch ++import os ++ ++EPOCH = '41fbb0ed-ea3a-4682-996b-e9c94a6aa9c6' ++BINDING = GroupJournalBinding('tenant:fixture:one', 'synthetic-directory', 'sha256:' + '1' * 64, ++ 'sha256:' + '2' * 64, 'synthetic-owner', EPOCH) ++REQUEST = '3' * 64 ++HEAD_DIGEST = 'a' * 64 ++META = (EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, False) ++ ++ ++def fixture(rows=(), rowcount=1, commit_error=None): ++ cursor = Mock() ++ cursor.__enter__ = Mock(return_value=cursor) ++ cursor.__exit__ = Mock(return_value=False) ++ cursor.fetchone.side_effect = rows ++ cursor.rowcount = rowcount ++ connection = Mock() ++ connection.__enter__ = Mock(return_value=connection) ++ connection.__exit__ = Mock(return_value=False, side_effect=commit_error) ++ connection.cursor.return_value = cursor ++ connector = Mock(return_value=connection) ++ continuity = Mock() ++ continuity.check.return_value = True ++ continuity.advance.return_value = True ++ store = PostgresGroupProvenance(connect=connector, binding=BINDING, continuity=continuity) ++ return store, connector, connection, cursor, continuity ++ ++ ++def receipt(version=4): ++ return {'key': 'synthetic-operation-key', 'binding': REQUEST, 'state': 'pending', 'intent': None, ++ 'groups': {}, 'created_groups': [], 'created_memberships': [], ++ '_pg_version': version, '_pg_context': BINDING.digest} ++ ++ ++class PostgresSourceTests(unittest.TestCase): ++ def test_explicit_binding_and_continuity_required_without_connecting(self): ++ connector = Mock() ++ for binding, continuity in ((None, Mock()), (BINDING, None), ({'tenant_scope': 'caller-json'}, Mock())): ++ with self.assertRaises(ValueError): ++ PostgresGroupProvenance(connect=connector, binding=binding, continuity=continuity) ++ connector.assert_not_called() ++ for change in ({'tenant_scope': ''}, {'principal': ''}, {'case_digest': 'unsigned'}, {'epoch': 'default'}): ++ with self.assertRaises(ValueError): ++ replace(BINDING, **change) ++ self.assertNotEqual(BINDING.digest, replace(BINDING, principal='another-owner').digest) ++ self.assertNotEqual(BINDING.digest, replace(BINDING, tenant_scope='another-explicit-scope').digest) ++ ++ def test_missing_environment_callback_path_keeps_production_disabled(self): ++ with patch.dict(os.environ, {'GROUP_POSTGRES_DSN': 'synthetic-only', 'GROUP_PROVENANCE_POSTGRES': 'true'}, clear=True): ++ self.assertIsNone(configured_group_provenance()) ++ ++ def test_checkpoint_mismatch_poisoned_without_receipt_query_or_effect(self): ++ store, connect, _, cursor, checkpoint = fixture([META]) ++ checkpoint.check.return_value = False ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.reserve('synthetic-operation-key', REQUEST) ++ self.assertFalse(any('group_receipts' in c.args[0] for c in cursor.execute.call_args_list)) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.check() ++ self.assertEqual(1, connect.call_count) ++ checkpoint.advance.assert_not_called() ++ ++ def test_restored_epoch_or_quarantine_refused(self): ++ for meta in ((EPOCH, 'other-directory', 7, HEAD_DIGEST, False), (EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, True), ++ ('98aa2b6d-a32a-44a5-a89f-b2480befc00e', 'synthetic-directory', 7, HEAD_DIGEST, False), None): ++ store, _, _, _, checkpoint = fixture([meta]) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.check() ++ checkpoint.check.assert_not_called() ++ ++ def test_exactly_one_version_update_required_before_checkpoint_advance(self): ++ store, _, _, cursor, checkpoint = fixture([META], rowcount=0) ++ original = receipt() ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.save(original) ++ query, params = next(c.args for c in cursor.execute.call_args_list if c.args[0].startswith('UPDATE identity_provisioner.group_receipts')) ++ self.assertIn('AND version = %s', query) ++ self.assertEqual(4, params[-1]) ++ self.assertEqual(4, original['_pg_version']) ++ checkpoint.advance.assert_not_called() ++ ++ def test_success_acknowledges_commit_and_updates_receipt_version(self): ++ store, _, connection, _, checkpoint = fixture([META]) ++ original = receipt() ++ store.save(original) ++ self.assertEqual(5, original['_pg_version']) ++ connection.__exit__.assert_called_once() ++ args = checkpoint.advance.call_args.args ++ self.assertEqual((EPOCH, 'synthetic-directory', 7, HEAD_DIGEST, 8), args[:5]) ++ self.assertRegex(args[5], r'^[0-9a-f]{64}$') ++ self.assertNotEqual(HEAD_DIGEST, args[5]) ++ connection.close.assert_called_once() ++ ++ def test_save_preserves_nested_receipt_references_held_by_provisioner(self): ++ store, *_ = fixture([META]) ++ original = receipt() ++ original['groups']['forge-coulomb-owners'] = {'id': None, 'before': [], 'after': ['tegwick']} ++ state = original['groups']['forge-coulomb-owners'] ++ groups, created, memberships = original['groups'], original['created_groups'], original['created_memberships'] ++ original['intent'] = {'operation': 'create_group', 'name': 'forge-coulomb-owners'} ++ store.save(original) ++ self.assertIs(groups, original['groups']) ++ self.assertIs(state, original['groups']['forge-coulomb-owners']) ++ self.assertIs(created, original['created_groups']) ++ self.assertIs(memberships, original['created_memberships']) ++ state['id'] = 4 ++ self.assertEqual(4, original['groups']['forge-coulomb-owners']['id']) ++ self.assertEqual(5, original['_pg_version']) ++ ++ def test_actual_provisioner_ensure_with_postgres_save_retains_created_ids(self): ++ from test_service_groups import Directory, req ++ directory = Directory() ++ directory.members[3].add('tegwick') ++ store, _, _, cursor, _ = fixture([META] * 32) ++ original = receipt(version=1) ++ payload = req() ++ original['key'] = payload['idempotency_key'] ++ original['binding'] = directory._group_binding(payload['groups'], payload['members'], payload['correlation_id']) ++ # Reservation input is already covered by SQL unit/native oracles. Keep ++ # real save/_write/transaction and the real provisioner creation path. ++ store.reserve = Mock(return_value=(original, True)) ++ directory.group_provenance = store ++ result = directory.ensure_service_groups(payload) ++ self.assertEqual('groups_ensured', result.status) ++ self.assertEqual({'forge-coulomb-owners', 'forge-coulomb-readers'}, {g['name'] for g in result.created_groups}) ++ self.assertEqual(1, len(result.created_memberships)) ++ self.assertEqual({'tegwick'}, directory.members[3]) ++ bodies = [json.loads(c.args[1][2]) for c in cursor.execute.call_args_list ++ if c.args[0].startswith('UPDATE identity_provisioner.group_receipts')] ++ self.assertTrue(all(g['id'] is not None for g in bodies[-1]['groups'].values())) ++ self.assertEqual('ensured', bodies[-1]['state']) ++ ++ def test_connection_close_failure_is_sanitized_and_stops_continuation(self): ++ store, _, connection, _, _ = fixture([META]) ++ connection.close.side_effect = OSError('synthetic-private-diagnostic') ++ with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): ++ store.check() ++ self.assertTrue(store._poisoned) ++ ++ def test_commit_failure_never_advances_owner_checkpoint_or_receipt(self): ++ store, _, _, _, checkpoint = fixture([META], commit_error=OSError('synthetic-secret-diagnostic')) ++ original = receipt() ++ with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): ++ store.save(original) ++ self.assertEqual(4, original['_pg_version']) ++ checkpoint.advance.assert_not_called() ++ ++ def test_lost_checkpoint_acknowledgment_refuses_then_poisoned(self): ++ store, connect, _, _, checkpoint = fixture([META]) ++ checkpoint.advance.side_effect = OSError('synthetic-secret-diagnostic') ++ original = receipt() ++ with self.assertRaisesRegex(GroupProvenanceUnavailable, '^group_provenance_unavailable$'): ++ store.save(original) ++ self.assertEqual(4, original['_pg_version']) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.save(original) ++ self.assertEqual(1, connect.call_count) ++ ++ def test_cross_case_body_or_version_cannot_be_imported(self): ++ for body in (dict(receipt(), _pg_context='0' * 64), dict(receipt(), _pg_version=6)): ++ store, _, _, _, _ = fixture([META, (REQUEST, 4, json.dumps(body), json.dumps(BINDING.__dict__, sort_keys=True))]) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.get('synthetic-operation-key', REQUEST) ++ ++ def test_same_counter_divergent_body_or_context_yields_distinct_marker(self): ++ store, *_ = fixture() ++ markers = [] ++ for change in ({}, {'intent': {'operation': 'different'}}, {'key': 'another-exact-key'}, {'_pg_version': 5}): ++ tx = {'head': 7, 'digest': HEAD_DIGEST} ++ store._transition(tx, dict(receipt(), **change), 'save') ++ markers.append(tx['next_digest']) ++ self.assertEqual(4, len(set(markers))) ++ tx = {'head': 7, 'digest': 'b' * 64} ++ store._transition(tx, receipt(), 'save') ++ self.assertNotIn(tx['next_digest'], markers) ++ foreign, *_ = fixture() ++ foreign.binding = replace(BINDING, principal='another-owner') ++ tx = {'head': 7, 'digest': HEAD_DIGEST} ++ foreign._transition(tx, receipt(), 'save') ++ self.assertNotIn(tx['next_digest'], markers) ++ ++ def test_reopened_loser_clone_at_same_head_refuses_different_marker(self): ++ # Both DBs report head8. The independent callback accepts only winner ++ # digest; loser must be refused before loading any receipt or effect. ++ loser_digest = 'b' * 64 ++ store, _, _, cursor, checkpoint = fixture([(EPOCH, 'synthetic-directory', 8, loser_digest, False)]) ++ checkpoint.check.side_effect = lambda epoch, directory, head, digest: (head, digest) == (8, 'c' * 64) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.check() ++ checkpoint.check.assert_called_once_with(EPOCH, 'synthetic-directory', 8, loser_digest) ++ self.assertFalse(any('group_receipts' in c.args[0] for c in cursor.execute.call_args_list)) ++ ++ def test_existing_request_mismatch_is_refused_not_rebound(self): ++ store, _, _, _, checkpoint = fixture([META, ('0' * 64, 4, '{}', '{}')]) ++ with self.assertRaisesRegex(ValueError, 'binding mismatch'): ++ store.get('synthetic-operation-key', REQUEST) ++ checkpoint.advance.assert_not_called() ++ self.assertFalse(store._poisoned) ++ ++ def test_active_fence_capacity_refusal_and_no_insert(self): ++ for rows in ([META, None, (1,)], [META, None, None, (256,)]): ++ store, _, _, cursor, checkpoint = fixture(rows) ++ with self.assertRaises(GroupProvenanceUnavailable): ++ store.reserve('synthetic-operation-key', REQUEST) ++ self.assertFalse(any('INSERT' in c.args[0] for c in cursor.execute.call_args_list)) ++ checkpoint.advance.assert_not_called() ++ ++ def test_new_reservation_retains_bound_context_and_write_intent(self): ++ store, _, _, cursor, checkpoint = fixture([META, None, None, (0,)]) ++ created, fresh = store.reserve('synthetic-operation-key', REQUEST) ++ self.assertTrue(fresh) ++ self.assertEqual('pending', created['state']) ++ self.assertEqual(BINDING.digest, created['_pg_context']) ++ insert = next(c for c in cursor.execute.call_args_list if c.args[0].startswith('INSERT')) ++ self.assertIn('context_json', insert.args[0]) ++ self.assertEqual(BINDING.tenant_scope, json.loads(insert.args[1][1])['tenant_scope']) ++ checkpoint.advance.assert_called_once() ++ ++ ++if __name__ == '__main__': ++ unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/verification.json b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/verification.json new file mode 100644 index 00000000..7248937b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/verification.json @@ -0,0 +1,26 @@ +{ + "kind": "source successor and fixture preparation proof only", + "source_command": "PYTHONPATH=/tmp/nk-group-postgres-r1 python3 -m pytest -q /tmp/nk-group-postgres-r1/tests", + "source_result": "82 passed in8.46s", + "baseline_tests": 65, + "new_source_tests": 17, + "fixture_plan_command": "python3 -m pytest -q docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/native-fixture/test_driver.py", + "fixture_plan_result": "13 passed in0.014s", + "patch_apply_check": true, + "canonical_modified_by_worker": false, + "docker_executed": false, + "native_postgres_compatibility": "R2/R3 safe failures retained; R4 not executed, pending exact review", + "counter_only_predecessor": "review-r0", + "source_revision": "6cb43c0cc55212363a5406b02db046e2384a7c40", + "source_patch_sha256": "5549d2982b61ff70aba4b3928fecd5639db0f1a732ab2f8630f678e7acf4d34d", + "native_manifest_sha256": "d862780e2954d7103a32294ff67e1bfe5e70b5f33bf66ffe73b8521a76bb362d", + "knowledge": { + "version": "0.2.6", + "digest": "8d1d6a9d4d07fec7b619618de7dfe2be2254c1790f85f4198c7fe19db9751c02", + "local_work_status": "withheld changed sources; source-reviewed current owner AGENTS/INTENT and accepted ADR0015; orientation only" + }, + "supervision": "custodian-codex supervised; reporting utility authority_granted=false; no new privilege/credential/native grant", + "source_refinement": "PostgreSQL save preserves nested receipt references; old source regression fails, fixed source tests pass", + "prior_source": "review-r3", + "worker_docker_executed": false +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/coordinator-review.json b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/coordinator-review.json new file mode 100644 index 00000000..b5c77f58 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/coordinator-review.json @@ -0,0 +1,22 @@ +{ + "observed_at": "2026-10-16T22:01:19.241256+00:00", + "reviewer": "custodian-coordinator", + "patch_sha256": "1f3f3ca94c46dadac8f24896053d4d23179de795e50a24298a0cfbcc46cca30e", + "base_revision": "878abc93588e12b6da1bac055cfbb3abb8d7adf1", + "all_nine_preimages_and_postimages_match": true, + "independent_tests": { + "passed": 65, + "exit_code": 0 + }, + "review_refinements": [ + "Exactly-one journal intent update; stable database device/inode per object", + "Retained definite before-mutation refusal releases fence; denied same-key reuse" + ], + "remaining": [ + "Actual disposable GraphQL compatibility fixture", + "Native storage and writer fencing/CAS/ABA acceptance", + "Protected case/source/principal callbacks, independent evidence and session revocation" + ], + "canonical_integration": false, + "native_acceptance": false +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/README.md b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/README.md new file mode 100644 index 00000000..5ad3a6b5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/README.md @@ -0,0 +1,50 @@ +# Prepared disposable LLDAP compatibility fixture + +Status: prepared, unit checked, **not executed or approved**. No Docker command +was run during preparation. The parent source candidate remains unchanged. + +`manifest.json` binds the exact candidate postimages, source manifest, fixture +code/tests and existing containment helper. Its image/engine pins come from the +reviewed isolated LLDAP/KeyCape R6 sources; their current cache/engine availability +has intentionally not been queried. Reviewed execution must reverify those pins +and refuse a missing image. There is no pull, build or push path. + +Default `python3 driver.py --plan` reads and prints the plan without loading the +containment helper, calling Docker or generating material. Execution is a +separate coordinator-reviewed action bound to the exact manifest digest via the +manifest's `execution_command`. Neither a flag nor this document supplies that +approval. Preparation checks run with: + +```bash +python3 -m pytest -q docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/test_driver.py +``` + +The fixture reuses `isolated-keycape.py` for fixed daemon routing, containment +inspection and exact fresh-name/label cleanup. It creates a network-none LLDAP +anchor and a Python helper sharing only that network namespace. Both are nonroot, +read-only and capability-free, with no host ports, host mounts or Docker socket +inside a container. Declared image volumes are masked with bounded tmpfs. Password, +JWT secret and key seed are newly generated in memory and enter private tmpfs +through stdin only. No real archive, key, password, account, LDAP endpoint or +production route is accepted as input. + +The three sequential Python invocations run the actual pinned candidate against +real disposable LLDAP GraphQL. First, ensure must inventory exactly newly created +groups/memberships. A separate Python process then reopens the same SQLite file, +replays ensure without mutation, and revokes while restoring the complete +preexisting directory snapshot—including existing empty/shared groups and +memberships. Finally, actual shared-member drift must refuse undo; an injected +lost response *after a successful actual GraphQL addition* must retain unresolved +intent and refuse ensure/revoke after reopening. Unknown and cross-case receipt +bindings must also refuse without changing directory state. + +All owned container names are registered before creation. `finally` invokes the +existing helper's exact label/name cleanup for both, including failed-create or +lost-reply cases, and requires successful explicit absence inventories. Cleanup +failure or any Docker request uncertainty keeps the overall result failed. A +private safe JSON receipt under `/tmp/nk48-groups-native-review-*` retains names, +labels, booleans and cleanup duties, never generated credential values. Each +container's PID1 also exits after 180 seconds; this fixture does **not** claim the +KeyCape hard-parent-loss/reaper proof. Native CAS/fencing, protected caller/session +and evidence callbacks, durable deployment storage and production acceptance +remain separate owner gates even if this fixture later passes. diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/driver.py b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/driver.py new file mode 100644 index 00000000..f451de0c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/driver.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Default is a read-only plan. Docker execution awaits independent exact review.""" +import argparse +import base64 +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import re +import secrets +import tarfile +import tempfile +import time +import uuid + +HERE = Path(__file__).resolve().parent +MANIFEST = HERE / 'manifest.json' + + +def require(condition): + if not condition: + raise RuntimeError('bounded_synthetic_fixture_refused') + + +def reviewed_manifest(expected): + raw = MANIFEST.read_bytes() + require(expected == hashlib.sha256(raw).hexdigest()) + manifest = json.loads(raw) + for path, digest in manifest['source_sha256'].items(): + require(bool(re.fullmatch(r'sha256:[0-9a-f]{64}', digest))) + require('sha256:' + hashlib.sha256(Path(path).read_bytes()).hexdigest() == digest) + return manifest + + +def load_containment(manifest): + spec = importlib.util.spec_from_file_location('reviewed_existing_containment', manifest['containment_helper']) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def session_plan(manifest, run_id): + require(bool(re.fullmatch(r'[a-f0-9]{32}', run_id))) + anchor = 'nk49-kc-' + run_id + '-groups-lldap' + probe = 'nk49-kc-' + run_id + '-groups-probe' + return {'run_id': run_id, 'containers': [anchor, probe], 'worker': anchor, 'issuer': probe, + 'images': {anchor: manifest['lldap_image'], probe: manifest['python_image']}, 'volumes': {}} + + +def base_args(session, name, network): + return ['docker', 'run', '--detach', '--pull=never', '--name', name, '--network', network, + '--label', 'net-kingdom.recovery-run=' + session['run_id'], '--read-only', '--cap-drop', 'ALL', + '--security-opt', 'no-new-privileges', '--user', '1000:1000', '--memory', '256m', '--cpus', '0.5', + '--pids-limit', '64', '--log-driver', 'none', '--restart', 'no', + '--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700'] + + +def tar_bytes(files): + out = io.BytesIO() + with tarfile.open(fileobj=out, mode='w') as archive: + for name, value in files.items(): + require(bool(re.fullmatch(r'[a-zA-Z0-9._/-]+', name)) and not name.startswith('/') and '..' not in Path(name).parts) + entry = tarfile.TarInfo(name) + entry.size = len(value) + entry.mode = 0o600 + entry.uid = entry.gid = 1000 + archive.addfile(entry, io.BytesIO(value)) + return out.getvalue() + + +IMPORT = r''' +import base64,json,os,sys +from pathlib import Path +os.umask(0o077) +root=Path('/fixture') +(root/'source').mkdir(mode=0o700) +(root/'journal').mkdir(mode=0o700) +for name,encoded in json.load(sys.stdin).items(): + if name not in ('source/provisioner.py','source/group_provenance.py','probe.py','private.json'):raise ValueError() + fd=os.open(root/name,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'wb') as output:output.write(base64.b64decode(encoded,validate=True)) +''' + + +def preflight(adapter, manifest): + adapter.docker_environment() # Refuse ambient alternate routing before calls. + contexts = json.loads(adapter.command(['docker', 'context', 'inspect'], timeout=15)) + require(len(contexts) == 1 and contexts[0]['Endpoints']['docker']['Host'] == manifest['fixed_endpoint']) + facts = json.loads(adapter.command(['docker', 'info', '--format', + '{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}'], timeout=15)) + require(facts == {'ID': manifest['engine_id'], 'Name': manifest['engine_name'], 'OSType': 'linux'}) + for image in (manifest['lldap_image'], manifest['python_image']): + value = adapter.command(['docker', 'image', 'inspect', '--format', '{{.Id}}', image], timeout=15).decode().strip() + require(bool(re.fullmatch(r'sha256:[a-f0-9]{64}', value))) + + +def execute(manifest, adapter, result): + session = session_plan(manifest, uuid.uuid4().hex) + result['session'] = {'run_id': session['run_id'], 'containers': session['containers'], + 'label': 'net-kingdom.recovery-run=' + session['run_id']} + deadline = time.monotonic() + 150 + def run(argv, **kwargs): + remaining = deadline - time.monotonic() + require(remaining > 0) + try: + return original_command(argv, timeout=min(45, remaining), **kwargs) + except BaseException: + result['daemon_request_uncertain'] = True + raise + # A label failure or daemon uncertainty never becomes success after cleanup. + original_command = adapter.command + adapter.command = run + try: + password = secrets.token_hex(24) + config = {'database_url': 'sqlite:///data/users.db?mode=rwc', 'ldap_base_dn': 'dc=synthetic,dc=invalid', + 'ldap_user_dn': 'admin', 'ldap_host': '127.0.0.1', 'http_host': '127.0.0.1', + 'ldap_port': 3890, 'http_port': 17170, 'key_seed': secrets.token_hex(32)} + raw_config = '\n'.join(k + ' = ' + json.dumps(v) for k, v in config.items()) + raw_config += '\n[smtp_options]\nenable_password_reset = false\n[ldaps_options]\nenabled = false\n' + anchor = session['worker'] + args = base_args(session, anchor, 'none') + [ + '--tmpfs', '/data:rw,nosuid,nodev,noexec,size=33554432,uid=1000,gid=1000,mode=0700', + '--tmpfs', '/run/fixture:rw,nosuid,nodev,noexec,size=1048576,uid=1000,gid=1000,mode=0700', + '--env', 'LLDAP_JWT_SECRET_FILE=/run/fixture/jwt', + '--env', 'LLDAP_LDAP_USER_PASS_FILE=/run/fixture/password', '--entrypoint', '/bin/sh', + manifest['lldap_image'], '-c', 'sleep 180'] + run(args) + # Inspect each anchor before importing even generated credentials. + adapter.inspect_session({**session, 'containers': [anchor], 'issuer': anchor}, worker_only=True) + run(['docker', 'exec', '-i', anchor, 'tar', '-xf', '-', '-C', '/run/fixture'], input=tar_bytes({ + 'lldap.toml': raw_config.encode(), 'jwt': secrets.token_hex(32).encode(), 'password': password.encode()})) + run(['docker', 'exec', '--detach', anchor, '/bin/sh', '-c', + 'exec /app/lldap run --config-file /run/fixture/lldap.toml > /dev/null 2>&1']) + anchor_id = run(['docker', 'container', 'inspect', anchor, '--format', '{{.Id}}']).decode().strip() + require(bool(re.fullmatch(r'[a-f0-9]{64}', anchor_id))) + args = base_args(session, session['issuer'], 'container:' + anchor_id) + [ + '--tmpfs', '/fixture:rw,nosuid,nodev,noexec,size=8388608,uid=1000,gid=1000,mode=0700', + '--tmpfs', '/privacyidea/etc/persistent:rw,nosuid,nodev,noexec,size=1048576,uid=1000,gid=1000,mode=0700', + '--entrypoint', 'python3', manifest['python_image'], '-c', 'import time;time.sleep(180)'] + run(args) + adapter.inspect_session(session, worker_only=True) + result['containment_verified_before_private_import'] = True + files = {'source/' + name: (HERE.parent / 'postimage/identity-provisioner' / name).read_bytes() + for name in ('provisioner.py', 'group_provenance.py')} + files.update({'probe.py': (HERE / 'probe.py').read_bytes(), 'private.json': json.dumps({'password': password}).encode()}) + run(['docker', 'exec', '-i', session['issuer'], 'python3', '-c', IMPORT], + input=json.dumps({k: base64.b64encode(v).decode() for k, v in files.items()}).encode()) + result['checks'] = {} + for phase in ('bootstrap', 'restart', 'faults'): + value = json.loads(run(['docker', 'exec', session['issuer'], 'python3', '/fixture/probe.py', phase], max_output=8192)) + expected = set(manifest['phase_oracles'][phase]) + require(value.get('status') == 'passed' and set(value.get('checks', {})) == expected) + require(all(v is True for v in value['checks'].values())) + result['checks'].update(value['checks']) + result['status'] = 'synthetic_native_compatibility_passed' + finally: + # Existing helper attempts every preregistered fresh exact-name/label duty, + # including create-time lost replies; no substring or global cleanup. + def cleanup_command(argv, **kwargs): + try: + return original_command(argv, timeout=15, **kwargs) + except BaseException: + result['daemon_request_uncertain'] = True + raise + adapter.command = cleanup_command + try: + adapter.cleanup(session, result) + result['container_cleanup_verified'] = True + except BaseException: + result['status'] = 'failed' + result['container_cleanup_verified'] = False + adapter.command = original_command + if result.get('daemon_request_uncertain'): + result['status'] = 'failed' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--plan', action='store_true') + parser.add_argument('--execute-reviewed-fixture', action='store_true') + parser.add_argument('--expected-manifest-sha256') + args = parser.parse_args() + if not args.execute_reviewed_fixture: + print(MANIFEST.read_text()) + return 0 + require(not args.plan) + manifest = reviewed_manifest(args.expected_manifest_sha256) + adapter = load_containment(manifest) + preflight(adapter, manifest) + os.umask(0o077) + output = Path(tempfile.mkdtemp(prefix='nk48-groups-native-review-', dir='/tmp')) / 'result.json' + result = {'schema': 'nk48.synthetic-groups-native-result.v1', 'status': 'failed', + 'manifest_sha256': args.expected_manifest_sha256, 'production_contacted': False, + 'actual_credentials_used': False, 'native_acceptance_claimed': False} + try: + execute(manifest, adapter, result) + except BaseException: + result['status'] = 'failed' + finally: + output.write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps({'status': result['status'], 'safe_result': str(output)})) + return int(result['status'] != 'synthetic_native_compatibility_passed') + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/execution-result.json b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/execution-result.json new file mode 100644 index 00000000..4b09dd78 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/execution-result.json @@ -0,0 +1,44 @@ +{ + "schema": "nk48.synthetic-groups-native-result.v1", + "status": "synthetic_native_compatibility_passed", + "manifest_sha256": "8a0514f5f14137355972400cd4852f48c892316d28e3abd3c86635d7cb81eef2", + "production_contacted": false, + "actual_credentials_used": false, + "native_acceptance_claimed": false, + "session": { + "run_id": "ee97d0d3572a4d28971d9b19c6d4de78", + "containers": [ + "nk49-kc-ee97d0d3572a4d28971d9b19c6d4de78-groups-lldap", + "nk49-kc-ee97d0d3572a4d28971d9b19c6d4de78-groups-probe" + ], + "label": "net-kingdom.recovery-run=ee97d0d3572a4d28971d9b19c6d4de78" + }, + "containment_verified_before_private_import": true, + "checks": { + "only_loopback_and_no_external_routes": true, + "actual_graphql_ensure_verified": true, + "created_inventory_exact": true, + "preexisting_membership_excluded": true, + "separate_process_sqlite_restart_verified": true, + "actual_graphql_revoke_verified": true, + "preexisting_empty_and_shared_groups_preserved": true, + "all_preexisting_users_and_memberships_preserved": true, + "rollback_replay_nonmutating": true, + "rolledback_key_cannot_recreate": true, + "shared_membership_drift_refused_before_undo": true, + "lost_actual_graphql_response_retains_intent": true, + "reopened_uncertainty_refuses_ensure_and_revoke": true, + "unknown_and_cross_case_receipts_refused": true + }, + "container_cleanup_obligations": [ + { + "container": "nk49-kc-ee97d0d3572a4d28971d9b19c6d4de78-groups-probe", + "status": "absence_verified" + }, + { + "container": "nk49-kc-ee97d0d3572a4d28971d9b19c6d4de78-groups-lldap", + "status": "absence_verified" + } + ], + "container_cleanup_verified": true +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/manifest.json b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/manifest.json new file mode 100644 index 00000000..a9b24f5a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/manifest.json @@ -0,0 +1,94 @@ +{ + "schema": "nk48.groups-native-compatibility-fixture-proposal.v1", + "status": "prepared_not_executed_not_approved", + "source_sha256": { + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/driver.py": "sha256:3dd7f809aad404b56cd9693e25333978db1dddc1bf382455535d9e4f1073230e", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/probe.py": "sha256:1ca6b8ad2fdeb1ff4f2f1ad12a19f820cc6524a039364ae2d334b5e5861da997", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/test_driver.py": "sha256:e3c869ceb1ff9c58eb7c22e716aa97e76d20e1c2f7e89cea632f7fcf6ed8d02e", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/source-manifest.json": "sha256:f4fcf0e89fcabb31aa6051ed40efb35437c60ae9b0ea0692d9647aac971334df", + "/home/worsch/net-kingdom/tools/bootstrap-recovery/isolated-keycape.py": "sha256:1c018fd7fbfd3afcc96ae61e7eafc283ac1dde3916c3bf7eac9eb50b934b57a5", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/provisioner.py": "sha256:2f8e4368e7155465bbc4070b1019c7105edf43bdc2ad3bbc5aa9bc19dc6ed7a5", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/group_provenance.py": "sha256:fbd4c88d7682ba3d46c5b325dad1bc4f4a7faac2cd3f6ce9e0f7a89283bf46d4", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/README.md": "sha256:2a0a1b78e8af8ffa9c789e5604cef00ec0b07ff83f8e475ebcd3b5a297c5b192" + }, + "candidate_patch_sha256": "1f3f3ca94c46dadac8f24896053d4d23179de795e50a24298a0cfbcc46cca30e", + "containment_helper": "/home/worsch/net-kingdom/tools/bootstrap-recovery/isolated-keycape.py", + "fixed_endpoint": "unix:///var/run/docker.sock", + "engine_id": "74f93783-6cef-43a8-a3c4-f62586cd3bc1", + "engine_name": "bnt-lap001", + "lldap_image": "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d", + "python_image": "ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473", + "image_pin_origin": "existing reviewed isolated-lldap.py / KeyCape R6 manifests; cache presence and daemon identity must be reverified at admitted execution, never pulled", + "pull_policy": "never", + "network": "network-none", + "scope": "actual pinned disposable LLDAP GraphQL compatibility and process-restart SQLite fixture, generated synthetic identities only; no native owner acceptance", + "reuse": [ + "isolated-keycape.py exact local endpoint/environment binding", + "existing inspect_session(worker_only=True) containment guard", + "existing exact fresh-name/label cleanup" + ], + "limits": { + "sequential_cases": true, + "max_service_containers": 2, + "host_ports": 0, + "host_mounts": 0, + "named_or_anonymous_volumes": 0, + "memory_bytes_per_container": 268435456, + "cpus_per_container": 0.5, + "pids_per_container": 64, + "lldap_tmpfs_bytes": 51380224, + "helper_tmpfs_bytes": 26214400, + "container_lifetime_seconds": 180, + "execution_deadline_seconds": 150, + "docker_rpc_timeout_seconds": 45, + "preflight_rpc_timeout_seconds": 15, + "cleanup_rpc_timeout_seconds": 15, + "cleanup_total_bound_seconds": 120, + "new_cloud_spend_eur": 0, + "local_capacity": "at most 1 CPU / 512 MiB memory plus bounded tmpfs; no paid resources/pulls/builds" + }, + "resources": { + "container_names": "nk49-kc--groups-{lldap,probe}", + "exact_label": "net-kingdom.recovery-run=", + "safe_result": "private /tmp/nk48-groups-native-review-/result.json", + "private_material": "generated in host memory, stdin-only import into container-private tmpfs; no host credential/archive files" + }, + "phase_oracles": { + "bootstrap": [ + "only_loopback_and_no_external_routes", + "actual_graphql_ensure_verified", + "created_inventory_exact", + "preexisting_membership_excluded" + ], + "restart": [ + "only_loopback_and_no_external_routes", + "separate_process_sqlite_restart_verified", + "actual_graphql_revoke_verified", + "preexisting_empty_and_shared_groups_preserved", + "all_preexisting_users_and_memberships_preserved", + "rollback_replay_nonmutating", + "rolledback_key_cannot_recreate" + ], + "faults": [ + "only_loopback_and_no_external_routes", + "shared_membership_drift_refused_before_undo", + "lost_actual_graphql_response_retains_intent", + "reopened_uncertainty_refuses_ensure_and_revoke", + "unknown_and_cross_case_receipts_refused" + ] + }, + "cleanup": "finally invokes existing exact name+label helper for both preregistered containers including failed/lost creation replies; every removal followed by explicit successful absence inventory; daemon uncertainty stays failed; no global prune or host secret deletion", + "forbidden": [ + "Docker calls before independent coordinator approval of exact manifest/driver", + "pull/build/push; host networking/ports/mounts; production routes", + "real archives/LDAP/accounts/passwords/keys; credentials from env/custody; Kubernetes/OpenBao/provider calls", + "counting this fixture as native writer fencing/CAS, owner assent, protected evidence/session or production acceptance" + ], + "execution_command": [ + "python3", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/driver.py", + "--execute-reviewed-fixture", + "--expected-manifest-sha256", + "" + ] +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/preparation-verification.json b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/preparation-verification.json new file mode 100644 index 00000000..cea5f4cb --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/preparation-verification.json @@ -0,0 +1,11 @@ +{ + "status": "prepared_not_executed_not_approved", + "docker_executed": false, + "unit_command": "python3 -m pytest -q docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/test_driver.py", + "unit_result": "7 passed in 0.05s", + "exit_code": 0, + "manifest_sha256": "8a0514f5f14137355972400cd4852f48c892316d28e3abd3c86635d7cb81eef2", + "source_candidate_unchanged": true, + "native_proof": "none; execution awaits exact independent coordinator review", + "plan_check": "driver.py --plan returned manifest without containment import, credentials or Docker" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/probe.py b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/probe.py new file mode 100644 index 00000000..6e5ba64a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/probe.py @@ -0,0 +1,160 @@ +"""Runs only inside the disposable loopback-only pinned Python helper.""" +import json +from pathlib import Path +import sys +import time + +ROOT = Path('/fixture') +sys.path.insert(0, str(ROOT / 'source')) +from group_provenance import GroupProvenance +from provisioner import LLDAPProvisioner + + +def require(value): + if not value: + raise RuntimeError('synthetic_oracle_failed') + + +def provider(journal, initialize=False): + settings = json.loads((ROOT / 'private.json').read_text()) + return LLDAPProvisioner(base_url='http://127.0.0.1:17170', admin_password=settings['password'], + group_provenance=GroupProvenance(str(ROOT / 'journal' / journal), initialize=initialize)) + + +def snapshot(p): + token = p._login() + users, groups = p._directory(token) + return {'users': users, 'groups': sorted([{'id': int(g['id']), 'name': g['displayName'], + 'members': sorted(p._group_members(token, int(g['id'])))} for g in groups], key=lambda x: x['id'])} + + +def request(case, groups, members): + return {'groups': groups, 'members': members, 'idempotency_key': 'synthetic-native-groups-' + case, + 'correlation_id': 'synthetic-native-case-' + case} + + +def rollback(payload): + return {**payload, 'idempotency_key': payload['idempotency_key'] + '-rollback', + 'ensure_idempotency_key': payload['idempotency_key'], + 'ensure_correlation_id': payload['correlation_id'], 'delete_if_empty': True} + + +def group(p, name): + token = p._login() + return p._ensure_group(token, p._directory(token)[1], name) + + +def refuse(call): + try: + call() + except (ValueError, RuntimeError): + return + raise RuntimeError('expected_native_refusal_missing') + + +def bootstrap(): + p = provider('positive.db', initialize=True) + for _ in range(80): + try: + token = p._login() + break + except Exception: + time.sleep(.25) + else: + raise RuntimeError('synthetic_directory_not_ready') + for username in ('fixture-owner', 'fixture-added', 'fixture-shared'): + p._gql(token, 'mutation U($id: String!, $email: String!) { createUser(user: {id: $id, email: $email}) { id } }', + {'id': username, 'email': username + '@synthetic.invalid'}) + shared = group(p, 'fixture-preexisting') + group(p, 'fixture-empty') + unrelated = group(p, 'fixture-unrelated') + p._add_group(token, 'fixture-owner', shared) + p._add_group(token, 'fixture-shared', unrelated) + before = snapshot(p) + payload = request('positive', ['fixture-preexisting', 'fixture-empty', 'fixture-created'], + {'fixture-preexisting': ['fixture-owner', 'fixture-added'], 'fixture-created': ['fixture-owner']}) + result = p.ensure_service_groups(payload) + require(result.status == 'groups_ensured') + require({g['name'] for g in result.created_groups} == {'fixture-created'}) + require({(g['name'], g['username']) for g in result.created_memberships} == { + ('fixture-preexisting', 'fixture-added'), ('fixture-created', 'fixture-owner')}) + (ROOT / 'restart.json').write_text(json.dumps({'before': before, 'after': snapshot(p), 'payload': payload, + 'created_groups': result.created_groups, 'created_memberships': result.created_memberships})) + return {'actual_graphql_ensure_verified': True, 'created_inventory_exact': True, + 'preexisting_membership_excluded': True} + + +def restart(): + p = provider('positive.db') + previous = json.loads((ROOT / 'restart.json').read_text()) + result = p.ensure_service_groups(previous['payload']) + require(result.resumed and list(result.created_groups) == previous['created_groups'] and + list(result.created_memberships) == previous['created_memberships']) + require(snapshot(p) == previous['after']) + result = p.revoke_service_groups(rollback(previous['payload'])) + require(result.status == 'groups_revoked_and_deleted') + require(snapshot(p) == previous['before']) + p = provider('positive.db') + require(p.revoke_service_groups(rollback(previous['payload'])).resumed) + refuse(lambda: p.ensure_service_groups(previous['payload'])) + require(snapshot(p) == previous['before']) + return {'separate_process_sqlite_restart_verified': True, 'actual_graphql_revoke_verified': True, + 'preexisting_empty_and_shared_groups_preserved': True, 'all_preexisting_users_and_memberships_preserved': True, + 'rollback_replay_nonmutating': True, 'rolledback_key_cannot_recreate': True} + + +def faults(): + p = provider('drift.db', initialize=True) + payload = request('drift', ['fixture-drift-created'], {'fixture-drift-created': ['fixture-owner']}) + p.ensure_service_groups(payload) + p._add_group(p._login(), 'fixture-shared', group(p, 'fixture-drift-created')) + before = snapshot(p) + refuse(lambda: p.revoke_service_groups(rollback(payload))) + require(snapshot(p) == before) + receipt = p.group_provenance.get(payload['idempotency_key'], p._group_binding(payload['groups'], payload['members'], payload['correlation_id'])) + require(receipt['state'] == 'uncertain') + p = provider('lost.db', initialize=True) + payload = request('lost-response', ['fixture-lost-created'], {'fixture-lost-created': ['fixture-added']}) + original = p._gql + lost = [] + def lose_response(token, query, variables, **kwargs): + result = original(token, query, variables, **kwargs) + if 'addUserToGroup' in query: + require(result.get('addUserToGroup', {}).get('ok') is True) + lost.append(True) + raise RuntimeError('synthetic_lost_response_after_actual_mutation') + return result + p._gql = lose_response + refuse(lambda: p.ensure_service_groups(payload)) + require(lost == [True]) + p = provider('lost.db') + before = snapshot(p) + require('fixture-added' in next(g for g in before['groups'] if g['name'] == 'fixture-lost-created')['members']) + receipt = p.group_provenance.get(payload['idempotency_key'], p._group_binding(payload['groups'], payload['members'], payload['correlation_id'])) + require(receipt['state'] == 'uncertain' and receipt['intent']['operation'] == 'add_member' and not receipt['created_memberships']) + refuse(lambda: p.ensure_service_groups(payload)) + refuse(lambda: p.revoke_service_groups(rollback(payload))) + require(snapshot(p) == before) + unknown = {**rollback(payload), 'ensure_idempotency_key': 'unknown-synthetic-receipt-key'} + refuse(lambda: p.revoke_service_groups(unknown)) + mismatch = {**rollback(payload), 'ensure_correlation_id': 'another-synthetic-case'} + refuse(lambda: p.revoke_service_groups(mismatch)) + require(snapshot(p) == before) + return {'shared_membership_drift_refused_before_undo': True, 'lost_actual_graphql_response_retains_intent': True, + 'reopened_uncertainty_refuses_ensure_and_revoke': True, 'unknown_and_cross_case_receipts_refused': True} + + +def main(): + try: + require({p.name for p in Path('/sys/class/net').iterdir()} == {'lo'}) + require(len(Path('/proc/net/route').read_text().splitlines()) == 1) + checks = {'only_loopback_and_no_external_routes': True} + checks.update({'bootstrap': bootstrap, 'restart': restart, 'faults': faults}[sys.argv[1]]()) + print(json.dumps({'status': 'passed', 'checks': checks})) + except Exception: + print(json.dumps({'status': 'failed', 'failure': 'synthetic_native_oracle_failed'})) + return 0 # Expected refusals are data; a failing Docker RPC stays uncertain. + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/test_driver.py b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/test_driver.py new file mode 100644 index 00000000..3c29131b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-fixture/test_driver.py @@ -0,0 +1,86 @@ +import contextlib +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import sys +import tarfile +import unittest +from unittest.mock import Mock, patch + +HERE = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location('group_fixture_driver', HERE / 'driver.py') +f = importlib.util.module_from_spec(spec) +spec.loader.exec_module(f) + + +class PlanTests(unittest.TestCase): + def test_default_and_explicit_plan_do_not_load_containment_or_generate_material(self): + for args in ([], ['--plan']): + with patch.object(sys, 'argv', ['fixture', *args]), patch.object(f, 'load_containment') as load, patch.object(f, 'execute') as execute, patch.object(f.secrets, 'token_hex') as generate, contextlib.redirect_stdout(io.StringIO()) as out: + self.assertEqual(0, f.main()) + self.assertEqual('prepared_not_executed_not_approved', json.loads(out.getvalue())['status']) + load.assert_not_called(); execute.assert_not_called(); generate.assert_not_called() + + def test_wrong_manifest_digest_refuses_before_loading_helper(self): + with patch.object(sys, 'argv', ['fixture', '--execute-reviewed-fixture', '--expected-manifest-sha256', '0' * 64]), patch.object(f, 'load_containment') as load: + with self.assertRaises(RuntimeError): + f.main() + load.assert_not_called() + + def test_manifest_exact_source_closure_and_images(self): + manifest = f.reviewed_manifest(hashlib.sha256(f.MANIFEST.read_bytes()).hexdigest()) + self.assertEqual('unix:///var/run/docker.sock', manifest['fixed_endpoint']) + self.assertEqual('never', manifest['pull_policy']) + self.assertEqual('network-none', manifest['network']) + self.assertEqual(0, manifest['limits']['host_ports']) + self.assertEqual(0, manifest['limits']['host_mounts']) + self.assertEqual(2, manifest['limits']['max_service_containers']) + self.assertIn('sha256:', manifest['lldap_image']) + self.assertIn('sha256:', manifest['python_image']) + + def test_exact_fresh_names_no_host_network_ports_or_mounts(self): + manifest = json.loads(f.MANIFEST.read_text()) + session = f.session_plan(manifest, 'f' * 32) + self.assertEqual(2, len(set(session['containers']))) + for name in session['containers']: + args = f.base_args(session, name, 'none') + self.assertIn('--pull=never', args) + self.assertIn('--read-only', args) + self.assertIn('--log-driver', args) + self.assertIn('net-kingdom.recovery-run=' + 'f' * 32, args) + self.assertFalse({'--publish', '-p', '--volume', '-v', '--privileged', '--network=host'} & set(args)) + with self.assertRaises(RuntimeError): + f.session_plan(manifest, 'not-a-fresh-run') + + def test_tar_private_only_generated_named_material(self): + raw = f.tar_bytes({'password': b'synthetic-only', 'lldap.toml': b'synthetic-config'}) + with tarfile.open(fileobj=io.BytesIO(raw)) as archive: + self.assertTrue(all(member.uid == member.gid == 1000 and member.mode == 0o600 for member in archive)) + for path in ('../outside', '/absolute', 'path/../../escape'): + with self.assertRaises(RuntimeError): + f.tar_bytes({path: b'synthetic'}) + + def test_failure_still_invokes_existing_exact_cleanup(self): + manifest = json.loads(f.MANIFEST.read_text()) + adapter = Mock() + adapter.command.side_effect = RuntimeError('synthetic transport fault') + result = {'status': 'failed'} + with self.assertRaises(RuntimeError): + f.execute(manifest, adapter, result) + adapter.cleanup.assert_called_once() + session = adapter.cleanup.call_args[0][0] + self.assertEqual(session['containers'], result['session']['containers']) + self.assertTrue(result['daemon_request_uncertain']) + self.assertEqual('failed', result['status']) + + def test_probe_syntax_and_no_network_expansion(self): + compile((HERE / 'probe.py').read_text(), str(HERE / 'probe.py'), 'exec') + compile(f.IMPORT, 'private-import', 'exec') + self.assertNotIn('subprocess', (HERE / 'probe.py').read_text()) + self.assertIn("http://127.0.0.1:17170", (HERE / 'probe.py').read_text()) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/ownership-and-admission.md b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/ownership-and-admission.md new file mode 100644 index 00000000..9492f97c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/ownership-and-admission.md @@ -0,0 +1,121 @@ +# T14 native journal storage and writer-isolation proposal + +Status: source preparation only; not owner-admitted, executed or cost/risk +approved. No live topology, credential, production, Kubernetes, Docker or +OpenBao read was performed. No external message or canonical write was made. +Exact source revisions/hashes are retained in `source-evidence.json`; negative +ownership conclusions are limited to the inspected declarations. + +## Ownership and existing routes + +NetKingdom is the group implementation owner and owns the attended provider +reconciliation. Its INTENT explicitly identifies identity-provisioner as +bootstrap-era runtime residue in this Taxonomy repository. The source reference +Deployment remains in `net-kingdom/sso-mfa/k8s/identity-provisioner`: one Recreate +replica, UID/GID 10001, read-only root, no journal storage. That locates the +source; it does not establish a managed runtime package assignment. The inspected +reef binding lists no identity-provisioner rApp. User Engine's dependency on the +service and secret reference do not transfer package ownership. + +The existing owner route names Platform credential custody and NetKingdom's +attended reconciliation, rather than a single declared workload. The group +consumer route also records that Warden's `identity-provisioner-groups` route +was dropped because the bearer belongs to User Engine runtime and pod ingress +admits User Engine. A service token, pod execution or untrusted receipt cannot +stand in for protected-case caller/session admission. These are source facts, +not new credential requests or refreshed custody acceptance. + +[NetKingdom scope](https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/INTENT.md), [existing group caller route](https://forgejo.coulomb.social/coulomb/ops-mason/src/commit/57411f25b990f0d70dcfdcbd07778f6219bb9bf8/docs/directory-group-binding.md), [custody routing catalog](https://forgejo.coulomb.social/coulomb/ops-warden/src/commit/d97652a3bb5fa733b063d71a1b74068efcccf572/registry/routing/catalog.yaml), [reef package bindings](https://forgejo.coulomb.social/coulomb/reef-railiance/src/commit/33b840faac109f134bf1115d631d5b1242052d9a/bindings/rapps.yaml) + +## Smallest conforming storage change + +Accepted NetKingdom ADR-0015 section 3 requires CNPG through `rapp-postgres` and +`database-per-consumer`; it expressly rejects per-workload SQLite-on-a-PVC for +production. The existing SQLite implementation and disposable fixture remain +local/test proof. Its original native-PVC instructions are corrected in the +separate `storage-policy-correction-r1` patch. Keep production group operations +disabled; there is no admitted SQLite exception. + +The smallest conforming proposal is a CNPG backend behind the existing journal +injection point, not a second broker/service and not a schema borrowed from User +Engine. NetKingdom owns the lifecycle semantics/backend; `rapp-postgres` owns +consumer/package provisioning and database recovery; `railiance-platform` owns +shared capacity, backup/custody governance and credential-broker catalog. Runtime +credentials use the existing dynamic database lease route, with DML runtime, +DDL migration and non-login owner roles separated. The package's standalone +ADR-0001 still says proposed, so its detailed rationale is not misrepresented as +a separately ratified estate standard; the accepted NetKingdom ADR and current +package declaration establish the applicable boundary here. + +A proposed isolated consumer is `identity-provisioner`, database/schema +`identity_provisioner`, namespace `sso`, runtime 2 / migration 1 connections, +5s/30s statement timeouts and 15s idle-transaction timeout. These are requested +bounds, not granted capacity. The source catalog already has four declarations +on `platform-pg`, counting the isolation probe, at its accepted four-consumer +ceiling; `platform-pg-2` has two declared consumers and is the named overflow +candidate. Placement still needs current package/Platform capacity admission; +source counts do not show live headroom. Retention/erasure is also an owner +decision: the renderer's 30-day default and co-resident maximum are not a chosen +journal horizon. A fully renderable native consumer declaration is deliberately +not produced before these owner choices. + +Tenant-keying must come from authoritative accepted case/caller scope. Existing +raw group requests contain no trustworthy tenant fact; neither a caller string +nor an invented platform-root default fills that gap. The current provider +seam's semantic operations remain reserve/get/claim-revoke/save/check. Preserve +bounded records, exact key/correlation/request binding, prestate, committed +write-before-call intent, confirmed-creation inventories, exactly-one conditional +updates and non-replayable uncertainty. SQL row versions and a unique active +slot can serialize cooperating producer calls; they cannot fence LDAP writers. + +[accepted ADR-0015](https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/docs/adr/ADR-0015-netkingdom-railiance-workload-packaging-and-relational-platform.md), [Platform/package boundary](https://forgejo.coulomb.social/coulomb/railiance-platform/src/commit/08c1f036f2750097750f435efd76e8f6ee6df393/docs/rapp-postgres-boundary.md), [current package contract](https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/declarations/rapp.yaml), [accepted cell ceiling](https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/docs/adr/ADR-0004-platform-pg-cell-ceiling.md), [rail storage boundary](https://forgejo.coulomb.social/coulomb/rail-kubernetes/src/commit/5fad47fdfb7d4cabc21cf10f52ac733ef59803b0/docs/wave-1-contract.md) + +## Writer isolation is independently open + +Current GROUP-MAINTENANCE explicitly requires an exclusive owner maintenance +window and states that two reads are not a lock. LLDAP's admin WebUI is another +declared writer. The native contract must include all affected group and user +writers: provisioner ensure/revoke, lifecycle deletion of users whose group edges +would disappear, attended admin helper, and administrative WebUI/API paths. +One replica, Recreate, a SQL lock or a NetworkPolicy is insufficient proof. + +Before native undo, the owner must prove a maintenance barrier/fencing contract +covering read/intent/write/readback and process loss/revocation, or supply native +provider CAS plus membership-generation/version semantics. Until then, destructive +native rollback remains refused. Exact postimage checks cannot detect a +remove/re-add ABA sequence or close the external read/write race. The successful +bounded disposable GraphQL fixture proves compatibility and conservative drift +refusal; it does not close these native races. + +[maintenance-window limitation](https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md), [declared admin UI writer](https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/sso-mfa/k8s/lldap/ingress.yaml) + +## Admission and recovery boundaries + +1. Explicitly assign the operating/package owner, then review the CNPG backend + with the existing positive/refusal/crash/concurrent/restore conformance suite. +2. Obtain the existing package/Platform consumer, placement, role/lease, network, + backup/restore and capacity acceptance. Do not create an unreviewed PVC or + silently exceed a cell ceiling. +3. Pin the image/source/backend/schema and bind protected owner callbacks to the + exact accepted case/source/principal/request/native objects, independent + evidence and per-attempt parent-session revocation. Keep production group + traffic disabled until that existing boundary is admitted. +4. Prove writer isolation or provider CAS/version semantics and prepare the + separately supervised native rollout with exact verification/rollback and + cost/risk estimates. Source preparation supplies none of those approvals. + +Before the first directory effect, rollback can disable group capability and +reverse newly admitted package/config references while retaining journal/schema +for analysis. After any intent or effect, quiesce protected writes, preserve the +journal/active slot/evidence and independently reconcile LDAP before continuation +or undo. Roll an image only to a fail-closed version: the old broad revoke +behavior is not an availability fallback. Never drop/truncate/reinitialize the +journal, downmigrate away intents or restore an older database and automatically +resume. A PostgreSQL restore behind an LDAP effect can lose its original intent; +restore therefore requires quarantine and independent continuity/reconciliation +rather than trusting absence in a snapshot. No operation rolls back unrelated +consumer databases, identity records, login/factor configuration or credentials. + +Native EUR cost and credible loss bounds are unknown and require the existing +owners' estimates. Shared storage is not assumed free, and wrong IAM rollback +has high-impact consequences. No spend or risk is accepted by this proposal. diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/proposal.json b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/proposal.json new file mode 100644 index 00000000..3ba1af32 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/proposal.json @@ -0,0 +1,92 @@ +{ + "schema": "nk48.group-journal-native-storage-proposal.v1", + "status": "proposed_not_admitted_not_executed", + "work": "NK-WP-0048-T14", + "supersedes_no_policy": true, + "finding": "ADR0015 accepted production CNPG/database-per-consumer conflicts with earlier SQLite PVC instructions; doc-only correction retained separately", + "owners": { + "implementation_and_attended_directory_reconciliation": "net-kingdom", + "runtime_package_assignment": "unresolved: no identity-provisioner package in inspected reef binding; User Engine dependency is not package ownership", + "shared_database_custody_governance": "railiance-platform", + "consumer_package_provisioning_and_recovery": "rapp-postgres", + "substrate": "railiance-cluster via rail-kubernetes; no generic persistent-storage contract", + "protected_caller_evidence_and_revocation": "existing Warden/global admission and named owner callbacks; not supplied by a service bearer" + }, + "production_route": { + "preferred": "implement the existing journal seam against CNPG via rapp-postgres; no new service/broker", + "alternative": "explicit scoped ADR0015 exception with owner/limits/duration/recovery/risk disposition, not assumed", + "sqlite_scope": "local/disposable tests only", + "default_governed_group_operations": "disabled/refused until native admission" + }, + "proposed_consumer_parameters": { + "not_a_deployable_declaration": true, + "consumer": "identity-provisioner", + "database": "identity_provisioner", + "schema": "identity_provisioner", + "proposed_cluster": "platform-pg-2", + "placement_status": "needs current owner admission; primary platform-pg has4 declared consumers at4 maximum", + "namespace": "sso", + "cost_attribution_key": "platform:identity-provisioner", + "roles": { + "owner": "identity_provisioner_owner", + "migration": "identity_provisioner_migrate", + "runtime": "identity_provisioner_app" + }, + "limits": { + "runtime_connections": 2, + "migration_connections": 1, + "runtime_statement_timeout": "5s", + "migration_statement_timeout": "30s", + "idle_in_transaction_timeout": "15s" + }, + "tenant_keying_required": true, + "authoritative_scope_binding": "must come from verified accepted case/caller context; missing scope refuses; no invented platform-root default", + "credential_delivery": "existing short-lived OpenBao dynamic database credential contract; no secret retrieval in this preparation", + "retention": "owner decision required; current renderer default30days and co-resident maximum are facts, not a chosen journal erasure horizon" + }, + "journal_requirements": [ + "preserve reserve/get/claim_revoke/save/check semantics and bounded receipt capacity without automatic eviction", + "reserve exact original ensure key/correlation/request digest before directory access", + "committed write intent before every directory mutation; success only after exact readback and exactly-one conditional state update", + "per-receipt monotonic revision/CAS plus unique active slot at directory-instance scope; cooperating producer serialization is not external LDAP fencing", + "retain refused-before-mutation as terminal original-key evidence while releasing active slot", + "pending/uncertain intent survives failover/restart/lease expiry, never ages into replay authority", + "runtime DML-only and migration DDL separated; no automatic empty database/schema recreation at service start", + "restore quarantine and independently reconciled continuity before re-enabling writes; a restored old database is not proof that later LDAP mutations never happened" + ], + "writer_isolation_requirements": [ + "inventory and gate all affected group/user writers: service ensure/revoke, lifecycle user deletion, attended lldap_admin helper and LLDAP admin WebUI/API", + "prove owner-held maintenance barrier/fencing for the whole read/intent/mutate/readback interval, including callback process failure and revocation", + "absence of a CAS API, a database row lock, one replica/Recreate, a NetworkPolicy or two equal reads is not that proof", + "if no enforceable fence or provider conditional membership generation/version exists, native destructive rollback remains refused" + ], + "admission_gates": [ + "identify operating/package owner explicitly without declaring a new rApp by assumption", + "source implementation + same positive/refusal/crash/concurrent/lost-store/restore conformance fixtures for CNPG backend", + "rapp-postgres consumer declaration/cell capacity/role/network/backup restore review + Platform custody/lease delivery assent", + "immutable image/source/backend schema and configuration pins with disabled group traffic until protected caller admission", + "exact accepted case/source/principal/native ids/request digest, independent evidence and parent-session revocation receipt integration", + "fresh owner-proven writer isolation or provider CAS plus independently verified prestate/poststate preservation", + "founder-scoped native rollout/verification/rollback proposal with bounded cost/risk and separate owner review" + ], + "rollback": { + "before_first_directory_mutation": "disable group capability; remove only newly admitted package/config references through owner-managed rollback; preserve journal and schema for analysis", + "after_any_intent_or_effect": "quiesce all protected group writes, retain journal/active slot/evidence, independently reconcile LDAP before any continued/undo action", + "image_rollback": "only a version that retains fail-closed governed group handling; do not restore old broad revoke endpoint for availability", + "database_rollback": "forward-compatible schema retention; no DROP/TRUNCATE/reinitialize, no restore behind intent watermark then reopen, no automatic downmigration", + "shared_services": "no rollback of other consumers, LLDAP identities, login/factor configuration or old credentials" + }, + "cost_risk": { + "source_preparation": "read-only local inspection, no new paid resources", + "native_cost_eur": "unknown; owner estimate/capacity reservation required before approval", + "native_loss_bound_eur": "unpriced; LDAP membership/identity availability and wrong rollback are high-impact; supervised review required", + "spend_authorized": false, + "risk_accepted": false + }, + "not_performed": [ + "no production/credential reads or writes", + "no owner messages/assent", + "no Docker/Kubernetes/OpenBao/provider calls", + "no canonical changes while coordinator publishes" + ] +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/public-source-links.json b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/public-source-links.json new file mode 100644 index 00000000..f42616f1 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/public-source-links.json @@ -0,0 +1,117 @@ +[ + { + "repo": "net-kingdom", + "path": "INTENT.md", + "url": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/INTENT.md" + }, + { + "repo": "net-kingdom", + "path": "docs/adr/ADR-0015-netkingdom-railiance-workload-packaging-and-relational-platform.md", + "url": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/docs/adr/ADR-0015-netkingdom-railiance-workload-packaging-and-relational-platform.md" + }, + { + "repo": "net-kingdom", + "path": "sso-mfa/k8s/identity-provisioner/deployment.yaml", + "url": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/sso-mfa/k8s/identity-provisioner/deployment.yaml" + }, + { + "repo": "net-kingdom", + "path": "docs/identity-provisioner-bind-repair.md", + "url": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/docs/identity-provisioner-bind-repair.md" + }, + { + "repo": "net-kingdom", + "path": "sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md", + "url": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md" + }, + { + "repo": "net-kingdom", + "path": "sso-mfa/k8s/lldap/ingress.yaml", + "url": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/sso-mfa/k8s/lldap/ingress.yaml" + }, + { + "repo": "ops-mason", + "path": "docs/directory-group-binding.md", + "url": "https://forgejo.coulomb.social/coulomb/ops-mason/src/commit/57411f25b990f0d70dcfdcbd07778f6219bb9bf8/docs/directory-group-binding.md" + }, + { + "repo": "ops-mason", + "path": "registry/capabilities/capability.security.structure-construction.md", + "url": "https://forgejo.coulomb.social/coulomb/ops-mason/src/commit/57411f25b990f0d70dcfdcbd07778f6219bb9bf8/registry/capabilities/capability.security.structure-construction.md" + }, + { + "repo": "ops-warden", + "path": "registry/routing/catalog.yaml", + "url": "https://forgejo.coulomb.social/coulomb/ops-warden/src/commit/d97652a3bb5fa733b063d71a1b74068efcccf572/registry/routing/catalog.yaml" + }, + { + "repo": "railiance-platform", + "path": "docs/rapp-postgres-boundary.md", + "url": "https://forgejo.coulomb.social/coulomb/railiance-platform/src/commit/08c1f036f2750097750f435efd76e8f6ee6df393/docs/rapp-postgres-boundary.md" + }, + { + "repo": "railiance-platform", + "path": "INTENT.md", + "url": "https://forgejo.coulomb.social/coulomb/railiance-platform/src/commit/08c1f036f2750097750f435efd76e8f6ee6df393/INTENT.md" + }, + { + "repo": "rapp-postgres", + "path": "declarations/rapp.yaml", + "url": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/declarations/rapp.yaml" + }, + { + "repo": "rapp-postgres", + "path": "scripts/render_consumers.py", + "url": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/scripts/render_consumers.py" + }, + { + "repo": "rapp-postgres", + "path": "docs/adr/ADR-0004-platform-pg-cell-ceiling.md", + "url": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/docs/adr/ADR-0004-platform-pg-cell-ceiling.md" + }, + { + "repo": "rapp-postgres", + "path": "docs/adr/ADR-0001-consumer-boundary-and-tenant-isolation.md", + "url": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/docs/adr/ADR-0001-consumer-boundary-and-tenant-isolation.md" + }, + { + "repo": "rapp-postgres", + "path": "consumers/tenant-engine.yaml", + "url": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/consumers/tenant-engine.yaml" + }, + { + "repo": "rapp-postgres", + "path": "consumers/audit-core.yaml", + "url": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/consumers/audit-core.yaml" + }, + { + "repo": "rapp-postgres", + "path": "consumers/core-hub.yaml", + "url": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/consumers/core-hub.yaml" + }, + { + "repo": "rapp-postgres", + "path": "consumers/isolation-probe.yaml", + "url": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/consumers/isolation-probe.yaml" + }, + { + "repo": "rapp-postgres", + "path": "consumers/sbom-nexus.yaml", + "url": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/consumers/sbom-nexus.yaml" + }, + { + "repo": "rapp-postgres", + "path": "consumers/canned-prompts.yaml", + "url": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/consumers/canned-prompts.yaml" + }, + { + "repo": "reef-railiance", + "path": "bindings/rapps.yaml", + "url": "https://forgejo.coulomb.social/coulomb/reef-railiance/src/commit/33b840faac109f134bf1115d631d5b1242052d9a/bindings/rapps.yaml" + }, + { + "repo": "rail-kubernetes", + "path": "docs/wave-1-contract.md", + "url": "https://forgejo.coulomb.social/coulomb/rail-kubernetes/src/commit/5fad47fdfb7d4cabc21cf10f52ac733ef59803b0/docs/wave-1-contract.md" + } +] diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/source-evidence.json b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/source-evidence.json new file mode 100644 index 00000000..54f5d20a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/source-evidence.json @@ -0,0 +1,224 @@ +{ + "kind": "read-only public-source orientation; no live topology or owner assent", + "sources": [ + { + "repo": "net-kingdom", + "path": "INTENT.md", + "revision": "878abc93588e12b6da1bac055cfbb3abb8d7adf1", + "working_sha256": "fd2ba8dff9dfd5583ec6d8b0348f273bfe00c59923b5d5d47cd22b1fb4658c54", + "head_sha256": "fd2ba8dff9dfd5583ec6d8b0348f273bfe00c59923b5d5d47cd22b1fb4658c54", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/INTENT.md" + }, + { + "repo": "net-kingdom", + "path": "docs/adr/ADR-0015-netkingdom-railiance-workload-packaging-and-relational-platform.md", + "revision": "878abc93588e12b6da1bac055cfbb3abb8d7adf1", + "working_sha256": "2000985ef211aeedd3656e15cedb289e655526c2dcc4a161a64ffc27f0289db1", + "head_sha256": "2000985ef211aeedd3656e15cedb289e655526c2dcc4a161a64ffc27f0289db1", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/docs/adr/ADR-0015-netkingdom-railiance-workload-packaging-and-relational-platform.md" + }, + { + "repo": "net-kingdom", + "path": "sso-mfa/k8s/identity-provisioner/deployment.yaml", + "revision": "878abc93588e12b6da1bac055cfbb3abb8d7adf1", + "working_sha256": "807878e939967c8b88dafd4746c2a321b373d9f552083a82248fa917d4d9ea73", + "head_sha256": "807878e939967c8b88dafd4746c2a321b373d9f552083a82248fa917d4d9ea73", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/sso-mfa/k8s/identity-provisioner/deployment.yaml" + }, + { + "repo": "net-kingdom", + "path": "docs/identity-provisioner-bind-repair.md", + "revision": "878abc93588e12b6da1bac055cfbb3abb8d7adf1", + "working_sha256": "df1e39eca8573cb06dae61386aa6ade316533947a0959f07fe4a03ea503fd148", + "head_sha256": "df1e39eca8573cb06dae61386aa6ade316533947a0959f07fe4a03ea503fd148", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/docs/identity-provisioner-bind-repair.md" + }, + { + "repo": "net-kingdom", + "path": "sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md", + "revision": "878abc93588e12b6da1bac055cfbb3abb8d7adf1", + "working_sha256": "03be1481f387d9fddbdeff550abbc2cbaa120db4105877c909acc7b3f00f8046", + "head_sha256": "03be1481f387d9fddbdeff550abbc2cbaa120db4105877c909acc7b3f00f8046", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md" + }, + { + "repo": "net-kingdom", + "path": "sso-mfa/k8s/lldap/ingress.yaml", + "revision": "878abc93588e12b6da1bac055cfbb3abb8d7adf1", + "working_sha256": "7cb1f0e09f22004adcdd66d617857123d08276aa685d677b0c6e81e0aabe5290", + "head_sha256": "7cb1f0e09f22004adcdd66d617857123d08276aa685d677b0c6e81e0aabe5290", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/sso-mfa/k8s/lldap/ingress.yaml" + }, + { + "repo": "ops-mason", + "path": "docs/directory-group-binding.md", + "revision": "57411f25b990f0d70dcfdcbd07778f6219bb9bf8", + "working_sha256": "b050e19ccabebf2fd27d7052cf4b6ecd2f9015874566230727674c1c75894fdf", + "head_sha256": "b050e19ccabebf2fd27d7052cf4b6ecd2f9015874566230727674c1c75894fdf", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/ops-mason/src/commit/57411f25b990f0d70dcfdcbd07778f6219bb9bf8/docs/directory-group-binding.md" + }, + { + "repo": "ops-mason", + "path": "registry/capabilities/capability.security.structure-construction.md", + "revision": "57411f25b990f0d70dcfdcbd07778f6219bb9bf8", + "working_sha256": "000ceac5b4c71569aed1c9af970980a0c72a34db13fdca40671c9ddbfe97409d", + "head_sha256": "000ceac5b4c71569aed1c9af970980a0c72a34db13fdca40671c9ddbfe97409d", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/ops-mason/src/commit/57411f25b990f0d70dcfdcbd07778f6219bb9bf8/registry/capabilities/capability.security.structure-construction.md" + }, + { + "repo": "ops-warden", + "path": "registry/routing/catalog.yaml", + "revision": "d97652a3bb5fa733b063d71a1b74068efcccf572", + "working_sha256": "c47065c0892fd0bd07c2bffab95884e7e907832eef3623c4d69d6ad3b01ebc53", + "head_sha256": "c47065c0892fd0bd07c2bffab95884e7e907832eef3623c4d69d6ad3b01ebc53", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/ops-warden/src/commit/d97652a3bb5fa733b063d71a1b74068efcccf572/registry/routing/catalog.yaml" + }, + { + "repo": "railiance-platform", + "path": "docs/rapp-postgres-boundary.md", + "revision": "08c1f036f2750097750f435efd76e8f6ee6df393", + "working_sha256": "6d80877d2eb7cef79a11f1d8ee40e8bb7fabdbdd122494a7aaa39b6d931e610d", + "head_sha256": "6d80877d2eb7cef79a11f1d8ee40e8bb7fabdbdd122494a7aaa39b6d931e610d", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/railiance-platform/src/commit/08c1f036f2750097750f435efd76e8f6ee6df393/docs/rapp-postgres-boundary.md" + }, + { + "repo": "railiance-platform", + "path": "INTENT.md", + "revision": "08c1f036f2750097750f435efd76e8f6ee6df393", + "working_sha256": "12c6540735af591f7a4a9fe85ff10d3cd00eec55b5b6b1ddf148abf2d3ac9a9c", + "head_sha256": "12c6540735af591f7a4a9fe85ff10d3cd00eec55b5b6b1ddf148abf2d3ac9a9c", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/railiance-platform/src/commit/08c1f036f2750097750f435efd76e8f6ee6df393/INTENT.md" + }, + { + "repo": "rapp-postgres", + "path": "declarations/rapp.yaml", + "revision": "a9f569c96d74aa384ac8d3ac4aa960e4061550b5", + "working_sha256": "a5811e9fa994ae5815214e81440b3be2a32b58236c09ec1887c54d0a7f9f3dce", + "head_sha256": "a5811e9fa994ae5815214e81440b3be2a32b58236c09ec1887c54d0a7f9f3dce", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/declarations/rapp.yaml" + }, + { + "repo": "rapp-postgres", + "path": "scripts/render_consumers.py", + "revision": "a9f569c96d74aa384ac8d3ac4aa960e4061550b5", + "working_sha256": "5d83e29e5ec675b3c78c0129d9e12213b624146f5883534a7e5c99aaaa057f61", + "head_sha256": "5d83e29e5ec675b3c78c0129d9e12213b624146f5883534a7e5c99aaaa057f61", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/scripts/render_consumers.py" + }, + { + "repo": "rapp-postgres", + "path": "docs/adr/ADR-0004-platform-pg-cell-ceiling.md", + "revision": "a9f569c96d74aa384ac8d3ac4aa960e4061550b5", + "working_sha256": "1f1eb521f8e6690bfdf650257308a6139540a65ca65d822a8def5f7a90fde82b", + "head_sha256": "1f1eb521f8e6690bfdf650257308a6139540a65ca65d822a8def5f7a90fde82b", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/docs/adr/ADR-0004-platform-pg-cell-ceiling.md" + }, + { + "repo": "rapp-postgres", + "path": "docs/adr/ADR-0001-consumer-boundary-and-tenant-isolation.md", + "revision": "a9f569c96d74aa384ac8d3ac4aa960e4061550b5", + "working_sha256": "3474c0e1b411ce65a4fee63288b22101662370db737ca7e41158263bf310eb06", + "head_sha256": "3474c0e1b411ce65a4fee63288b22101662370db737ca7e41158263bf310eb06", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/docs/adr/ADR-0001-consumer-boundary-and-tenant-isolation.md" + }, + { + "repo": "rapp-postgres", + "path": "consumers/tenant-engine.yaml", + "revision": "a9f569c96d74aa384ac8d3ac4aa960e4061550b5", + "working_sha256": "92f36d587f001c86ff401aedd25cb49fa171d49e000b0a6ab551e182e5c11448", + "head_sha256": "92f36d587f001c86ff401aedd25cb49fa171d49e000b0a6ab551e182e5c11448", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/consumers/tenant-engine.yaml" + }, + { + "repo": "rapp-postgres", + "path": "consumers/audit-core.yaml", + "revision": "a9f569c96d74aa384ac8d3ac4aa960e4061550b5", + "working_sha256": "a2db53f901df056546b2c028f287f8fc50486e6e6cd81026b9e926b6b64272b3", + "head_sha256": "a2db53f901df056546b2c028f287f8fc50486e6e6cd81026b9e926b6b64272b3", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/consumers/audit-core.yaml" + }, + { + "repo": "rapp-postgres", + "path": "consumers/core-hub.yaml", + "revision": "a9f569c96d74aa384ac8d3ac4aa960e4061550b5", + "working_sha256": "8fe3e8b4488548b2f5e01f1d46edd9fd5fe4f0e4f65a3821f172adbfb5ae1d84", + "head_sha256": "8fe3e8b4488548b2f5e01f1d46edd9fd5fe4f0e4f65a3821f172adbfb5ae1d84", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/consumers/core-hub.yaml" + }, + { + "repo": "rapp-postgres", + "path": "consumers/isolation-probe.yaml", + "revision": "a9f569c96d74aa384ac8d3ac4aa960e4061550b5", + "working_sha256": "8ea24d3bd04ab683b47ceff2586932ea4fdb0d45c49896167bb5b352a67a5cfb", + "head_sha256": "8ea24d3bd04ab683b47ceff2586932ea4fdb0d45c49896167bb5b352a67a5cfb", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/consumers/isolation-probe.yaml" + }, + { + "repo": "rapp-postgres", + "path": "consumers/sbom-nexus.yaml", + "revision": "a9f569c96d74aa384ac8d3ac4aa960e4061550b5", + "working_sha256": "0b21d05fe7b9d21cbc9eb761f455329739ceb71f8dd3d1200971426962c11236", + "head_sha256": "0b21d05fe7b9d21cbc9eb761f455329739ceb71f8dd3d1200971426962c11236", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/consumers/sbom-nexus.yaml" + }, + { + "repo": "rapp-postgres", + "path": "consumers/canned-prompts.yaml", + "revision": "a9f569c96d74aa384ac8d3ac4aa960e4061550b5", + "working_sha256": "97d4b439cec7e788b7c1fa2c7181c2606ed098dcf37a38c1f73f7393e6046429", + "head_sha256": "97d4b439cec7e788b7c1fa2c7181c2606ed098dcf37a38c1f73f7393e6046429", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/rapp-postgres/src/commit/a9f569c96d74aa384ac8d3ac4aa960e4061550b5/consumers/canned-prompts.yaml" + }, + { + "repo": "reef-railiance", + "path": "bindings/rapps.yaml", + "revision": "33b840faac109f134bf1115d631d5b1242052d9a", + "working_sha256": "796007c68f0eda1d50cebddf9e11a2c123a8814ebaf67f739ed5d2af2d6fa3f1", + "head_sha256": "796007c68f0eda1d50cebddf9e11a2c123a8814ebaf67f739ed5d2af2d6fa3f1", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/reef-railiance/src/commit/33b840faac109f134bf1115d631d5b1242052d9a/bindings/rapps.yaml" + }, + { + "repo": "rail-kubernetes", + "path": "docs/wave-1-contract.md", + "revision": "5fad47fdfb7d4cabc21cf10f52ac733ef59803b0", + "working_sha256": "2974f8bd9096fe1f096e9f557fbbc2c29fc80462c46d693462b2258abdc9fbff", + "head_sha256": "2974f8bd9096fe1f096e9f557fbbc2c29fc80462c46d693462b2258abdc9fbff", + "working_matches_head": true, + "public_source": "https://forgejo.coulomb.social/coulomb/rail-kubernetes/src/commit/5fad47fdfb7d4cabc21cf10f52ac733ef59803b0/docs/wave-1-contract.md" + } + ], + "negative_search_scope": [ + "reef-railiance/bindings/rapps.yaml does not name identity-provisioner", + "rapp-postgres/consumers does not contain identity-provisioner", + "identity-provisioner reference deployment does not configure GROUP_PROVENANCE_DB or a volume" + ], + "catalog_counts_from_source": { + "platform-pg": 4, + "platform-pg-2": 2, + "maximum_each": 4, + "isolation_probe_counted": true, + "not_live_capacity_evidence": true + } +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/verification.json b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/verification.json new file mode 100644 index 00000000..6fff92f3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/native-storage-proposal-r1/verification.json @@ -0,0 +1,10 @@ +{ + "kind": "read-only source consistency checks; not native validation", + "source_files": 23, + "all_inspected_working_sources_match_pinned_HEAD": true, + "proposal_json_parses": true, + "no_native_assent_or_cost_risk_grant": true, + "no_runtime_call_executed": true, + "proposal_sha256": "d8221a90f90f4948efbad3be12b5a945f3ef8d1c381bd69bbebf0d397b09e1eb", + "report_sha256": "086308d7f7b430b0a355982eb63e36304f2862f808ce9e833366fa6fa5b612e3" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/docs/service-group-provenance.md b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/docs/service-group-provenance.md new file mode 100644 index 00000000..8c2451fa --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/docs/service-group-provenance.md @@ -0,0 +1,100 @@ +# Durable service group creation provenance + +This source change narrows the existing authenticated identity-provisioner +`/v1/groups/ensure` and `/v1/groups/revoke` endpoints. It introduces no new +principal, credential, policy decision, or rollback authorization. An endpoint's +service token and a journal receipt do not establish an accepted case, caller +session, owner assent, or permission to use a protected execution lane. + +## Request and receipt migration + +Ensure keeps `groups`, `members`, `idempotency_key` and `correlation_id`. +The original correlation is now part of its immutable binding: the original +idempotency key with a different correlation or group/member request is refused. +The response adds `ensure_idempotency_key`, `ensure_correlation_id`, +`created_groups` (name and exact directory id) and `created_memberships` +(name, directory id and username). The owner journal independently retains the +complete prestate and expected postimage. Response inventories are advisory; +caller-supplied inventories are rejected and never authorize undo. + +Revoke retains the original group/member request, its own `idempotency_key` and +`correlation_id`, and optional boolean `delete_if_empty`. It additionally requires +`ensure_idempotency_key` and `ensure_correlation_id` from the original ensure. +Legacy revoke requests fail before directory access. No old deployment inventory +is imported as proof of creation: pre-upgrade groups and memberships cannot be +rolled back automatically without independent owner reconciliation. + +An ensure replay returns its original receipt and never recreates subsequently +removed state. Reusing its key after rollback is refused. Revoke replays return +its original outcome; changing deletion options after completion is refused. +There are at most 16 groups, 256 requested membership entries, 4096 retained +preexisting members per operation, and 256 journal records by default. Records +have no timeout or automatic eviction. Capacity exhaustion stops new operations. + +## Storage and deployment preparation + +The existing reference Deployment runs one replica with `Recreate`, UID/GID +10001 and `readOnlyRootFilesystem: true`; it currently has no provenance volume. +The image copies `group_provenance.py`. Native rollout still needs owner review, +a rebuilt immutable image and a separately admitted persistent-volume change. +Do not turn off the read-only root filesystem or grant container root. + +Set `GROUP_PROVENANCE_DB` only to an absolute, non-symlink path on an +owner-controlled persistent volume. For example, after the deployment owner has +prepared it, `/var/lib/identity-provisioner/group-state/provenance.sqlite3` can +use an inner directory owned by UID10001 with mode0700 and a mode0600 database. +SQLite's journal files need the same private writable directory. A writable mount +with only the database file mounted is insufficient. `/tmp`, an `emptyDir`, an +image layer and a process-local dictionary do not retain restart duties. + +The owner explicitly initializes a genuinely new empty store with +`GroupProvenance(absolute_path, initialize=True)` in an attended, authorized +preparation step under the service UID. This refuses an existing file. Ordinary +startup uses `GroupProvenance(absolute_path)` and never creates a replacement +for a missing store. Missing/untrusted/unavailable configured storage prevents +startup or returns a fixed `group_provenance` dependency failure, without local +paths or database diagnostics in HTTP responses. With no configuration, ordinary +directory readiness names `service_groups: disabled`, while group endpoints +refuse work. With configuration, readiness checks storage custody and schema +before reporting `service_groups: available`. This is capability reporting, +not native admission or proof that every retained operation is resolved. + +Back up and recover the journal as security-relevant owner state, together with +its directory pre/postimage evidence and outstanding intents. Losing the journal +is not permission to initialize a replacement and repeat old keys. No recovery +API, arbitrary JSON import, receipt expiry or automatic uncertainty-clearing is +introduced here. An owner must independently reconcile unresolved intents; +manual edits to declare success are not evidence. + +## Safety and remaining native acceptance + +Before each directory mutation, durable intent is committed. A successful +response and exact readback are required before recording a confirmed creation. +A definite failure before any mutation attempt retains a `refused` record and +releases the active fence; its original key still cannot retry. Transport loss, +readback failure after an attempted mutation or a process exit leaves pending/uncertain state; +it survives reopening and blocks new mutations instead of becoming retry success. +Every database connection rechecks the private path and original device/inode; +missing/replaced storage is refused. Every intent update must affect exactly one +matching journal row before execution can continue. +The journal records mutation intent and confirmed outcomes, not an atomic LDAP +transaction or a complete audit emission guarantee. + +Rollback validates every targeted group id and complete member postimage before +its first destructive operation, and verifies each subsequent change. It removes +only memberships confirmed newly added by that ensure. It deletes only groups +confirmed newly created by that ensure, and only while empty. Preexisting groups, +including empty groups, are never deleted. Existing members are never included +in the removal inventory. Detected shared-member drift or group replacement +refuses the operation before any destructive call and retains uncertainty. + +LLDAP exposes no membership-generation token or conditional mutation/CAS here. +Readback cannot detect a remove/re-add ABA sequence or close a read/write race +with an external writer. Native rollback therefore still requires an owner-proven +writer isolation/fencing contract or a provider-side versioned/CAS operation; +these synthetic fixtures do not prove that boundary. A durable SQLite file is +trusted owner-held state, not tamper-evident remote evidence or an authorization +broker. Existing caller authentication is unchanged; case/source/principal binding, +per-attempt protected session revocation, independently admitted evidence callbacks, +native storage/backup recovery and native endpoint acceptance remain outstanding +under NK-WP-0048-T14/T16. This source change alone does not finish those tasks. diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/Containerfile b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/Containerfile new file mode 100644 index 00000000..755d68ef --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/Containerfile @@ -0,0 +1,9 @@ +FROM lldap/lldap:stable@sha256:2a8454b668c1aba7157e832eab0e242e1e7eb5fb7591d7e7774ba05286511ca8 AS lldap +FROM python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9 +RUN useradd --create-home --uid 10001 provisioner +WORKDIR /app +COPY --from=lldap /app/lldap_set_password /app/lldap_set_password +COPY provisioner.py group_provenance.py password_setup.py server.py /app/ +USER 10001:10001 +EXPOSE 8080 +CMD ["python", "server.py"] diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/group_provenance.py b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/group_provenance.py new file mode 100644 index 00000000..82bff979 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/group_provenance.py @@ -0,0 +1,170 @@ +"""Bounded, owner-private recovery journal; never an authorization source. + +An unresolved write intent is retained indefinitely and blocks further writes. +Only the directory owner may reconcile it using independently obtained evidence. +""" +from __future__ import annotations + +import json +from contextlib import contextmanager +import os +from pathlib import Path +import sqlite3 +import stat +from typing import Any +from urllib.parse import quote + + +class GroupProvenanceUnavailable(RuntimeError): + def __init__(self): + super().__init__("group_provenance_unavailable") + + +class GroupProvenance: + def __init__(self, path: str, *, capacity: int = 256, initialize: bool = False) -> None: + target = Path(path) + if not target.is_absolute() or target.parent.resolve() != target.parent or capacity < 1 or capacity > 4096: + raise ValueError("private absolute provenance path and bounded capacity required") + parent = target.parent.lstat() + if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() or parent.st_mode & 0o077: + raise ValueError("provenance directory must be owner-private") + # Service startup never creates a replacement for missing durable state. + flags = os.O_RDWR | os.O_NOFOLLOW + if initialize: + flags |= os.O_CREAT | os.O_EXCL + fd = os.open(target, flags, 0o600) + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077 or info.st_nlink != 1: + raise ValueError("provenance file must be owner-private and regular") + finally: + os.close(fd) + self.path = str(target) + self._device, self._inode = info.st_dev, info.st_ino + self.capacity = capacity + with self._db() as db: + if initialize: + db.execute("CREATE TABLE group_receipts (key TEXT PRIMARY KEY, binding TEXT NOT NULL, state TEXT NOT NULL, active INTEGER UNIQUE, body TEXT NOT NULL)") + db.execute("PRAGMA user_version=1") + if db.execute("PRAGMA user_version").fetchone()[0] != 1: + raise RuntimeError("unrecognized group provenance schema") + db.execute("SELECT key, binding, state, active, body FROM group_receipts LIMIT 0") + + def _check_file(self): + try: + target = Path(self.path) + parent = target.parent.lstat() + info = target.lstat() + if target.parent.resolve() != target.parent or not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() or parent.st_mode & 0o077: + raise GroupProvenanceUnavailable() + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077 or info.st_nlink != 1: + raise GroupProvenanceUnavailable() + if (info.st_dev, info.st_ino) != (self._device, self._inode): + raise GroupProvenanceUnavailable() + except OSError: + raise GroupProvenanceUnavailable() from None + + def check(self): + with self._db() as db: + if db.execute("PRAGMA user_version").fetchone()[0] != 1: + raise GroupProvenanceUnavailable() + db.execute("SELECT key FROM group_receipts LIMIT 0") + + @staticmethod + def _load(body, key, binding): + try: + if not isinstance(body, str) or len(body) > 1048576: + raise GroupProvenanceUnavailable() + value = json.loads(body) + if not isinstance(value, dict) or value.get("key") != key or value.get("binding") != binding: + raise GroupProvenanceUnavailable() + if value.get("state") not in {"pending", "ensured", "revoking", "revoked", "uncertain", "refused"}: + raise GroupProvenanceUnavailable() + if not isinstance(value.get("groups"), dict) or len(value["groups"]) > 16: + raise GroupProvenanceUnavailable() + for field, limit in (("created_groups", 16), ("created_memberships", 256)): + if not isinstance(value.get(field), list) or len(value[field]) > limit or any(not isinstance(item, dict) for item in value[field]): + raise GroupProvenanceUnavailable() + if "intent" not in value or value["intent"] is not None and not isinstance(value["intent"], dict): + raise GroupProvenanceUnavailable() + for group in value["groups"].values(): + if not isinstance(group, dict) or "id" not in group or not all(isinstance(group.get(f), list) and all(isinstance(u, str) for u in group[f]) for f in ("before", "after")): + raise GroupProvenanceUnavailable() + if value["state"] in {"ensured", "revoking", "revoked"} and ( + not isinstance(value.get("requested_groups"), list) or not isinstance(value.get("ensure_correlation_id"), str) + ): + raise GroupProvenanceUnavailable() + return value + except (TypeError, ValueError): + raise GroupProvenanceUnavailable() from None + + @contextmanager + def _db(self): + db = None + try: + self._check_file() + db = sqlite3.connect("file:" + quote(self.path, safe="/") + "?mode=rw", uri=True, timeout=5) + self._check_file() + db.execute("PRAGMA synchronous=FULL") + with db: + yield db + except sqlite3.Error: + raise GroupProvenanceUnavailable() from None + finally: + if db is not None: + db.close() + + def reserve(self, key: str, binding: str) -> tuple[dict[str, Any], bool]: + with self._db() as db: + db.execute("BEGIN IMMEDIATE") + row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone() + if row: + if row[0] != binding: + raise ValueError("ensure idempotency key is bound to another request") + return self._load(row[1], key, binding), False + if db.execute("SELECT COUNT(*) FROM group_receipts").fetchone()[0] >= self.capacity: + raise RuntimeError("provenance capacity reached; retained records require owner disposition") + receipt = {"key": key, "binding": binding, "state": "pending", "intent": None, + "groups": {}, "created_groups": [], "created_memberships": []} + try: + db.execute("INSERT INTO group_receipts VALUES (?, ?, ?, 1, ?)", + (key, binding, "pending", json.dumps(receipt, sort_keys=True))) + except sqlite3.IntegrityError: + raise RuntimeError("another group operation is unresolved") from None + return receipt, True + + def get(self, key: str, binding: str) -> dict[str, Any]: + with self._db() as db: + row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone() + if not row or row[0] != binding: + raise ValueError("unknown ensure receipt or request binding mismatch") + return self._load(row[1], key, binding) + + def claim_revoke(self, key: str, binding: str, delete_if_empty: bool) -> dict[str, Any]: + with self._db() as db: + db.execute("BEGIN IMMEDIATE") + row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone() + if not row or row[0] != binding: + raise ValueError("unknown ensure receipt or request binding mismatch") + receipt = self._load(row[1], key, binding) + if receipt["state"] == "revoked": + if receipt["delete_if_empty"] != delete_if_empty: + raise ValueError("completed rollback options cannot change") + return receipt + if receipt["state"] != "ensured": + raise RuntimeError("group provenance unresolved; independent owner recovery required") + receipt.update(state="revoking", delete_if_empty=delete_if_empty) + try: + db.execute("UPDATE group_receipts SET state='revoking', active=1, body=? WHERE key=?", + (json.dumps(receipt, sort_keys=True), key)) + except sqlite3.IntegrityError: + raise RuntimeError("another group operation is unresolved") from None + return receipt + + def save(self, receipt: dict[str, Any]) -> None: + active = 1 if receipt["state"] in {"pending", "revoking", "uncertain"} else None + with self._db() as db: + written = db.execute("UPDATE group_receipts SET state=?, active=?, body=? WHERE key=? AND binding=?", + (receipt["state"], active, json.dumps(receipt, sort_keys=True), receipt["key"], receipt["binding"])) + if written.rowcount != 1: + raise GroupProvenanceUnavailable() diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/provisioner.py b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/provisioner.py new file mode 100644 index 00000000..1e66be63 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/provisioner.py @@ -0,0 +1,674 @@ +"""NetKingdom's idempotent LLDAP lifecycle adapter.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import hashlib +import re +import time +from typing import Any, Callable +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from group_provenance import GroupProvenance, GroupProvenanceUnavailable + + +@dataclass(frozen=True) +class Result: + provider: str + external_subject: str + status: str + resumed: bool + + +@dataclass(frozen=True) +class GroupResult(Result): + ensure_idempotency_key: str + ensure_correlation_id: str + created_groups: tuple[dict[str, Any], ...] + created_memberships: tuple[dict[str, Any], ...] + + +@dataclass(frozen=True) +class DriftResult: + provider: str + external_subject: str + status: str + drift: tuple[str, ...] + changed: tuple[str, ...] = () + + +class DependencyFailure(RuntimeError): + """Directory dependency failed. Messages never carry credentials or upstream bodies.""" + + def __init__(self, code: str, *, dependency: str = "directory") -> None: + self.code = code + self.dependency = dependency + super().__init__("dependency_unavailable") + + def payload(self) -> dict[str, str]: + return { + "error": "dependency_unavailable", + "dependency": self.dependency, + "reason": self.code, + } + + +class LLDAPProvisioner: + def __init__( + self, + *, + base_url: str, + admin_password: str, + opener: Callable = urlopen, + auth_rejected_cooldown: float = 30.0, + clock: Callable[[], float] = time.monotonic, + group_provenance: GroupProvenance | None = None, + ) -> None: + self.group_provenance = group_provenance + self.base_url = base_url.rstrip("/") + self.admin_password = admin_password + self.opener = opener + self.auth_rejected_cooldown = auth_rejected_cooldown + self.clock = clock + self._auth_rejected_until = 0.0 + + def provision(self, payload: dict[str, Any]) -> Result: + _required(payload, "user_id", "tenant", "primary_email", "idempotency_key", "correlation_id") + email = str(payload["primary_email"]).strip().lower() + username = _username(email, payload.get("preferred_username")) + token = self._login() + users, groups = self._directory(token) + existing = next((user for user in users if user.get("id") == username), None) + resumed = existing is not None + created = existing is None + if created: + self._gql(token, """ +mutation CreateUser($id: String!, $email: String!, $display: String!) { + createUser(user: {id: $id, email: $email, displayName: $display}) { id } +}""", { + "id": username, + "email": email, + "display": str(payload.get("display_name") or username), + }) + elif str(existing.get("email", "")).lower() != email: + raise ValueError("directory username collision") + roles = {str(role) for role in payload.get("roles", ())} + group_names = [f"{payload['tenant']}:users"] + if "tenant-admin" in roles: + group_names.append(f"{payload['tenant']}:admins") + try: + for name in group_names: + group_id = self._ensure_group(token, groups, name) + self._add_group(token, username, group_id) + except Exception: + if created: + try: + self._delete(token, username) + except Exception: + pass + raise + return Result("netkingdom-lldap", _oidc_subject(username), "password_setup_required", resumed) + + def tenant_access(self, payload: dict[str, Any]) -> Result: + """Change only the two role groups owned by the specified tenant.""" + _required(payload, "external_subject", "tenant", "idempotency_key", "correlation_id") + tenant = str(payload["tenant"]) + if not re.fullmatch(r"tenant:[a-z0-9][a-z0-9._-]*(?::[a-z0-9][a-z0-9._-]*)*", tenant): + raise ValueError("invalid tenant identifier") + if tenant == "tenant:platform:root": + raise ValueError("platform root is not a tenant access target") + if not isinstance(payload.get("enabled"), bool): + raise ValueError("enabled must be boolean") + roles = payload.get("roles", []) + if not isinstance(roles, (list, tuple)) or any(r not in {"user", "tenant-admin"} for r in roles): + raise ValueError("unsupported tenant role") + subject = _directory_username(str(payload["external_subject"])) + if not re.fullmatch(r"[A-Za-z0-9._-]+", subject): + raise ValueError("invalid directory subject") + token = self._login() + user = self._user(token, subject) + if user is None: + raise ValueError("login identity not found; create it before changing access") + managed = {f"{tenant}:users", f"{tenant}:admins"} + desired = {f"{tenant}:users"} if payload["enabled"] else set() + if payload["enabled"] and "tenant-admin" in roles: + desired.add(f"{tenant}:admins") + current = {str(g["displayName"]): int(g["id"]) for g in user.get("groups", ())} + groups = list(self._directory(token)[1]) + for name in sorted(desired - current.keys()): + self._add_group(token, subject, self._ensure_group(token, groups, name)) + for name in sorted((managed & current.keys()) - desired): + self._remove_group(token, subject, current[name]) + checked = self._user(token, subject) + if checked is None or ({g["displayName"] for g in checked.get("groups", ())} & managed) != desired: + raise RuntimeError("tenant access readback did not confirm the requested state") + return Result("netkingdom-lldap", _oidc_subject(subject), + "tenant_active" if payload["enabled"] else "tenant_disabled", False) + + def suspend(self, subject: str) -> Result: + subject = _directory_username(subject) + token = self._login() + _, groups = self._directory(token) + group_id = self._ensure_group(token, groups, "netkingdom-suspended") + self._add_group(token, subject, group_id) + return Result("netkingdom-lldap", subject, "suspended", False) + + def reactivate(self, subject: str) -> Result: + subject = _directory_username(subject) + token = self._login() + _, groups = self._directory(token) + group = next((item for item in groups if item.get("displayName") == "netkingdom-suspended"), None) + if group: + self._gql(token, """ +mutation Remove($userId: String!, $groupId: Int!) { + removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } +}""", {"userId": subject, "groupId": int(group["id"])}) + return Result("netkingdom-lldap", subject, "active", False) + + def deprovision(self, subject: str) -> Result: + subject = _directory_username(subject) + token = self._login() + if self._user(token, subject) is None: + return Result("netkingdom-lldap", subject, "deprovisioned", True) + self._delete(token, subject) + return Result("netkingdom-lldap", subject, "deprovisioned", False) + + def drift(self, payload: dict[str, Any]) -> DriftResult: + subject, email, desired_groups, desired_status = _desired(payload) + token = self._login() + user = self._user(token, subject) + drift = self._drift(user, email, desired_groups, desired_status, str(payload["tenant"])) + status = "in_sync" if not drift else "drifted" + return DriftResult("netkingdom-lldap", subject, status, tuple(drift)) + + def reconcile(self, payload: dict[str, Any]) -> DriftResult: + subject, email, desired_groups, desired_status = _desired(payload) + token = self._login() + user = self._user(token, subject) + changed: list[str] = [] + if user is None: + result = self.provision(payload) + changed.append("user:created") + subject = result.external_subject + token = self._login() + user = self._user(token, subject) + if user is None: + raise RuntimeError("directory reconciliation did not create the identity") + if str(user.get("email", "")).lower() != email: + raise ValueError("directory email drift requires explicit identity repair") + + groups = list(self._directory(token)[1]) + current = {str(item["displayName"]): int(item["id"]) for item in user.get("groups", ())} + tenant = str(payload["tenant"]) + managed = { + name for name in current + if name in {f"{tenant}:users", f"{tenant}:admins", "netkingdom-suspended"} + } + for name in sorted(desired_groups - managed): + self._add_group(token, subject, self._ensure_group(token, groups, name)) + changed.append(f"group:added:{name}") + for name in sorted(managed - desired_groups): + self._remove_group(token, subject, current[name]) + changed.append(f"group:removed:{name}") + + user = self._user(token, subject) + remaining = self._drift(user, email, desired_groups, desired_status, tenant) + status = "reconciled" if not remaining else "drifted" + return DriftResult("netkingdom-lldap", subject, status, tuple(remaining), tuple(changed)) + + def preflight(self) -> dict[str, str]: + """One login plus one directory read. Auth rejection is not retried during cooldown.""" + now = self.clock() + if now < self._auth_rejected_until: + raise DependencyFailure("auth_rejected") + try: + token = self._login(timeout=3) + self._gql(token, "query { groups { id } }", {}, timeout=3) + except DependencyFailure as exc: + if exc.code == "auth_rejected": + self._auth_rejected_until = now + self.auth_rejected_cooldown + raise + if self.group_provenance is not None: + self.group_provenance.check() + return {"status": "ready", "dependency": "directory", + "service_groups": "available" if self.group_provenance is not None else "disabled"} + + def _login(self, timeout: float = 10) -> str: + return directory_login( + base_url=self.base_url, + admin_password=self.admin_password, + opener=self.opener, + timeout=timeout, + ) + + def _group_store(self) -> GroupProvenance: + store = self.group_provenance + if store is None: + raise GroupProvenanceUnavailable() + store.check() + return store + + @staticmethod + def _group_binding(groups, members, correlation_id) -> str: + # Original correlation is part of the exact ensure binding. + canonical = {"ensure_correlation_id": correlation_id, "groups": sorted(groups), "members": {g: sorted(members.get(g, [])) for g in sorted(groups)}} + return hashlib.sha256(json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + @staticmethod + def _group_result(receipt, *, resumed=False) -> GroupResult: + status = "groups_ensured" if receipt["state"] == "ensured" else ( + "groups_revoked_and_deleted" if receipt.get("deleted") else "groups_revoked") + return GroupResult("netkingdom-lldap", ",".join(receipt["requested_groups"]), status, resumed, + receipt["key"], receipt["ensure_correlation_id"], tuple(receipt["created_groups"]), tuple(receipt["created_memberships"])) + + def ensure_service_groups(self, payload: dict[str, Any]) -> GroupResult: + """Additive ensure with durable creation provenance, not new authority. + + Replays return the original result, never recreate removed state. Pending + or ambiguous calls survive restart and require independent owner recovery. + """ + groups, members = _service_group_request(payload) + store = self._group_store() + receipt, fresh = store.reserve(payload["idempotency_key"], self._group_binding(groups, members, payload["correlation_id"])) + if not fresh: + if receipt["state"] != "ensured": + raise RuntimeError("group provenance unresolved or already rolled back") + return self._group_result(receipt, resumed=True) + attempted = False + try: + token = self._login() + users, directory_groups = self._directory(token) + known = {str(u.get("id")) for u in users} + if {u for us in members.values() for u in us} - known: + raise ValueError("unknown directory users") + ids = {str(g["displayName"]): int(g["id"]) for g in directory_groups} + receipt["requested_groups"] = sorted(groups) + receipt["ensure_correlation_id"] = payload["correlation_id"] + # Complete prestate is persisted before the first directory mutation. + for name in groups: + gid = ids.get(name) + before = sorted(self._group_members(token, gid)) if gid is not None else [] + if len(before) + sum(len(g["before"]) for g in receipt["groups"].values()) > 4096: + raise ValueError("group prestate exceeds bounded provenance capacity") + receipt["groups"][name] = {"id": gid, "before": before, "after": sorted(set(before) | set(members.get(name, [])))} + store.save(receipt) + for name in groups: + state = receipt["groups"][name] + if state["id"] is None: + receipt["intent"] = {"operation": "create_group", "name": name} + store.save(receipt) + attempted = True + gid = self._ensure_group(token, directory_groups, name) + state["id"] = gid + # A successful creation response plus readback is needed. + checked = {str(g["displayName"]): int(g["id"]) for g in self._directory(token)[1]} + if checked.get(name) != gid or self._group_members(token, gid): + raise RuntimeError("created group readback differs") + receipt["created_groups"].append({"name": name, "id": gid}) + receipt["intent"] = None + store.save(receipt) + for username in members.get(name, []): + if username in state["before"]: + continue + receipt["intent"] = {"operation": "add_member", "name": name, "id": state["id"], "username": username} + store.save(receipt) + # Unlike the lifecycle helper, duplicate/racing additions are + # ambiguous, never accepted as proof that this attempt created it. + attempted = True + result = self._gql(token, "mutation Add($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }", + {"userId": username, "groupId": state["id"]}) + if result.get("addUserToGroup", {}).get("ok") is not True or username not in self._group_members(token, state["id"]): + raise RuntimeError("membership creation did not read back") + receipt["created_memberships"].append({"name": name, "id": state["id"], "username": username}) + receipt["intent"] = None + store.save(receipt) + self._check_group_postimage(token, receipt) + receipt["state"] = "ensured" + store.save(receipt) + except Exception: + receipt["state"] = "uncertain" if attempted else "refused" + receipt["failure_phase"] = "mutation_attempted" if attempted else "before_mutation" + store.save(receipt) + raise + return self._group_result(receipt) + + def _check_group_postimage(self, token, receipt) -> None: + ids = {str(g["displayName"]): int(g["id"]) for g in self._directory(token)[1]} + for name, state in receipt["groups"].items(): + if ids.get(name) != state["id"] or sorted(self._group_members(token, state["id"])) != state["after"]: + raise RuntimeError("group postimage changed; independent owner reconciliation required") + + def revoke_service_groups(self, payload: dict[str, Any]) -> GroupResult: + """Reverse confirmed creations from the owner journal only. + + Caller-supplied creation inventories confer no rights. This protects + preexisting state and refuses detected drift; native LLDAP has no CAS or + membership-generation token, so external writer isolation is still an + owner acceptance prerequisite (readback alone cannot exclude ABA). + """ + groups, members = _service_group_request(payload) + _required(payload, "ensure_idempotency_key", "ensure_correlation_id") + delete_if_empty = payload.get("delete_if_empty", False) + if not isinstance(delete_if_empty, bool): + raise ValueError("delete_if_empty must be true or false") + store = self._group_store() + receipt = store.claim_revoke(payload["ensure_idempotency_key"], self._group_binding(groups, members, payload["ensure_correlation_id"]), delete_if_empty) + if receipt["state"] == "revoked": + return self._group_result(receipt, resumed=True) + try: + token = self._login() + # Validate the entire operation before the first destructive call. + self._check_group_postimage(token, receipt) + for edge in receipt["created_memberships"]: + receipt["intent"] = {"operation": "remove_member", **edge} + store.save(receipt) + self._remove_group(token, edge["username"], edge["id"]) + expected = receipt["groups"][edge["name"]]["after"] + expected.remove(edge["username"]) + self._check_group_postimage(token, receipt) + receipt["intent"] = None + store.save(receipt) + receipt["deleted"] = [] + if delete_if_empty: + for group in receipt["created_groups"]: + # Only this attempt's groups, never a preexisting empty group. + self._check_group_postimage(token, receipt) + if self._group_members(token, group["id"]): + raise RuntimeError("created group is no longer empty") + receipt["intent"] = {"operation": "delete_group", **group} + store.save(receipt) + self._gql(token, "mutation Del($id: Int!) { deleteGroup(groupId: $id) { ok } }", {"id": group["id"]}) + if any(int(g["id"]) == group["id"] or g["displayName"] == group["name"] for g in self._directory(token)[1]): + raise RuntimeError("group deletion did not read back") + receipt["deleted"].append(group["name"]) + del receipt["groups"][group["name"]] + receipt["intent"] = None + store.save(receipt) + receipt["state"] = "revoked" + store.save(receipt) + except Exception: + receipt["state"] = "uncertain" + store.save(receipt) + raise + return self._group_result(receipt) + + def _group_members(self, token: str, group_id: int) -> list[str]: + value = self._gql(token, "query G($id: Int!) { group(groupId: $id) { users { id } } }", {"id": group_id}) + return [str(u["id"]) for u in (value.get("group") or {}).get("users", ())] + + def _directory(self, token: str) -> tuple[list[dict], list[dict]]: + value = self._gql(token, "query { users { id email displayName } groups { id displayName } }", {}) + return list(value["users"]), list(value["groups"]) + + def _user(self, token: str, subject: str) -> dict[str, Any] | None: + try: + value = self._gql(token, """ +query User($id: String!) { + user(userId: $id) { id email displayName groups { id displayName } } +}""", {"id": subject}) + except ValueError as exc: + if "not found" in str(exc).lower(): + return None + raise + user = value.get("user") + return dict(user) if user else None + + def _ensure_group(self, token: str, groups: list[dict], name: str) -> int: + existing = next((group for group in groups if group.get("displayName") == name), None) + if existing: + return int(existing["id"]) + created = self._gql( + token, + "mutation CreateGroup($name: String!) { createGroup(name: $name) { id displayName } }", + {"name": name}, + )["createGroup"] + groups.append(created) + return int(created["id"]) + + def _add_group(self, token: str, username: str, group_id: int) -> None: + try: + self._gql(token, """ +mutation Add($userId: String!, $groupId: Int!) { + addUserToGroup(userId: $userId, groupId: $groupId) { ok } +}""", {"userId": username, "groupId": group_id}) + except ValueError as exc: + if "already" not in str(exc).lower() and "unique" not in str(exc).lower(): + raise + + def _remove_group(self, token: str, username: str, group_id: int) -> None: + self._gql(token, """ +mutation Remove($userId: String!, $groupId: Int!) { + removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } +}""", {"userId": username, "groupId": group_id}) + + def _delete(self, token: str, subject: str) -> None: + self._gql( + token, + "mutation Delete($id: String!) { deleteUser(userId: $id) { ok } }", + {"id": subject}, + ) + + @staticmethod + def _drift( + user: dict[str, Any] | None, + email: str, + desired_groups: set[str], + desired_status: str, + tenant: str, + ) -> list[str]: + if user is None: + return ["user:missing"] + drift: list[str] = [] + if str(user.get("email", "")).lower() != email: + drift.append("email:mismatch") + current = {str(item["displayName"]) for item in user.get("groups", ())} + managed = { + name for name in current + if name in {f"{tenant}:users", f"{tenant}:admins", "netkingdom-suspended"} + } + drift.extend(f"group:missing:{name}" for name in sorted(desired_groups - managed)) + drift.extend(f"group:unexpected:{name}" for name in sorted(managed - desired_groups)) + suspended = "netkingdom-suspended" in current + if suspended != (desired_status == "suspended"): + drift.append("status:mismatch") + return drift + + def _gql(self, token: str, query: str, variables: dict[str, Any], timeout: float = 15) -> dict: + request = Request( + self.base_url + "/api/graphql", + data=json.dumps({"query": query, "variables": variables}).encode(), + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with self.opener(request, timeout=timeout) as response: + payload = json.loads(response.read()) + except HTTPError as exc: + status = int(getattr(exc, "code", 0) or 0) + _discard(exc) + if status in {401, 403}: + raise DependencyFailure("auth_rejected") from None + raise DependencyFailure("protocol_error") from None + except (TimeoutError, URLError, OSError): + raise DependencyFailure("unreachable") from None + except (json.JSONDecodeError, TypeError, ValueError, UnicodeDecodeError): + raise DependencyFailure("protocol_error") from None + if payload.get("errors"): + raise ValueError(str(payload["errors"][0].get("message", "LLDAP GraphQL error"))) + return dict(payload.get("data") or {}) + + +def directory_login( + *, + base_url: str, + admin_password: str, + opener: Callable = urlopen, + timeout: float = 10, +) -> str: + request = Request( + base_url.rstrip("/") + "/auth/simple/login", + data=json.dumps({"username": "admin", "password": admin_password}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with opener(request, timeout=timeout) as response: + payload = json.loads(response.read()) + except HTTPError as exc: + status = int(getattr(exc, "code", 0) or 0) + _discard(exc) + if status in {401, 403}: + raise DependencyFailure("auth_rejected") from None + raise DependencyFailure("protocol_error") from None + except (TimeoutError, URLError, OSError): + raise DependencyFailure("unreachable") from None + except (json.JSONDecodeError, TypeError, ValueError, UnicodeDecodeError): + raise DependencyFailure("protocol_error") from None + if not isinstance(payload, dict): + raise DependencyFailure("protocol_error") + token = str(payload.get("token") or "") + if not token: + raise DependencyFailure("protocol_error") + return token + + +def _discard(exc: BaseException) -> None: + read = getattr(exc, "read", None) + if callable(read): + try: + read(65536) + except Exception: + pass + + +SERVICE_GROUP = re.compile(r"[a-z][a-z0-9-]{1,62}") +RESERVED_GROUP_PREFIXES = ("tenant", "netkingdom-", "net-kingdom-", "lldap_") +DIRECTORY_USERNAME = re.compile(r"[a-z][a-z0-9._-]{0,31}") + + +def _service_group_request(payload: dict[str, Any]) -> tuple[list[str], dict[str, list[str]]]: + """Validate a service-access group request before anything is changed. + + Service-access groups gate sign-in to an internal service (SecurityCaseType + oidc-browser-relying-party). Tenant, platform identity/administration, + platform-suspension and LLDAP admin groups are reserved and can never be + touched through this path. + """ + if not isinstance(payload, dict): + raise ValueError("service group request must be an object") + _required(payload, "groups", "idempotency_key", "correlation_id") + allowed = {"groups", "members", "idempotency_key", "correlation_id", "delete_if_empty", "ensure_idempotency_key", "ensure_correlation_id"} + if set(payload) - allowed: + raise ValueError("unsupported service group request fields") + if not isinstance(payload["idempotency_key"], str) or len(payload["idempotency_key"]) > 256: + raise ValueError("invalid idempotency key") + if "ensure_idempotency_key" in payload and (not isinstance(payload["ensure_idempotency_key"], str) or not 16 <= len(payload["ensure_idempotency_key"]) <= 256): + raise ValueError("invalid ensure idempotency key") + for key in ("correlation_id", "ensure_correlation_id"): + if key in payload and (not isinstance(payload[key], str) or not 1 <= len(payload[key]) <= 256): + raise ValueError("invalid correlation identifier") + groups = payload["groups"] + if not isinstance(groups, list) or not groups or len(groups) > 16: + raise ValueError("groups must be a list of 1 to 16 names") + for name in groups: + if not isinstance(name, str) or not SERVICE_GROUP.fullmatch(name): + raise ValueError(f"invalid service group name {name!r}") + if name.startswith(RESERVED_GROUP_PREFIXES): + raise ValueError(f"group {name!r} is reserved") + members = payload.get("members", {}) + if not isinstance(members, dict): + raise ValueError("members must map group names to usernames") + total_members = 0 + for group, users in members.items(): + if group not in groups: + raise ValueError(f"members names group {group!r}, which is not in groups") + if not isinstance(users, list) or not all(isinstance(u, str) and DIRECTORY_USERNAME.fullmatch(u) for u in users): + raise ValueError(f"members of {group!r} must be directory usernames") + total_members += len(users) + if total_members > 256: + raise ValueError("at most 256 service memberships per request") + return list(dict.fromkeys(groups)), {g: list(dict.fromkeys(u)) for g, u in members.items()} + + +def dispatch( + provisioner: LLDAPProvisioner, path: str, payload: dict[str, Any] +) -> Result | DriftResult: + if path == "/v1/groups/ensure": + return provisioner.ensure_service_groups(payload) + if path == "/v1/groups/revoke": + return provisioner.revoke_service_groups(payload) + if path == "/v1/identities/tenant-access": + return provisioner.tenant_access(payload) + if path == "/v1/identities/provision": + return provisioner.provision(payload) + if path == "/v1/identities/drift": + return provisioner.drift(payload) + if path == "/v1/identities/reconcile": + return provisioner.reconcile(payload) + _required(payload, "external_subject", "idempotency_key", "correlation_id") + subject = _directory_username(str(payload["external_subject"])) + if path == "/v1/identities/suspend": + return provisioner.suspend(subject) + if path == "/v1/identities/reactivate": + return provisioner.reactivate(subject) + if path == "/v1/identities/deprovision": + return provisioner.deprovision(subject) + raise KeyError(path) + + +def _username(email: str, preferred: object = None) -> str: + if preferred is not None: + value = str(preferred).strip().lower() + if not re.fullmatch(r"[a-z][a-z0-9._-]{2,31}", value): + raise ValueError("preferred_username is invalid") + if value in {"admin", "administrator", "platform-root", "root", "system"}: + raise ValueError("preferred_username is reserved") + return value + local = email.partition("@")[0].lower() + value = re.sub(r"[^a-z0-9._-]+", "-", local).strip("-") + if not value or "@" not in email: + raise ValueError("valid primary_email is required") + return value[:64] + + +def _required(payload: dict[str, Any], *fields: str) -> None: + missing = [field for field in fields if not payload.get(field)] + if missing: + raise ValueError("missing required fields: " + ", ".join(missing)) + if len(str(payload.get("idempotency_key", ""))) < 16: + raise ValueError("idempotency_key must contain at least 16 characters") + + +def _desired(payload: dict[str, Any]) -> tuple[str, str, set[str], str]: + _required( + payload, + "external_subject", + "tenant", + "primary_email", + "idempotency_key", + "correlation_id", + ) + subject = str(payload["external_subject"]) + email = str(payload["primary_email"]).strip().lower() + if _username(email) != subject: + raise ValueError("external_subject does not match canonical email username") + desired_status = str(payload.get("desired_status", "active")) + if desired_status not in {"active", "suspended"}: + raise ValueError("desired_status must be active or suspended") + tenant = str(payload["tenant"]) + groups = {f"{tenant}:users"} + if "tenant-admin" in {str(role) for role in payload.get("roles", ())}: + groups.add(f"{tenant}:admins") + if desired_status == "suspended": + groups.add("netkingdom-suspended") + return subject, email, groups, desired_status + + +def _directory_username(subject: str) -> str: + return subject[4:].split(",", 1)[0] if subject.startswith("uid=") else subject + + +def _oidc_subject(username: str) -> str: + return f"uid={username},ou=people,dc=netkingdom,dc=local" diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/server.py b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/server.py new file mode 100644 index 00000000..8d43db86 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/server.py @@ -0,0 +1,203 @@ +from dataclasses import asdict +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from html import escape +import json +import os +from urllib.parse import parse_qs, urlsplit +import secrets + +from group_provenance import GroupProvenance, GroupProvenanceUnavailable +from provisioner import DependencyFailure, LLDAPProvisioner, _directory_username, dispatch +from password_setup import LLDAPPasswordSetter, PasswordSetupGrants + + +class Handler(BaseHTTPRequestHandler): + provisioner: LLDAPProvisioner + password_setups: PasswordSetupGrants + service_token: str + signin_url: str = "" + + def do_GET(self): + path = urlsplit(self.path) + if path.path == "/healthz": + return self._send(200, {"status": "ok"}) + if path.path == "/readyz": + try: + return self._send(200, self.provisioner.preflight()) + except GroupProvenanceUnavailable: + return self._send(503, {"error": "dependency_unavailable", "dependency": "group_provenance"}) + except DependencyFailure as exc: + return self._send(503, exc.payload()) + except RuntimeError: + return self._send(503, { + "error": "dependency_unavailable", + "dependency": "directory", + "reason": "unreachable", + }) + if path.path == "/setup/password": + token = parse_qs(path.query).get("token", [""])[0] + if not self.password_setups.valid(token): + return self._html(400, _expired_page()) + return self._html(200, _setup_page(token, self.password_setups.login_name(token))) + self._send(404, {"error": "not_found"}) + + def do_POST(self): + path = urlsplit(self.path).path + if path == "/setup/password": + return self._set_password() + supplied = self.headers.get("Authorization", "").removeprefix("Bearer ") + if not secrets.compare_digest(supplied, self.service_token): + return self._send(403, {"error": "access_denied"}) + try: + length = min(int(self.headers.get("Content-Length", "0")), 65536) + payload = json.loads(self.rfile.read(length)) + result = dispatch(self.provisioner, path, payload) + except KeyError: + return self._send(404, {"error": "not_found"}) + except (ValueError, json.JSONDecodeError) as exc: + return self._send(400, {"error": "invalid_request", "message": str(exc)}) + except GroupProvenanceUnavailable: + return self._send(503, {"error": "dependency_unavailable", "dependency": "group_provenance"}) + except DependencyFailure as exc: + return self._send(503, exc.payload()) + except RuntimeError: + return self._send(503, {"error": "dependency_unavailable"}) + response = asdict(result) + if path == "/v1/identities/provision" and result.status == "password_setup_required": + response["password_setup_url"] = self.password_setups.issue( + _directory_username(result.external_subject), tenant=str(payload.get("tenant", "")) + ) + self._send(200, response) + + def _set_password(self): + if self.headers.get("Content-Type", "").partition(";")[0] != "application/x-www-form-urlencoded": + return self._html(400, _expired_page()) + length = min(int(self.headers.get("Content-Length", "0")), 8192) + body = parse_qs(self.rfile.read(length).decode("utf-8", "replace")) + token = body.get("token", [""])[0] + password = body.get("password", [""])[0] + confirmation = body.get("confirmation", [""])[0] + login_name = self.password_setups.login_name(token) + if password != confirmation: + return self._html(400, _setup_page(token, login_name, "Passwords do not match.")) + try: + return_to = self.password_setups.consume(token, password) + except ValueError as exc: + return self._html(400, _setup_page(token, login_name, str(exc))) + except (DependencyFailure, RuntimeError): + return self._html(503, _failed_page()) + return self._html(200, _complete_page(return_to, self.signin_url)) + + def _send(self, status: int, payload: dict): + body = json.dumps(payload, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _html(self, status: int, body: str): + payload = body.encode() + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format, *args): + return + + +def configured_group_provenance(): + path = os.environ.get("GROUP_PROVENANCE_DB", "") + try: + return GroupProvenance(path) if path else None + except (OSError, ValueError, RuntimeError): + raise GroupProvenanceUnavailable() from None + + +def main(): + Handler.provisioner = LLDAPProvisioner( + base_url=os.environ["LLDAP_URL"], + admin_password=os.environ["LLDAP_ADMIN_PASSWORD"], + group_provenance=configured_group_provenance(), + ) + Handler.service_token = os.environ["PROVISIONER_SERVICE_TOKEN"].strip() + if not Handler.service_token: + raise ValueError("PROVISIONER_SERVICE_TOKEN must not be empty") + Handler.signin_url = _https_or_empty(os.environ.get("PASSWORD_SETUP_SIGNIN_URL", "")) + Handler.password_setups = PasswordSetupGrants( + public_url=os.environ["PASSWORD_SETUP_PUBLIC_URL"], + setter=LLDAPPasswordSetter( + base_url=os.environ["LLDAP_URL"], + admin_password=os.environ["LLDAP_ADMIN_PASSWORD"], + ), + tenant_returns=json.loads(os.environ.get("PASSWORD_SETUP_TENANT_RETURNS", "{}")), + ttl_seconds=int(os.environ.get("PASSWORD_SETUP_TTL_SECONDS", "900")), + ) + ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever() + + +def _page(title: str, content: str) -> str: + return f""" + +{escape(title)}

{escape(title)}

{content}
""" + + +def _https_or_empty(url: str) -> str: + url = url.strip() + if url and not url.startswith("https://"): + raise ValueError("PASSWORD_SETUP_SIGNIN_URL must be an HTTPS URL") + return url + + +def _setup_page(token: str, login_name: str = "", error: str = "") -> str: + message = f'

{escape(error)}

' if error else "" + # A read-only username field lets password managers store the new + # password against the directory login name, not against nothing. + username = ( + f'' + if login_name else "" + ) + return _page("Set up your password", f"""{message} +

This single-use link expires shortly. Choose a password of at least 12 characters.

+
+ +{username} + + +
""") + + +def _expired_page() -> str: + return _page("Link unavailable", "

This password setup link is invalid or expired. Request a new link from your tenant administrator.

") + + +def _failed_page() -> str: + return _page("Setup unavailable", "

Password setup could not be completed. Request a new link and try again.

") + + +def _complete_page(return_to: str = "", signin_url: str = "") -> str: + content = "

Your password is ready. Sign in with your own account to continue.

" + if return_to: + content += f'

Continue to your company

' + elif signin_url: + content += f'

Sign in

' + else: + content += "

Return to the application to sign in and enroll MFA.

" + return _page("Password set", content) + + +if __name__ == "__main__": + main() diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/tests/test_directory_contract.py b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/tests/test_directory_contract.py new file mode 100644 index 00000000..00491cac --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/tests/test_directory_contract.py @@ -0,0 +1,336 @@ +"""Contained provisioner-to-directory contract: login, preflight, redacted failure.""" + +from __future__ import annotations + +import io +import json +import pathlib +import sys +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +sys.path.insert(0, str(pathlib.Path(__file__).parents[1])) +from password_setup import LLDAPPasswordSetter, PasswordSetupGrants +from provisioner import DependencyFailure, LLDAPProvisioner, directory_login +from server import Handler + + +SECRET = "synthetic-only-directory-password" +TOKEN = "synthetic-directory-token" + + +class DirectoryState: + def __init__(self) -> None: + self.password = SECRET + self.logins = 0 + self.graphql = 0 + self.users: dict[str, dict] = {} + self.groups: dict[str, int] = {} + self.next_group = 1 + self.login_status = 200 + self.login_body: dict | bytes = {"token": TOKEN} + + +class FakeDirectory(BaseHTTPRequestHandler): + state: DirectoryState + + def do_POST(self): + raw = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if self.path == "/auth/simple/login": + self.state.logins += 1 + payload = json.loads(raw) + if self.state.login_status != 200: + body = self.state.login_body + if isinstance(body, dict): + body = json.dumps(body).encode() + self._raw(self.state.login_status, body) + return + if payload.get("username") != "admin" or payload.get("password") != self.state.password: + leaked = json.dumps({"message": "invalid", "password": payload.get("password")}).encode() + self._raw(401, leaked) + return + self._send(200, {"token": TOKEN}) + return + if self.path == "/api/graphql": + self.state.graphql += 1 + if self.headers.get("Authorization") != f"Bearer {TOKEN}": + self._send(401, {"message": "unauthorized"}) + return + self._send(200, self._graphql(json.loads(raw))) + return + self._send(404, {"error": "not_found"}) + + def _graphql(self, body: dict) -> dict: + query = str(body.get("query") or "") + variables = body.get("variables") or {} + if "createUser" in query: + user_id = str(variables["id"]) + self.state.users[user_id] = { + "id": user_id, + "email": variables["email"], + "displayName": variables["display"], + "groups": [], + } + return {"data": {"createUser": {"id": user_id}}} + if "createGroup" in query: + name = str(variables["name"]) + group_id = self.state.next_group + self.state.next_group += 1 + self.state.groups[name] = group_id + return {"data": {"createGroup": {"id": group_id, "displayName": name}}} + if "addUserToGroup" in query: + user = self.state.users[str(variables["userId"])] + group_id = int(variables["groupId"]) + name = next(n for n, i in self.state.groups.items() if i == group_id) + user["groups"].append({"id": group_id, "displayName": name}) + return {"data": {"addUserToGroup": {"ok": True}}} + if "users {" in query and "groups {" in query: + return { + "data": { + "users": [ + {"id": user["id"], "email": user["email"], "displayName": user["displayName"]} + for user in self.state.users.values() + ], + "groups": [ + {"id": group_id, "displayName": name} + for name, group_id in self.state.groups.items() + ], + } + } + if "groups { id }" in query: + return {"data": {"groups": [{"id": group_id} for group_id in self.state.groups.values()]}} + return {"data": {}} + + def _send(self, status: int, payload: dict) -> None: + self._raw(status, json.dumps(payload).encode()) + + def _raw(self, status: int, body: bytes) -> None: + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + return + + +def serve(handler): + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread, f"http://127.0.0.1:{server.server_port}" + + +class DirectoryContractTests(unittest.TestCase): + def setUp(self): + self.state = DirectoryState() + + class Bound(FakeDirectory): + state = self.state + + self.directory, self.directory_thread, self.directory_url = serve(Bound) + self.provisioner = LLDAPProvisioner( + base_url=self.directory_url, + admin_password=SECRET, + auth_rejected_cooldown=60, + clock=lambda: self.now, + ) + self.now = 0.0 + + class App(Handler): + service_token = "fixture-only-service-token" + provisioner = self.provisioner + password_setups = PasswordSetupGrants( + public_url="https://kc.example/setup/password", + setter=LLDAPPasswordSetter( + base_url=self.directory_url, + admin_password=SECRET, + helper="/bin/true", + runner=lambda *args, **kwargs: type("R", (), {"returncode": 0})(), + ), + ) + + self.app, self.app_thread, self.app_url = serve(App) + + def tearDown(self): + for server, thread in ( + (self.app, self.app_thread), + (self.directory, self.directory_thread), + ): + server.shutdown() + server.server_close() + thread.join() + + def get(self, path: str): + try: + response = urlopen(self.app_url + path) + except HTTPError as error: + response = error + with response: + body = response.read().decode() + return response.status, json.loads(body) if body.startswith("{") else body + + def provision(self): + payload = { + "user_id": "person-1", + "tenant": "tenant:trial:demo", + "primary_email": "person@example.test", + "display_name": "Person", + "idempotency_key": "fixture-long-idempotency", + "correlation_id": "corr-fixture", + "roles": ["tenant-admin"], + } + request = Request( + self.app_url + "/v1/identities/provision", + data=json.dumps(payload).encode(), + headers={"Authorization": "Bearer fixture-only-service-token"}, + ) + try: + response = urlopen(request) + except HTTPError as error: + response = error + with response: + return response.status, json.loads(response.read()) + + def test_preflight_covers_login_and_directory_read(self): + self.assertEqual( + {"status": "ready", "dependency": "directory", "service_groups": "disabled"}, + self.provisioner.preflight(), + ) + self.assertEqual(1, self.state.logins) + self.assertEqual(1, self.state.graphql) + + def test_healthz_stays_ok_when_directory_rejects_bind(self): + self.state.login_status = 401 + self.state.login_body = {"message": "invalid", "password": SECRET} + live_status, live = self.get("/healthz") + ready_status, ready = self.get("/readyz") + self.assertEqual(200, live_status) + self.assertEqual({"status": "ok"}, live) + self.assertEqual(503, ready_status) + self.assertEqual( + { + "error": "dependency_unavailable", + "dependency": "directory", + "reason": "auth_rejected", + }, + ready, + ) + self.assertNotIn(SECRET, json.dumps(ready)) + + def test_rejected_bind_is_not_retried_during_cooldown(self): + self.state.login_status = 401 + with self.assertRaises(DependencyFailure) as failure: + self.provisioner.preflight() + self.assertEqual("auth_rejected", failure.exception.code) + self.assertEqual(1, self.state.logins) + self.now = 30.0 + with self.assertRaises(DependencyFailure): + self.provisioner.preflight() + self.assertEqual(1, self.state.logins) + self.now = 60.0 + with self.assertRaises(DependencyFailure): + self.provisioner.preflight() + self.assertEqual(2, self.state.logins) + + def test_provision_returns_redacted_structured_failure_not_http_error(self): + self.state.login_status = 401 + self.state.login_body = {"message": "invalid", "password": SECRET} + status, payload = self.provision() + self.assertEqual(503, status) + self.assertEqual("dependency_unavailable", payload["error"]) + self.assertEqual("directory", payload["dependency"]) + self.assertEqual("auth_rejected", payload["reason"]) + serialized = json.dumps(payload) + self.assertNotIn(SECRET, serialized) + self.assertNotIn("HTTPError", serialized) + self.assertNotIn("password", serialized) + + def test_native_provision_and_password_setup_journey(self): + status, payload = self.provision() + self.assertEqual(200, status) + self.assertEqual("password_setup_required", payload["status"]) + self.assertIn("uid=person,ou=people,dc=netkingdom,dc=local", payload["external_subject"]) + self.assertIn("person", self.state.users) + self.assertIn("tenant:trial:demo:users", self.state.groups) + self.assertIn("tenant:trial:demo:admins", self.state.groups) + token = payload["password_setup_url"].partition("token=")[2] + request = Request( + self.app_url + "/setup/password", + data=urlencode({ + "token": token, + "password": "recipient-long-password", + "confirmation": "recipient-long-password", + }).encode(), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urlopen(request) as response: + body = response.read().decode() + self.assertEqual(200, response.status) + self.assertIn("Password set", body) + self.assertNotIn(SECRET, body) + self.assertNotIn("recipient-long-password", body) + self.assertNotIn(token, body) + + +class DirectoryLoginMappingTests(unittest.TestCase): + def test_http_401_becomes_auth_rejected_without_body_or_cause(self): + def opener(request, timeout=10): + raise HTTPError( + request.full_url, + 401, + "Unauthorized", + hdrs=None, + fp=io.BytesIO(json.dumps({"password": SECRET, "token": "leaked"}).encode()), + ) + + with self.assertRaises(DependencyFailure) as failure: + directory_login(base_url="http://directory", admin_password=SECRET, opener=opener) + self.assertEqual("auth_rejected", failure.exception.code) + self.assertIsNone(failure.exception.__cause__) + self.assertNotIn(SECRET, str(failure.exception)) + self.assertNotIn("leaked", str(failure.exception.payload())) + self.assertEqual( + { + "error": "dependency_unavailable", + "dependency": "directory", + "reason": "auth_rejected", + }, + failure.exception.payload(), + ) + + def test_unreachable_directory_is_structured_and_not_retried(self): + calls = [] + + def opener(request, timeout=10): + calls.append(timeout) + raise URLError("connection refused") + + with self.assertRaises(DependencyFailure) as failure: + directory_login(base_url="http://directory", admin_password=SECRET, opener=opener) + self.assertEqual("unreachable", failure.exception.code) + self.assertEqual([10], calls) + + def test_password_setter_rejects_bind_once(self): + calls = [] + + def opener(request, timeout=10): + calls.append(json.loads(request.data)["password"]) + raise HTTPError(request.full_url, 401, "Unauthorized", hdrs=None, fp=io.BytesIO(b"{}")) + + setter = LLDAPPasswordSetter( + base_url="http://directory", + admin_password=SECRET, + opener=opener, + runner=lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("helper must not run")), + ) + with self.assertRaises(DependencyFailure) as failure: + setter("person", "recipient-long-password") + self.assertEqual("auth_rejected", failure.exception.code) + self.assertEqual([SECRET], calls) + self.assertNotIn(SECRET, str(failure.exception)) diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/tests/test_group_provenance.py b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/tests/test_group_provenance.py new file mode 100644 index 00000000..1bcb284e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/tests/test_group_provenance.py @@ -0,0 +1,326 @@ +"""Synthetic actual provisioner/HTTP/storage proof; no live LDAP or authority.""" +import concurrent.futures +from dataclasses import asdict +from http.server import ThreadingHTTPServer +import json +import os +import sqlite3 +from pathlib import Path +import tempfile +import threading +import unittest +from unittest.mock import patch +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +from group_provenance import GroupProvenance +from provisioner import LLDAPProvisioner, dispatch +from server import Handler, configured_group_provenance +from test_service_groups import Directory, req + + +def rollback(**kw): + return req(ensure_idempotency_key=req()["idempotency_key"], ensure_correlation_id=req()["correlation_id"], **kw) + + +class ProvenanceTests(unittest.TestCase): + def test_inventory_excludes_preexisting_membership_and_empty_group(self): + d = Directory() + d.members[3].add("tegwick") + result = d.ensure_service_groups(req()) + self.assertEqual([{"name": "forge-coulomb-owners", "id": 4}, {"name": "forge-coulomb-readers", "id": 5}], list(result.created_groups)) + self.assertEqual([{"name": "forge-coulomb-owners", "id": 4, "username": "tegwick"}], list(result.created_memberships)) + d.revoke_service_groups(rollback(delete_if_empty=True)) + self.assertEqual({"tegwick"}, d.members[3]) + self.assertIn("forge-admins", d.groups) + self.assertNotIn("forge-coulomb-owners", d.groups) + + def test_preexisting_empty_group_never_deleted(self): + d = Directory() + d.ensure_service_groups(req()) + d.revoke_service_groups(rollback(delete_if_empty=True)) + self.assertIn("forge-admins", d.groups) + self.assertEqual(set(), d.members[3]) + + def test_unknown_receipt_and_digest_mismatch_before_login(self): + d = Directory() + with self.assertRaises(ValueError): + d.revoke_service_groups(rollback()) + d.ensure_service_groups(req()) + d.calls.clear() + with patch.object(d, "_login", side_effect=AssertionError("must not login")): + with self.assertRaises(ValueError): + d.revoke_service_groups(rollback(members={"forge-admins": ["agent-codex"]})) + with self.assertRaises(ValueError): + d.ensure_service_groups(req(groups=["another-group"], members={})) + self.assertEqual([], d.calls) + + def test_unsigned_inventory_and_legacy_revoke_cannot_mutate(self): + d = Directory() + d.ensure_service_groups(req()) + d.calls.clear() + for payload in [req(), rollback(created_groups=[{"name": "forge-admins", "id": 3}]), rollback(created_memberships=[]), rollback(provenance={})]: + with self.assertRaises(ValueError): + d.revoke_service_groups(payload) + self.assertEqual([], d.calls) + + def test_shared_membership_drift_refuses_all_destructive_calls(self): + d = Directory() + d.ensure_service_groups(req()) + d.members[3].add("agent-codex") + d.calls.clear() + with self.assertRaisesRegex(RuntimeError, "postimage"): + d.revoke_service_groups(rollback(delete_if_empty=True)) + self.assertEqual([], d.calls) + self.assertEqual({"tegwick", "agent-codex"}, d.members[3]) + + def test_replaced_group_identity_refuses_all_destructive_calls(self): + d = Directory() + d.ensure_service_groups(req()) + d.groups["forge-admins"] = 10 + d.members[10] = {"tegwick"} + d.calls.clear() + with self.assertRaisesRegex(RuntimeError, "postimage"): + d.revoke_service_groups(rollback()) + self.assertEqual([], d.calls) + + def test_successful_ensure_replay_never_recreates_later_removed_state(self): + d = Directory() + first = d.ensure_service_groups(req()) + d.members[3].clear() + d.calls.clear() + replay = d.ensure_service_groups(req()) + self.assertTrue(replay.resumed) + self.assertEqual(first.created_memberships, replay.created_memberships) + self.assertEqual([], d.calls) + self.assertEqual(set(), d.members[3]) + + def test_interrupted_add_persists_intent_and_blocks_restart_retry(self): + d = Directory() + original = d._gql + def lost_response(token, query, variables): + result = original(token, query, variables) + if "addUserToGroup" in query: + raise RuntimeError("synthetic lost response") + return result + d._gql = lost_response + with self.assertRaises(RuntimeError): + d.ensure_service_groups(req()) + d.group_provenance = GroupProvenance(d.group_provenance.path) + receipt = d.group_provenance.get(req()["idempotency_key"], d._group_binding(req()["groups"], req()["members"], req()["correlation_id"])) + self.assertEqual("uncertain", receipt["state"]) + self.assertEqual("add_member", receipt["intent"]["operation"]) + self.assertEqual([], receipt["created_memberships"]) + d.calls.clear() + for operation, payload in [(d.ensure_service_groups, req()), (d.revoke_service_groups, rollback()), (d.ensure_service_groups, req(idempotency_key="different-key-is-not-recovery"))]: + with self.assertRaises(RuntimeError): + operation(payload) + self.assertEqual([], d.calls) + + def test_interrupted_rollback_retains_confirmed_inventory_and_pending_intent(self): + d = Directory() + d.ensure_service_groups(req()) + original = d._remove_group + def lost_response(*args): + original(*args) + raise RuntimeError("lost remove response") + d._remove_group = lost_response + with self.assertRaises(RuntimeError): + d.revoke_service_groups(rollback()) + d.group_provenance = GroupProvenance(d.group_provenance.path) + receipt = d.group_provenance.get(req()["idempotency_key"], d._group_binding(req()["groups"], req()["members"], req()["correlation_id"])) + self.assertEqual("uncertain", receipt["state"]) + self.assertEqual("remove_member", receipt["intent"]["operation"]) + self.assertEqual(2, len(receipt["created_memberships"])) + d.calls.clear() + with self.assertRaises(RuntimeError): + d.revoke_service_groups(rollback()) + self.assertEqual([], d.calls) + + def test_unresolved_pre_call_intent_survives_hard_process_loss(self): + d = Directory() + d._gql = lambda *_: (_ for _ in ()).throw(SystemExit("process lost before response")) + with self.assertRaises(SystemExit): + d.ensure_service_groups(req()) + d.group_provenance = GroupProvenance(d.group_provenance.path) + receipt = d.group_provenance.get(req()["idempotency_key"], d._group_binding(req()["groups"], req()["members"], req()["correlation_id"])) + self.assertEqual("pending", receipt["state"]) + self.assertEqual("add_member", receipt["intent"]["operation"]) + with self.assertRaises(RuntimeError): + d.ensure_service_groups(req()) + + def test_missing_config_private_permissions_and_lost_database_fail_closed(self): + with patch.dict(os.environ, {}, clear=True): + self.assertIsNone(configured_group_provenance()) + d = Directory() + path = d.group_provenance.path + with patch.dict(os.environ, {"GROUP_PROVENANCE_DB": path}): + self.assertIsInstance(configured_group_provenance(), GroupProvenance) + os.chmod(path, 0o644) + with self.assertRaises(RuntimeError): + configured_group_provenance() + os.chmod(path, 0o600) + os.unlink(path) + with self.assertRaises(RuntimeError): + configured_group_provenance() + self.assertFalse(Path(path).exists()) + provider = LLDAPProvisioner(base_url="https://invalid", admin_password="synthetic") + with patch.object(provider, "_login", side_effect=AssertionError("no directory")): + with self.assertRaises(RuntimeError): + provider.ensure_service_groups(req()) + with self.assertRaises(RuntimeError): + provider.revoke_service_groups(rollback()) + + def test_missing_intent_row_stops_before_any_directory_effect(self): + d = Directory() + store = d.group_provenance + original_save = store.save + def lose_row(receipt): + if receipt["intent"] is not None: + with sqlite3.connect(store.path) as db: + db.execute("DELETE FROM group_receipts") + original_save(receipt) + store.save = lose_row + with self.assertRaisesRegex(RuntimeError, "group_provenance_unavailable"): + d.ensure_service_groups(req()) + self.assertEqual([], d.calls) + + def test_live_instance_rejects_replaced_database_before_effect(self): + d = Directory() + original = d.group_provenance + replacement = str(Path(d.storage.name) / "replacement.db") + GroupProvenance(replacement, initialize=True) + os.replace(replacement, original.path) + with self.assertRaisesRegex(RuntimeError, "group_provenance_unavailable"): + d.ensure_service_groups(req()) + self.assertEqual([], d.calls) + + def test_failed_before_mutation_is_retained_without_stranding_other_keys(self): + for failure in ("unknown-user", "oversized-prestate", "directory-read-failed"): + d = Directory() + old_members = d._group_members + request = req(members={"forge-admins": ["nobody"]}) if failure == "unknown-user" else req() + if failure == "oversized-prestate": + d._group_members = lambda *_: [f"synthetic{i}" for i in range(4097)] + if failure == "directory-read-failed": + d._group_members = lambda *_: (_ for _ in ()).throw(RuntimeError("read failed")) + with self.assertRaises((ValueError, RuntimeError)): + d.ensure_service_groups(request) + binding = d._group_binding(request["groups"], request["members"], request["correlation_id"]) + receipt = d.group_provenance.get(request["idempotency_key"], binding) + self.assertEqual("refused", receipt["state"]) + self.assertEqual("before_mutation", receipt["failure_phase"]) + self.assertEqual([], d.calls) + d._group_members = old_members + with self.assertRaises(RuntimeError): + d.ensure_service_groups(request) + self.assertEqual("groups_ensured", d.ensure_service_groups(req(idempotency_key="another-valid-operation-key")).status) + + def test_current_correlations_are_bound_to_original_ensure(self): + d = Directory() + d.ensure_service_groups(req()) + d.calls.clear() + with self.assertRaises(ValueError): + d.ensure_service_groups(req(correlation_id="other-case")) + payload = rollback() + payload["ensure_correlation_id"] = "other-case" + with self.assertRaises(ValueError): + d.revoke_service_groups(payload) + self.assertEqual([], d.calls) + + def test_readiness_separates_directory_health_from_group_capability(self): + provider = LLDAPProvisioner(base_url="https://invalid", admin_password="synthetic") + provider._login = lambda **_: "synthetic" + provider._gql = lambda *_args, **_kw: {"groups": []} + self.assertEqual("disabled", provider.preflight()["service_groups"]) + d = Directory() + provider.group_provenance = d.group_provenance + self.assertEqual("available", provider.preflight()["service_groups"]) + os.chmod(d.group_provenance.path, 0o644) + with self.assertRaisesRegex(RuntimeError, "group_provenance_unavailable"): + provider.preflight() + + def test_concurrent_sqlite_reservation_allows_one_original_attempt(self): + d = Directory() + path = d.group_provenance.path + def reserve(_): + return GroupProvenance(path).reserve("synthetic-concurrent-key", "same-binding")[1] + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + fresh = list(pool.map(reserve, range(24))) + self.assertEqual(1, sum(fresh)) + + def test_bounded_records_never_age_or_evict_into_authority(self): + d = Directory() + store = GroupProvenance(d.group_provenance.path, capacity=1) + receipt, _ = store.reserve("one", "binding") + receipt.update(state="ensured", requested_groups=[], ensure_correlation_id="test") + store.save(receipt) + with self.assertRaisesRegex(RuntimeError, "capacity"): + store.reserve("two", "binding") + self.assertFalse(store.reserve("one", "binding")[1]) + + def test_rollback_replay_and_key_reuse_do_not_recreate(self): + d = Directory() + d.ensure_service_groups(req()) + first = d.revoke_service_groups(rollback(delete_if_empty=True)) + d.calls.clear() + result = d.revoke_service_groups(rollback(delete_if_empty=True)) + self.assertEqual(first.status, result.status) + self.assertTrue(result.resumed) + with self.assertRaises(RuntimeError): + d.ensure_service_groups(req()) + with self.assertRaises(ValueError): + d.revoke_service_groups(rollback(delete_if_empty=False)) + self.assertEqual([], d.calls) + + def test_real_http_handler_reinitialized_from_config_retains_receipt(self): + d = Directory() + path = d.group_provenance.path + def boot(): + with patch.dict(os.environ, {"GROUP_PROVENANCE_DB": path}): + provider = LLDAPProvisioner(base_url="https://synthetic.invalid", admin_password="unused", group_provenance=configured_group_provenance()) + # Only directory transport is synthetic; actual initialized adapter, + # dispatch, HTTP authentication, configuration and SQLite are used. + for name in ("_login", "_directory", "_group_members", "_ensure_group", "_gql", "_remove_group"): + setattr(provider, name, getattr(d, name)) + class LocalHandler(Handler): + provisioner = provider + service_token = "synthetic-service-token" + server = ThreadingHTTPServer(("127.0.0.1", 0), LocalHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread + def stop(server, thread): + server.shutdown(); server.server_close(); thread.join() + def post(server, endpoint, payload): + request = Request(f"http://127.0.0.1:{server.server_port}/v1/groups/{endpoint}", data=json.dumps(payload).encode(), + headers={"Authorization": "Bearer synthetic-service-token"}) + with urlopen(request) as response: + return json.load(response) + server, thread = boot() + try: + created = post(server, "ensure", req()) + finally: + stop(server, thread) + server, thread = boot() + try: + d.calls.clear() + replay = post(server, "ensure", req()) + self.assertTrue(replay["resumed"]) + self.assertEqual(created["created_memberships"], replay["created_memberships"]) + self.assertEqual([], d.calls) + revoked = post(server, "revoke", rollback(delete_if_empty=True)) + self.assertEqual("groups_revoked_and_deleted", revoked["status"]) + self.assertIn("forge-admins", d.groups) + with self.assertRaises(HTTPError) as error: + post(server, "revoke", req()) + self.assertEqual(400, error.exception.code) + d.calls.clear() + os.chmod(path, 0o644) + with self.assertRaises(HTTPError) as error: + post(server, "ensure", req(idempotency_key="different-invocation-key")) + self.assertEqual(503, error.exception.code) + self.assertEqual({"error": "dependency_unavailable", "dependency": "group_provenance"}, json.load(error.exception)) + self.assertEqual([], d.calls) + finally: + stop(server, thread) diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/tests/test_service_groups.py b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/tests/test_service_groups.py new file mode 100644 index 00000000..c6dd48bd --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/identity-provisioner/tests/test_service_groups.py @@ -0,0 +1,140 @@ +import unittest +import tempfile +from pathlib import Path + +from group_provenance import GroupProvenance + +from provisioner import LLDAPProvisioner, dispatch + + +class Directory(LLDAPProvisioner): + """In-memory LLDAP: users, groups and memberships.""" + + def __init__(self): + self.storage = tempfile.TemporaryDirectory() + self.group_provenance = GroupProvenance(str(Path(self.storage.name) / "groups.db"), initialize=True) + self.users = {"tegwick", "agent-codex"} + self.groups = {"lldap_admin": 1, "tenant:trial:one:users": 2, "forge-admins": 3} + self.members = {1: {"tegwick"}, 2: set(), 3: set()} + self.calls = [] + self.drop_add = False + + def _login(self): + return "test-token" + + def _directory(self, token): + return ([{"id": u} for u in sorted(self.users)], + [{"id": i, "displayName": n} for n, i in self.groups.items()]) + + def _user(self, token, subject): + return {"id": subject, "groups": [{"id": i, "displayName": n} for n, i in self.groups.items() + if subject in self.members[i]]} + + def _ensure_group(self, token, groups, name): + if name not in self.groups: + self.calls.append(("create", name)) + self.groups[name] = max(self.groups.values()) + 1 + self.members[self.groups[name]] = set() + return self.groups[name] + + def _add_group(self, token, username, group_id): + self.calls.append(("add", username, group_id)) + if not self.drop_add: + self.members[group_id].add(username) + + def _remove_group(self, token, username, group_id): + self.calls.append(("remove", username, group_id)) + self.members[group_id].discard(username) + + def _group_members(self, token, group_id): + return sorted(self.members[group_id]) + + def _gql(self, token, query, variables): + if "addUserToGroup" in query: + self._add_group(token, variables["userId"], variables["groupId"]) + return {"addUserToGroup": {"ok": True}} + if "deleteGroup" in query: + self.calls.append(("delete", variables["id"])) + name = next(n for n, i in self.groups.items() if i == variables["id"]) + del self.groups[name] + return {"deleteGroup": {"ok": True}} + raise AssertionError(query) + + +def req(**kw): + p = {"groups": ["forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"], + "members": {"forge-admins": ["tegwick"], "forge-coulomb-owners": ["tegwick"]}, + "idempotency_key": "forgejo-netkingdom-oidc-1", "correlation_id": "test"} + p.update(kw) + return p + + +class EnsureTests(unittest.TestCase): + def test_creates_missing_groups_and_adds_members(self): + d = Directory() + r = dispatch(d, "/v1/groups/ensure", req()) + self.assertEqual("groups_ensured", r.status) + self.assertFalse(r.resumed) + self.assertEqual({"forge-coulomb-owners", "forge-coulomb-readers"}, + {c[1] for c in d.calls if c[0] == "create"}) + self.assertIn("tegwick", d.members[d.groups["forge-admins"]]) + self.assertIn("tegwick", d.members[d.groups["forge-coulomb-owners"]]) + + def test_idempotent_and_reports_resumed(self): + d = Directory() + dispatch(d, "/v1/groups/ensure", req()) + d.calls.clear() + r = dispatch(d, "/v1/groups/ensure", req()) + self.assertTrue(r.resumed) + self.assertEqual([], [c for c in d.calls if c[0] == "create"]) + + def test_never_removes_other_memberships(self): + d = Directory() + d.members[3].add("agent-codex") + dispatch(d, "/v1/groups/ensure", req()) + self.assertIn("agent-codex", d.members[3]) + self.assertEqual([], [c for c in d.calls if c[0] == "remove"]) + + def test_reserved_and_invalid_requests_change_nothing(self): + bad = [req(groups=["lldap_admin"]), req(groups=["tenant-x"]), req(groups=["netkingdom-suspended"]), + req(groups=["Forge Admins"]), req(groups=[]), + req(members={"not-listed": ["tegwick"]}), + req(members={"forge-admins": ["Not A User"]}), + req(members={"forge-admins": ["nobody"]})] + for payload in bad: + d = Directory() + with self.assertRaises(ValueError, msg=payload): + dispatch(d, "/v1/groups/ensure", payload) + self.assertEqual([], d.calls, payload) + + def test_failed_readback_never_reports_success(self): + d = Directory() + d.drop_add = True + with self.assertRaises(RuntimeError): + dispatch(d, "/v1/groups/ensure", req()) + + +class RevokeTests(unittest.TestCase): + def test_removes_only_listed_memberships_and_keeps_groups_by_default(self): + d = Directory() + d.members[3].add("agent-codex") + dispatch(d, "/v1/groups/ensure", req()) + r = dispatch(d, "/v1/groups/revoke", req(ensure_idempotency_key=req()["idempotency_key"], ensure_correlation_id=req()["correlation_id"])) + self.assertEqual("groups_revoked", r.status) + self.assertEqual({"agent-codex"}, d.members[d.groups["forge-admins"]]) + self.assertIn("forge-coulomb-owners", d.groups) + + def test_deletes_only_empty_listed_groups_when_asked(self): + d = Directory() + d.members[3].add("agent-codex") + dispatch(d, "/v1/groups/ensure", req()) + r = dispatch(d, "/v1/groups/revoke", req(delete_if_empty=True, ensure_idempotency_key=req()["idempotency_key"], ensure_correlation_id=req()["correlation_id"])) + self.assertEqual("groups_revoked_and_deleted", r.status) + self.assertIn("forge-admins", d.groups) + self.assertNotIn("forge-coulomb-owners", d.groups) + self.assertNotIn("forge-coulomb-readers", d.groups) + self.assertIn("lldap_admin", d.groups) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/sso-mfa/k8s/README.md b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/sso-mfa/k8s/README.md new file mode 100644 index 00000000..ce23ee63 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/postimage/sso-mfa/k8s/README.md @@ -0,0 +1,82 @@ +# NetKingdom Kubernetes integration guidance + +This directory holds bootstrap tooling, integration references and migration +history. Current managed deployment belongs to the service/package owners +under [ADR-0015](../../docs/adr/ADR-0015-netkingdom-railiance-workload-packaging-and-relational-platform.md). +The [original foundation procedure](FOUNDATIONS-HISTORICAL.md) is retained as +historical material, including its old prerequisites and apply commands. + +## Operating baseline + +The [September 28 review](../../history/2026-09-28-open-workplan-infrastructure-review.md) +observed one Ready Railiance01 node at `92.205.62.239`, healthy lightweight +identity components and single-instance CNPG databases. This is a dated +inventory, not an HA, recovery or user-login acceptance claim. + +| Surface | Current role and owner | +| --- | --- | +| KeyCape / Authelia / LLDAP (`sso`) and privacyIDEA (`mfa`) | Lightweight identity composition; issuer implementation in `key-cape`, integration contracts here | +| User Engine | [rapp-user-engine](../../../rapp-user-engine/README.md) owns managed manifests, rollout and rollback | +| Tenant Engine | [rapp-tenant-engine](../../../rapp-tenant-engine/README.md) owns managed runtime and database-consumption configuration | +| flex-auth consumer services | [Owner values and chart pointers](tenant-engine/README.md); caller authentication is enforced by the owner declarations | +| OpenBao and database custody | `railiance-platform`, with managed packages and consumer declarations in their owning repositories | +| Kubernetes and host substrate | [railiance-cluster operator runbook](../../../railiance-cluster/docs/operator-runbook.md) and `railiance-infra` | + +OpenBao's public browser endpoint `bao.coulomb.social` is retired. Operators +use the named `openbao-ui-railiance01` tunnel at `http://127.0.0.1:18200`; +workloads use the internal Service. Follow the platform's +[operator-only cutover record](../../../railiance-platform/workplans/RPF-WP-0025-openbao-operator-only-access.md) +and credential routing in [AGENTS.md](../../AGENTS.md). Route access before +requesting credentials; never copy tokens or Secret values into evidence. + +## Read-only orientation + +Check the context before interpreting these results. All commands below read +resource metadata and readiness; none applies manifests or initiates login. + +```bash +kubectl config current-context +kubectl get nodes -o wide +kubectl -n sso get deployments +kubectl -n mfa get deployments +kubectl -n user-engine get deployments +kubectl -n tenant-engine get deployments +kubectl -n flex-auth get deployments +kubectl -n openbao get statefulsets,deployments,services,ingresses +kubectl get clusters.postgresql.cnpg.io -A +``` + +Ready replicas do not prove negative authorization, actual-user MFA, successful +backup restoration or independent failure domains. Use the relevant owner's +verification and recovery procedure for those claims. + +## Deployment and recovery + +Start with the owning package's current declaration, immutable image and +reviewed rollout/rollback procedure. Do not recursively apply this tree. +`tenant-engine/runtime.yaml` is retired (NK-WP-0039-T04) and holds owner pointers only, no +objects; the Tenant Engine and flex-auth declarations live with their owners. + +The scripts and manifests elsewhere in this directory have individual scopes; +their presence here does not make them current production repair commands. +The [attended procedure inventory](../../docs/attended-procedure-inventory.md) +records their exercise limits. Use the [custody model](../../docs/openbao-unseal-custody-models.md) +and current owner runbooks to prepare recovery. A database-only drill does not +prove restoration of LLDAP, Authelia, privacyIDEA and its matching encryption +material, or all identity database state. + +CoulombCore identity cutover and retained cluster deletion completed on +2026-10-15 under the recorded approval and passing restart gate in +[NK-WP-0022](../../workplans/NK-WP-0022-railiance01-identity-cutover-and-coulombcore-retirement.md). +Railiance01 is authoritative. Recover through current owner runbooks and +protected independent backups; the old cluster and its local PVCs are gone. + + +### Identity-provisioner service group provenance + +The [owner storage and request migration contract](../../docs/service-group-provenance.md) +describes the durable journal required by governed service-group ensure/revoke. +The existing reference identity-provisioner Deployment has a read-only root +filesystem, UID10001 and no provenance volume. A private persistent directory, +explicit store initialization, immutable image rebuild and owner-admitted +rollout are required; this guidance does not apply a native volume change. diff --git a/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/review-r0/postimage/docs/service-group-provenance.md b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/review-r0/postimage/docs/service-group-provenance.md new file mode 100644 index 00000000..42aea82c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/review-r0/postimage/docs/service-group-provenance.md @@ -0,0 +1,95 @@ +# Durable service group creation provenance + +This source change narrows the existing authenticated identity-provisioner +`/v1/groups/ensure` and `/v1/groups/revoke` endpoints. It introduces no new +principal, credential, policy decision, or rollback authorization. An endpoint's +service token and a journal receipt do not establish an accepted case, caller +session, owner assent, or permission to use a protected execution lane. + +## Request and receipt migration + +Ensure keeps `groups`, `members`, `idempotency_key` and `correlation_id`. +The original correlation is now part of its immutable binding: the original +idempotency key with a different correlation or group/member request is refused. +The response adds `ensure_idempotency_key`, `ensure_correlation_id`, +`created_groups` (name and exact directory id) and `created_memberships` +(name, directory id and username). The owner journal independently retains the +complete prestate and expected postimage. Response inventories are advisory; +caller-supplied inventories are rejected and never authorize undo. + +Revoke retains the original group/member request, its own `idempotency_key` and +`correlation_id`, and optional boolean `delete_if_empty`. It additionally requires +`ensure_idempotency_key` and `ensure_correlation_id` from the original ensure. +Legacy revoke requests fail before directory access. No old deployment inventory +is imported as proof of creation: pre-upgrade groups and memberships cannot be +rolled back automatically without independent owner reconciliation. + +An ensure replay returns its original receipt and never recreates subsequently +removed state. Reusing its key after rollback is refused. Revoke replays return +its original outcome; changing deletion options after completion is refused. +There are at most 16 groups, 256 requested membership entries, 4096 retained +preexisting members per operation, and 256 journal records by default. Records +have no timeout or automatic eviction. Capacity exhaustion stops new operations. + +## Storage and deployment preparation + +The existing reference Deployment runs one replica with `Recreate`, UID/GID +10001 and `readOnlyRootFilesystem: true`; it currently has no provenance volume. +The image copies `group_provenance.py`. Native rollout still needs owner review, +a rebuilt immutable image and a separately admitted persistent-volume change. +Do not turn off the read-only root filesystem or grant container root. + +Set `GROUP_PROVENANCE_DB` only to an absolute, non-symlink path on an +owner-controlled persistent volume. For example, after the deployment owner has +prepared it, `/var/lib/identity-provisioner/group-state/provenance.sqlite3` can +use an inner directory owned by UID10001 with mode0700 and a mode0600 database. +SQLite's journal files need the same private writable directory. A writable mount +with only the database file mounted is insufficient. `/tmp`, an `emptyDir`, an +image layer and a process-local dictionary do not retain restart duties. + +The owner explicitly initializes a genuinely new empty store with +`GroupProvenance(absolute_path, initialize=True)` in an attended, authorized +preparation step under the service UID. This refuses an existing file. Ordinary +startup uses `GroupProvenance(absolute_path)` and never creates a replacement +for a missing store. Missing/untrusted/unavailable configured storage prevents +startup or returns a fixed `group_provenance` dependency failure, without local +paths or database diagnostics in HTTP responses. With no configuration, ordinary +directory readiness names `service_groups: disabled`, while group endpoints +refuse work. With configuration, readiness checks storage custody and schema +before reporting `service_groups: available`. This is capability reporting, +not native admission or proof that every retained operation is resolved. + +Back up and recover the journal as security-relevant owner state, together with +its directory pre/postimage evidence and outstanding intents. Losing the journal +is not permission to initialize a replacement and repeat old keys. No recovery +API, arbitrary JSON import, receipt expiry or automatic uncertainty-clearing is +introduced here. An owner must independently reconcile unresolved intents; +manual edits to declare success are not evidence. + +## Safety and remaining native acceptance + +Before each directory mutation, durable intent is committed. A successful +response and exact readback are required before recording a confirmed creation. +Transport loss, readback failure or a process exit leaves pending/uncertain state; +it survives reopening and blocks new mutations instead of becoming retry success. +The journal records mutation intent and confirmed outcomes, not an atomic LDAP +transaction or a complete audit emission guarantee. + +Rollback validates every targeted group id and complete member postimage before +its first destructive operation, and verifies each subsequent change. It removes +only memberships confirmed newly added by that ensure. It deletes only groups +confirmed newly created by that ensure, and only while empty. Preexisting groups, +including empty groups, are never deleted. Existing members are never included +in the removal inventory. Detected shared-member drift or group replacement +refuses the operation before any destructive call and retains uncertainty. + +LLDAP exposes no membership-generation token or conditional mutation/CAS here. +Readback cannot detect a remove/re-add ABA sequence or close a read/write race +with an external writer. Native rollback therefore still requires an owner-proven +writer isolation/fencing contract or a provider-side versioned/CAS operation; +these synthetic fixtures do not prove that boundary. A durable SQLite file is +trusted owner-held state, not tamper-evident remote evidence or an authorization +broker. Existing caller authentication is unchanged; case/source/principal binding, +per-attempt protected session revocation, independently admitted evidence callbacks, +native storage/backup recovery and native endpoint acceptance remain outstanding +under NK-WP-0048-T14/T16. This source change alone does not finish those tasks.