docs: preserve reviewed NetKingdom session artifacts
This commit is contained in:
parent
9b732544d0
commit
7ab0939939
50 changed files with 9399 additions and 0 deletions
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
@ -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()
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
|
|
@ -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))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue