fix(identity): retain group provenance and prepare sealed recovery custody
Some checks failed
Some checks failed
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1468b-63fa-7710-85dc-8f2e16b3ebfa
This commit is contained in:
parent
878abc9358
commit
4f65e41ac5
22 changed files with 1685 additions and 89 deletions
114
docs/service-group-provenance.md
Normal file
114
docs/service-group-provenance.md
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
@ -3,7 +3,7 @@ FROM python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e
|
||||||
RUN useradd --create-home --uid 10001 provisioner
|
RUN useradd --create-home --uid 10001 provisioner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=lldap /app/lldap_set_password /app/lldap_set_password
|
COPY --from=lldap /app/lldap_set_password /app/lldap_set_password
|
||||||
COPY provisioner.py password_setup.py server.py /app/
|
COPY provisioner.py group_provenance.py password_setup.py server.py /app/
|
||||||
USER 10001:10001
|
USER 10001:10001
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["python", "server.py"]
|
CMD ["python", "server.py"]
|
||||||
|
|
|
||||||
170
identity-provisioner/group_provenance.py
Normal file
170
identity-provisioner/group_provenance.py
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
"""Bounded, owner-private recovery journal; never an authorization source.
|
||||||
|
|
||||||
|
An unresolved write intent is retained indefinitely and blocks further writes.
|
||||||
|
Only the directory owner may reconcile it using independently obtained evidence.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import stat
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
|
class GroupProvenanceUnavailable(RuntimeError):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("group_provenance_unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
class GroupProvenance:
|
||||||
|
def __init__(self, path: str, *, capacity: int = 256, initialize: bool = False) -> None:
|
||||||
|
target = Path(path)
|
||||||
|
if not target.is_absolute() or target.parent.resolve() != target.parent or capacity < 1 or capacity > 4096:
|
||||||
|
raise ValueError("private absolute provenance path and bounded capacity required")
|
||||||
|
parent = target.parent.lstat()
|
||||||
|
if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() or parent.st_mode & 0o077:
|
||||||
|
raise ValueError("provenance directory must be owner-private")
|
||||||
|
# Service startup never creates a replacement for missing durable state.
|
||||||
|
flags = os.O_RDWR | os.O_NOFOLLOW
|
||||||
|
if initialize:
|
||||||
|
flags |= os.O_CREAT | os.O_EXCL
|
||||||
|
fd = os.open(target, flags, 0o600)
|
||||||
|
try:
|
||||||
|
info = os.fstat(fd)
|
||||||
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077 or info.st_nlink != 1:
|
||||||
|
raise ValueError("provenance file must be owner-private and regular")
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
self.path = str(target)
|
||||||
|
self._device, self._inode = info.st_dev, info.st_ino
|
||||||
|
self.capacity = capacity
|
||||||
|
with self._db() as db:
|
||||||
|
if initialize:
|
||||||
|
db.execute("CREATE TABLE group_receipts (key TEXT PRIMARY KEY, binding TEXT NOT NULL, state TEXT NOT NULL, active INTEGER UNIQUE, body TEXT NOT NULL)")
|
||||||
|
db.execute("PRAGMA user_version=1")
|
||||||
|
if db.execute("PRAGMA user_version").fetchone()[0] != 1:
|
||||||
|
raise RuntimeError("unrecognized group provenance schema")
|
||||||
|
db.execute("SELECT key, binding, state, active, body FROM group_receipts LIMIT 0")
|
||||||
|
|
||||||
|
def _check_file(self):
|
||||||
|
try:
|
||||||
|
target = Path(self.path)
|
||||||
|
parent = target.parent.lstat()
|
||||||
|
info = target.lstat()
|
||||||
|
if target.parent.resolve() != target.parent or not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() or parent.st_mode & 0o077:
|
||||||
|
raise GroupProvenanceUnavailable()
|
||||||
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077 or info.st_nlink != 1:
|
||||||
|
raise GroupProvenanceUnavailable()
|
||||||
|
if (info.st_dev, info.st_ino) != (self._device, self._inode):
|
||||||
|
raise GroupProvenanceUnavailable()
|
||||||
|
except OSError:
|
||||||
|
raise GroupProvenanceUnavailable() from None
|
||||||
|
|
||||||
|
def check(self):
|
||||||
|
with self._db() as db:
|
||||||
|
if db.execute("PRAGMA user_version").fetchone()[0] != 1:
|
||||||
|
raise GroupProvenanceUnavailable()
|
||||||
|
db.execute("SELECT key FROM group_receipts LIMIT 0")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load(body, key, binding):
|
||||||
|
try:
|
||||||
|
if not isinstance(body, str) or len(body) > 1048576:
|
||||||
|
raise GroupProvenanceUnavailable()
|
||||||
|
value = json.loads(body)
|
||||||
|
if not isinstance(value, dict) or value.get("key") != key or value.get("binding") != binding:
|
||||||
|
raise GroupProvenanceUnavailable()
|
||||||
|
if value.get("state") not in {"pending", "ensured", "revoking", "revoked", "uncertain", "refused"}:
|
||||||
|
raise GroupProvenanceUnavailable()
|
||||||
|
if not isinstance(value.get("groups"), dict) or len(value["groups"]) > 16:
|
||||||
|
raise GroupProvenanceUnavailable()
|
||||||
|
for field, limit in (("created_groups", 16), ("created_memberships", 256)):
|
||||||
|
if not isinstance(value.get(field), list) or len(value[field]) > limit or any(not isinstance(item, dict) for item in value[field]):
|
||||||
|
raise GroupProvenanceUnavailable()
|
||||||
|
if "intent" not in value or value["intent"] is not None and not isinstance(value["intent"], dict):
|
||||||
|
raise GroupProvenanceUnavailable()
|
||||||
|
for group in value["groups"].values():
|
||||||
|
if not isinstance(group, dict) or "id" not in group or not all(isinstance(group.get(f), list) and all(isinstance(u, str) for u in group[f]) for f in ("before", "after")):
|
||||||
|
raise GroupProvenanceUnavailable()
|
||||||
|
if value["state"] in {"ensured", "revoking", "revoked"} and (
|
||||||
|
not isinstance(value.get("requested_groups"), list) or not isinstance(value.get("ensure_correlation_id"), str)
|
||||||
|
):
|
||||||
|
raise GroupProvenanceUnavailable()
|
||||||
|
return value
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise GroupProvenanceUnavailable() from None
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _db(self):
|
||||||
|
db = None
|
||||||
|
try:
|
||||||
|
self._check_file()
|
||||||
|
db = sqlite3.connect("file:" + quote(self.path, safe="/") + "?mode=rw", uri=True, timeout=5)
|
||||||
|
self._check_file()
|
||||||
|
db.execute("PRAGMA synchronous=FULL")
|
||||||
|
with db:
|
||||||
|
yield db
|
||||||
|
except sqlite3.Error:
|
||||||
|
raise GroupProvenanceUnavailable() from None
|
||||||
|
finally:
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def reserve(self, key: str, binding: str) -> tuple[dict[str, Any], bool]:
|
||||||
|
with self._db() as db:
|
||||||
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone()
|
||||||
|
if row:
|
||||||
|
if row[0] != binding:
|
||||||
|
raise ValueError("ensure idempotency key is bound to another request")
|
||||||
|
return self._load(row[1], key, binding), False
|
||||||
|
if db.execute("SELECT COUNT(*) FROM group_receipts").fetchone()[0] >= self.capacity:
|
||||||
|
raise RuntimeError("provenance capacity reached; retained records require owner disposition")
|
||||||
|
receipt = {"key": key, "binding": binding, "state": "pending", "intent": None,
|
||||||
|
"groups": {}, "created_groups": [], "created_memberships": []}
|
||||||
|
try:
|
||||||
|
db.execute("INSERT INTO group_receipts VALUES (?, ?, ?, 1, ?)",
|
||||||
|
(key, binding, "pending", json.dumps(receipt, sort_keys=True)))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
raise RuntimeError("another group operation is unresolved") from None
|
||||||
|
return receipt, True
|
||||||
|
|
||||||
|
def get(self, key: str, binding: str) -> dict[str, Any]:
|
||||||
|
with self._db() as db:
|
||||||
|
row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone()
|
||||||
|
if not row or row[0] != binding:
|
||||||
|
raise ValueError("unknown ensure receipt or request binding mismatch")
|
||||||
|
return self._load(row[1], key, binding)
|
||||||
|
|
||||||
|
def claim_revoke(self, key: str, binding: str, delete_if_empty: bool) -> dict[str, Any]:
|
||||||
|
with self._db() as db:
|
||||||
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone()
|
||||||
|
if not row or row[0] != binding:
|
||||||
|
raise ValueError("unknown ensure receipt or request binding mismatch")
|
||||||
|
receipt = self._load(row[1], key, binding)
|
||||||
|
if receipt["state"] == "revoked":
|
||||||
|
if receipt["delete_if_empty"] != delete_if_empty:
|
||||||
|
raise ValueError("completed rollback options cannot change")
|
||||||
|
return receipt
|
||||||
|
if receipt["state"] != "ensured":
|
||||||
|
raise RuntimeError("group provenance unresolved; independent owner recovery required")
|
||||||
|
receipt.update(state="revoking", delete_if_empty=delete_if_empty)
|
||||||
|
try:
|
||||||
|
db.execute("UPDATE group_receipts SET state='revoking', active=1, body=? WHERE key=?",
|
||||||
|
(json.dumps(receipt, sort_keys=True), key))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
raise RuntimeError("another group operation is unresolved") from None
|
||||||
|
return receipt
|
||||||
|
|
||||||
|
def save(self, receipt: dict[str, Any]) -> None:
|
||||||
|
active = 1 if receipt["state"] in {"pending", "revoking", "uncertain"} else None
|
||||||
|
with self._db() as db:
|
||||||
|
written = db.execute("UPDATE group_receipts SET state=?, active=?, body=? WHERE key=? AND binding=?",
|
||||||
|
(receipt["state"], active, json.dumps(receipt, sort_keys=True), receipt["key"], receipt["binding"]))
|
||||||
|
if written.rowcount != 1:
|
||||||
|
raise GroupProvenanceUnavailable()
|
||||||
|
|
@ -4,12 +4,15 @@ from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from group_provenance import GroupProvenance, GroupProvenanceUnavailable
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Result:
|
class Result:
|
||||||
|
|
@ -19,6 +22,14 @@ class Result:
|
||||||
resumed: bool
|
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)
|
@dataclass(frozen=True)
|
||||||
class DriftResult:
|
class DriftResult:
|
||||||
provider: str
|
provider: str
|
||||||
|
|
@ -53,7 +64,9 @@ class LLDAPProvisioner:
|
||||||
opener: Callable = urlopen,
|
opener: Callable = urlopen,
|
||||||
auth_rejected_cooldown: float = 30.0,
|
auth_rejected_cooldown: float = 30.0,
|
||||||
clock: Callable[[], float] = time.monotonic,
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
group_provenance: GroupProvenance | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
self.group_provenance = group_provenance
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.admin_password = admin_password
|
self.admin_password = admin_password
|
||||||
self.opener = opener
|
self.opener = opener
|
||||||
|
|
@ -217,7 +230,10 @@ mutation Remove($userId: String!, $groupId: Int!) {
|
||||||
if exc.code == "auth_rejected":
|
if exc.code == "auth_rejected":
|
||||||
self._auth_rejected_until = now + self.auth_rejected_cooldown
|
self._auth_rejected_until = now + self.auth_rejected_cooldown
|
||||||
raise
|
raise
|
||||||
return {"status": "ready", "dependency": "directory"}
|
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:
|
def _login(self, timeout: float = 10) -> str:
|
||||||
return directory_login(
|
return directory_login(
|
||||||
|
|
@ -227,53 +243,156 @@ mutation Remove($userId: String!, $groupId: Int!) {
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
def ensure_service_groups(self, payload: dict[str, Any]) -> Result:
|
def _group_store(self) -> GroupProvenance:
|
||||||
"""Create the listed service-access groups if absent and add the listed
|
store = self.group_provenance
|
||||||
memberships. Never removes anything and never creates users."""
|
if store is None:
|
||||||
groups, members = _service_group_request(payload)
|
raise GroupProvenanceUnavailable()
|
||||||
token = self._login()
|
store.check()
|
||||||
users, directory_groups = self._directory(token)
|
return store
|
||||||
known = {str(u.get("id")) for u in users}
|
|
||||||
unknown = sorted({u for us in members.values() for u in us} - known)
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"unknown directory users: {unknown}")
|
|
||||||
existed = {str(g.get("displayName")) for g in directory_groups}
|
|
||||||
ids = {name: self._ensure_group(token, directory_groups, name) for name in groups}
|
|
||||||
for group, users_ in members.items():
|
|
||||||
for username in users_:
|
|
||||||
self._add_group(token, username, ids[group])
|
|
||||||
for group, users_ in members.items():
|
|
||||||
for username in users_:
|
|
||||||
user = self._user(token, username) or {}
|
|
||||||
if group not in {str(g.get("displayName")) for g in user.get("groups", ())}:
|
|
||||||
raise RuntimeError(f"membership of {username!r} in {group!r} did not read back")
|
|
||||||
return Result("netkingdom-lldap", ",".join(groups), "groups_ensured", set(groups) <= existed)
|
|
||||||
|
|
||||||
def revoke_service_groups(self, payload: dict[str, Any]) -> Result:
|
@staticmethod
|
||||||
"""Rollback of ensure_service_groups: remove exactly the listed memberships,
|
def _group_binding(groups, members, correlation_id) -> str:
|
||||||
and delete a listed group only when delete_if_empty is true and no member
|
# Original correlation is part of the exact ensure binding.
|
||||||
remains."""
|
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)
|
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)
|
delete_if_empty = payload.get("delete_if_empty", False)
|
||||||
if not isinstance(delete_if_empty, bool):
|
if not isinstance(delete_if_empty, bool):
|
||||||
raise ValueError("delete_if_empty must be true or false")
|
raise ValueError("delete_if_empty must be true or false")
|
||||||
token = self._login()
|
store = self._group_store()
|
||||||
_, directory_groups = self._directory(token)
|
receipt = store.claim_revoke(payload["ensure_idempotency_key"], self._group_binding(groups, members, payload["ensure_correlation_id"]), delete_if_empty)
|
||||||
ids = {str(g.get("displayName")): int(g["id"]) for g in directory_groups}
|
if receipt["state"] == "revoked":
|
||||||
for group, users_ in members.items():
|
return self._group_result(receipt, resumed=True)
|
||||||
if group not in ids:
|
try:
|
||||||
continue
|
token = self._login()
|
||||||
for username in users_:
|
# Validate the entire operation before the first destructive call.
|
||||||
user = self._user(token, username) or {}
|
self._check_group_postimage(token, receipt)
|
||||||
if group in {str(g.get("displayName")) for g in user.get("groups", ())}:
|
for edge in receipt["created_memberships"]:
|
||||||
self._remove_group(token, username, ids[group])
|
receipt["intent"] = {"operation": "remove_member", **edge}
|
||||||
deleted = []
|
store.save(receipt)
|
||||||
if delete_if_empty:
|
self._remove_group(token, edge["username"], edge["id"])
|
||||||
for group in groups:
|
expected = receipt["groups"][edge["name"]]["after"]
|
||||||
if group in ids and not self._group_members(token, ids[group]):
|
expected.remove(edge["username"])
|
||||||
self._gql(token, "mutation Del($id: Int!) { deleteGroup(groupId: $id) { ok } }", {"id": ids[group]})
|
self._check_group_postimage(token, receipt)
|
||||||
deleted.append(group)
|
receipt["intent"] = None
|
||||||
return Result("netkingdom-lldap", ",".join(groups), "groups_revoked" if not deleted else "groups_revoked_and_deleted", False)
|
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]:
|
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})
|
value = self._gql(token, "query G($id: Int!) { group(groupId: $id) { users { id } } }", {"id": group_id})
|
||||||
|
|
@ -437,7 +556,19 @@ def _service_group_request(payload: dict[str, Any]) -> tuple[list[str], dict[str
|
||||||
platform-suspension and LLDAP admin groups are reserved and can never be
|
platform-suspension and LLDAP admin groups are reserved and can never be
|
||||||
touched through this path.
|
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")
|
_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"]
|
groups = payload["groups"]
|
||||||
if not isinstance(groups, list) or not groups or len(groups) > 16:
|
if not isinstance(groups, list) or not groups or len(groups) > 16:
|
||||||
raise ValueError("groups must be a list of 1 to 16 names")
|
raise ValueError("groups must be a list of 1 to 16 names")
|
||||||
|
|
@ -446,14 +577,18 @@ def _service_group_request(payload: dict[str, Any]) -> tuple[list[str], dict[str
|
||||||
raise ValueError(f"invalid service group name {name!r}")
|
raise ValueError(f"invalid service group name {name!r}")
|
||||||
if name.startswith(RESERVED_GROUP_PREFIXES):
|
if name.startswith(RESERVED_GROUP_PREFIXES):
|
||||||
raise ValueError(f"group {name!r} is reserved")
|
raise ValueError(f"group {name!r} is reserved")
|
||||||
members = payload.get("members") or {}
|
members = payload.get("members", {})
|
||||||
if not isinstance(members, dict):
|
if not isinstance(members, dict):
|
||||||
raise ValueError("members must map group names to usernames")
|
raise ValueError("members must map group names to usernames")
|
||||||
|
total_members = 0
|
||||||
for group, users in members.items():
|
for group, users in members.items():
|
||||||
if group not in groups:
|
if group not in groups:
|
||||||
raise ValueError(f"members names group {group!r}, which is 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):
|
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")
|
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()}
|
return list(dict.fromkeys(groups)), {g: list(dict.fromkeys(u)) for g, u in members.items()}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import os
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
|
from group_provenance import GroupProvenance, GroupProvenanceUnavailable
|
||||||
from provisioner import DependencyFailure, LLDAPProvisioner, _directory_username, dispatch
|
from provisioner import DependencyFailure, LLDAPProvisioner, _directory_username, dispatch
|
||||||
from password_setup import LLDAPPasswordSetter, PasswordSetupGrants
|
from password_setup import LLDAPPasswordSetter, PasswordSetupGrants
|
||||||
|
|
||||||
|
|
@ -23,6 +24,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if path.path == "/readyz":
|
if path.path == "/readyz":
|
||||||
try:
|
try:
|
||||||
return self._send(200, self.provisioner.preflight())
|
return self._send(200, self.provisioner.preflight())
|
||||||
|
except GroupProvenanceUnavailable:
|
||||||
|
return self._send(503, {"error": "dependency_unavailable", "dependency": "group_provenance"})
|
||||||
except DependencyFailure as exc:
|
except DependencyFailure as exc:
|
||||||
return self._send(503, exc.payload())
|
return self._send(503, exc.payload())
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
|
|
@ -53,6 +56,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
return self._send(404, {"error": "not_found"})
|
return self._send(404, {"error": "not_found"})
|
||||||
except (ValueError, json.JSONDecodeError) as exc:
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
return self._send(400, {"error": "invalid_request", "message": str(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:
|
except DependencyFailure as exc:
|
||||||
return self._send(503, exc.payload())
|
return self._send(503, exc.payload())
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
|
|
@ -109,10 +114,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
return
|
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():
|
def main():
|
||||||
Handler.provisioner = LLDAPProvisioner(
|
Handler.provisioner = LLDAPProvisioner(
|
||||||
base_url=os.environ["LLDAP_URL"],
|
base_url=os.environ["LLDAP_URL"],
|
||||||
admin_password=os.environ["LLDAP_ADMIN_PASSWORD"],
|
admin_password=os.environ["LLDAP_ADMIN_PASSWORD"],
|
||||||
|
group_provenance=configured_group_provenance(),
|
||||||
)
|
)
|
||||||
Handler.service_token = os.environ["PROVISIONER_SERVICE_TOKEN"].strip()
|
Handler.service_token = os.environ["PROVISIONER_SERVICE_TOKEN"].strip()
|
||||||
if not Handler.service_token:
|
if not Handler.service_token:
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ class DirectoryContractTests(unittest.TestCase):
|
||||||
|
|
||||||
def test_preflight_covers_login_and_directory_read(self):
|
def test_preflight_covers_login_and_directory_read(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{"status": "ready", "dependency": "directory"},
|
{"status": "ready", "dependency": "directory", "service_groups": "disabled"},
|
||||||
self.provisioner.preflight(),
|
self.provisioner.preflight(),
|
||||||
)
|
)
|
||||||
self.assertEqual(1, self.state.logins)
|
self.assertEqual(1, self.state.logins)
|
||||||
|
|
|
||||||
326
identity-provisioner/tests/test_group_provenance.py
Normal file
326
identity-provisioner/tests/test_group_provenance.py
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
"""Synthetic actual provisioner/HTTP/storage proof; no live LDAP or authority."""
|
||||||
|
import concurrent.futures
|
||||||
|
from dataclasses import asdict
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from group_provenance import GroupProvenance
|
||||||
|
from provisioner import LLDAPProvisioner, dispatch
|
||||||
|
from server import Handler, configured_group_provenance
|
||||||
|
from test_service_groups import Directory, req
|
||||||
|
|
||||||
|
|
||||||
|
def rollback(**kw):
|
||||||
|
return req(ensure_idempotency_key=req()["idempotency_key"], ensure_correlation_id=req()["correlation_id"], **kw)
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceTests(unittest.TestCase):
|
||||||
|
def test_inventory_excludes_preexisting_membership_and_empty_group(self):
|
||||||
|
d = Directory()
|
||||||
|
d.members[3].add("tegwick")
|
||||||
|
result = d.ensure_service_groups(req())
|
||||||
|
self.assertEqual([{"name": "forge-coulomb-owners", "id": 4}, {"name": "forge-coulomb-readers", "id": 5}], list(result.created_groups))
|
||||||
|
self.assertEqual([{"name": "forge-coulomb-owners", "id": 4, "username": "tegwick"}], list(result.created_memberships))
|
||||||
|
d.revoke_service_groups(rollback(delete_if_empty=True))
|
||||||
|
self.assertEqual({"tegwick"}, d.members[3])
|
||||||
|
self.assertIn("forge-admins", d.groups)
|
||||||
|
self.assertNotIn("forge-coulomb-owners", d.groups)
|
||||||
|
|
||||||
|
def test_preexisting_empty_group_never_deleted(self):
|
||||||
|
d = Directory()
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
d.revoke_service_groups(rollback(delete_if_empty=True))
|
||||||
|
self.assertIn("forge-admins", d.groups)
|
||||||
|
self.assertEqual(set(), d.members[3])
|
||||||
|
|
||||||
|
def test_unknown_receipt_and_digest_mismatch_before_login(self):
|
||||||
|
d = Directory()
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
d.revoke_service_groups(rollback())
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
d.calls.clear()
|
||||||
|
with patch.object(d, "_login", side_effect=AssertionError("must not login")):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
d.revoke_service_groups(rollback(members={"forge-admins": ["agent-codex"]}))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
d.ensure_service_groups(req(groups=["another-group"], members={}))
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
|
||||||
|
def test_unsigned_inventory_and_legacy_revoke_cannot_mutate(self):
|
||||||
|
d = Directory()
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
d.calls.clear()
|
||||||
|
for payload in [req(), rollback(created_groups=[{"name": "forge-admins", "id": 3}]), rollback(created_memberships=[]), rollback(provenance={})]:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
d.revoke_service_groups(payload)
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
|
||||||
|
def test_shared_membership_drift_refuses_all_destructive_calls(self):
|
||||||
|
d = Directory()
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
d.members[3].add("agent-codex")
|
||||||
|
d.calls.clear()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "postimage"):
|
||||||
|
d.revoke_service_groups(rollback(delete_if_empty=True))
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
self.assertEqual({"tegwick", "agent-codex"}, d.members[3])
|
||||||
|
|
||||||
|
def test_replaced_group_identity_refuses_all_destructive_calls(self):
|
||||||
|
d = Directory()
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
d.groups["forge-admins"] = 10
|
||||||
|
d.members[10] = {"tegwick"}
|
||||||
|
d.calls.clear()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "postimage"):
|
||||||
|
d.revoke_service_groups(rollback())
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
|
||||||
|
def test_successful_ensure_replay_never_recreates_later_removed_state(self):
|
||||||
|
d = Directory()
|
||||||
|
first = d.ensure_service_groups(req())
|
||||||
|
d.members[3].clear()
|
||||||
|
d.calls.clear()
|
||||||
|
replay = d.ensure_service_groups(req())
|
||||||
|
self.assertTrue(replay.resumed)
|
||||||
|
self.assertEqual(first.created_memberships, replay.created_memberships)
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
self.assertEqual(set(), d.members[3])
|
||||||
|
|
||||||
|
def test_interrupted_add_persists_intent_and_blocks_restart_retry(self):
|
||||||
|
d = Directory()
|
||||||
|
original = d._gql
|
||||||
|
def lost_response(token, query, variables):
|
||||||
|
result = original(token, query, variables)
|
||||||
|
if "addUserToGroup" in query:
|
||||||
|
raise RuntimeError("synthetic lost response")
|
||||||
|
return result
|
||||||
|
d._gql = lost_response
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
d.group_provenance = GroupProvenance(d.group_provenance.path)
|
||||||
|
receipt = d.group_provenance.get(req()["idempotency_key"], d._group_binding(req()["groups"], req()["members"], req()["correlation_id"]))
|
||||||
|
self.assertEqual("uncertain", receipt["state"])
|
||||||
|
self.assertEqual("add_member", receipt["intent"]["operation"])
|
||||||
|
self.assertEqual([], receipt["created_memberships"])
|
||||||
|
d.calls.clear()
|
||||||
|
for operation, payload in [(d.ensure_service_groups, req()), (d.revoke_service_groups, rollback()), (d.ensure_service_groups, req(idempotency_key="different-key-is-not-recovery"))]:
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
operation(payload)
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
|
||||||
|
def test_interrupted_rollback_retains_confirmed_inventory_and_pending_intent(self):
|
||||||
|
d = Directory()
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
original = d._remove_group
|
||||||
|
def lost_response(*args):
|
||||||
|
original(*args)
|
||||||
|
raise RuntimeError("lost remove response")
|
||||||
|
d._remove_group = lost_response
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
d.revoke_service_groups(rollback())
|
||||||
|
d.group_provenance = GroupProvenance(d.group_provenance.path)
|
||||||
|
receipt = d.group_provenance.get(req()["idempotency_key"], d._group_binding(req()["groups"], req()["members"], req()["correlation_id"]))
|
||||||
|
self.assertEqual("uncertain", receipt["state"])
|
||||||
|
self.assertEqual("remove_member", receipt["intent"]["operation"])
|
||||||
|
self.assertEqual(2, len(receipt["created_memberships"]))
|
||||||
|
d.calls.clear()
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
d.revoke_service_groups(rollback())
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
|
||||||
|
def test_unresolved_pre_call_intent_survives_hard_process_loss(self):
|
||||||
|
d = Directory()
|
||||||
|
d._gql = lambda *_: (_ for _ in ()).throw(SystemExit("process lost before response"))
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
d.group_provenance = GroupProvenance(d.group_provenance.path)
|
||||||
|
receipt = d.group_provenance.get(req()["idempotency_key"], d._group_binding(req()["groups"], req()["members"], req()["correlation_id"]))
|
||||||
|
self.assertEqual("pending", receipt["state"])
|
||||||
|
self.assertEqual("add_member", receipt["intent"]["operation"])
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
|
||||||
|
def test_missing_config_private_permissions_and_lost_database_fail_closed(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
self.assertIsNone(configured_group_provenance())
|
||||||
|
d = Directory()
|
||||||
|
path = d.group_provenance.path
|
||||||
|
with patch.dict(os.environ, {"GROUP_PROVENANCE_DB": path}):
|
||||||
|
self.assertIsInstance(configured_group_provenance(), GroupProvenance)
|
||||||
|
os.chmod(path, 0o644)
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
configured_group_provenance()
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
os.unlink(path)
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
configured_group_provenance()
|
||||||
|
self.assertFalse(Path(path).exists())
|
||||||
|
provider = LLDAPProvisioner(base_url="https://invalid", admin_password="synthetic")
|
||||||
|
with patch.object(provider, "_login", side_effect=AssertionError("no directory")):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
provider.ensure_service_groups(req())
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
provider.revoke_service_groups(rollback())
|
||||||
|
|
||||||
|
def test_missing_intent_row_stops_before_any_directory_effect(self):
|
||||||
|
d = Directory()
|
||||||
|
store = d.group_provenance
|
||||||
|
original_save = store.save
|
||||||
|
def lose_row(receipt):
|
||||||
|
if receipt["intent"] is not None:
|
||||||
|
with sqlite3.connect(store.path) as db:
|
||||||
|
db.execute("DELETE FROM group_receipts")
|
||||||
|
original_save(receipt)
|
||||||
|
store.save = lose_row
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "group_provenance_unavailable"):
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
|
||||||
|
def test_live_instance_rejects_replaced_database_before_effect(self):
|
||||||
|
d = Directory()
|
||||||
|
original = d.group_provenance
|
||||||
|
replacement = str(Path(d.storage.name) / "replacement.db")
|
||||||
|
GroupProvenance(replacement, initialize=True)
|
||||||
|
os.replace(replacement, original.path)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "group_provenance_unavailable"):
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
|
||||||
|
def test_failed_before_mutation_is_retained_without_stranding_other_keys(self):
|
||||||
|
for failure in ("unknown-user", "oversized-prestate", "directory-read-failed"):
|
||||||
|
d = Directory()
|
||||||
|
old_members = d._group_members
|
||||||
|
request = req(members={"forge-admins": ["nobody"]}) if failure == "unknown-user" else req()
|
||||||
|
if failure == "oversized-prestate":
|
||||||
|
d._group_members = lambda *_: [f"synthetic{i}" for i in range(4097)]
|
||||||
|
if failure == "directory-read-failed":
|
||||||
|
d._group_members = lambda *_: (_ for _ in ()).throw(RuntimeError("read failed"))
|
||||||
|
with self.assertRaises((ValueError, RuntimeError)):
|
||||||
|
d.ensure_service_groups(request)
|
||||||
|
binding = d._group_binding(request["groups"], request["members"], request["correlation_id"])
|
||||||
|
receipt = d.group_provenance.get(request["idempotency_key"], binding)
|
||||||
|
self.assertEqual("refused", receipt["state"])
|
||||||
|
self.assertEqual("before_mutation", receipt["failure_phase"])
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
d._group_members = old_members
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
d.ensure_service_groups(request)
|
||||||
|
self.assertEqual("groups_ensured", d.ensure_service_groups(req(idempotency_key="another-valid-operation-key")).status)
|
||||||
|
|
||||||
|
def test_current_correlations_are_bound_to_original_ensure(self):
|
||||||
|
d = Directory()
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
d.calls.clear()
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
d.ensure_service_groups(req(correlation_id="other-case"))
|
||||||
|
payload = rollback()
|
||||||
|
payload["ensure_correlation_id"] = "other-case"
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
d.revoke_service_groups(payload)
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
|
||||||
|
def test_readiness_separates_directory_health_from_group_capability(self):
|
||||||
|
provider = LLDAPProvisioner(base_url="https://invalid", admin_password="synthetic")
|
||||||
|
provider._login = lambda **_: "synthetic"
|
||||||
|
provider._gql = lambda *_args, **_kw: {"groups": []}
|
||||||
|
self.assertEqual("disabled", provider.preflight()["service_groups"])
|
||||||
|
d = Directory()
|
||||||
|
provider.group_provenance = d.group_provenance
|
||||||
|
self.assertEqual("available", provider.preflight()["service_groups"])
|
||||||
|
os.chmod(d.group_provenance.path, 0o644)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "group_provenance_unavailable"):
|
||||||
|
provider.preflight()
|
||||||
|
|
||||||
|
def test_concurrent_sqlite_reservation_allows_one_original_attempt(self):
|
||||||
|
d = Directory()
|
||||||
|
path = d.group_provenance.path
|
||||||
|
def reserve(_):
|
||||||
|
return GroupProvenance(path).reserve("synthetic-concurrent-key", "same-binding")[1]
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
fresh = list(pool.map(reserve, range(24)))
|
||||||
|
self.assertEqual(1, sum(fresh))
|
||||||
|
|
||||||
|
def test_bounded_records_never_age_or_evict_into_authority(self):
|
||||||
|
d = Directory()
|
||||||
|
store = GroupProvenance(d.group_provenance.path, capacity=1)
|
||||||
|
receipt, _ = store.reserve("one", "binding")
|
||||||
|
receipt.update(state="ensured", requested_groups=[], ensure_correlation_id="test")
|
||||||
|
store.save(receipt)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "capacity"):
|
||||||
|
store.reserve("two", "binding")
|
||||||
|
self.assertFalse(store.reserve("one", "binding")[1])
|
||||||
|
|
||||||
|
def test_rollback_replay_and_key_reuse_do_not_recreate(self):
|
||||||
|
d = Directory()
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
first = d.revoke_service_groups(rollback(delete_if_empty=True))
|
||||||
|
d.calls.clear()
|
||||||
|
result = d.revoke_service_groups(rollback(delete_if_empty=True))
|
||||||
|
self.assertEqual(first.status, result.status)
|
||||||
|
self.assertTrue(result.resumed)
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
d.ensure_service_groups(req())
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
d.revoke_service_groups(rollback(delete_if_empty=False))
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
|
||||||
|
def test_real_http_handler_reinitialized_from_config_retains_receipt(self):
|
||||||
|
d = Directory()
|
||||||
|
path = d.group_provenance.path
|
||||||
|
def boot():
|
||||||
|
with patch.dict(os.environ, {"GROUP_PROVENANCE_DB": path}):
|
||||||
|
provider = LLDAPProvisioner(base_url="https://synthetic.invalid", admin_password="unused", group_provenance=configured_group_provenance())
|
||||||
|
# Only directory transport is synthetic; actual initialized adapter,
|
||||||
|
# dispatch, HTTP authentication, configuration and SQLite are used.
|
||||||
|
for name in ("_login", "_directory", "_group_members", "_ensure_group", "_gql", "_remove_group"):
|
||||||
|
setattr(provider, name, getattr(d, name))
|
||||||
|
class LocalHandler(Handler):
|
||||||
|
provisioner = provider
|
||||||
|
service_token = "synthetic-service-token"
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), LocalHandler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return server, thread
|
||||||
|
def stop(server, thread):
|
||||||
|
server.shutdown(); server.server_close(); thread.join()
|
||||||
|
def post(server, endpoint, payload):
|
||||||
|
request = Request(f"http://127.0.0.1:{server.server_port}/v1/groups/{endpoint}", data=json.dumps(payload).encode(),
|
||||||
|
headers={"Authorization": "Bearer synthetic-service-token"})
|
||||||
|
with urlopen(request) as response:
|
||||||
|
return json.load(response)
|
||||||
|
server, thread = boot()
|
||||||
|
try:
|
||||||
|
created = post(server, "ensure", req())
|
||||||
|
finally:
|
||||||
|
stop(server, thread)
|
||||||
|
server, thread = boot()
|
||||||
|
try:
|
||||||
|
d.calls.clear()
|
||||||
|
replay = post(server, "ensure", req())
|
||||||
|
self.assertTrue(replay["resumed"])
|
||||||
|
self.assertEqual(created["created_memberships"], replay["created_memberships"])
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
revoked = post(server, "revoke", rollback(delete_if_empty=True))
|
||||||
|
self.assertEqual("groups_revoked_and_deleted", revoked["status"])
|
||||||
|
self.assertIn("forge-admins", d.groups)
|
||||||
|
with self.assertRaises(HTTPError) as error:
|
||||||
|
post(server, "revoke", req())
|
||||||
|
self.assertEqual(400, error.exception.code)
|
||||||
|
d.calls.clear()
|
||||||
|
os.chmod(path, 0o644)
|
||||||
|
with self.assertRaises(HTTPError) as error:
|
||||||
|
post(server, "ensure", req(idempotency_key="different-invocation-key"))
|
||||||
|
self.assertEqual(503, error.exception.code)
|
||||||
|
self.assertEqual({"error": "dependency_unavailable", "dependency": "group_provenance"}, json.load(error.exception))
|
||||||
|
self.assertEqual([], d.calls)
|
||||||
|
finally:
|
||||||
|
stop(server, thread)
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import unittest
|
import unittest
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from group_provenance import GroupProvenance
|
||||||
|
|
||||||
from provisioner import LLDAPProvisioner, dispatch
|
from provisioner import LLDAPProvisioner, dispatch
|
||||||
|
|
||||||
|
|
@ -7,6 +11,8 @@ class Directory(LLDAPProvisioner):
|
||||||
"""In-memory LLDAP: users, groups and memberships."""
|
"""In-memory LLDAP: users, groups and memberships."""
|
||||||
|
|
||||||
def __init__(self):
|
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.users = {"tegwick", "agent-codex"}
|
||||||
self.groups = {"lldap_admin": 1, "tenant:trial:one:users": 2, "forge-admins": 3}
|
self.groups = {"lldap_admin": 1, "tenant:trial:one:users": 2, "forge-admins": 3}
|
||||||
self.members = {1: {"tegwick"}, 2: set(), 3: set()}
|
self.members = {1: {"tegwick"}, 2: set(), 3: set()}
|
||||||
|
|
@ -44,6 +50,9 @@ class Directory(LLDAPProvisioner):
|
||||||
return sorted(self.members[group_id])
|
return sorted(self.members[group_id])
|
||||||
|
|
||||||
def _gql(self, token, query, variables):
|
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:
|
if "deleteGroup" in query:
|
||||||
self.calls.append(("delete", variables["id"]))
|
self.calls.append(("delete", variables["id"]))
|
||||||
name = next(n for n, i in self.groups.items() if i == variables["id"])
|
name = next(n for n, i in self.groups.items() if i == variables["id"])
|
||||||
|
|
@ -108,18 +117,18 @@ class EnsureTests(unittest.TestCase):
|
||||||
class RevokeTests(unittest.TestCase):
|
class RevokeTests(unittest.TestCase):
|
||||||
def test_removes_only_listed_memberships_and_keeps_groups_by_default(self):
|
def test_removes_only_listed_memberships_and_keeps_groups_by_default(self):
|
||||||
d = Directory()
|
d = Directory()
|
||||||
|
d.members[3].add("agent-codex")
|
||||||
dispatch(d, "/v1/groups/ensure", req())
|
dispatch(d, "/v1/groups/ensure", req())
|
||||||
d.members[d.groups["forge-admins"]].add("agent-codex")
|
r = dispatch(d, "/v1/groups/revoke", req(ensure_idempotency_key=req()["idempotency_key"], ensure_correlation_id=req()["correlation_id"]))
|
||||||
r = dispatch(d, "/v1/groups/revoke", req())
|
|
||||||
self.assertEqual("groups_revoked", r.status)
|
self.assertEqual("groups_revoked", r.status)
|
||||||
self.assertEqual({"agent-codex"}, d.members[d.groups["forge-admins"]])
|
self.assertEqual({"agent-codex"}, d.members[d.groups["forge-admins"]])
|
||||||
self.assertIn("forge-coulomb-owners", d.groups)
|
self.assertIn("forge-coulomb-owners", d.groups)
|
||||||
|
|
||||||
def test_deletes_only_empty_listed_groups_when_asked(self):
|
def test_deletes_only_empty_listed_groups_when_asked(self):
|
||||||
d = Directory()
|
d = Directory()
|
||||||
|
d.members[3].add("agent-codex")
|
||||||
dispatch(d, "/v1/groups/ensure", req())
|
dispatch(d, "/v1/groups/ensure", req())
|
||||||
d.members[d.groups["forge-admins"]].add("agent-codex")
|
r = dispatch(d, "/v1/groups/revoke", req(delete_if_empty=True, ensure_idempotency_key=req()["idempotency_key"], ensure_correlation_id=req()["correlation_id"]))
|
||||||
r = dispatch(d, "/v1/groups/revoke", req(delete_if_empty=True))
|
|
||||||
self.assertEqual("groups_revoked_and_deleted", r.status)
|
self.assertEqual("groups_revoked_and_deleted", r.status)
|
||||||
self.assertIn("forge-admins", d.groups)
|
self.assertIn("forge-admins", d.groups)
|
||||||
self.assertNotIn("forge-coulomb-owners", d.groups)
|
self.assertNotIn("forge-coulomb-owners", d.groups)
|
||||||
|
|
|
||||||
54
sso-mfa/bootstrap/sops-custody-fd.py
Normal file
54
sso-mfa/bootstrap/sops-custody-fd.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Attended custody through a sealed anonymous Linux descriptor, never a key path."""
|
||||||
|
import argparse
|
||||||
|
import fcntl
|
||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
SEALS=fcntl.F_SEAL_WRITE|fcntl.F_SEAL_GROW|fcntl.F_SEAL_SHRINK|fcntl.F_SEAL_SEAL
|
||||||
|
|
||||||
|
class Refused(Exception):pass
|
||||||
|
|
||||||
|
def sealed_key(material):
|
||||||
|
if not isinstance(material,bytes) or not 1<=len(material)<=16384 or not re.fullmatch(b'AGE-SECRET-KEY-1[A-Z0-9]+\n?',material):raise Refused()
|
||||||
|
fd=os.memfd_create('netkingdom-custody',os.MFD_CLOEXEC|os.MFD_ALLOW_SEALING)
|
||||||
|
try:
|
||||||
|
os.fchmod(fd,0o600)
|
||||||
|
with os.fdopen(os.dup(fd),'wb') as out:out.write(material);out.flush()
|
||||||
|
os.lseek(fd,0,os.SEEK_SET)
|
||||||
|
fcntl.fcntl(fd,fcntl.F_ADD_SEALS,SEALS)
|
||||||
|
if fcntl.fcntl(fd,fcntl.F_GET_SEALS)!=SEALS:raise Refused()
|
||||||
|
return fd
|
||||||
|
except BaseException:os.close(fd);raise
|
||||||
|
|
||||||
|
def run(material,recipient,command):
|
||||||
|
if not re.fullmatch(r'age1[023456789acdefghjklmnpqrstuvwxyz]{58}',recipient) or not command:raise Refused()
|
||||||
|
fd=sealed_key(material)
|
||||||
|
try:
|
||||||
|
result=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{fd}'],pass_fds=(fd,),stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=10)
|
||||||
|
if result.returncode or result.stdout.strip()!=recipient.encode():raise Refused()
|
||||||
|
# The key must not compete with any ambient custody source. Children
|
||||||
|
# receive only this exact inherited descriptor, with close-on-exec elsewhere.
|
||||||
|
env={k:v for k,v in os.environ.items() if k not in ('NETKINGDOM_CUSTODY_AGE_KEY_FILE','NETKINGDOM_CUSTODY_AGE_KEY_FD','SOPS_AGE_KEY_FILE','SOPS_AGE_KEY','SOPS_AGE_KEY_CMD')}
|
||||||
|
env['NETKINGDOM_CUSTODY_AGE_KEY_FD']=str(fd)
|
||||||
|
return subprocess.run(command,env=env,pass_fds=(fd,)).returncode
|
||||||
|
finally:os.close(fd)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--expected-recipient',required=True);p.add_argument('command',nargs=argparse.REMAINDER);args=p.parse_args()
|
||||||
|
command=args.command[1:] if args.command[:1]==['--'] else args.command
|
||||||
|
try:
|
||||||
|
if (not all(os.isatty(fd) for fd in (0,1,2)) or not command
|
||||||
|
or not re.fullmatch(r'age1[023456789acdefghjklmnpqrstuvwxyz]{58}',args.expected_recipient)):raise Refused()
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter('error',getpass.GetPassWarning)
|
||||||
|
material=getpass.getpass('Paste custody age private key line (input hidden): ').encode()+b'\n'
|
||||||
|
return run(material,args.expected_recipient,command)
|
||||||
|
except Exception:
|
||||||
|
print('ERROR: sealed custody input or execution refused; private diagnostics suppressed.',file=sys.stderr);return 1
|
||||||
|
|
||||||
|
if __name__=='__main__':raise SystemExit(main())
|
||||||
|
|
@ -14,6 +14,7 @@ EXPECTED_RECIPIENT="${EXPECTED_AGE_RECIPIENT:-}"
|
||||||
SOURCE_FILE=""
|
SOURCE_FILE=""
|
||||||
INPUT_MODE=""
|
INPUT_MODE=""
|
||||||
CHECK_ONLY=false
|
CHECK_ONLY=false
|
||||||
|
SEALED_FD=false
|
||||||
COMMAND=()
|
COMMAND=()
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
|
|
@ -27,6 +28,7 @@ Options:
|
||||||
--from-stdin Read custody age key material from stdin.
|
--from-stdin Read custody age key material from stdin.
|
||||||
--prompt Prompt for one AGE-SECRET-KEY-1... line.
|
--prompt Prompt for one AGE-SECRET-KEY-1... line.
|
||||||
--check-only Validate the supplied key and exit.
|
--check-only Validate the supplied key and exit.
|
||||||
|
--sealed-fd Hidden prompt, explicit command, no named key file.
|
||||||
-h, --help Show this help.
|
-h, --help Show this help.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
@ -83,6 +85,10 @@ while [[ $# -gt 0 ]]; do
|
||||||
INPUT_MODE="prompt"
|
INPUT_MODE="prompt"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--sealed-fd)
|
||||||
|
SEALED_FD=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--check-only)
|
--check-only)
|
||||||
CHECK_ONLY=true
|
CHECK_ONLY=true
|
||||||
shift
|
shift
|
||||||
|
|
@ -121,6 +127,11 @@ if [[ "$CHECK_ONLY" == false && ${#COMMAND[@]} -eq 0 && ! -t 0 ]]; then
|
||||||
fail "an interactive custody shell requires a terminal; supply an explicit command"
|
fail "an interactive custody shell requires a terminal; supply an explicit command"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$SEALED_FD" == true ]]; then
|
||||||
|
[[ "$INPUT_MODE" == prompt && "$CHECK_ONLY" == false && ${#COMMAND[@]} -gt 0 ]] || fail "sealed descriptor mode requires --prompt and an explicit command"
|
||||||
|
exec python3 "$SCRIPT_DIR/sops-custody-fd.py" --expected-recipient "$EXPECTED_RECIPIENT" -- "${COMMAND[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/netkingdom-sops-age.XXXXXX")"
|
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/netkingdom-sops-age.XXXXXX")"
|
||||||
KEY_FILE="$TMP_DIR/keys.txt"
|
KEY_FILE="$TMP_DIR/keys.txt"
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
|
@ -185,7 +196,7 @@ if [[ "$CHECK_ONLY" == true ]]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The supplied key must not compete with an ambient inline key or key command.
|
# The supplied key must not compete with an ambient inline key or key command.
|
||||||
unset SOPS_AGE_KEY SOPS_AGE_KEY_CMD
|
unset SOPS_AGE_KEY SOPS_AGE_KEY_CMD NETKINGDOM_CUSTODY_AGE_KEY_FD
|
||||||
export SOPS_AGE_KEY_FILE="$KEY_FILE"
|
export SOPS_AGE_KEY_FILE="$KEY_FILE"
|
||||||
export NETKINGDOM_CUSTODY_AGE_KEY_FILE="$KEY_FILE"
|
export NETKINGDOM_CUSTODY_AGE_KEY_FILE="$KEY_FILE"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import fcntl
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
|
@ -99,7 +100,24 @@ def age_document(source, recipient, *, raw=None):
|
||||||
|
|
||||||
|
|
||||||
def private_key_fd():
|
def private_key_fd():
|
||||||
|
inherited = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FD', '')
|
||||||
name = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FILE', '')
|
name = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FILE', '')
|
||||||
|
if inherited:
|
||||||
|
if name or not re.fullmatch(r'[1-9][0-9]{0,8}',inherited) or int(inherited)<3:
|
||||||
|
raise Refused('ambiguous custody descriptor')
|
||||||
|
fd=os.dup(int(inherited))
|
||||||
|
try:
|
||||||
|
info=os.fstat(fd)
|
||||||
|
required=fcntl.F_SEAL_WRITE|fcntl.F_SEAL_GROW|fcntl.F_SEAL_SHRINK|fcntl.F_SEAL_SEAL
|
||||||
|
if (not stat.S_ISREG(info.st_mode) or info.st_uid!=os.getuid() or info.st_mode&0o077
|
||||||
|
or info.st_nlink!=0 or not 0<info.st_size<=16384
|
||||||
|
or fcntl.fcntl(fd,fcntl.F_GET_SEALS)!=required):
|
||||||
|
raise Refused('custody descriptor must be anonymous, private and sealed')
|
||||||
|
# No named file exists to survive parent SIGKILL. Existing callers
|
||||||
|
# own the returned duplicate and verify the expected age recipient.
|
||||||
|
return fd
|
||||||
|
except BaseException:os.close(fd);raise
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
raise Refused('explicit temporary custody key required')
|
raise Refused('explicit temporary custody key required')
|
||||||
path = Path(os.path.abspath(name))
|
path = Path(os.path.abspath(name))
|
||||||
|
|
|
||||||
|
|
@ -70,3 +70,16 @@ CoulombCore identity cutover and retained cluster deletion completed on
|
||||||
[NK-WP-0022](../../workplans/NK-WP-0022-railiance01-identity-cutover-and-coulombcore-retirement.md).
|
[NK-WP-0022](../../workplans/NK-WP-0022-railiance01-identity-cutover-and-coulombcore-retirement.md).
|
||||||
Railiance01 is authoritative. Recover through current owner runbooks and
|
Railiance01 is authoritative. Recover through current owner runbooks and
|
||||||
protected independent backups; the old cluster and its local PVCs are gone.
|
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. The implemented SQLite backend
|
||||||
|
is local/disposable only. Accepted ADR-0015 rejects SQLite-on-a-PVC for production;
|
||||||
|
native journal storage requires the CNPG/`rapp-postgres` consumer contract or an
|
||||||
|
explicit governed exception, plus owner-admitted image/storage/caller and writer
|
||||||
|
isolation. Keep production group operations disabled pending that admission.
|
||||||
|
This guidance does not apply or authorize a native volume change.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"schema": "nk49.keycape-native-fixture-proposal.v1",
|
"schema": "nk49.keycape-native-fixture-proposal.v1",
|
||||||
"status": "prepared_not_executed_not_approved",
|
"status": "prepared_not_executed_not_approved",
|
||||||
"source_sha256": {
|
"source_sha256": {
|
||||||
"sso-mfa/bootstrap/sops-private.py": "sha256:113d17ee4cd7ad405e73cc51e1823dc02bd2e618e7fa67c1b624f0226e334ec6",
|
"sso-mfa/bootstrap/sops-private.py": "sha256:acac68e2936ed38b7d7c9e35eb4619647c99c117a3928a1c5ce704b570906780",
|
||||||
"sso-mfa/k8s/privacyidea/key-custody-remote.py": "sha256:27b2b885c81923d85054e3999c5613e104fe627779b2b50398ed9bed5088f47e",
|
"sso-mfa/k8s/privacyidea/key-custody-remote.py": "sha256:27b2b885c81923d85054e3999c5613e104fe627779b2b50398ed9bed5088f47e",
|
||||||
"tests/run_isolated_keycape_native_fixture.py": "sha256:ecbadecc475622eaaf20a726110aebef5f4fe7af141110a98aca1640179b4a03",
|
"tests/run_isolated_keycape_native_fixture.py": "sha256:ecbadecc475622eaaf20a726110aebef5f4fe7af141110a98aca1640179b4a03",
|
||||||
"tests/test_identity_bundle.py": "sha256:be5ce74097594cc7f7e77dbccd6cb53aa72f263cd1bb442ce5464038aed1116c",
|
"tests/test_identity_bundle.py": "sha256:be5ce74097594cc7f7e77dbccd6cb53aa72f263cd1bb442ce5464038aed1116c",
|
||||||
|
|
@ -24,18 +24,24 @@
|
||||||
"tools/bootstrap-recovery/install-identity-backup.py": "sha256:5c612a2a51c9b59aad8cc01d418c2f563cbd92c62dc1fa8be994e5fb59df90f1",
|
"tools/bootstrap-recovery/install-identity-backup.py": "sha256:5c612a2a51c9b59aad8cc01d418c2f563cbd92c62dc1fa8be994e5fb59df90f1",
|
||||||
"tools/bootstrap-recovery/inventory-scaleway.py": "sha256:a219df205708fa4ed44b763a418585d63c820c25434bc0fd14e805eb5c2f79d3",
|
"tools/bootstrap-recovery/inventory-scaleway.py": "sha256:a219df205708fa4ed44b763a418585d63c820c25434bc0fd14e805eb5c2f79d3",
|
||||||
"tools/bootstrap-recovery/inventory-storage-share.py": "sha256:673f9b17fb2f3fef6fcdb5c50a2bcca69e3df3157a9e5582a2d995c6b87640b5",
|
"tools/bootstrap-recovery/inventory-storage-share.py": "sha256:673f9b17fb2f3fef6fcdb5c50a2bcca69e3df3157a9e5582a2d995c6b87640b5",
|
||||||
"tools/bootstrap-recovery/isolated-keycape-session.py": "sha256:ba274034cd8491089048ad54324be735b7869d54b9d916330e987d7c288531f7",
|
"tools/bootstrap-recovery/isolated-keycape-session.py": "sha256:8fa38efe28167b59a6307f342b2ac8fcda78ecda60e6228f22017424d2343c22",
|
||||||
"tools/bootstrap-recovery/isolated-keycape.py": "sha256:1c018fd7fbfd3afcc96ae61e7eafc283ac1dde3916c3bf7eac9eb50b934b57a5",
|
"tools/bootstrap-recovery/isolated-keycape.py": "sha256:f67627b7d1647d75187dd899874efe22383bc91de5d97dabb9801076668ab3c2",
|
||||||
"tools/bootstrap-recovery/isolated-lldap.py": "sha256:35bfa8411186ad936a199bd450c7e18c2ad51059301a6db9650e6bd055ab1b81",
|
"tools/bootstrap-recovery/isolated-lldap.py": "sha256:35bfa8411186ad936a199bd450c7e18c2ad51059301a6db9650e6bd055ab1b81",
|
||||||
"tools/bootstrap-recovery/isolated-privacyidea.py": "sha256:4df7196678aac1ad594f32abf45c68dc21a9d2760d9e8a536a215a4a3c5d277f",
|
"tools/bootstrap-recovery/isolated-privacyidea.py": "sha256:4df7196678aac1ad594f32abf45c68dc21a9d2760d9e8a536a215a4a3c5d277f",
|
||||||
"tools/bootstrap-recovery/prepare-identity-coverage.py": "sha256:cd5f3067d28b4c7cc93ed08b8cf3869218bdecf7c54b5dffed22d697de71dbb1",
|
"tools/bootstrap-recovery/prepare-identity-coverage.py": "sha256:cd5f3067d28b4c7cc93ed08b8cf3869218bdecf7c54b5dffed22d697de71dbb1",
|
||||||
"tools/bootstrap-recovery/recovery-kit.py": "sha256:6fa0f1494dccaf003b6c6396c5de8b36faa2b276eeb16badd04383f5efb71e66",
|
"tools/bootstrap-recovery/recovery-kit.py": "sha256:6fa0f1494dccaf003b6c6396c5de8b36faa2b276eeb16badd04383f5efb71e66",
|
||||||
"tools/bootstrap-recovery/retrieve-identity-offline.py": "sha256:3421dbb3f3280a001d33ea2deb12ab609404a0b4254cd43c21518d77d166f972",
|
"tools/bootstrap-recovery/retrieve-identity-offline.py": "sha256:3421dbb3f3280a001d33ea2deb12ab609404a0b4254cd43c21518d77d166f972",
|
||||||
"tools/bootstrap-recovery/retrieve-identity.py": "sha256:ec123d47a07305253570dacab7e103f8c193a23f20c9fc87466ea4a95ae0ec13",
|
"tools/bootstrap-recovery/retrieve-identity.py": "sha256:ec123d47a07305253570dacab7e103f8c193a23f20c9fc87466ea4a95ae0ec13",
|
||||||
"tools/bootstrap-recovery/run-isolated-keycape.sh": "sha256:fba8f6c2247f0ff951663a8d6372aa2276ca21aaac6f77fbfdb7f730f525fefa",
|
"tools/bootstrap-recovery/run-isolated-keycape.sh": "sha256:0d994c9c14ebdffdbf7879bd7562042531083d0e8955424d0ae099898a141668",
|
||||||
"tools/bootstrap-recovery/verify-bundle.py": "sha256:885b05a2011e711228e42e316bda70bbe0d97d481383d61aa8469d0f10452370",
|
"tools/bootstrap-recovery/verify-bundle.py": "sha256:885b05a2011e711228e42e316bda70bbe0d97d481383d61aa8469d0f10452370",
|
||||||
"tools/bootstrap-recovery/verify-identity-delivery.py": "sha256:63658d1976806f8d0ce80ba8f750bbcfdca59da936b6eb6a78a250f0e53710f1",
|
"tools/bootstrap-recovery/verify-identity-delivery.py": "sha256:63658d1976806f8d0ce80ba8f750bbcfdca59da936b6eb6a78a250f0e53710f1",
|
||||||
"tools/bootstrap-recovery/verify-identity.py": "sha256:039864d0141b552887752bf17cfdcf1bef4bdc9126f946a9c0533e4fd54ebf4f"
|
"tools/bootstrap-recovery/verify-identity.py": "sha256:039864d0141b552887752bf17cfdcf1bef4bdc9126f946a9c0533e4fd54ebf4f",
|
||||||
|
"sso-mfa/bootstrap/sops-custody-unlock.sh": "sha256:86c527a8ae0d0cca66a2345c566b25fbff5bc52c1e22c725548c0fbb2f86a58e",
|
||||||
|
"sso-mfa/bootstrap/sops-custody-fd.py": "sha256:df29eaba83ec6b5fc311664dcf4b22d8f3c8ed7274ed8e6c419ada0dcd169c0f",
|
||||||
|
"tools/bootstrap-recovery/prepare-isolated-keycape.py": "sha256:5098bdfa4e0ad30627c1716e5e355a2c78dca6ccc607bd2148a2e06126632288",
|
||||||
|
"tests/test_prepare_isolated_keycape.py": "sha256:4b0871f9a11a3940983464f1b40eca1613110b1cb3cb9694f4891d58e14c700a",
|
||||||
|
"tests/test_sealed_custody.py": "sha256:f0fa07c89e2419464dbee0489462864a4533770417a554b1ab1d1ed5d7b6c49f",
|
||||||
|
"tests/test_private_manifest_binding.py": "sha256:2e7ee16af65390343435b7210ef191aa2a10e9dcbb8759ed95fa8bf6fb81893b"
|
||||||
},
|
},
|
||||||
"fixed_endpoint": "unix:///var/run/docker.sock",
|
"fixed_endpoint": "unix:///var/run/docker.sock",
|
||||||
"engine_id": "74f93783-6cef-43a8-a3c4-f62586cd3bc1",
|
"engine_id": "74f93783-6cef-43a8-a3c4-f62586cd3bc1",
|
||||||
|
|
@ -75,7 +81,7 @@
|
||||||
"synthetic_source_root": "private /dev/shm/nk49-keycape-fixture-<suffix>",
|
"synthetic_source_root": "private /dev/shm/nk49-keycape-fixture-<suffix>",
|
||||||
"safe_result": "private /tmp/nk49-keycape-native-fixture-review-<suffix>/result.json"
|
"safe_result": "private /tmp/nk49-keycape-native-fixture-review-<suffix>/result.json"
|
||||||
},
|
},
|
||||||
"scope": "candidate itself on complete synthetic format/integrity archive fixture; no native PI/LLDAP/Authelia proof, real custody, real factor or real client credential",
|
"scope": "candidate itself on complete synthetic format/integrity archive fixture; no native PI/LLDAP/Authelia proof, real custody, real factor or real client credential; successor binds sealed-descriptor custody preparation sources; synthetic fixture itself overrides custody with generated fixture key as before",
|
||||||
"mutations": [
|
"mutations": [
|
||||||
"conditional creation of exact fresh local containers and local-driver tmpfs volumes",
|
"conditional creation of exact fresh local containers and local-driver tmpfs volumes",
|
||||||
"private synthetic file import to those volumes",
|
"private synthetic file import to those volumes",
|
||||||
|
|
|
||||||
108
tests/test_prepare_isolated_keycape.py
Normal file
108
tests/test_prepare_isolated_keycape.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import pty
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT=Path(__file__).parents[1]
|
||||||
|
spec=importlib.util.spec_from_file_location('prepare',ROOT/'tools/bootstrap-recovery/prepare-isolated-keycape.py')
|
||||||
|
p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p)
|
||||||
|
|
||||||
|
class PreparationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
|
||||||
|
self.path=Path(self.temp.name)/'ciphertext';self.path.write_bytes(b'synthetic ciphertext');self.path.chmod(0o600)
|
||||||
|
self.transfer={'schema':'netkingdom.identity-offsite.v1','status':'readback_verified','provider':'Storage Share',
|
||||||
|
'folder':'Backup/railiance-backups/net-kingdom-identity','artifact':'net-kingdom-identity-20261016T063159Z-5882771aa2ce.tar.age',
|
||||||
|
'ciphertext_sha256':'a'*64,'ciphertext_bytes':len(b'synthetic ciphertext'),'etag':'"synthetic"','fetched_path':str(self.path),
|
||||||
|
'independent_owner_retrieval_verified':True}
|
||||||
|
self.admission={name:'a'*64 for name in ('ciphertext_sha256','manifest_sha256','verifier_sha256','trust_sha256','tls_cert_sha256')}
|
||||||
|
self.admission.update(issuer='https://issuer.example',consumer=dict(client_id='existing',secret_env='KEYCAPE_EXISTING_SECRET',audience='existing-api',scope='read',subject='svc:existing',tenant='existing',roles='reader',deny_scope='write'),
|
||||||
|
source_sha256={name:'sha256:'+hashlib.sha256((ROOT/name).read_bytes()).hexdigest() for name in p.SOURCE_PATHS})
|
||||||
|
def test_complete_public_bindings_admitted_without_reading_ciphertext(self):
|
||||||
|
original=Path.read_bytes
|
||||||
|
def read(path):
|
||||||
|
self.assertNotEqual(path,self.path)
|
||||||
|
return original(path)
|
||||||
|
with patch.object(Path,'read_bytes',read):self.assertTrue(p.validate(self.transfer,self.admission,ROOT))
|
||||||
|
def test_oversize_metadata_refused_before_any_content_read(self):
|
||||||
|
self.path.write_bytes(b'x'*(128*1024+1))
|
||||||
|
with patch.object(Path,'read_bytes',side_effect=AssertionError('unbounded read')):
|
||||||
|
with self.assertRaises(p.Refused):p.read_metadata(self.path)
|
||||||
|
def test_duplicate_json_keys_refused_at_every_level(self):
|
||||||
|
for raw in (b'{"issuer":"one","issuer":"two"}',b'{"consumer":{"client_id":"one","client_id":"two"}}'):
|
||||||
|
with self.subTest(raw=raw),self.assertRaises(p.Refused):p.strict_json(raw)
|
||||||
|
def test_bounded_metadata_reader_accepts_exact_limit(self):
|
||||||
|
self.path.write_bytes(b'x'*128)
|
||||||
|
self.assertEqual(p.read_metadata(self.path,128),b'x'*128)
|
||||||
|
with self.assertRaises(p.Refused):p.read_metadata(self.path,127)
|
||||||
|
def test_wrong_archive_binding_refused(self):
|
||||||
|
self.admission['ciphertext_sha256']='b'*64
|
||||||
|
with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||||
|
def test_ciphertext_size_mismatch_refused_without_decryption(self):
|
||||||
|
self.transfer['ciphertext_bytes']+=1
|
||||||
|
with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||||
|
def test_missing_independent_transfer_refused(self):
|
||||||
|
self.transfer['independent_owner_retrieval_verified']=False
|
||||||
|
with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||||
|
def test_missing_or_drifted_source_refused(self):
|
||||||
|
for source in p.SOURCE_PATHS:
|
||||||
|
bad=copy.deepcopy(self.admission);bad['source_sha256'][source]='0'*64
|
||||||
|
with self.subTest(source=source),self.assertRaises(p.Refused):p.validate(self.transfer,bad,ROOT)
|
||||||
|
bad=copy.deepcopy(self.admission);bad['source_sha256'].pop(next(iter(p.SOURCE_PATHS)))
|
||||||
|
with self.assertRaises(p.Refused):p.validate(self.transfer,bad,ROOT)
|
||||||
|
def test_unreviewed_extra_dependency_refused(self):
|
||||||
|
self.admission['source_sha256']['unreviewed.py']='a'*64
|
||||||
|
with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||||
|
def test_ambiguous_or_unsupported_issuer_refused(self):
|
||||||
|
for issuer in ('http://issuer.example','https://u:p@issuer.example','https://issuer.example:8443','https://issuer.example/path','https://issuer.example?x=1','https://issuer.example#fragment'):
|
||||||
|
self.admission['issuer']=issuer
|
||||||
|
with self.subTest(issuer=issuer),self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||||
|
def test_missing_consumer_or_overlapping_denial_refused(self):
|
||||||
|
self.admission['consumer']['deny_scope']='read'
|
||||||
|
with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||||
|
self.admission['consumer'].pop('roles')
|
||||||
|
with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||||
|
def test_symlink_private_input_refused_without_read(self):
|
||||||
|
link=self.path.parent/'link';link.symlink_to(self.path)
|
||||||
|
with self.assertRaises(p.Refused):p.metadata_path(link)
|
||||||
|
def test_publicly_readable_private_input_refused(self):
|
||||||
|
self.path.chmod(0o644)
|
||||||
|
with self.assertRaises(p.Refused):p.metadata_path(self.path)
|
||||||
|
def test_bad_admission_digest_never_reads_tls_or_starts_custody(self):
|
||||||
|
admission=self.path.parent/'admission';admission.write_text(json.dumps(self.admission));admission.chmod(0o600)
|
||||||
|
argv=['prepare','--transfer-receipt',str(self.path),'--admission',str(admission),'--admission-sha256','b'*64,
|
||||||
|
'--tls-cert','/never/read-cert','--tls-key','/never/read-key','--trust','/never/read-trust']
|
||||||
|
with patch('sys.argv',argv),patch('builtins.print') as output:
|
||||||
|
self.assertEqual(p.main(),1)
|
||||||
|
self.assertEqual(output.call_args.args,('ERROR: reviewed metadata/source/dependency preflight refused; no key requested.',))
|
||||||
|
def test_actual_launcher_bad_digest_stops_before_hidden_prompt(self):
|
||||||
|
master,slave=pty.openpty()
|
||||||
|
try:
|
||||||
|
args=['bash',str(ROOT/'tools/bootstrap-recovery/run-isolated-keycape.sh'),str(self.path),str(self.path),'b'*64,str(self.path),str(self.path),str(self.path)]
|
||||||
|
process=subprocess.Popen(args,stdin=slave,stdout=slave,stderr=slave)
|
||||||
|
process.wait(timeout=5);os.close(slave);slave=None
|
||||||
|
output=b''
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
block=os.read(master,4096)
|
||||||
|
if not block:break
|
||||||
|
output+=block
|
||||||
|
except OSError:break
|
||||||
|
self.assertEqual(process.returncode,1)
|
||||||
|
self.assertIn(b'preflight refused; no key requested.',output)
|
||||||
|
self.assertNotIn(b'Paste custody',output)
|
||||||
|
self.assertNotIn(b'Enter only',output)
|
||||||
|
finally:
|
||||||
|
if slave is not None:os.close(slave)
|
||||||
|
os.close(master)
|
||||||
|
def test_launcher_prepares_before_sealed_descriptor_custody(self):
|
||||||
|
script=(ROOT/'tools/bootstrap-recovery/run-isolated-keycape.sh').read_text()
|
||||||
|
self.assertLess(script.index('python3 tools/bootstrap-recovery/prepare-isolated-keycape.py'),script.index('bash sso-mfa/bootstrap/sops-custody-unlock.sh'))
|
||||||
|
self.assertIn('--sealed-fd --prompt -- python3',script)
|
||||||
98
tests/test_private_manifest_binding.py
Normal file
98
tests/test_private_manifest_binding.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Generated archive/key only; actual age authentication + archive restore.
|
||||||
|
|
||||||
|
Docker and issuer behavior are intentionally mocked here, not native evidence.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
from datetime import datetime,timedelta,timezone
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from unittest.mock import MagicMock,patch
|
||||||
|
import pytest
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes,serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
from test_identity_bundle import stage,b
|
||||||
|
|
||||||
|
ROOT=Path(__file__).parents[1]
|
||||||
|
def load(name,path):
|
||||||
|
spec=importlib.util.spec_from_file_location(name,path);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||||
|
s=load('session_manifest',ROOT/'tools/bootstrap-recovery/isolated-keycape-session.py')
|
||||||
|
h=load('helper_manifest',ROOT/'sso-mfa/bootstrap/sops-custody-fd.py')
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def envelope(stage,tmp_path,monkeypatch):
|
||||||
|
raw=subprocess.run(['age-keygen'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout
|
||||||
|
key=h.sealed_key(next(line for line in raw.splitlines() if line.startswith(b'AGE-SECRET-KEY-'))+b'\n')
|
||||||
|
recipient=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{key}'],pass_fds=(key,),stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout.decode().strip()
|
||||||
|
profile=b.profile();profile['public_recipient']=recipient;monkeypatch.setattr(b,'profile',lambda:profile);monkeypatch.setattr(s.bundle,'profile',lambda:profile)
|
||||||
|
artifact=tmp_path/'identity.tar.age';sealed=b.seal(stage,artifact,{'test_only':True})
|
||||||
|
signer=rsa.generate_private_key(public_exponent=65537,key_size=2048);name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'synthetic.invalid')]);now=datetime.now(timezone.utc)
|
||||||
|
cert=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(signer.public_key()).serial_number(1).not_valid_before(now-timedelta(minutes=1)).not_valid_after(now+timedelta(hours=1)).sign(signer,hashes.SHA256())).public_bytes(serialization.Encoding.PEM)
|
||||||
|
tls={'/recovery-tls/server.pem':cert,'/recovery-tls/trust.pem':cert,'/recovery-tls/server-key.pem':signer.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.TraditionalOpenSSL,serialization.NoEncryption())}
|
||||||
|
transfer=dict(schema='netkingdom.identity-offsite.v1',status='readback_verified',provider='Storage Share',folder='Backup/railiance-backups/net-kingdom-identity',artifact='net-kingdom-identity-20261016T063159Z-5882771aa2ce.tar.age',etag='"synthetic"',ciphertext_sha256=sealed['ciphertext_sha256'],ciphertext_bytes=artifact.stat().st_size,fetched_path=str(artifact),independent_owner_retrieval_verified=True)
|
||||||
|
admission=dict(ciphertext_sha256=sealed['ciphertext_sha256'],manifest_binding_mode='ciphertext-derived-v1',issuer='https://synthetic.invalid',tls_cert_sha256=hashlib.sha256(cert).hexdigest(),trust_sha256=hashlib.sha256(cert).hexdigest(),verifier_sha256=hashlib.sha256(b'synthetic executable').hexdigest())
|
||||||
|
try:yield artifact,key,transfer,admission,tls
|
||||||
|
finally:os.close(key)
|
||||||
|
|
||||||
|
def execute(envelope,*,wrong_key=False):
|
||||||
|
artifact,key,transfer,admission,tls=envelope;receipt={};seen={};checkpoints=[]
|
||||||
|
if wrong_key:
|
||||||
|
raw=subprocess.run(['age-keygen'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout
|
||||||
|
key=h.sealed_key(next(line for line in raw.splitlines() if line.startswith(b'AGE-SECRET-KEY-'))+b'\n')
|
||||||
|
actualrun=s.run
|
||||||
|
def run(argv,**kwargs):return actualrun(argv,**kwargs) if argv[0]=='age-keygen' else b''
|
||||||
|
def native(root,manifest,checked,receipt,**kwargs):seen.update(checked);receipt['status']='synthetic_flow_checked'
|
||||||
|
execution={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'uid':os.getuid(),'deadline':time.monotonic()+60}
|
||||||
|
try:
|
||||||
|
with patch.object(s,'verify_execution'),patch.object(s.local,'preflight',return_value={}),patch.object(s.local.verify.custody,'private_key_fd',side_effect=lambda:os.dup(key)),patch.object(s,'start_reaper',return_value=MagicMock()),patch.object(s,'run',side_effect=run),patch.object(s.adapter,'container_file',return_value=b'synthetic executable'),patch.object(s.adapter,'private_payload',return_value={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'issuer: https://synthetic.invalid\nport: 8080').decode()},'env':{}}),patch.object(s,'provision'),patch.object(s.adapter,'check_jwks'),patch.object(s.adapter,'native_checks',side_effect=native),patch.object(s,'cleanup_all'):
|
||||||
|
# Key file must be valid at the later public signer construction.
|
||||||
|
payload_key=rsa.generate_private_key(public_exponent=65537,key_size=2048).private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.TraditionalOpenSSL,serialization.NoEncryption())
|
||||||
|
with patch.object(s.adapter,'private_payload',return_value={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'issuer: https://synthetic.invalid\nport: 8080').decode(),'/etc/keycape/key.pem':base64.b64encode(payload_key).decode()},'env':{}}):
|
||||||
|
s.execute(transfer,admission,tls,receipt,execution,checkpoint=lambda r:checkpoints.append(json.loads(json.dumps(r))))
|
||||||
|
return receipt,seen,checkpoints
|
||||||
|
finally:
|
||||||
|
if wrong_key:os.close(key)
|
||||||
|
|
||||||
|
def test_exact_ciphertext_authenticated_restore_derives_private_manifest_without_safe_leak(envelope):
|
||||||
|
receipt,seen,checkpoints=execute(envelope)
|
||||||
|
derived=seen['manifest_sha256'];assert len(derived)==64
|
||||||
|
safe=json.dumps([receipt,checkpoints]);assert derived not in safe;assert 'manifest_sha256' not in safe
|
||||||
|
assert receipt['all_manifest_hashes_verified'] is True
|
||||||
|
assert receipt['temporary_plaintext_cleanup_verified'] is True
|
||||||
|
|
||||||
|
def test_changed_ciphertext_refuses_before_archive_decryption(envelope):
|
||||||
|
artifact,*_=envelope;artifact.write_bytes(artifact.read_bytes()[:-1]+b'!')
|
||||||
|
with patch.object(s.adapter,'process') as process,pytest.raises(s.adapter.Refused):execute(envelope)
|
||||||
|
assert not process.called
|
||||||
|
|
||||||
|
def test_modified_ciphertext_rebound_metadata_still_fails_authenticated_age(envelope):
|
||||||
|
artifact,key,transfer,admission,tls=envelope
|
||||||
|
raw=artifact.read_bytes();artifact.write_bytes(raw[:-32]+b'!'*32)
|
||||||
|
digest=hashlib.sha256(artifact.read_bytes()).hexdigest()
|
||||||
|
transfer['ciphertext_sha256']=digest;admission['ciphertext_sha256']=digest
|
||||||
|
with pytest.raises(s.adapter.Refused):execute(envelope)
|
||||||
|
|
||||||
|
def test_wrong_key_refuses_before_decryption_and_never_exposes_manifest(envelope):
|
||||||
|
with pytest.raises(s.adapter.Refused):execute(envelope,wrong_key=True)
|
||||||
|
|
||||||
|
def test_wrong_explicit_manifest_refuses_after_full_restore(envelope):
|
||||||
|
admission=envelope[3];admission['manifest_binding_mode']='explicit-digest-v1';admission['manifest_sha256']='0'*64
|
||||||
|
with pytest.raises(s.adapter.Refused):execute(envelope)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('mode',['derived','ciphertext-derived-v2',None,True,{}])
|
||||||
|
def test_unknown_binding_mode_refuses_before_effects(envelope,mode):
|
||||||
|
envelope[3]['manifest_binding_mode']=mode
|
||||||
|
with patch.object(s.local,'preflight') as preflight,pytest.raises(s.adapter.Refused):execute(envelope)
|
||||||
|
assert not preflight.called
|
||||||
|
|
||||||
|
def test_conflicting_binding_and_unverified_transfer_refuse(envelope):
|
||||||
|
admission=envelope[3];transfer=envelope[2];admission['manifest_sha256']='0'*64
|
||||||
|
with pytest.raises(s.adapter.Refused):s.adapter.manifest_binding_mode(admission,transfer)
|
||||||
|
admission.pop('manifest_sha256');transfer['independent_owner_retrieval_verified']=False
|
||||||
|
with pytest.raises(s.adapter.Refused):s.adapter.manifest_binding_mode(admission,transfer)
|
||||||
143
tests/test_sealed_custody.py
Normal file
143
tests/test_sealed_custody.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import fcntl
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import pty
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT=Path(__file__).parents[1]
|
||||||
|
def load(name,path):
|
||||||
|
spec=importlib.util.spec_from_file_location(name,path);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||||
|
h=load('fdhelper',ROOT/'sso-mfa/bootstrap/sops-custody-fd.py')
|
||||||
|
c=load('custody',ROOT/'sso-mfa/bootstrap/sops-private.py')
|
||||||
|
|
||||||
|
class SealedCustodyTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
# Capture generated test key in memory only, never into test output/path.
|
||||||
|
r=subprocess.run(['age-keygen'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True)
|
||||||
|
cls.material=next(x for x in r.stdout.splitlines() if x.startswith(b'AGE-SECRET-KEY-'))+b'\n'
|
||||||
|
fd=h.sealed_key(cls.material)
|
||||||
|
try:cls.recipient=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{fd}'],pass_fds=(fd,),stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout.decode().strip()
|
||||||
|
finally:os.close(fd)
|
||||||
|
def test_sealed_descriptor_private_duplicate_is_cloexec_and_decrypts(self):
|
||||||
|
encrypted=subprocess.run(['age','-r',self.recipient],input=b'synthetic recovery material',stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout
|
||||||
|
original=h.sealed_key(self.material)
|
||||||
|
try:
|
||||||
|
with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FD':str(original)},clear=True):fd=c.private_key_fd()
|
||||||
|
try:
|
||||||
|
self.assertFalse(os.get_inheritable(fd));self.assertEqual(os.fstat(fd).st_nlink,0)
|
||||||
|
r=subprocess.run(['age','-d','-i',f'/proc/self/fd/{fd}'],pass_fds=(fd,),input=encrypted,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL)
|
||||||
|
self.assertEqual(r.returncode,0);self.assertEqual(r.stdout,b'synthetic recovery material')
|
||||||
|
finally:os.close(fd)
|
||||||
|
finally:os.close(original)
|
||||||
|
def test_incomplete_seals_or_named_file_refused(self):
|
||||||
|
for named in (False,True):
|
||||||
|
if named:
|
||||||
|
file=tempfile.TemporaryFile();fd=file.fileno();file.write(self.material);file.flush()
|
||||||
|
else:fd=os.memfd_create('synthetic-unsealed',os.MFD_CLOEXEC|os.MFD_ALLOW_SEALING);os.write(fd,self.material)
|
||||||
|
try:
|
||||||
|
with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FD':str(fd)},clear=True),self.assertRaises((c.Refused,OSError)):
|
||||||
|
c.private_key_fd()
|
||||||
|
finally:
|
||||||
|
if named:file.close()
|
||||||
|
else:os.close(fd)
|
||||||
|
def test_ambiguous_sources_refused(self):
|
||||||
|
with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FD':'3','NETKINGDOM_CUSTODY_AGE_KEY_FILE':'/not/opened'},clear=True),self.assertRaises(c.Refused):c.private_key_fd()
|
||||||
|
def test_malformed_descriptor_text_refused_before_dup(self):
|
||||||
|
for value in ('-1','2','03',' 3','3 ','1000000000','3/4'):
|
||||||
|
with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FD':value},clear=True),patch.object(c.os,'dup') as duplicate:
|
||||||
|
with self.subTest(value=value),self.assertRaises(c.Refused):c.private_key_fd()
|
||||||
|
duplicate.assert_not_called()
|
||||||
|
def test_bad_expected_recipient_refused_before_prompt(self):
|
||||||
|
with patch('sys.argv',['sealed','--expected-recipient','age1bad','--','true']),patch.object(h.os,'isatty',return_value=True),patch.object(h.getpass,'getpass') as prompt,patch('builtins.print'):
|
||||||
|
self.assertEqual(h.main(),1);prompt.assert_not_called()
|
||||||
|
def test_existing_file_mode_still_accepts_private_owner_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
path=Path(temp)/'synthetic-key';path.write_bytes(self.material);path.chmod(0o600)
|
||||||
|
with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FILE':str(path)},clear=True):fd=c.private_key_fd()
|
||||||
|
try:self.assertEqual(os.read(fd,16384),self.material)
|
||||||
|
finally:os.close(fd)
|
||||||
|
def test_helper_passes_exact_fd_and_clears_ambient_sources(self):
|
||||||
|
code="""import os,sys
|
||||||
|
fd=int(os.environ['NETKINGDOM_CUSTODY_AGE_KEY_FD'])
|
||||||
|
assert os.fstat(fd).st_nlink==0
|
||||||
|
assert all(k not in os.environ for k in ('NETKINGDOM_CUSTODY_AGE_KEY_FILE','SOPS_AGE_KEY_FILE','SOPS_AGE_KEY','SOPS_AGE_KEY_CMD'))
|
||||||
|
"""
|
||||||
|
with patch.dict(os.environ,{'SOPS_AGE_KEY':'synthetic-competing','NETKINGDOM_CUSTODY_AGE_KEY_FILE':'/not/read'}):
|
||||||
|
self.assertEqual(h.run(self.material,self.recipient,[sys.executable,'-c',code]),0)
|
||||||
|
def test_normal_and_error_return_close_parent_fd(self):
|
||||||
|
actual=h.sealed_key
|
||||||
|
for status in (0,7):
|
||||||
|
fds=[]
|
||||||
|
def capture(material):
|
||||||
|
fd=actual(material);fds.append(fd);return fd
|
||||||
|
with patch.object(h,'sealed_key',side_effect=capture):self.assertEqual(h.run(self.material,self.recipient,[sys.executable,'-c',f'raise SystemExit({status})']),status)
|
||||||
|
with self.assertRaises(OSError):os.fstat(fds[0])
|
||||||
|
def test_wrong_recipient_does_not_start_child_and_closes_fd(self):
|
||||||
|
other=subprocess.run(['age-keygen'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout
|
||||||
|
other=next(x for x in other.splitlines() if x.startswith(b'AGE-SECRET-KEY-'))+b'\n';fd=h.sealed_key(other)
|
||||||
|
try:recipient=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{fd}'],pass_fds=(fd,),stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout.decode().strip()
|
||||||
|
finally:os.close(fd)
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
marker=Path(temp)/'child'
|
||||||
|
with self.assertRaises(h.Refused):h.run(self.material,recipient,[sys.executable,'-c','import pathlib,sys;pathlib.Path(sys.argv[1]).touch()',str(marker)])
|
||||||
|
self.assertFalse(marker.exists())
|
||||||
|
def test_actual_attended_shell_sealed_mode_hides_key_and_runs_original_fd_consumer(self):
|
||||||
|
code="import importlib.util,os,sys;spec=importlib.util.spec_from_file_location('c',sys.argv[1]);c=importlib.util.module_from_spec(spec);spec.loader.exec_module(c);fd=c.private_key_fd();assert os.fstat(fd).st_nlink==0;os.close(fd)"
|
||||||
|
master,slave=pty.openpty()
|
||||||
|
child=subprocess.Popen(['bash','-x',str(ROOT/'sso-mfa/bootstrap/sops-custody-unlock.sh'),'--expected-recipient',self.recipient,'--sealed-fd','--prompt','--',sys.executable,'-c',code,str(ROOT/'sso-mfa/bootstrap/sops-private.py')],stdin=slave,stdout=slave,stderr=slave)
|
||||||
|
os.close(slave);captured=b'';sent=False;end=time.monotonic()+5
|
||||||
|
try:
|
||||||
|
while time.monotonic()<end:
|
||||||
|
if select.select([master],[],[],.05)[0]:
|
||||||
|
try:chunk=os.read(master,65536)
|
||||||
|
except OSError:break
|
||||||
|
if not chunk:break
|
||||||
|
captured+=chunk
|
||||||
|
if b'input hidden): ' in captured and not sent:os.write(master,self.material);sent=True
|
||||||
|
self.assertEqual(child.wait(timeout=2),0)
|
||||||
|
self.assertTrue(sent);self.assertNotIn(self.material.strip(),captured)
|
||||||
|
finally:
|
||||||
|
if child.poll() is None:child.kill();child.wait(timeout=2)
|
||||||
|
os.close(master)
|
||||||
|
def test_actual_hard_parent_loss_leaves_no_named_key_and_closes_on_child_exit(self):
|
||||||
|
# Public child metadata only crosses the test pipe. The generated key is
|
||||||
|
# supplied over stdin and never becomes an argv/environment/path value.
|
||||||
|
child_code="import json,os,time;fd=int(os.environ['NETKINGDOM_CUSTODY_AGE_KEY_FD']);print(json.dumps({'pid':os.getpid(),'fd':fd,'nlink':os.fstat(fd).st_nlink}),flush=True);time.sleep(30)"
|
||||||
|
parent_code="""import importlib.util,sys
|
||||||
|
spec=importlib.util.spec_from_file_location('h',sys.argv[1]);h=importlib.util.module_from_spec(spec);spec.loader.exec_module(h)
|
||||||
|
h.run(sys.stdin.buffer.read(),sys.argv[2],[sys.executable,'-c',sys.argv[3]])
|
||||||
|
"""
|
||||||
|
parent=subprocess.Popen([sys.executable,'-c',parent_code,str(ROOT/'sso-mfa/bootstrap/sops-custody-fd.py'),self.recipient,child_code],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,start_new_session=True)
|
||||||
|
info=None;child_pidfd=None
|
||||||
|
try:
|
||||||
|
parent.stdin.write(self.material);parent.stdin.close()
|
||||||
|
info=json.loads(parent.stdout.readline());self.assertEqual(info['nlink'],0)
|
||||||
|
fdpath=Path('/proc')/str(info['pid'])/'fd'/str(info['fd']);self.assertTrue(fdpath.exists())
|
||||||
|
child_pidfd=os.pidfd_open(info['pid'])
|
||||||
|
parent.kill();parent.wait(timeout=5)
|
||||||
|
# Admitted child retains its inherited FD until it exits; do not
|
||||||
|
# confuse parent loss with revocation of that active child.
|
||||||
|
self.assertTrue(fdpath.exists())
|
||||||
|
signal.pidfd_send_signal(child_pidfd,signal.SIGKILL)
|
||||||
|
# pidfd exit readiness proves the exact child's descriptor table was
|
||||||
|
# released. /proc may become inaccessible during process teardown;
|
||||||
|
# do not interpret permission errors or a reused numeric PID as proof.
|
||||||
|
poller=select.poll();poller.register(child_pidfd,select.POLLIN)
|
||||||
|
self.assertTrue(poller.poll(5000))
|
||||||
|
finally:
|
||||||
|
if parent.poll() is None:parent.kill();parent.wait(timeout=5)
|
||||||
|
if child_pidfd is not None:
|
||||||
|
try:signal.pidfd_send_signal(child_pidfd,signal.SIGKILL)
|
||||||
|
except ProcessLookupError:pass
|
||||||
|
finally:os.close(child_pidfd)
|
||||||
|
parent.stdout.close()
|
||||||
|
|
@ -382,6 +382,7 @@ m.reaper_main(json.loads(sys.argv[2]),json.loads(sys.argv[3]),json.loads(sys.arg
|
||||||
|
|
||||||
def execute(transfer,admission,tls,receipt,execution,checkpoint=None):
|
def execute(transfer,admission,tls,receipt,execution,checkpoint=None):
|
||||||
"""Existing private custody/transfer/archive APIs; no second archive format."""
|
"""Existing private custody/transfer/archive APIs; no second archive format."""
|
||||||
|
adapter.manifest_binding_mode(admission,transfer)
|
||||||
verify_execution(execution)
|
verify_execution(execution)
|
||||||
if execution['pid']!=os.getpid():raise adapter.Refused()
|
if execution['pid']!=os.getpid():raise adapter.Refused()
|
||||||
target=local.preflight()
|
target=local.preflight()
|
||||||
|
|
@ -422,6 +423,8 @@ def execute(transfer,admission,tls,receipt,execution,checkpoint=None):
|
||||||
if p.returncode or archive.stat().st_size>bundle.LIMIT+4*1024*1024:raise adapter.Refused()
|
if p.returncode or archive.stat().st_size>bundle.LIMIT+4*1024*1024:raise adapter.Refused()
|
||||||
root=work/'restored';root.mkdir(mode=0o700)
|
root=work/'restored';root.mkdir(mode=0o700)
|
||||||
manifest,_=bundle.restore(archive,root)
|
manifest,_=bundle.restore(archive,root)
|
||||||
|
# Data binding comes from exact reviewed ciphertext, never a new grant.
|
||||||
|
admission=adapter.private_manifest_admission(admission,manifest)
|
||||||
payload=adapter.private_payload(root,manifest,admission['manifest_sha256'])
|
payload=adapter.private_payload(root,manifest,admission['manifest_sha256'])
|
||||||
import yaml
|
import yaml
|
||||||
cfg=yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml']))
|
cfg=yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml']))
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,32 @@ def load_bundle():
|
||||||
return module
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_binding_mode(admission,transfer):
|
||||||
|
"""Versioned private-manifest binding; legacy explicit digests stay valid."""
|
||||||
|
mode=admission.get('manifest_binding_mode','explicit-digest-v1')
|
||||||
|
if mode not in ('explicit-digest-v1','ciphertext-derived-v1'):raise Refused()
|
||||||
|
if (not isinstance(admission.get('ciphertext_sha256'),str)
|
||||||
|
or not re.fullmatch('[a-f0-9]{64}',admission['ciphertext_sha256'])
|
||||||
|
or admission['ciphertext_sha256']!=transfer.get('ciphertext_sha256')
|
||||||
|
or transfer.get('independent_owner_retrieval_verified') is not True):raise Refused()
|
||||||
|
if mode=='explicit-digest-v1':
|
||||||
|
if not isinstance(admission.get('manifest_sha256'),str) or not re.fullmatch('[a-f0-9]{64}',admission['manifest_sha256']):raise Refused()
|
||||||
|
elif 'manifest_sha256' in admission:raise Refused()
|
||||||
|
return mode
|
||||||
|
|
||||||
|
|
||||||
|
def private_manifest_admission(admission,manifest):
|
||||||
|
# Only called after the fixed ciphertext bytes were hashed, authenticated
|
||||||
|
# decryption succeeded and bundle.restore checked the complete archive.
|
||||||
|
# Never return this enriched private object in a safe receipt/checkpoint.
|
||||||
|
digest=hashlib.sha256(json.dumps(manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
||||||
|
mode=admission.get('manifest_binding_mode','explicit-digest-v1')
|
||||||
|
if mode=='explicit-digest-v1':
|
||||||
|
if digest!=admission['manifest_sha256']:raise Refused()
|
||||||
|
elif mode!='ciphertext-derived-v1' or 'manifest_sha256' in admission:raise Refused()
|
||||||
|
return {**admission,'manifest_sha256':digest}
|
||||||
|
|
||||||
|
|
||||||
def private_payload(root, manifest, expected_manifest_sha256):
|
def private_payload(root, manifest, expected_manifest_sha256):
|
||||||
"""Bind the complete validated archive before selecting KeyCape material.
|
"""Bind the complete validated archive before selecting KeyCape material.
|
||||||
|
|
||||||
|
|
|
||||||
119
tools/bootstrap-recovery/prepare-isolated-keycape.py
Normal file
119
tools/bootstrap-recovery/prepare-isolated-keycape.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate reviewed public recovery metadata before requesting custody input.
|
||||||
|
|
||||||
|
Never opens TLS material, archive plaintext or any key. This validates bindings,
|
||||||
|
not owner assent, archive contents or recovery authority.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[2]
|
||||||
|
# The executable closure extends the synthetic fixture closure with the attended
|
||||||
|
# custody entry point and this validator. Tests and historical receipts do not run.
|
||||||
|
SOURCE_PATHS=tuple(sorted([
|
||||||
|
'sso-mfa/bootstrap/sops-custody-unlock.sh','sso-mfa/bootstrap/sops-private.py','sso-mfa/bootstrap/sops-custody-fd.py',
|
||||||
|
'sso-mfa/k8s/privacyidea/key-custody-remote.py',
|
||||||
|
*('tools/bootstrap-recovery/'+name for name in (
|
||||||
|
'isolated-keycape.py','isolated-keycape-session.py','run-isolated-keycape.sh',
|
||||||
|
'prepare-isolated-keycape.py','isolated-lldap.py','identity-bundle.py',
|
||||||
|
'identity-backup-profile.json','retrieve-identity-offline.py','identity-offsite.py',
|
||||||
|
'verify-identity.py','identity-custody.json','identity-files.py',
|
||||||
|
'check-privacyidea-database.py'))]))
|
||||||
|
|
||||||
|
class Refused(Exception):pass
|
||||||
|
|
||||||
|
def require(value):
|
||||||
|
if not value:raise Refused()
|
||||||
|
|
||||||
|
def metadata_path(path):
|
||||||
|
path=Path(os.path.abspath(path))
|
||||||
|
require(not any(p.is_symlink() for p in (path,*path.parents)))
|
||||||
|
st=path.stat()
|
||||||
|
require(stat.S_ISREG(st.st_mode) and st.st_uid==os.getuid() and st.st_nlink==1 and not st.st_mode&0o077)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def read_metadata(path,limit=128*1024):
|
||||||
|
path=metadata_path(path)
|
||||||
|
require(0<path.stat().st_size<=limit)
|
||||||
|
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
||||||
|
with os.fdopen(fd,'rb') as source:
|
||||||
|
info=os.fstat(source.fileno())
|
||||||
|
require(stat.S_ISREG(info.st_mode) and info.st_uid==os.getuid() and info.st_nlink==1 and not info.st_mode&0o077 and 0<info.st_size<=limit)
|
||||||
|
raw=source.read(limit+1)
|
||||||
|
require(0<len(raw)<=limit)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def strict_json(raw):
|
||||||
|
def unique(pairs):
|
||||||
|
result={}
|
||||||
|
for key,value in pairs:
|
||||||
|
require(key not in result)
|
||||||
|
result[key]=value
|
||||||
|
return result
|
||||||
|
return json.loads(raw,object_pairs_hook=unique)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(transfer,admission,root=ROOT):
|
||||||
|
require(isinstance(admission,dict))
|
||||||
|
for field in ('ciphertext_sha256','verifier_sha256','trust_sha256','tls_cert_sha256'):
|
||||||
|
require(isinstance(admission.get(field),str) and re.fullmatch('[a-f0-9]{64}',admission[field]))
|
||||||
|
require(admission['ciphertext_sha256']==transfer.get('ciphertext_sha256'))
|
||||||
|
require(transfer.get('independent_owner_retrieval_verified') is True)
|
||||||
|
u=urlsplit(admission.get('issuer',''))
|
||||||
|
require(u.scheme=='https' and u.hostname and not u.username and not u.password and not u.query and not u.fragment and u.port in (None,443) and u.path in ('','/'))
|
||||||
|
consumer=admission.get('consumer',{})
|
||||||
|
fields=('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')
|
||||||
|
require(isinstance(consumer,dict) and set(consumer)==set(fields))
|
||||||
|
for field in fields:
|
||||||
|
value=consumer[field]
|
||||||
|
require(isinstance(value,str) and 0<len(value)<=1024 and value.strip()==value and not any(ord(c)<32 or ord(c)==127 for c in value))
|
||||||
|
require(re.fullmatch('KEYCAPE_[A-Z0-9_]+',consumer['secret_env']))
|
||||||
|
require(not set(consumer['scope'].split())&set(consumer['deny_scope'].split()))
|
||||||
|
sources=admission.get('source_sha256')
|
||||||
|
require(isinstance(sources,dict) and set(sources)==set(SOURCE_PATHS))
|
||||||
|
for name,expected in sources.items():
|
||||||
|
require(isinstance(expected,str) and re.fullmatch('sha256:[a-f0-9]{64}',expected))
|
||||||
|
path=root/name
|
||||||
|
require(not any(p.is_symlink() for p in (path,*path.parents)))
|
||||||
|
require('sha256:'+hashlib.sha256(path.read_bytes()).hexdigest()==expected)
|
||||||
|
spec=importlib.util.spec_from_file_location('adapter',root/'tools/bootstrap-recovery/isolated-keycape.py')
|
||||||
|
adapter=importlib.util.module_from_spec(spec);spec.loader.exec_module(adapter)
|
||||||
|
adapter.manifest_binding_mode(admission,transfer)
|
||||||
|
# Validate provider receipt through the existing fixed contract, with no I/O.
|
||||||
|
spec=importlib.util.spec_from_file_location('offline',root/'tools/bootstrap-recovery/retrieve-identity-offline.py')
|
||||||
|
offline=importlib.util.module_from_spec(spec);spec.loader.exec_module(offline)
|
||||||
|
offline.validate(transfer)
|
||||||
|
ciphertext=metadata_path(transfer['fetched_path']) # no content read
|
||||||
|
require(ciphertext.stat().st_size==transfer['ciphertext_bytes'])
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p=argparse.ArgumentParser(description=__doc__)
|
||||||
|
for name in ('transfer-receipt','admission','admission-sha256','tls-cert','tls-key','trust'):p.add_argument('--'+name,required=True)
|
||||||
|
args=p.parse_args()
|
||||||
|
try:
|
||||||
|
require(re.fullmatch('[a-f0-9]{64}',args.admission_sha256))
|
||||||
|
raw=read_metadata(args.admission)
|
||||||
|
require(hashlib.sha256(raw).hexdigest()==args.admission_sha256)
|
||||||
|
transfer_raw=read_metadata(args.transfer_receipt)
|
||||||
|
validate(strict_json(transfer_raw),strict_json(raw))
|
||||||
|
for path in (args.tls_cert,args.tls_key,args.trust):metadata_path(path)
|
||||||
|
# Import all third-party dependencies before the attended custody prompt.
|
||||||
|
import yaml
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
except Exception:
|
||||||
|
print('ERROR: reviewed metadata/source/dependency preflight refused; no key requested.')
|
||||||
|
return 1
|
||||||
|
print('OK: reviewed metadata and source closure verified; private contents not inspected.')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__=='__main__':raise SystemExit(main())
|
||||||
|
|
@ -14,6 +14,9 @@ done
|
||||||
for tool in python3 age age-keygen docker; do
|
for tool in python3 age age-keygen docker; do
|
||||||
command -v "$tool" >/dev/null || { printf 'ERROR: required tool unavailable: %s\n' "$tool"; exit 1; }
|
command -v "$tool" >/dev/null || { printf 'ERROR: required tool unavailable: %s\n' "$tool"; exit 1; }
|
||||||
done
|
done
|
||||||
|
python3 tools/bootstrap-recovery/prepare-isolated-keycape.py \
|
||||||
|
--transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \
|
||||||
|
--tls-cert "$4" --tls-key "$5" --trust "$6"
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import importlib.util
|
import importlib.util
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -29,12 +32,12 @@ print('OK: fixed local target and cached pinned images available.')
|
||||||
PY
|
PY
|
||||||
NK49_KC_RUN="$(mktemp -d /tmp/nk49-isolated-keycape-XXXXXXXX)"
|
NK49_KC_RUN="$(mktemp -d /tmp/nk49-isolated-keycape-XXXXXXXX)"
|
||||||
printf 'Safe evidence directory: %s\n' "$NK49_KC_RUN"
|
printf 'Safe evidence directory: %s\n' "$NK49_KC_RUN"
|
||||||
printf '%s\n' 'Owner-reviewed candidate: isolated original issuer/trust and current registered service-client checks only.'
|
printf '%s\n' 'Isolated original issuer/trust and selected registered service-client checks only.'
|
||||||
printf '%s\n' 'No browser, downstream consumer or retained-factor MFA acceptance is claimed.'
|
printf '%s\n' 'No browser, downstream consumer or retained-factor MFA acceptance is claimed.'
|
||||||
printf '%s\n' 'Enter only the independently held backup age key at the hidden prompt.'
|
printf '%s\n' 'Enter only the independently held backup age key at the hidden prompt.'
|
||||||
bash sso-mfa/bootstrap/sops-custody-unlock.sh \
|
bash sso-mfa/bootstrap/sops-custody-unlock.sh \
|
||||||
--expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \
|
--expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \
|
||||||
--prompt -- python3 tools/bootstrap-recovery/isolated-keycape-session.py \
|
--sealed-fd --prompt -- python3 tools/bootstrap-recovery/isolated-keycape-session.py \
|
||||||
--transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \
|
--transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \
|
||||||
--tls-cert "$4" --tls-key "$5" --trust "$6" --receipt "$NK49_KC_RUN/recovery.json"
|
--tls-cert "$4" --tls-key "$5" --trust "$6" --receipt "$NK49_KC_RUN/recovery.json"
|
||||||
printf 'Safe receipt: %s/recovery.json\n' "$NK49_KC_RUN"
|
printf 'Safe receipt: %s/recovery.json\n' "$NK49_KC_RUN"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ flavor: implementation
|
||||||
owner: claude-code
|
owner: claude-code
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
created: "2026-10-08"
|
created: "2026-10-08"
|
||||||
updated: "2026-10-16"
|
updated: "2026-10-17"
|
||||||
depends_on: []
|
depends_on: []
|
||||||
related_repos:
|
related_repos:
|
||||||
- ops-mason
|
- ops-mason
|
||||||
|
|
@ -323,7 +323,7 @@ the rationale. The type is `acknowledged`; it becomes `implemented` with T06.
|
||||||
```task
|
```task
|
||||||
id: NK-WP-0048-T06
|
id: NK-WP-0048-T06
|
||||||
status: wait
|
status: wait
|
||||||
depends_on: [NK-WP-0048-T05, NK-WP-0048-T08, NK-WP-0048-T09, NK-WP-0048-T12, NK-WP-0048-T13, NK-WP-0048-T14, NK-WP-0048-T15, NK-WP-0048-T16, NK-WP-0048-T17, NK-WP-0048-T18, MASON-WP-0010-T03, WARDEN-WP-0044-T03]
|
depends_on: [NK-WP-0048-T05, NK-WP-0048-T08, NK-WP-0048-T09, NK-WP-0048-T12, NK-WP-0048-T13, NK-WP-0048-T14, NK-WP-0048-T15, NK-WP-0048-T16, NK-WP-0048-T17, NK-WP-0048-T18, NK-WP-0048-T20, MASON-WP-0010-T03, WARDEN-WP-0044-T03]
|
||||||
blocking_reason: "Type1.2 architecture has a confirmed source-only founder acknowledgment; canonical1.1 remains unchanged/default and the1.2 reference case stays proposed. Needs explicit version selection/reference-source integration without normal admission bypass, closed owner adapters plus global/native receipt verifier (WARDEN-WP-0044-T03), independently verified six-object structural materialization and CCR42 claims/created-only provenance, exact broker/case/source reconciliation, then separately authorized native mint/delivery and attended registry/consumer verification. Source acknowledgment, a bundle or local tests do not prove implemented/materialized/live status."
|
blocking_reason: "Type1.2 architecture has a confirmed source-only founder acknowledgment; canonical1.1 remains unchanged/default and the1.2 reference case stays proposed. Needs explicit version selection/reference-source integration without normal admission bypass, closed owner adapters plus global/native receipt verifier (WARDEN-WP-0044-T03), independently verified six-object structural materialization and CCR42 claims/created-only provenance, exact broker/case/source reconciliation, then separately authorized native mint/delivery and attended registry/consumer verification. Source acknowledgment, a bundle or local tests do not prove implemented/materialized/live status."
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d9b26bcf-a7c6-59a2-92a0-f913d76a203c"
|
state_hub_task_id: "d9b26bcf-a7c6-59a2-92a0-f913d76a203c"
|
||||||
|
|
@ -686,24 +686,48 @@ hook; exact pre-existing staged/working brief bytes were restored. The separate
|
||||||
canonical integration receipt retains those refusals and projection evidence.
|
canonical integration receipt retains those refusals and projection evidence.
|
||||||
Native admission and all other-owner acceptance remain open.
|
Native admission and all other-owner acceptance remain open.
|
||||||
|
|
||||||
## T14 — Preserve created-only provenance for group ensure and revoke
|
## T14 — Admit governed group rollback against native owner boundaries
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: NK-WP-0048-T14
|
id: NK-WP-0048-T14
|
||||||
status: todo
|
status: todo
|
||||||
|
depends_on: [NK-WP-0048-T20]
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "fce95440-a25b-5f27-8ef3-a3d182099113"
|
state_hub_task_id: "fce95440-a25b-5f27-8ef3-a3d182099113"
|
||||||
```
|
```
|
||||||
|
|
||||||
Owner: NetKingdom. Next action: extend or wrap the existing identity-provisioner
|
Owner: NetKingdom. Next action: specify a CNPG-backed journal through the
|
||||||
ensure/revoke contract with independently retained prestate and exact newly
|
existing `rapp-postgres` database-per-consumer contract, or obtain an explicit
|
||||||
created groups/memberships before exposing reversible owner callbacks. Current
|
scoped governed exception to accepted ADR-0015 before any production SQLite
|
||||||
ensure returns no membership-creation inventory; current revoke may remove a
|
proposal. The implemented SQLite backend supplies local/disposable proof only;
|
||||||
membership that predated this case. Completion requires actual helper-backed
|
a new PVC is not an admitted production path. Prepare the native writer-isolation
|
||||||
positive/refusal fixtures, all validation before mutation, per-attempt evidence
|
contract for the existing identity-provisioner, and bind the
|
||||||
and revocation, readback, ambiguity handling, and rollback that preserves every
|
existing protected owner callbacks to the accepted case/source/principal,
|
||||||
pre-existing/shared group and membership. No existing endpoint or service token
|
per-attempt evidence and session revocation. Identify the actual deployment and
|
||||||
is treated as new authorization.
|
custody owner through the existing ADR-0015 routes before proposing a rollout;
|
||||||
|
an existing service token, caller-supplied JSON or provenance receipt establishes
|
||||||
|
no new authorization.
|
||||||
|
|
||||||
|
2026-10-17 refinement: T20 extracts the completed durable provenance source
|
||||||
|
candidate and disposable LLDAP compatibility proof from this task. The source
|
||||||
|
returns exact creation inventories, retains prestate/intents across process
|
||||||
|
restart and refuses detected shared-member/group-identity drift. Its publication
|
||||||
|
and projection are still pending the coordinator's separate integration receipt.
|
||||||
|
This narrows T14 without closing its original externally referenced native gate.
|
||||||
|
The task remains `todo` because preparation of the native contracts can proceed;
|
||||||
|
no missing owner assent is represented as received.
|
||||||
|
|
||||||
|
Completion still requires independently reviewed/admitted ADR-0015-conforming
|
||||||
|
CNPG storage (or an explicit governed exception), backup/lost-store recovery and
|
||||||
|
migration of legacy requests; a native
|
||||||
|
writer-isolation/fencing or provider CAS/version contract that closes external
|
||||||
|
writer races and remove/re-add ABA; exact case/source/principal and current
|
||||||
|
native object bindings for protected callbacks; and actual per-attempt evidence,
|
||||||
|
revocation and readback/refusal verification through the existing owner paths.
|
||||||
|
Only independently retained confirmed creations may be undone. Every
|
||||||
|
pre-existing/shared group and membership must remain preserved; unresolved
|
||||||
|
intents never expire into retry authority. The disposable fixture supplies none
|
||||||
|
of these native custody, authorization, owner-acceptance or production claims.
|
||||||
|
|
||||||
## T15 — Define authenticated recovery for lost registration process state
|
## T15 — Define authenticated recovery for lost registration process state
|
||||||
|
|
||||||
|
|
@ -880,3 +904,74 @@ projection. T19's assigned UUID is retained. The original staged and working bri
|
||||||
bytes were restored and independently compared. The exact source, projection and
|
bytes were restored and independently compared. The exact source, projection and
|
||||||
publication checks are in Custodian browser-resume-r1/canonical-integration.json.
|
publication checks are in Custodian browser-resume-r1/canonical-integration.json.
|
||||||
Native acceptance remains open under T16 and the other named owner tasks.
|
Native acceptance remains open under T16 and the other named owner tasks.
|
||||||
|
|
||||||
|
|
||||||
|
## T20 — Prepare durable group provenance and prove disposable LLDAP compatibility
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: NK-WP-0048-T20
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "863fcbdb-e246-51ba-87ca-a2f44d01d6f8"
|
||||||
|
```
|
||||||
|
|
||||||
|
Completed 2026-10-17, source candidate preparation and bounded synthetic
|
||||||
|
compatibility proof only. This extracts evidenced implementation/fixture scope
|
||||||
|
from T14; T14 retains native storage, writer isolation, protected callback and
|
||||||
|
owner-acceptance gates. Source integration/publication and State Hub projection
|
||||||
|
are pending the coordinator's separate receipts; no revision or task UUID is
|
||||||
|
invented here.
|
||||||
|
|
||||||
|
The existing identity-provisioner group ensure/revoke interface now has a
|
||||||
|
reviewed candidate for a bounded owner-private SQLite journal, proven only in
|
||||||
|
local/disposable use. The initial storage instructions wrongly suggested a native
|
||||||
|
persistent-volume path; source review against accepted ADR-0015 corrected them
|
||||||
|
before publication. Production SQLite-on-a-PVC is not admitted: T14 retains
|
||||||
|
CNPG/rapp-postgres storage admission or a separately governed exception.
|
||||||
|
The local journal saves
|
||||||
|
complete prestate and intent before each attempted directory mutation, inventories
|
||||||
|
only confirmed new groups/memberships, binds original ensure idempotency and
|
||||||
|
correlation to the exact request digest, and refuses unsigned caller inventories
|
||||||
|
or legacy broad revoke requests. Replays never recreate removed state. Missing,
|
||||||
|
replaced or untrusted storage and missing intent rows stop execution; definite
|
||||||
|
before-mutation refusals retain evidence without stranding unrelated valid work.
|
||||||
|
Interrupted writes/readback remain unresolved across reopening. Revoke validates
|
||||||
|
all targeted native ids and complete member postimages before destructive work,
|
||||||
|
removes only confirmed creations and never deletes a preexisting empty group.
|
||||||
|
Server/configuration wiring, image-copy closure and owner storage/request
|
||||||
|
migration instructions are included. The existing endpoint's caller
|
||||||
|
authentication remains the existing boundary, not a new grant.
|
||||||
|
|
||||||
|
The candidate was prepared against NetKingdom
|
||||||
|
`878abc93588e12b6da1bac055cfbb3abb8d7adf1`; exact source patch digest
|
||||||
|
`sha256:1f3f3ca94c46dadac8f24896053d4d23179de795e50a24298a0cfbcc46cca30e`.
|
||||||
|
The independent Custodian review passed all 65 provisioner checks. The earlier
|
||||||
|
62-check candidate, two independent-review findings and subsequent fixes remain
|
||||||
|
retained rather than overwritten. Seven additional fixture-plan checks exercise
|
||||||
|
the preparation boundary; these counts overlap the same candidate and do not
|
||||||
|
measure task-completion percentage or independent acceptance.
|
||||||
|
|
||||||
|
A separately reviewed bounded fixture then exercised the exact candidate against
|
||||||
|
cached pinned disposable LLDAP and Python images, through actual GraphQL. All 14
|
||||||
|
closed compatibility/containment oracles passed: exact creation inventory,
|
||||||
|
separate-process SQLite reopening/replay/revoke, complete preexisting directory
|
||||||
|
preservation including empty/shared groups and memberships, refusal of shared
|
||||||
|
membership drift, retained uncertainty after a lost response following a real
|
||||||
|
addition, and refusal of unknown/cross-case receipt bindings. The fixture used
|
||||||
|
only newly generated synthetic users/passwords/key material in private tmpfs,
|
||||||
|
with a network-none anchor and shared loopback helper, no host ports/mounts,
|
||||||
|
no image pulls and no production or actual credential contact. The coordinator's
|
||||||
|
safe receipt reports both exact owned containers absent after cleanup; final
|
||||||
|
independent cleanup verification remains the coordinator's integration duty.
|
||||||
|
|
||||||
|
Evidence: Custodian
|
||||||
|
`docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/`, including exact
|
||||||
|
source base/pre/postimages, patch/manifest, independent review and retained
|
||||||
|
refinements. Native fixture manifest
|
||||||
|
`sha256:8a0514f5f14137355972400cd4852f48c892316d28e3abd3c86635d7cb81eef2`;
|
||||||
|
retained safe result `native-fixture/execution-result.json` (original
|
||||||
|
`/tmp/nk48-groups-native-review-5pywb19z/result.json`). The source task supplies
|
||||||
|
no native owner assent, production rollout, protected session/evidence callbacks,
|
||||||
|
ordinary type admission or consumer login proof. LLDAP has no conditional
|
||||||
|
membership mutation or generation token here: exact readback cannot exclude
|
||||||
|
ABA or a concurrent external writer, so T14 remains open.
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
created: "2026-10-12"
|
created: "2026-10-12"
|
||||||
updated: "2026-10-16"
|
updated: "2026-10-17"
|
||||||
flavor: implementation
|
flavor: implementation
|
||||||
depends_on: []
|
depends_on: []
|
||||||
state_hub_workstream_id: "17d315f0-829e-57c7-85cc-3a7914152804"
|
state_hub_workstream_id: "17d315f0-829e-57c7-85cc-3a7914152804"
|
||||||
|
|
@ -28,8 +28,8 @@ These are retrospective task records of evidenced work, not additional delivery
|
||||||
or newly granted authority. The earlier dated narrative remains in Git at
|
or newly granted authority. The earlier dated narrative remains in Git at
|
||||||
`cc2dd01`; its evidence references are carried into the tasks below.
|
`cc2dd01`; its evidence references are carried into the tasks below.
|
||||||
|
|
||||||
**Current position:** 22 tasks done; 2 in progress; 1 ready to start (`todo`);
|
**Current position, source refinement 2026-10-17:** 24 tasks done; 2 in
|
||||||
6 waiting on explicit prerequisites. These are task counts, not an effort-based
|
progress; 3 ready to start (`todo`); 7 waiting on explicit prerequisites. These are task counts, not an effort-based
|
||||||
completion percentage. Assignees are also written in task descriptions because
|
completion percentage. Assignees are also written in task descriptions because
|
||||||
the current Forge projector does not populate the dedicated Hub assignee field. The outage-retrieval tool and restore-packet draft are now prepared;
|
the current Forge projector does not populate the dedicated Hub assignee field. The outage-retrieval tool and restore-packet draft are now prepared;
|
||||||
independent outage retrieval and Storage Share publication are accepted;
|
independent outage retrieval and Storage Share publication are accepted;
|
||||||
|
|
@ -958,27 +958,150 @@ retained, and scanner policy/exemptions are unchanged.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: NK-WP-0049-T33
|
id: NK-WP-0049-T33
|
||||||
status: todo
|
status: wait
|
||||||
priority: high
|
priority: high
|
||||||
assignee: codex
|
assignee: codex
|
||||||
depends_on: [NK-WP-0049-T32]
|
depends_on: [NK-WP-0049-T32, NK-WP-0049-T34, NK-WP-0049-T35, NK-WP-0049-T36]
|
||||||
|
blocking_reason: "Source custody/preflight preparation is evidenced in T34; await original TLS/trust recovery disposition T35 and selected original registration/private-admission metadata T36 before final reviewed command and existing owner process."
|
||||||
state_hub_task_id: "56c8d469-7a10-5d50-a30e-95cbf3cd4756"
|
state_hub_task_id: "56c8d469-7a10-5d50-a30e-95cbf3cd4756"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Accountable worker:** NetKingdom codex with KeyCape and selected consumer owners.
|
**Accountable worker:** NetKingdom codex with KeyCape and selected consumer owners.
|
||||||
Prepare the exact archive/manifest, original issuer/TLS/trust and executable
|
This umbrella retains final attempt readiness. Completed custody/preflight source
|
||||||
bindings, selected current registration and dependency graph using public metadata.
|
preparation is extracted into T34; T35 resolves original TLS/trust recovery and
|
||||||
Use the existing independently held archive-key and owner-controlled TLS custody
|
T36 prepares the selected original registration and private admission. This split
|
||||||
paths; never put secret values in the packet. Validate the attended launcher and
|
records distinct outcomes without counting source preparation as recovery.
|
||||||
its full dependency closure, execution deadline, recovery obligations and safe
|
|
||||||
receipt path before asking for any owner-held input. Bind approved source revision
|
|
||||||
and rollback/abort limits through the existing owner process.
|
|
||||||
|
|
||||||
**Done when:** reviewed executable command and metadata-only admission packet
|
After those deliverables, assemble the exact existing six-argument attended
|
||||||
are ready for the existing attended custody path; prerequisites and independent
|
command and admission digest, bind the integrated source/dependency revision and
|
||||||
receipt verification are explicit. T18 still owns actual execution, original
|
fixed local target, and record execution deadline, safe receipt location, abort,
|
||||||
consumer continuity and acceptance. T19 retains retained-factor MFA. This task
|
rollback and independent receipt verification through the existing owner process.
|
||||||
neither authorizes a production consumer redirect nor creates a new client grant.
|
Keep private manifest derivation inside custody and original TLS/client/key inputs
|
||||||
|
in the owner's own terminal; the current null fields must refuse execution.
|
||||||
|
Existing session authorization is retained within scope; this record does not
|
||||||
|
supply missing owner assent or approve any actual recovery attempt.
|
||||||
|
|
||||||
|
**Done when:** reviewed executable command and complete metadata-only/private
|
||||||
|
admission packet are ready for the existing attended custody path; prerequisites
|
||||||
|
and independent receipt verification are explicit. T18 still owns actual
|
||||||
|
execution, original consumer continuity and acceptance. T19 retains retained-factor
|
||||||
|
MFA. This task neither authorizes a production consumer redirect nor creates a
|
||||||
|
new client grant.
|
||||||
|
|
||||||
|
## Deliver attended custody, metadata preflight and private manifest preparation source
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: NK-WP-0049-T34
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
assignee: codex
|
||||||
|
depends_on: [NK-WP-0049-T32]
|
||||||
|
state_hub_task_id: "821b3a56-6f36-5812-abc9-5a00e37cb2e7"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Accountable worker:** NetKingdom codex; independent Custodian source review.
|
||||||
|
Completed 2026-10-17 as source preparation. Existing attended custody entry points
|
||||||
|
now have a reviewed candidate sealed anonymous descriptor mode: hidden prompt,
|
||||||
|
actual public-recipient verification, strict owned/private/full-seal descriptor
|
||||||
|
admission, non-inheritable returned duplicate and explicit descriptor passing.
|
||||||
|
The existing file mode stays compatible, with its original hard-loss limitation;
|
||||||
|
no improved file-mode cleanup is claimed. Exact parent-loss tests prove descriptor
|
||||||
|
lifetime without signalling inherited user shell groups. An admitted child retains
|
||||||
|
its descriptor until it exits; parent loss is not child revocation.
|
||||||
|
|
||||||
|
The candidate checks bounded, duplicate-free public JSON, exact admission digest,
|
||||||
|
transfer/archive and selected service bindings, typed hashes for all 17 invoked
|
||||||
|
local source/config dependencies and required Python imports before requesting a
|
||||||
|
key. TLS and ciphertext preparation uses file metadata only. Legacy explicit
|
||||||
|
manifest digests remain supported; versioned ciphertext-derived binding computes
|
||||||
|
the canonical digest privately only after exact reviewed ciphertext, authenticated
|
||||||
|
age decryption and full existing archive validation. The derived private digest
|
||||||
|
never enters safe receipts/checkpoints and grants no new consumer authority.
|
||||||
|
|
||||||
|
The coordinator independently passed 111 focused checks. The existing custody
|
||||||
|
regression suite passed 94 checks; 61 real-SOPS tests skipped because that trusted
|
||||||
|
runtime is unavailable, so that compatibility coverage remains unverified. Tests
|
||||||
|
use generated keys/archive material only, including actual age cryptography,
|
||||||
|
synthetic hidden-prompt and exact parent-loss processes. Manifest-builder tests
|
||||||
|
mock Docker/issuer behavior and confer no new native proof. Frozen R6/R7 proof,
|
||||||
|
intermediate test failures and scanner history remain retained.
|
||||||
|
|
||||||
|
Bounded public image readback separately establishes the cached original image's
|
||||||
|
13670142-byte `/keycape` executable as
|
||||||
|
`sha256:cd89121393e1465aa76009763602a5b8c75f9dddb5679aea6242623f135b51b4`.
|
||||||
|
This is executable identity metadata, not recovery or custody acceptance.
|
||||||
|
|
||||||
|
Evidence: Custodian `docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/`
|
||||||
|
(`candidate.patch`, `review.json`, source closure, attempt/admission metadata and
|
||||||
|
`public-verifier-image-readback.json`). Canonical integration/publication and
|
||||||
|
projection receipts must be recorded separately when performed; this completed
|
||||||
|
source-preparation record does not invent them. T33 remains open for complete
|
||||||
|
attempt readiness; actual recovery/consumer acceptance remains T18 and MFA T19.
|
||||||
|
|
||||||
|
## Establish original KeyCape TLS and trust recovery ownership for outage use
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: NK-WP-0049-T35
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
assignee: codex
|
||||||
|
depends_on: [NK-WP-0049-T34]
|
||||||
|
state_hub_task_id: "1bfd1703-589f-5511-bad5-812d860e4a71"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Accountable worker:** NetKingdom codex, coordinating with the source owner of
|
||||||
|
Ingress/cert-manager/Traefik and the original TLS custody/provider boundary.
|
||||||
|
**Next action:** identify and record that actual owner's existing recovery path,
|
||||||
|
then obtain its metadata/disposition through the ordinary owner process. A missing
|
||||||
|
exact credential-routing catalog match is not evidence that no owner exists.
|
||||||
|
|
||||||
|
Current source declares `kc.coulomb.social`, `sso/kc-tls`, cert-manager
|
||||||
|
`ClusterIssuer/letsencrypt-prod` and Traefik TLS termination before KeyCape 8080.
|
||||||
|
The identity backup profile excludes `kc-tls`; the verified archive therefore
|
||||||
|
establishes no independent recovery of that TLS identity/trust. Determine whether
|
||||||
|
the exact original certificate/key/trust is independently recoverable during an
|
||||||
|
issuer/cluster outage, or whether a documented reissue path is acceptable for the
|
||||||
|
selected continuity claim. Do not read or place the TLS key in work records, use
|
||||||
|
a fresh self-signed trust substitute, mutate production or claim owner assent.
|
||||||
|
|
||||||
|
**Done when:** named owner/provider, independent outage prerequisites, original
|
||||||
|
public identity/certificate/trust hashes or accepted reissue disposition, private
|
||||||
|
custody entry point and verification/abort limits are explicit and reviewed.
|
||||||
|
Record any unavailable recovery capability as a live owner work record and bind
|
||||||
|
its dependency/unblock condition; a prose-only gap or untested archive is not
|
||||||
|
completion. This delivers preparation/disposition, not actual TLS recovery.
|
||||||
|
|
||||||
|
## Prepare selected original service registration and private admission bindings
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: NK-WP-0049-T36
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
assignee: codex
|
||||||
|
depends_on: [NK-WP-0049-T34]
|
||||||
|
state_hub_task_id: "8b72334b-fc27-5707-a189-bad345eaf3bc"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Accountable worker:** NetKingdom codex with KeyCape and the selected existing
|
||||||
|
service consumer's owner. **Next action:** identify that owner and the exact
|
||||||
|
original registration's non-secret selector metadata using existing source/owner
|
||||||
|
evidence; preserve independent private custody and never retrieve a client secret.
|
||||||
|
|
||||||
|
Bind reviewed ciphertext `7117b3fe1e4840c86b301c94c85e6b16709e5626bb0884bd0580bde630e30e30`,
|
||||||
|
its independently verified transfer, source/image/executable closure and an
|
||||||
|
explicit versioned manifest-binding mode. For the original service registration,
|
||||||
|
record client identifier, secret environment name, audience, granted scope,
|
||||||
|
subject, tenant, required roles and denied scope without values or new grants.
|
||||||
|
Describe the native service-client dependency graph and distinguish unexercised
|
||||||
|
LLDAP/Authelia/privacyIDEA/downstream flows. Keep private manifest material inside
|
||||||
|
the existing custody process, never in Git, safe receipts, State Hub or chat.
|
||||||
|
|
||||||
|
**Done when:** the selected existing service owner and exact non-secret registration
|
||||||
|
bindings are independently established, the private admission builder validates
|
||||||
|
positive/negative source/archive/selector cases, and its metadata is ready to
|
||||||
|
combine with T35's original TLS/trust disposition under T33. This task may prepare
|
||||||
|
registration metadata while T35 proceeds independently; it does not finalize the
|
||||||
|
whole admission, perform actual recovery, redirect a consumer or confer assent.
|
||||||
|
|
||||||
## Accept remaining recovery and caller evidence and close the G08 handoff
|
## Accept remaining recovery and caller evidence and close the G08 handoff
|
||||||
|
|
||||||
|
|
@ -999,7 +1122,7 @@ Owner boundary: NetKingdom, Platform and affected service owners, with Custodian
|
||||||
G08 receiving the final evidence. The completed archive/job milestones remain
|
G08 receiving the final evidence. The completed archive/job milestones remain
|
||||||
accepted independently of this final gate.
|
accepted independently of this final gate.
|
||||||
|
|
||||||
**Done when:** T02 and T15–T31 have their stated completed or explicitly accepted
|
**Done when:** T02 and T15–T36 have their stated completed or explicitly accepted
|
||||||
handoff outcomes; service authentication/token/audit proof and current/legacy
|
handoff outcomes; service authentication/token/audit proof and current/legacy
|
||||||
caller dispositions are accepted; the source workplan and projected tasks agree;
|
caller dispositions are accepted; the source workplan and projected tasks agree;
|
||||||
and G08 has an exact evidence/handoff record. Record actionable residuals as live
|
and G08 has an exact evidence/handoff record. Record actionable residuals as live
|
||||||
|
|
@ -1035,3 +1158,11 @@ failures/automation errors; existing warnings and scanner false-positive history
|
||||||
remain retained. Custodian identity-restore-adapter-next/canonical-r7-integration.json
|
remain retained. Custodian identity-restore-adapter-next/canonical-r7-integration.json
|
||||||
binds source/projection/native-fixture evidence separately. T18 remains waiting
|
binds source/projection/native-fixture evidence separately. T18 remains waiting
|
||||||
for the actual owner-held attempt and selected consumer acceptance.
|
for the actual owner-held attempt and selected consumer acceptance.
|
||||||
|
|
||||||
|
|
||||||
|
2026-10-17 source/attempt refinement: T34 records completed attended custody,
|
||||||
|
preflight/private-manifest source preparation and its bounded tests; T35 and T36
|
||||||
|
make original TLS recovery ownership and selected original admission metadata live
|
||||||
|
work. T33 retains final attempt readiness and its original Hub UUID. This adds no
|
||||||
|
actual recovery, MFA, native authority or owner assent. Source task counts above
|
||||||
|
refer to this file; projection has not been claimed for this proposed refinement.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue