net-kingdom/docs/service-group-provenance.md
tegwick 70f408ca23
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Identity provider journey acceptance / provider (push) Failing after 13s
Build and Publish identity-provisioner / build-and-push (push) Successful in 26s
feat(identity): add PostgreSQL provenance and verify sealed SOPS custody
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1468b-63fa-7710-85dc-8f2e16b3ebfa
2026-10-17 01:36:38 +02:00

172 lines
11 KiB
Markdown

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