docs: preserve reviewed NetKingdom session artifacts
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Reject generated-only commits / generated-commit-guard (push) Successful in 13s

This commit is contained in:
codex 2026-11-01 23:02:35 +01:00
parent 9b732544d0
commit 7ab0939939
50 changed files with 9399 additions and 0 deletions

View file

@ -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()

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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()

View file

@ -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.

View file

@ -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"]

View file

@ -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()

View file

@ -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

View file

@ -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.

View file

@ -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"

View file

@ -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

View file

@ -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()

View file

@ -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"
}

View file

@ -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()

View file

@ -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.

View file

@ -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"]

View file

@ -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()

View file

@ -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

View file

@ -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.

View file

@ -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"

View file

@ -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

View file

@ -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()

View file

@ -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"
}

View file

@ -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()

View file

@ -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"
}

View file

@ -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()

View file

@ -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
}

View file

@ -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
}

View file

@ -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.

View file

@ -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())

View file

@ -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
}

View file

@ -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-<fresh32hex>-groups-{lldap,probe}",
"exact_label": "net-kingdom.recovery-run=<same-fresh32hex>",
"safe_result": "private /tmp/nk48-groups-native-review-<suffix>/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",
"<reviewed-exact-manifest-sha256>"
]
}

View file

@ -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"
}

View file

@ -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())

View file

@ -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()

View file

@ -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.

View file

@ -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"
]
}

View file

@ -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"
}
]

View file

@ -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
}
}

View file

@ -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"
}

View file

@ -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.

View file

@ -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"]

View file

@ -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()

View file

@ -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"

View file

@ -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"""<!doctype html><html lang="en"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{escape(title)}</title><style>
body{{font:16px system-ui;max-width:36rem;margin:4rem auto;padding:0 1rem}}
label,input,button{{display:block;width:100%;box-sizing:border-box;margin:.75rem 0}}
input,button{{padding:.7rem}}.error{{color:#a00}}
</style><main><h1>{escape(title)}</h1>{content}</main></html>"""
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'<p class="error">{escape(error)}</p>' 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'<label>Login name<input type="text" name="username" value="{escape(login_name, quote=True)}" '
'readonly autocomplete="username"></label>'
if login_name else ""
)
return _page("Set up your password", f"""{message}
<p>This single-use link expires shortly. Choose a password of at least 12 characters.</p>
<form method="post" action="/setup/password">
<input type="hidden" name="token" value="{escape(token)}">
{username}
<label>New password<input type="password" name="password" minlength="12" required autocomplete="new-password"></label>
<label>Confirm password<input type="password" name="confirmation" minlength="12" required autocomplete="new-password"></label>
<button type="submit">Set password</button></form>""")
def _expired_page() -> str:
return _page("Link unavailable", "<p>This password setup link is invalid or expired. Request a new link from your tenant administrator.</p>")
def _failed_page() -> str:
return _page("Setup unavailable", "<p>Password setup could not be completed. Request a new link and try again.</p>")
def _complete_page(return_to: str = "", signin_url: str = "") -> str:
content = "<p>Your password is ready. Sign in with your own account to continue.</p>"
if return_to:
content += f'<p><a rel="noreferrer" href="{escape(return_to, quote=True)}">Continue to your company</a></p>'
elif signin_url:
content += f'<p><a rel="noreferrer" href="{escape(signin_url, quote=True)}">Sign in</a></p>'
else:
content += "<p>Return to the application to sign in and enroll MFA.</p>"
return _page("Password set", content)
if __name__ == "__main__":
main()

View file

@ -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))

View file

@ -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)

View file

@ -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()

View file

@ -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.

View file

@ -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.