Implement authenticated custody observations and durable root-grant baseline
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
This commit is contained in:
parent
1999a144c9
commit
0d0751d39d
18 changed files with 1129 additions and 14 deletions
|
|
@ -1,4 +1,4 @@
|
|||
"""Proposed NK-ADR-0017 observation seam; deliberately absent from runtime.
|
||||
"""Accepted NK-ADR-0017 custody observations; runtime is unconfigured by default.
|
||||
|
||||
Reader and baseline implementations are deployment-owner responsibilities.
|
||||
No claims, account roles, fixture identities or local cache can create a grant.
|
||||
|
|
@ -7,8 +7,8 @@ from __future__ import annotations
|
|||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Mapping, Protocol
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -62,22 +62,24 @@ class CustodyObservationAdapter:
|
|||
self, *, reader: CustodyReader, baselines: CustodyBaselineStore,
|
||||
policy: CustodyPolicy, source_id: str, record_id: str,
|
||||
allow_initial_baseline: bool = False,
|
||||
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||
):
|
||||
if not source_id or not record_id:
|
||||
raise ValueError("trusted source and record bindings required")
|
||||
self.reader, self.baselines, self.policy = reader, baselines, policy
|
||||
self.source_id, self.record_id = source_id, record_id
|
||||
self.allow_initial_baseline = allow_initial_baseline
|
||||
self.clock = clock
|
||||
|
||||
def observe(
|
||||
self, *, issuer: str, subject: str, tenant: str, now: datetime,
|
||||
self, *, issuer: str, subject: str, tenant: str, now: datetime | None = None,
|
||||
) -> dict | None:
|
||||
"""Always re-read; failures never reuse or refresh an old observation.
|
||||
|
||||
Diagnostics are suppressed because transport/validator errors may contain
|
||||
credentials or rejected data. This supplies a fact, never authorization.
|
||||
"""
|
||||
if tenant != "platform" or now.utcoffset() is None:
|
||||
if tenant != "platform":
|
||||
return None
|
||||
try:
|
||||
previous = self.baselines.load()
|
||||
|
|
@ -90,6 +92,10 @@ class CustodyObservationAdapter:
|
|||
):
|
||||
return None
|
||||
read = self.reader.read()
|
||||
# Sample after owner I/O; request-start time is not a check time.
|
||||
now = self.clock() if now is None else now
|
||||
if now.utcoffset() is None:
|
||||
return None
|
||||
document = deepcopy(dict(read.document))
|
||||
if (read.source_id != self.source_id
|
||||
or document.get("record_id") != self.record_id
|
||||
|
|
|
|||
60
src/user_engine/custody_bootstrap.py
Normal file
60
src/user_engine/custody_bootstrap.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Controlled baseline initialization; never publishes a grant or resets history."""
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
|
||||
from user_engine.custody import CustodyBaseline
|
||||
|
||||
|
||||
def initialize_baseline(adapter, *, expected_sha256):
|
||||
"""Bind initial authenticated read to a human-reviewed publisher receipt.
|
||||
|
||||
Receipt digest is SHA256 of UTF-8 canonical JSON (sorted keys, compact
|
||||
separators, ensure_ascii=True). Existing baselines are never overwritten.
|
||||
Exceptions/input values are suppressed, including credential errors.
|
||||
"""
|
||||
try:
|
||||
if not re.fullmatch("[0-9a-f]{64}", expected_sha256):
|
||||
return False
|
||||
if adapter.baselines.load() is not None:
|
||||
return False
|
||||
read = adapter.reader.read()
|
||||
now = adapter.clock()
|
||||
document = deepcopy(dict(read.document))
|
||||
if (read.source_id != adapter.source_id
|
||||
or document.get("record_id") != adapter.record_id
|
||||
or document.get("owner_system") != "net-kingdom-custody"
|
||||
or adapter.policy.validate(document)
|
||||
or read.read_at.utcoffset() is None or now.utcoffset() is None):
|
||||
return False
|
||||
age = (now - read.read_at).total_seconds()
|
||||
updated = datetime.fromisoformat(document["updated_at"].replace("Z", "+00:00"))
|
||||
if updated > read.read_at or not 0 <= age <= document["freshness"]["max_cache_seconds"]:
|
||||
return False
|
||||
digest = hashlib.sha256(json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()).hexdigest()
|
||||
if digest != expected_sha256:
|
||||
return False
|
||||
return adapter.baselines.compare_and_store(None, CustodyBaseline(adapter.source_id, document))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
from user_engine.runtime import _custody_adapter, _required
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--expected-sha256", required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
adapter = _custody_adapter(_required("USER_ENGINE_DATABASE_URL"))
|
||||
accepted = adapter is not None and initialize_baseline(adapter, expected_sha256=args.expected_sha256)
|
||||
except Exception:
|
||||
accepted = False
|
||||
print("custody baseline initialized" if accepted else "custody baseline not initialized")
|
||||
return 0 if accepted else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1
src/user_engine/custody_canon/__init__.py
Normal file
1
src/user_engine/custody_canon/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Pinned NetKingdom custody policy; provenance in provenance.json."""
|
||||
135
src/user_engine/custody_canon/custody-record_v0.1.schema.json
Normal file
135
src/user_engine/custody_canon/custody-record_v0.1.schema.json
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://policy.coulomb.social/schemas/custody-record/v0.1",
|
||||
"title": "NetKingdom platform-root custody record v0.1",
|
||||
"description": "Machine-readable, non-secret form of the platform-root custody record (NK-ADR-0017, status accepted; NK-WP-0045-T02). The record is the single authority for the root grant. Entries are keyed by exact (issuer, subject), never by username. The root grant is relation platform-root-custodian in tenant:platform only; directory membership and IAM claims are projections and cannot ground an entry. Revocation and supersession are recorded custody actions that add a record revision; entries are never deleted. No secret material: token, key, share, recovery-code, password and reset-link fields are rejected everywhere.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"kind",
|
||||
"record_id",
|
||||
"revision",
|
||||
"updated_at",
|
||||
"owner_system",
|
||||
"freshness",
|
||||
"entries"
|
||||
],
|
||||
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
|
||||
"properties": {
|
||||
"schema_version": { "const": "0.1" },
|
||||
"kind": { "const": "netkingdom-custody-record" },
|
||||
"record_id": {
|
||||
"description": "Stable identifier of the custody record. Revisions of one record share it.",
|
||||
"type": "string",
|
||||
"pattern": "^nk-custody:[a-z0-9][a-z0-9-]{0,62}$"
|
||||
},
|
||||
"revision": {
|
||||
"description": "Monotonic per record: every custody action (grant, revocation, supersession) increments it, so a revocation is distinguishable from a stale read.",
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"updated_at": { "type": "string", "format": "date-time" },
|
||||
"owner_system": { "const": "net-kingdom-custody" },
|
||||
"freshness": {
|
||||
"description": "Consumer cache bound. Observations older than this are stale; root-grant-dependent actions re-check and fail closed when the record is unreadable or stale.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["max_cache_seconds", "on_unreadable"],
|
||||
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
|
||||
"properties": {
|
||||
"max_cache_seconds": { "type": "integer", "minimum": 0, "maximum": 900 },
|
||||
"on_unreadable": { "const": "no-grant" }
|
||||
}
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/entry" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"nonSecretName": {
|
||||
"description": "Field names that would carry secret material are rejected at every level.",
|
||||
"type": "string",
|
||||
"not": {
|
||||
"pattern": "(?i)(token|key|secret|share|recovery|password|passphrase|reset|seed|private|otp|credential)"
|
||||
}
|
||||
},
|
||||
"timestamp": { "type": "string", "format": "date-time" },
|
||||
"subject": {
|
||||
"description": "Exact OIDC subject reference. No username, email, group or role is accepted as the key.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["issuer", "subject"],
|
||||
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
|
||||
"properties": {
|
||||
"issuer": { "type": "string", "pattern": "^https://[^\\s/]+(/[^\\s]*)?$" },
|
||||
"subject": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^\\S+$" }
|
||||
}
|
||||
},
|
||||
"grant": {
|
||||
"description": "Membership-envelope mapping from NK-ADR-0017 section 2. Fixed: no other tenant, relation or owner is representable.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["scope_type", "scope_id", "relation", "owner_system"],
|
||||
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
|
||||
"properties": {
|
||||
"scope_type": { "const": "tenant" },
|
||||
"scope_id": { "const": "platform" },
|
||||
"relation": { "const": "platform-root-custodian" },
|
||||
"owner_system": { "const": "net-kingdom-custody" }
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"description": "A recorded custody action with actor, reason and the record revision it introduced.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["at", "actor", "reason", "revision"],
|
||||
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
|
||||
"properties": {
|
||||
"at": { "$ref": "#/$defs/timestamp" },
|
||||
"actor": { "$ref": "#/$defs/subject" },
|
||||
"reason": { "type": "string", "minLength": 1, "maxLength": 500 },
|
||||
"revision": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"evidence": {
|
||||
"description": "Non-secret pointer to the custody ceremony that grounds the entry. A projection (directory group, IAM claim) is never a basis.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["basis", "ref"],
|
||||
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
|
||||
"properties": {
|
||||
"basis": { "const": "custody-ceremony" },
|
||||
"ref": { "type": "string", "minLength": 1, "maxLength": 300 }
|
||||
}
|
||||
},
|
||||
"supersession": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["entry_id", "revision"],
|
||||
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
|
||||
"properties": {
|
||||
"entry_id": { "$ref": "#/$defs/entryId" },
|
||||
"revision": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"entryId": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,62}$" },
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["entry_id", "subject", "grant", "evidence", "granted"],
|
||||
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
|
||||
"properties": {
|
||||
"entry_id": { "$ref": "#/$defs/entryId" },
|
||||
"subject": { "$ref": "#/$defs/subject" },
|
||||
"grant": { "$ref": "#/$defs/grant" },
|
||||
"evidence": { "$ref": "#/$defs/evidence" },
|
||||
"granted": { "$ref": "#/$defs/action" },
|
||||
"superseded_by": { "$ref": "#/$defs/supersession" },
|
||||
"revoked": { "$ref": "#/$defs/action" }
|
||||
},
|
||||
"not": { "required": ["superseded_by", "revoked"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
231
src/user_engine/custody_canon/policy.py
Normal file
231
src/user_engine/custody_canon/policy.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = ["jsonschema>=4.10,<5"]
|
||||
# ///
|
||||
"""Validate platform-root custody records (NK-ADR-0017, NK-WP-0045-T03).
|
||||
|
||||
Checks the JSON Schema (`canon/schemas/custody-record_v0.1.schema.json`) and
|
||||
the semantics the schema cannot express: unique entries, at most one active
|
||||
grant per exact (issuer, subject), revision ordering, supersession links,
|
||||
append-only successors, and secret-looking values. It also provides the
|
||||
fail-closed read rule a consumer applies to an observation (15-minute bound).
|
||||
|
||||
Offline and read-only: it never contacts an issuer, OpenBao or a directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import jsonschema
|
||||
|
||||
SCHEMA = pathlib.Path(__file__).with_name("custody-record_v0.1.schema.json")
|
||||
MAX_CACHE_SECONDS = 900
|
||||
|
||||
# Value shapes that look like secret material (OpenBao/Vault tokens, PEM keys,
|
||||
# JWTs). Field names are rejected by the schema; this catches smuggled values.
|
||||
SECRET_VALUE = re.compile(
|
||||
r"(\bhv[sbr]\.[A-Za-z0-9_-]{8,}|\bs\.[A-Za-z0-9]{20,}"
|
||||
r"|-----BEGIN [A-Z ]*PRIVATE KEY-----"
|
||||
r"|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.)"
|
||||
)
|
||||
|
||||
|
||||
def load_schema() -> dict[str, Any]:
|
||||
return json.loads(SCHEMA.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def parse_time(value: str) -> dt.datetime:
|
||||
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def key(entry: dict[str, Any]) -> tuple[str, str]:
|
||||
return (entry["subject"]["issuer"], entry["subject"]["subject"])
|
||||
|
||||
|
||||
def is_active(entry: dict[str, Any]) -> bool:
|
||||
return "revoked" not in entry and "superseded_by" not in entry
|
||||
|
||||
|
||||
def schema_errors(document: Any, schema: dict[str, Any]) -> list[str]:
|
||||
validator = jsonschema.Draft202012Validator(
|
||||
schema, format_checker=jsonschema.FormatChecker()
|
||||
)
|
||||
errors = []
|
||||
for error in sorted(validator.iter_errors(document), key=lambda e: list(e.path)):
|
||||
where = "/".join(str(part) for part in error.path) or "<root>"
|
||||
errors.append(f"schema: {where}: {error.message}")
|
||||
return errors
|
||||
|
||||
|
||||
def _walk_strings(node: Any, path: str = ""):
|
||||
if isinstance(node, dict):
|
||||
for name, value in node.items():
|
||||
yield from _walk_strings(value, f"{path}/{name}")
|
||||
elif isinstance(node, list):
|
||||
for index, value in enumerate(node):
|
||||
yield from _walk_strings(value, f"{path}/{index}")
|
||||
elif isinstance(node, str):
|
||||
yield path or "<root>", node
|
||||
|
||||
|
||||
def semantic_errors(record: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
for where, value in _walk_strings(record):
|
||||
if SECRET_VALUE.search(value):
|
||||
errors.append(f"{where}: value looks like secret material")
|
||||
|
||||
revision = record["revision"]
|
||||
entries = record["entries"]
|
||||
by_id: dict[str, dict[str, Any]] = {}
|
||||
for entry in entries:
|
||||
if entry["entry_id"] in by_id:
|
||||
errors.append(f"{entry['entry_id']}: duplicate entry_id")
|
||||
by_id[entry["entry_id"]] = entry
|
||||
|
||||
active: dict[tuple[str, str], str] = {}
|
||||
for entry in entries:
|
||||
eid = entry["entry_id"]
|
||||
granted = entry["granted"]
|
||||
if granted["revision"] > revision:
|
||||
errors.append(f"{eid}: granted.revision is after record revision")
|
||||
if is_active(entry):
|
||||
if key(entry) in active:
|
||||
errors.append(
|
||||
f"{eid}: second active grant for {key(entry)} "
|
||||
f"(already {active[key(entry)]})"
|
||||
)
|
||||
active[key(entry)] = eid
|
||||
revoked = entry.get("revoked")
|
||||
if revoked:
|
||||
if not granted["revision"] < revoked["revision"] <= revision:
|
||||
errors.append(f"{eid}: revoked.revision must follow the grant and not exceed the record revision")
|
||||
if parse_time(revoked["at"]) < parse_time(granted["at"]):
|
||||
errors.append(f"{eid}: revoked before granted")
|
||||
link = entry.get("superseded_by")
|
||||
if link:
|
||||
successor = by_id.get(link["entry_id"])
|
||||
if successor is None or link["entry_id"] == eid:
|
||||
errors.append(f"{eid}: superseded_by names no other entry")
|
||||
else:
|
||||
if successor["granted"]["revision"] != link["revision"]:
|
||||
errors.append(f"{eid}: superseded_by.revision must equal the successor's granted.revision")
|
||||
if parse_time(successor["granted"]["at"]) < parse_time(granted["at"]):
|
||||
errors.append(f"{eid}: successor granted before the superseded entry")
|
||||
if not granted["revision"] < link["revision"] <= revision:
|
||||
errors.append(f"{eid}: superseded_by.revision must follow the grant and not exceed the record revision")
|
||||
return errors
|
||||
|
||||
|
||||
def validate(record: Any, schema: dict[str, Any] | None = None) -> list[str]:
|
||||
errors = schema_errors(record, schema or load_schema())
|
||||
if errors:
|
||||
return errors
|
||||
return semantic_errors(record)
|
||||
|
||||
|
||||
def successor_errors(previous: dict[str, Any], current: dict[str, Any]) -> list[str]:
|
||||
"""A later read must be a strictly newer, append-only revision."""
|
||||
if validate(previous) or validate(current):
|
||||
return ["invalid record in revision comparison"]
|
||||
errors: list[str] = []
|
||||
if previous["record_id"] != current["record_id"]:
|
||||
return ["record_id changed between revisions"]
|
||||
if current["revision"] < previous["revision"]:
|
||||
return ["revision went backwards: stale read, keep the newer observation"]
|
||||
if current["revision"] == previous["revision"]:
|
||||
if current != previous:
|
||||
errors.append("same revision with different content")
|
||||
return errors
|
||||
now = {entry["entry_id"]: entry for entry in current["entries"]}
|
||||
before = {entry["entry_id"]: entry for entry in previous["entries"]}
|
||||
if parse_time(current["updated_at"]) < parse_time(previous["updated_at"]):
|
||||
errors.append("updated_at went backwards")
|
||||
for entry in current["entries"]:
|
||||
earlier = before.get(entry["entry_id"], {})
|
||||
for field in ("granted", "revoked", "superseded_by"):
|
||||
if field in entry and field not in earlier:
|
||||
if entry[field]["revision"] <= previous["revision"]:
|
||||
errors.append(f"{entry['entry_id']}: new {field} must follow the previous record revision")
|
||||
for entry in previous["entries"]:
|
||||
later = now.get(entry["entry_id"])
|
||||
if later is None:
|
||||
errors.append(f"{entry['entry_id']}: entry removed; custody actions append, never delete")
|
||||
continue
|
||||
for field in ("subject", "grant", "evidence", "granted"):
|
||||
if later[field] != entry[field]:
|
||||
errors.append(f"{entry['entry_id']}: {field} rewritten")
|
||||
for field in ("revoked", "superseded_by"):
|
||||
if field in entry and later.get(field) != entry[field]:
|
||||
errors.append(f"{entry['entry_id']}: {field} rewritten")
|
||||
return errors
|
||||
|
||||
|
||||
def observe_root_grant(
|
||||
record: dict[str, Any] | None,
|
||||
issuer: str,
|
||||
subject: str,
|
||||
tenant: str,
|
||||
read_at: dt.datetime,
|
||||
now: dt.datetime,
|
||||
previous_record: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the ADR-0017 section 3 observation, or None (fail closed).
|
||||
|
||||
None when: the tenant is not `platform`; the record is missing, invalid or
|
||||
older than the cache bound; or no active entry matches the exact
|
||||
(issuer, subject).
|
||||
"""
|
||||
if tenant != "platform" or record is None or validate(record):
|
||||
return None
|
||||
if previous_record is not None and successor_errors(previous_record, record):
|
||||
return None
|
||||
if read_at.utcoffset() is None or now.utcoffset() is None:
|
||||
return None
|
||||
if parse_time(record["updated_at"]) > read_at:
|
||||
return None
|
||||
bound = min(record["freshness"]["max_cache_seconds"], MAX_CACHE_SECONDS)
|
||||
if not dt.timedelta(0) <= now - read_at <= dt.timedelta(seconds=bound):
|
||||
return None
|
||||
for entry in record["entries"]:
|
||||
if (key(entry) == (issuer, subject) and is_active(entry)
|
||||
and parse_time(entry["granted"]["at"]) <= read_at):
|
||||
return {
|
||||
"subject": dict(entry["subject"]),
|
||||
"grant": dict(entry["grant"]),
|
||||
"source": {"record_id": record["record_id"], "revision": record["revision"]},
|
||||
"observed_at": read_at.isoformat().replace("+00:00", "Z"),
|
||||
"expires_at": (read_at + dt.timedelta(seconds=bound)).isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("paths", nargs="+", type=pathlib.Path)
|
||||
args = parser.parse_args(argv)
|
||||
schema = load_schema()
|
||||
failed = False
|
||||
for path in args.paths:
|
||||
try:
|
||||
errors = validate(json.loads(path.read_text(encoding="utf-8")), schema)
|
||||
except (OSError, ValueError):
|
||||
errors = ["unreadable input"]
|
||||
# jsonschema messages may contain the rejected secret value. Never
|
||||
# expose those messages (or input paths) through the command line.
|
||||
if errors:
|
||||
print(f"custody record: rejected ({len(errors)} errors)", file=sys.stderr)
|
||||
failed = failed or bool(errors)
|
||||
if not errors:
|
||||
print("custody record: ok")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
8
src/user_engine/custody_canon/provenance.json
Normal file
8
src/user_engine/custody_canon/provenance.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"repository": "net-kingdom",
|
||||
"commit": "db319335b27b61b855382ee5af5d690f06eeb010",
|
||||
"source_validator_sha256": "9156e399c8d92a45b817bfc3738c89843487ce57e1718ed747f804f95cc719bb",
|
||||
"schema_sha256": "bb6cd6da5dc48eddcf118ae78c2cacfafd5f860098fd8a8368fe6bc845776d07",
|
||||
"packaged_policy_sha256": "a1ba5a6f586a7d64e406aebcde783ab033db010be31c132593f07fb0a93a7bc4",
|
||||
"adaptation": "Schema resource lookup only; no algorithm changes."
|
||||
}
|
||||
168
src/user_engine/custody_runtime.py
Normal file
168
src/user_engine/custody_runtime.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Bounded owner transport and durable baseline for accepted custody observations."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import Callable
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
||||
|
||||
from user_engine.custody import CustodyBaseline, CustodyRead
|
||||
|
||||
|
||||
class CustodyReadUnavailable(RuntimeError):
|
||||
def __init__(self):
|
||||
super().__init__("custody read unavailable")
|
||||
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class OpenBaoCustodyReader:
|
||||
"""Dedicated Kubernetes login, fresh exact KV read, best-effort revoke.
|
||||
|
||||
Never reuse the flex-auth JWT, ESO AppRole or a cached document. Tokens and
|
||||
owner response/error bodies are never included in raised errors or reprs.
|
||||
"""
|
||||
def __init__(self, *, base_url: str, auth_mount: str, role: str,
|
||||
jwt_file: str, source_id: str,
|
||||
data_path: str = "platform/data/custody/net-kingdom/platform-root",
|
||||
timeout_seconds: float = 3, max_bytes: int = 262144,
|
||||
allow_http: bool = False, opener=None,
|
||||
clock: Callable = lambda: datetime.now(timezone.utc)):
|
||||
url = urlsplit(base_url)
|
||||
if (url.scheme not in ({"https", "http"} if allow_http else {"https"})
|
||||
or not url.hostname or url.username or url.password
|
||||
or url.path not in ("", "/") or url.query or url.fragment):
|
||||
raise ValueError("trusted OpenBao origin required")
|
||||
if (not re.fullmatch(r"[a-zA-Z0-9_-]+", auth_mount)
|
||||
or not re.fullmatch(r"[a-zA-Z0-9_-]+", role)
|
||||
or data_path != "platform/data/custody/net-kingdom/platform-root"
|
||||
or not source_id or not jwt_file
|
||||
or not math.isfinite(timeout_seconds) or not 0 < timeout_seconds <= 30
|
||||
or not 1024 <= max_bytes <= 262144):
|
||||
raise ValueError("invalid custody reader configuration")
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.auth_mount, self.role, self.jwt_file = auth_mount, role, jwt_file
|
||||
self.source_id, self.data_path = source_id, data_path
|
||||
self.timeout_seconds, self.max_bytes = timeout_seconds, max_bytes
|
||||
self.opener = opener or build_opener(ProxyHandler({}), NoRedirect())
|
||||
self.clock = clock
|
||||
|
||||
def _request(self, path, *, deadline, token=None, body=None):
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise CustodyReadUnavailable()
|
||||
headers = {"Accept": "application/json", "Cache-Control": "no-cache, no-store"}
|
||||
if token:
|
||||
headers["X-Vault-Token"] = token
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(self.base_url + "/v1/" + path, data=data, headers=headers,
|
||||
method="GET" if data is None else "POST")
|
||||
with self.opener.open(request, timeout=remaining) as response:
|
||||
if response.status != 200:
|
||||
raise CustodyReadUnavailable()
|
||||
if (response.headers.get("Age") not in (None, "0")
|
||||
or response.headers.get("X-Cache", "").lower() not in ("", "miss")
|
||||
or response.headers.get("Content-Encoding", "identity") != "identity"):
|
||||
raise CustodyReadUnavailable()
|
||||
# Read incrementally under one overall deadline, including body I/O.
|
||||
chunks, size = [], 0
|
||||
while True:
|
||||
if time.monotonic() >= deadline:
|
||||
raise CustodyReadUnavailable()
|
||||
read_chunk = getattr(response, "read1", response.read)
|
||||
chunk = read_chunk(min(65536, self.max_bytes + 1 - size))
|
||||
size += len(chunk)
|
||||
if size > self.max_bytes:
|
||||
raise CustodyReadUnavailable()
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
return json.loads(b"".join(chunks))
|
||||
|
||||
def read(self):
|
||||
token = None
|
||||
deadline = time.monotonic() + self.timeout_seconds
|
||||
try:
|
||||
with Path(self.jwt_file).open("rb") as handle:
|
||||
raw = handle.read(16385)
|
||||
if len(raw) > 16384:
|
||||
raise CustodyReadUnavailable()
|
||||
jwt = raw.decode().strip()
|
||||
if not jwt or "\n" in jwt or "\r" in jwt:
|
||||
raise CustodyReadUnavailable()
|
||||
login = self._request("auth/" + self.auth_mount + "/login", deadline=deadline,
|
||||
body={"role": self.role, "jwt": jwt})
|
||||
token = login["auth"]["client_token"]
|
||||
if not isinstance(token, str) or not token or "\n" in token or "\r" in token:
|
||||
raise CustodyReadUnavailable()
|
||||
envelope = self._request(self.data_path, deadline=deadline, token=token)
|
||||
document = envelope["data"]["data"]["document"]
|
||||
if not isinstance(document, dict):
|
||||
raise CustodyReadUnavailable()
|
||||
return CustodyRead(self.source_id, document, self.clock())
|
||||
except Exception:
|
||||
raise CustodyReadUnavailable() from None
|
||||
finally:
|
||||
# Revoke via same trusted origin. This never renews observation time.
|
||||
if token:
|
||||
try:
|
||||
request = Request(self.base_url + "/v1/auth/token/revoke-self",
|
||||
data=b"{}", headers={"X-Vault-Token": token}, method="POST")
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining > 0:
|
||||
with self.opener.open(request, timeout=remaining):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class PostgresCustodyBaselineStore:
|
||||
"""One persistent singleton row, with compare-and-swap across workers.
|
||||
|
||||
Use a separate DB connection; no domain-store transaction is committed here.
|
||||
Backups/restores must preserve this security high-water mark. A rolled-back
|
||||
database requires controlled reconciliation before runtime reactivation.
|
||||
"""
|
||||
def __init__(self, connection):
|
||||
self.connection = connection
|
||||
with connection.transaction():
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("CREATE TABLE IF NOT EXISTS user_engine_custody_baseline "
|
||||
"(singleton boolean PRIMARY KEY CHECK (singleton), payload jsonb NOT NULL)")
|
||||
|
||||
@staticmethod
|
||||
def _payload(baseline):
|
||||
return json.dumps({"source_id": baseline.source_id, "document": baseline.document}, sort_keys=True)
|
||||
|
||||
def load(self):
|
||||
with self.connection.transaction():
|
||||
with self.connection.cursor() as cursor:
|
||||
cursor.execute("SELECT payload FROM user_engine_custody_baseline WHERE singleton = true")
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
value = json.loads(row[0]) if isinstance(row[0], str) else row[0]
|
||||
return CustodyBaseline(value["source_id"], value["document"])
|
||||
|
||||
def compare_and_store(self, previous, current):
|
||||
with self.connection.transaction():
|
||||
with self.connection.cursor() as cursor:
|
||||
if previous is None:
|
||||
cursor.execute("INSERT INTO user_engine_custody_baseline (singleton, payload) "
|
||||
"VALUES (true, %s::jsonb) ON CONFLICT DO NOTHING RETURNING singleton",
|
||||
(self._payload(current),))
|
||||
else:
|
||||
cursor.execute("UPDATE user_engine_custody_baseline SET payload = %s::jsonb "
|
||||
"WHERE singleton = true AND payload = %s::jsonb RETURNING singleton",
|
||||
(self._payload(current), self._payload(previous)))
|
||||
return cursor.fetchone() is not None
|
||||
|
|
@ -42,6 +42,7 @@ def create_application() -> PortalApplication:
|
|||
expected_issuer=_required("USER_ENGINE_OIDC_ISSUER"),
|
||||
expected_audience=_required("USER_ENGINE_OIDC_AUDIENCE"),
|
||||
),
|
||||
custody=_custody_adapter(database_url),
|
||||
authorization=FlexAuthHTTPAdapter(
|
||||
base_url=_required("USER_ENGINE_FLEX_AUTH_URL"),
|
||||
timeout_seconds=float(os.environ.get("USER_ENGINE_FLEX_AUTH_TIMEOUT", "3")),
|
||||
|
|
@ -171,5 +172,34 @@ def _required(name: str) -> str:
|
|||
return value
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _custody_adapter(database_url):
|
||||
"""Opt-in only after owner delivery/admission and baseline bootstrap."""
|
||||
if os.environ.get("USER_ENGINE_CUSTODY_ENABLED", "false").lower() != "true":
|
||||
return None
|
||||
import psycopg
|
||||
from user_engine.custody import CustodyObservationAdapter
|
||||
from user_engine.custody_runtime import OpenBaoCustodyReader, PostgresCustodyBaselineStore
|
||||
from user_engine.custody_canon import policy
|
||||
|
||||
origin = _required("USER_ENGINE_CUSTODY_BAO_URL")
|
||||
mount = _required("USER_ENGINE_CUSTODY_AUTH_MOUNT")
|
||||
role = _required("USER_ENGINE_CUSTODY_ROLE")
|
||||
source = origin.rstrip("/") + "/v1/platform/data/custody/net-kingdom/platform-root#auth=" + mount + "/" + role
|
||||
reader = OpenBaoCustodyReader(
|
||||
base_url=origin, auth_mount=mount, role=role,
|
||||
jwt_file=_required("USER_ENGINE_CUSTODY_JWT_FILE"), source_id=source,
|
||||
timeout_seconds=float(os.environ.get("USER_ENGINE_CUSTODY_TIMEOUT", "3")),
|
||||
allow_http=os.environ.get("USER_ENGINE_CUSTODY_ALLOW_HTTP", "false").lower() == "true",
|
||||
)
|
||||
baselines = PostgresCustodyBaselineStore(psycopg.connect(database_url))
|
||||
return CustodyObservationAdapter(
|
||||
reader=reader, baselines=baselines, policy=policy, source_id=source,
|
||||
record_id="nk-custody:platform-root", allow_initial_baseline=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from dataclasses import dataclass, field, replace
|
|||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
|
||||
from user_engine.custody import CustodyObservationAdapter
|
||||
|
||||
from user_engine.domain import (
|
||||
Account,
|
||||
AccessControlFact,
|
||||
|
|
@ -110,7 +112,7 @@ class ReadinessReport:
|
|||
|
||||
@dataclass(frozen=True)
|
||||
class AccountAuthority:
|
||||
"""Current account state only; not a root entitlement or tenant lifecycle grant."""
|
||||
"""Account state and optional observed custody fact; never authorization."""
|
||||
issuer: str
|
||||
subject: str
|
||||
tenant: str
|
||||
|
|
@ -121,6 +123,7 @@ class AccountAuthority:
|
|||
tenant_account_status: AccountStatus | None
|
||||
observed_at: datetime
|
||||
decision_id: str | None
|
||||
root_grant: Mapping[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -310,11 +313,13 @@ class UserEngineService:
|
|||
identity_adapter: IdentityClaimsAdapter,
|
||||
authorization: AuthorizationCheckPort,
|
||||
factor_verifier: FactorVerificationAdapter | None = None,
|
||||
custody: CustodyObservationAdapter | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.identity_adapter = identity_adapter
|
||||
self.authorization = authorization
|
||||
self.factor_verifier = factor_verifier
|
||||
self.custody = custody
|
||||
if not self.store.ready:
|
||||
self.store.migrate()
|
||||
|
||||
|
|
@ -1820,6 +1825,8 @@ class UserEngineService:
|
|||
account_status=account.status,
|
||||
tenant_account_status=scoped.status if scoped is not None else None,
|
||||
observed_at=observed_at, decision_id=decision.decision_id,
|
||||
root_grant=(self.custody.observe(issuer=issuer, subject=subject, tenant=tenant)
|
||||
if self.custody is not None else None),
|
||||
)
|
||||
|
||||
def create_user(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue