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
82
docs/root-custody-runtime.md
Normal file
82
docs/root-custody-runtime.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# Root custody consumer delivery
|
||||||
|
|
||||||
|
USER-WP-0037-T02 implements accepted NK-ADR-0017 without creating custody entries.
|
||||||
|
The runtime remains disabled unless USER_ENGINE_CUSTODY_ENABLED=true. Optional
|
||||||
|
AccountAuthority.root_grant is a source fact, never a flex-auth decision.
|
||||||
|
|
||||||
|
## Configuration and owner boundary
|
||||||
|
|
||||||
|
The deployment owner is rapp-user-engine: namespace user-engine, Deployment
|
||||||
|
user-engine, ServiceAccount user-engine. A dedicated projected JWT with audience
|
||||||
|
openbao must be supplied; the existing flex-auth audience token is unsuitable.
|
||||||
|
railiance-platform supplies auth/kubernetes role user-engine-root-custody-reader,
|
||||||
|
exact-path-only reader policy, no default policy, and maximum 60-second token TTL.
|
||||||
|
No ESO AppRole or operator token is used. Login occurs on every read; revoke-self
|
||||||
|
is best-effort and may be denied by the exact-path policy. TTL limits that residue;
|
||||||
|
the consumer does not claim confirmed revocation.
|
||||||
|
|
||||||
|
| Variable | Binding |
|
||||||
|
| --- | --- |
|
||||||
|
| USER_ENGINE_CUSTODY_ENABLED | true to opt in; unset defaults disabled |
|
||||||
|
| USER_ENGINE_CUSTODY_BAO_URL | Owner-confirmed origin only; no path or credentials |
|
||||||
|
| USER_ENGINE_CUSTODY_AUTH_MOUNT | kubernetes |
|
||||||
|
| USER_ENGINE_CUSTODY_ROLE | user-engine-root-custody-reader |
|
||||||
|
| USER_ENGINE_CUSTODY_JWT_FILE | Dedicated audience=openbao projected JWT file |
|
||||||
|
| USER_ENGINE_CUSTODY_TIMEOUT | Total request deadline, default 3 seconds |
|
||||||
|
| USER_ENGINE_CUSTODY_ALLOW_HTTP | Default false; true requires reviewed in-cluster transport trust and network controls |
|
||||||
|
|
||||||
|
The path is fixed to /v1/platform/data/custody/net-kingdom/platform-root and the
|
||||||
|
whole JSON document is data.data.document. Responses are bounded to 256 KiB;
|
||||||
|
redirects, proxy environment settings, reported cache hits, compressed bodies,
|
||||||
|
invalid records and source changes are rejected. Errors never print values.
|
||||||
|
HTTPS uses system CA verification by default. The currently proposed in-cluster
|
||||||
|
HTTP route has no TLS origin authentication; owner-approved network confinement
|
||||||
|
and exact origin binding are required before enabling it. A configured origin
|
||||||
|
alone does not prove live trust.
|
||||||
|
|
||||||
|
The embedded custody_canon package contains validator/schema and provenance
|
||||||
|
hashes, pinned to NetKingdom db319335b27b61b855382ee5af5d690f06eeb010. Only
|
||||||
|
schema lookup is relocated. Policy updates require reviewing canon and refreshing
|
||||||
|
pin/hashed provenance. No sibling checkout is used in production. root_grant
|
||||||
|
retains exact subject, fixed grant, record id/revision, owner read observed_at,
|
||||||
|
and expires_at from the record's tighter cache bound. Check time is sampled
|
||||||
|
after I/O; local reads never renew owner read time.
|
||||||
|
|
||||||
|
## Durable baseline and controlled initialization
|
||||||
|
|
||||||
|
The existing USER_ENGINE_DATABASE_URL holds the singleton
|
||||||
|
user_engine_custody_baseline table using a separate connection. Compare-and-swap
|
||||||
|
prevents competing workers overwriting a newer baseline. Empty/revoked records
|
||||||
|
also persist. Startup never creates an initial baseline from a record automatically.
|
||||||
|
|
||||||
|
After the publisher has returned a reviewed source/read admission receipt, an
|
||||||
|
attended owner may initialize an absent baseline. An explicitly approved empty
|
||||||
|
entries[] document establishes no grant and needs no invented ceremony. Every
|
||||||
|
non-empty grant still requires human custody-ceremony provenance. Initialize using:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m user_engine.custody_bootstrap --expected-sha256 <publisher-document-digest>
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with the same secret-backed runtime environment and dedicated reader JWT.
|
||||||
|
The non-secret digest must come from a reviewed publisher receipt, not fixtures;
|
||||||
|
it hashes UTF-8 JSON with sorted keys, compact separators and ensure_ascii=True.
|
||||||
|
The command performs a fresh authenticated read, validates record/id/time and
|
||||||
|
digest, then atomically initializes only an absent baseline. It emits one boolean
|
||||||
|
outcome and never resets an existing baseline. It creates no custody entry.
|
||||||
|
|
||||||
|
Source/record changes and database restores require owner reconciliation; there
|
||||||
|
is intentionally no automatic reset command. Preserve baseline high-water records
|
||||||
|
outside the restored database and compare with publisher history/audit receipts.
|
||||||
|
If both source and consumer databases restore backwards, their mutually agreeing
|
||||||
|
old state cannot detect rollback by itself. Keep this feature disabled through
|
||||||
|
restore until owner audit/history establishes the latest revision and a reviewed
|
||||||
|
reconciliation updates the baseline. Never delete the row to make a read pass.
|
||||||
|
|
||||||
|
## Remaining live acceptance
|
||||||
|
|
||||||
|
The code and offline fixtures do not establish a real grant. Delivery requires
|
||||||
|
publisher ceremony provenance, role/policy receipts, projected-token packaging,
|
||||||
|
network trust, owner-reviewed baseline initialization, image digest promotion and
|
||||||
|
positive/denial/outage/revocation/supersession/restore receipts through the actual
|
||||||
|
Hub workload route. USER-WP-0037-T03 retains its separate Hub admission blocker.
|
||||||
|
|
@ -3,6 +3,7 @@ name = "user-engine"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Headless user-domain and profile engine."
|
description = "Headless user-domain and profile engine."
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
dependencies = ["jsonschema>=4.10,<5"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
postgres = ["psycopg[binary]>=3.2,<4"]
|
postgres = ["psycopg[binary]>=3.2,<4"]
|
||||||
|
|
@ -21,6 +22,7 @@ where = ["src"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
user_engine = ["migrations/postgres/*.sql"]
|
user_engine = ["migrations/postgres/*.sql"]
|
||||||
|
"user_engine.custody_canon" = ["*.json"]
|
||||||
|
|
||||||
[tool.user-engine]
|
[tool.user-engine]
|
||||||
package = "user_engine"
|
package = "user_engine"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
Reader and baseline implementations are deployment-owner responsibilities.
|
||||||
No claims, account roles, fixture identities or local cache can create a grant.
|
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 copy import deepcopy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Mapping, Protocol
|
from typing import Any, Callable, Mapping, Protocol
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -62,22 +62,24 @@ class CustodyObservationAdapter:
|
||||||
self, *, reader: CustodyReader, baselines: CustodyBaselineStore,
|
self, *, reader: CustodyReader, baselines: CustodyBaselineStore,
|
||||||
policy: CustodyPolicy, source_id: str, record_id: str,
|
policy: CustodyPolicy, source_id: str, record_id: str,
|
||||||
allow_initial_baseline: bool = False,
|
allow_initial_baseline: bool = False,
|
||||||
|
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||||
):
|
):
|
||||||
if not source_id or not record_id:
|
if not source_id or not record_id:
|
||||||
raise ValueError("trusted source and record bindings required")
|
raise ValueError("trusted source and record bindings required")
|
||||||
self.reader, self.baselines, self.policy = reader, baselines, policy
|
self.reader, self.baselines, self.policy = reader, baselines, policy
|
||||||
self.source_id, self.record_id = source_id, record_id
|
self.source_id, self.record_id = source_id, record_id
|
||||||
self.allow_initial_baseline = allow_initial_baseline
|
self.allow_initial_baseline = allow_initial_baseline
|
||||||
|
self.clock = clock
|
||||||
|
|
||||||
def observe(
|
def observe(
|
||||||
self, *, issuer: str, subject: str, tenant: str, now: datetime,
|
self, *, issuer: str, subject: str, tenant: str, now: datetime | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""Always re-read; failures never reuse or refresh an old observation.
|
"""Always re-read; failures never reuse or refresh an old observation.
|
||||||
|
|
||||||
Diagnostics are suppressed because transport/validator errors may contain
|
Diagnostics are suppressed because transport/validator errors may contain
|
||||||
credentials or rejected data. This supplies a fact, never authorization.
|
credentials or rejected data. This supplies a fact, never authorization.
|
||||||
"""
|
"""
|
||||||
if tenant != "platform" or now.utcoffset() is None:
|
if tenant != "platform":
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
previous = self.baselines.load()
|
previous = self.baselines.load()
|
||||||
|
|
@ -90,6 +92,10 @@ class CustodyObservationAdapter:
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
read = self.reader.read()
|
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))
|
document = deepcopy(dict(read.document))
|
||||||
if (read.source_id != self.source_id
|
if (read.source_id != self.source_id
|
||||||
or document.get("record_id") != self.record_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_issuer=_required("USER_ENGINE_OIDC_ISSUER"),
|
||||||
expected_audience=_required("USER_ENGINE_OIDC_AUDIENCE"),
|
expected_audience=_required("USER_ENGINE_OIDC_AUDIENCE"),
|
||||||
),
|
),
|
||||||
|
custody=_custody_adapter(database_url),
|
||||||
authorization=FlexAuthHTTPAdapter(
|
authorization=FlexAuthHTTPAdapter(
|
||||||
base_url=_required("USER_ENGINE_FLEX_AUTH_URL"),
|
base_url=_required("USER_ENGINE_FLEX_AUTH_URL"),
|
||||||
timeout_seconds=float(os.environ.get("USER_ENGINE_FLEX_AUTH_TIMEOUT", "3")),
|
timeout_seconds=float(os.environ.get("USER_ENGINE_FLEX_AUTH_TIMEOUT", "3")),
|
||||||
|
|
@ -171,5 +172,34 @@ def _required(name: str) -> str:
|
||||||
return value
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ from dataclasses import dataclass, field, replace
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Callable, Iterable, Mapping
|
from typing import Any, Callable, Iterable, Mapping
|
||||||
|
|
||||||
|
from user_engine.custody import CustodyObservationAdapter
|
||||||
|
|
||||||
from user_engine.domain import (
|
from user_engine.domain import (
|
||||||
Account,
|
Account,
|
||||||
AccessControlFact,
|
AccessControlFact,
|
||||||
|
|
@ -110,7 +112,7 @@ class ReadinessReport:
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AccountAuthority:
|
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
|
issuer: str
|
||||||
subject: str
|
subject: str
|
||||||
tenant: str
|
tenant: str
|
||||||
|
|
@ -121,6 +123,7 @@ class AccountAuthority:
|
||||||
tenant_account_status: AccountStatus | None
|
tenant_account_status: AccountStatus | None
|
||||||
observed_at: datetime
|
observed_at: datetime
|
||||||
decision_id: str | None
|
decision_id: str | None
|
||||||
|
root_grant: Mapping[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -310,11 +313,13 @@ class UserEngineService:
|
||||||
identity_adapter: IdentityClaimsAdapter,
|
identity_adapter: IdentityClaimsAdapter,
|
||||||
authorization: AuthorizationCheckPort,
|
authorization: AuthorizationCheckPort,
|
||||||
factor_verifier: FactorVerificationAdapter | None = None,
|
factor_verifier: FactorVerificationAdapter | None = None,
|
||||||
|
custody: CustodyObservationAdapter | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.store = store
|
self.store = store
|
||||||
self.identity_adapter = identity_adapter
|
self.identity_adapter = identity_adapter
|
||||||
self.authorization = authorization
|
self.authorization = authorization
|
||||||
self.factor_verifier = factor_verifier
|
self.factor_verifier = factor_verifier
|
||||||
|
self.custody = custody
|
||||||
if not self.store.ready:
|
if not self.store.ready:
|
||||||
self.store.migrate()
|
self.store.migrate()
|
||||||
|
|
||||||
|
|
@ -1820,6 +1825,8 @@ class UserEngineService:
|
||||||
account_status=account.status,
|
account_status=account.status,
|
||||||
tenant_account_status=scoped.status if scoped is not None else None,
|
tenant_account_status=scoped.status if scoped is not None else None,
|
||||||
observed_at=observed_at, decision_id=decision.decision_id,
|
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(
|
def create_user(
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,25 @@ class AccountAuthorityTests(unittest.TestCase):
|
||||||
self.store.find_identity = checked
|
self.store.find_identity = checked
|
||||||
with self.assertRaises(AuthorizationDenied): self.lookup()
|
with self.assertRaises(AuthorizationDenied): self.lookup()
|
||||||
|
|
||||||
|
def test_custody_mapping_only_after_independent_authorization(self):
|
||||||
|
calls = []
|
||||||
|
class Custody:
|
||||||
|
def observe(inner, **values):
|
||||||
|
calls.append(values)
|
||||||
|
return {"fixture": "observed-fact"} if values["tenant"] == "platform" else None
|
||||||
|
self.service.custody = Custody()
|
||||||
|
result = self.lookup(tenant="platform")
|
||||||
|
self.assertEqual(result.root_grant, {"fixture": "observed-fact"})
|
||||||
|
self.assertEqual(calls[-1], {"issuer": self.session.actor.issuer, "subject": self.session.actor.subject, "tenant": "platform"})
|
||||||
|
self.policy.effect = AuthorizationEffect.DENY
|
||||||
|
with self.assertRaises(AuthorizationDenied):
|
||||||
|
self.lookup(tenant="platform")
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
|
||||||
|
|
||||||
def test_does_not_emit_root_grants_or_profile_details(self):
|
def test_does_not_emit_root_grants_or_profile_details(self):
|
||||||
result = self.lookup()
|
result = self.lookup()
|
||||||
|
self.assertIsNone(result.root_grant)
|
||||||
self.assertFalse(hasattr(result,'root_entitled'))
|
self.assertFalse(hasattr(result,'root_entitled'))
|
||||||
self.assertFalse(hasattr(result,'roles'))
|
self.assertFalse(hasattr(result,'roles'))
|
||||||
self.assertFalse(hasattr(result,'primary_email'))
|
self.assertFalse(hasattr(result,'primary_email'))
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,7 @@ from datetime import datetime, timedelta, timezone
|
||||||
from user_engine.custody import CustodyBaseline, CustodyObservationAdapter, CustodyRead
|
from user_engine.custody import CustodyBaseline, CustodyObservationAdapter, CustodyRead
|
||||||
|
|
||||||
CANON = Path(__file__).resolve().parents[2] / "net-kingdom/tools/custody-record"
|
CANON = Path(__file__).resolve().parents[2] / "net-kingdom/tools/custody-record"
|
||||||
try:
|
from user_engine.custody_canon import policy
|
||||||
spec = importlib.util.spec_from_file_location("custody_canon", CANON / "validate.py")
|
|
||||||
policy = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(policy)
|
|
||||||
except (FileNotFoundError, ModuleNotFoundError):
|
|
||||||
policy = None
|
|
||||||
|
|
||||||
|
|
||||||
class Baselines:
|
class Baselines:
|
||||||
|
|
@ -31,7 +26,7 @@ class Baselines:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipIf(policy is None, "optional sibling NetKingdom validator/jsonschema absent")
|
@unittest.skipUnless(CANON.exists(), "optional sibling NetKingdom fixtures absent")
|
||||||
class CustodyAdapterTests(unittest.TestCase):
|
class CustodyAdapterTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.document = json.loads((CANON / "fixtures/valid/grant.json").read_text())
|
self.document = json.loads((CANON / "fixtures/valid/grant.json").read_text())
|
||||||
|
|
@ -106,6 +101,14 @@ class CustodyAdapterTests(unittest.TestCase):
|
||||||
self.assertIsNone(self.observe())
|
self.assertIsNone(self.observe())
|
||||||
self.assertIsNone(self.baselines.value)
|
self.assertIsNone(self.baselines.value)
|
||||||
|
|
||||||
|
def test_check_clock_sampled_after_owner_read(self):
|
||||||
|
self.adapter.clock = lambda: self.now + timedelta(seconds=2)
|
||||||
|
self.read_at = self.now + timedelta(seconds=1)
|
||||||
|
result = self.adapter.observe(**self.subject, tenant="platform")
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result["observed_at"], self.read_at.isoformat().replace("+00:00", "Z"))
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_and_persistence_conflict_deny(self):
|
def test_bootstrap_and_persistence_conflict_deny(self):
|
||||||
self.adapter.allow_initial_baseline = False
|
self.adapter.allow_initial_baseline = False
|
||||||
self.assertIsNone(self.observe())
|
self.assertIsNone(self.observe())
|
||||||
|
|
|
||||||
33
tests/test_custody_bootstrap.py
Normal file
33
tests/test_custody_bootstrap.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
import test_custody_adapter as adapter_fixtures
|
||||||
|
CANON = adapter_fixtures.CANON
|
||||||
|
from user_engine.custody_bootstrap import initialize_baseline
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(CANON.exists(), "optional sibling fixtures absent")
|
||||||
|
class CustodyBootstrapTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.fixture = adapter_fixtures.CustodyAdapterTests()
|
||||||
|
self.fixture.setUp()
|
||||||
|
self.fixture.adapter.clock = lambda: self.fixture.now
|
||||||
|
self.digest = hashlib.sha256(json.dumps(self.fixture.document, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()).hexdigest()
|
||||||
|
|
||||||
|
def test_authenticated_receipt_match_initializes_once(self):
|
||||||
|
self.assertTrue(initialize_baseline(self.fixture.adapter, expected_sha256=self.digest))
|
||||||
|
self.assertFalse(initialize_baseline(self.fixture.adapter, expected_sha256=self.digest))
|
||||||
|
self.assertEqual(self.fixture.reads, 1)
|
||||||
|
|
||||||
|
def test_wrong_receipt_and_failure_do_not_seed_baseline(self):
|
||||||
|
self.assertFalse(initialize_baseline(self.fixture.adapter, expected_sha256="0" * 64))
|
||||||
|
self.fixture.failure = True
|
||||||
|
self.assertFalse(initialize_baseline(self.fixture.adapter, expected_sha256=self.digest))
|
||||||
|
self.assertIsNone(self.fixture.baselines.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_approved_document_initializes_no_grant(self):
|
||||||
|
self.fixture.document["entries"] = []
|
||||||
|
digest = hashlib.sha256(json.dumps(self.fixture.document, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()).hexdigest()
|
||||||
|
self.assertTrue(initialize_baseline(self.fixture.adapter, expected_sha256=digest))
|
||||||
|
self.assertIsNone(self.fixture.observe())
|
||||||
14
tests/test_custody_policy_package.py
Normal file
14
tests/test_custody_policy_package.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import hashlib
|
||||||
|
from importlib.resources import files
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from user_engine.custody_canon import policy
|
||||||
|
|
||||||
|
|
||||||
|
class CustodyPolicyPackageTests(unittest.TestCase):
|
||||||
|
def test_pinned_policy_and_schema_integrity(self):
|
||||||
|
package = files("user_engine.custody_canon")
|
||||||
|
receipt = json.loads(package.joinpath("provenance.json").read_text())
|
||||||
|
self.assertEqual(hashlib.sha256(package.joinpath("policy.py").read_bytes()).hexdigest(), receipt["packaged_policy_sha256"])
|
||||||
|
self.assertEqual(hashlib.sha256(package.joinpath("custody-record_v0.1.schema.json").read_bytes()).hexdigest(), receipt["schema_sha256"])
|
||||||
|
self.assertEqual(policy.load_schema()["$id"], "https://policy.coulomb.social/schemas/custody-record/v0.1")
|
||||||
130
tests/test_custody_transport.py
Normal file
130
tests/test_custody_transport.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
|
||||||
|
from user_engine.custody_runtime import OpenBaoCustodyReader, CustodyReadUnavailable
|
||||||
|
|
||||||
|
|
||||||
|
class Response(io.BytesIO):
|
||||||
|
def __init__(self, value, *, headers=None):
|
||||||
|
super().__init__(json.dumps(value).encode())
|
||||||
|
self.headers = headers or {}
|
||||||
|
self.status = 200
|
||||||
|
|
||||||
|
|
||||||
|
class Opener:
|
||||||
|
def __init__(self, responses):
|
||||||
|
self.responses, self.requests = list(responses), []
|
||||||
|
def open(self, request, timeout):
|
||||||
|
self.requests.append((request, timeout))
|
||||||
|
response = self.responses.pop(0)
|
||||||
|
if isinstance(response, Exception):
|
||||||
|
raise response
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class CustodyTransportTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.jwt = Path(self.temp.name) / "token"
|
||||||
|
self.jwt.write_text("fixture-jwt\n")
|
||||||
|
self.now = datetime.now(timezone.utc)
|
||||||
|
self.document = {"revision": 4}
|
||||||
|
|
||||||
|
def reader(self, opener, **options):
|
||||||
|
return OpenBaoCustodyReader(base_url="https://bao.example", auth_mount="kubernetes",
|
||||||
|
role="user-engine-root-custody-reader", jwt_file=str(self.jwt), source_id="trusted",
|
||||||
|
opener=opener, clock=lambda: self.now, **options)
|
||||||
|
|
||||||
|
def login(self):
|
||||||
|
return Response({"auth": {"client_token": "fixture-reader-token"}})
|
||||||
|
|
||||||
|
def test_dedicated_login_fresh_exact_read_and_revoke(self):
|
||||||
|
opener = Opener([self.login(), Response({"data": {"data": {"document": self.document}}}), Response({})])
|
||||||
|
result = self.reader(opener).read()
|
||||||
|
self.assertEqual(result.document, self.document)
|
||||||
|
self.assertEqual(result.read_at, self.now)
|
||||||
|
login, read, revoke = [entry[0] for entry in opener.requests]
|
||||||
|
self.assertEqual(json.loads(login.data), {"role": "user-engine-root-custody-reader", "jwt": "fixture-jwt"})
|
||||||
|
self.assertEqual(read.full_url, "https://bao.example/v1/platform/data/custody/net-kingdom/platform-root")
|
||||||
|
self.assertEqual(read.get_header("X-vault-token"), "fixture-reader-token")
|
||||||
|
self.assertTrue(revoke.full_url.endswith("auth/token/revoke-self"))
|
||||||
|
self.assertTrue(all(0 < timeout <= 3 for _, timeout in opener.requests))
|
||||||
|
|
||||||
|
def test_owner_denial_errors_are_suppressed(self):
|
||||||
|
opener = Opener([HTTPError("https://secret-value.invalid", 403, "sensitive", {}, None)])
|
||||||
|
with self.assertRaises(CustodyReadUnavailable) as error:
|
||||||
|
self.reader(opener).read()
|
||||||
|
self.assertEqual(str(error.exception), "custody read unavailable")
|
||||||
|
self.assertIsNone(error.exception.__cause__)
|
||||||
|
|
||||||
|
def test_cache_hit_malformed_and_oversized_responses_deny(self):
|
||||||
|
for response in (Response({"data": {}}, headers={"Age": "1"}), Response({"wrong": "envelope"}),
|
||||||
|
Response({"data": {"data": {"document": {"padding": "x" * 2000}}}})):
|
||||||
|
opener = Opener([self.login(), response, Response({})])
|
||||||
|
with self.assertRaises(CustodyReadUnavailable):
|
||||||
|
self.reader(opener, max_bytes=1024).read()
|
||||||
|
|
||||||
|
def test_token_rotation_is_read_from_file_each_login(self):
|
||||||
|
opener = Opener([self.login(), Response({"data": {"data": {"document": self.document}}}), Response({}),
|
||||||
|
self.login(), Response({"data": {"data": {"document": self.document}}}), Response({})])
|
||||||
|
reader = self.reader(opener)
|
||||||
|
reader.read()
|
||||||
|
self.jwt.write_text("rotated-fixture-jwt")
|
||||||
|
reader.read()
|
||||||
|
self.assertEqual(json.loads(opener.requests[3][0].data)["jwt"], "rotated-fixture-jwt")
|
||||||
|
|
||||||
|
def test_plaintext_untrusted_origin_and_path_are_configuration_errors(self):
|
||||||
|
for origin in ("http://bao.example", "https://user:pass@bao.example", "https://bao.example/path"):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
OpenBaoCustodyReader(base_url=origin, auth_mount="kubernetes", role="reader", jwt_file="fixture", source_id="source")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.reader(Opener([]), data_path="platform/data/other")
|
||||||
|
|
||||||
|
|
||||||
|
class CustodyNestedIntegrationTests(unittest.TestCase):
|
||||||
|
def test_nested_owner_response_to_serialized_account_authority(self):
|
||||||
|
from datetime import timedelta
|
||||||
|
from dataclasses import replace
|
||||||
|
from user_engine.adapters.local import InMemoryUserEngineStore
|
||||||
|
from user_engine.custody import CustodyObservationAdapter
|
||||||
|
from user_engine.custody_canon import policy
|
||||||
|
from user_engine.service import UserEngineService
|
||||||
|
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, StaticAuthorizationCheckPort, human_actor_claims
|
||||||
|
from user_engine.web import _jsonable
|
||||||
|
from test_custody_adapter import Baselines, CANON
|
||||||
|
if not CANON.exists():
|
||||||
|
self.skipTest("optional sibling fixtures absent")
|
||||||
|
document = json.loads((CANON / "fixtures/valid/grant.json").read_text())
|
||||||
|
now = datetime(2026, 10, 3, 12, tzinfo=timezone.utc)
|
||||||
|
claims = human_actor_claims()
|
||||||
|
claims.update(iss=document["entries"][0]["subject"]["issuer"], sub=document["entries"][0]["subject"]["subject"], tenant="platform")
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
jwt_file = Path(directory) / "token"
|
||||||
|
jwt_file.write_text("fixture-openbao-audience-jwt")
|
||||||
|
opener = Opener([Response({"auth": {"client_token": "fixture-token"}}),
|
||||||
|
Response({"data": {"data": {"document": document}}}), Response({})])
|
||||||
|
source = "https://bao.example/v1/platform/data/custody/net-kingdom/platform-root#auth=kubernetes/user-engine-root-custody-reader"
|
||||||
|
reader = OpenBaoCustodyReader(base_url="https://bao.example", auth_mount="kubernetes",
|
||||||
|
role="user-engine-root-custody-reader", jwt_file=str(jwt_file), source_id=source,
|
||||||
|
opener=opener, clock=lambda: now)
|
||||||
|
adapter = CustodyObservationAdapter(reader=reader, baselines=Baselines(), policy=policy,
|
||||||
|
source_id=source, record_id=document["record_id"], allow_initial_baseline=True,
|
||||||
|
clock=lambda: now + timedelta(milliseconds=1))
|
||||||
|
service = UserEngineService(store=InMemoryUserEngineStore(), identity_adapter=FixtureIdentityClaimsAdapter(),
|
||||||
|
authorization=StaticAuthorizationCheckPort(), custody=adapter)
|
||||||
|
session = service.me(claims)
|
||||||
|
result = service.lookup_account_authority(replace(session.actor, subject="fixture-hub-reader"),
|
||||||
|
issuer=claims["iss"], subject=claims["sub"], tenant="platform")
|
||||||
|
envelope = _jsonable(result)
|
||||||
|
observation = envelope["root_grant"]
|
||||||
|
self.assertEqual(observation["source"], {"record_id": document["record_id"], "revision": 1})
|
||||||
|
self.assertEqual(observation["subject"], document["entries"][0]["subject"])
|
||||||
|
self.assertEqual(observation["observed_at"], "2026-10-03T12:00:00Z")
|
||||||
|
self.assertEqual(observation["expires_at"], "2026-10-03T12:15:00Z")
|
||||||
|
self.assertEqual(opener.requests[1][0].full_url, "https://bao.example/v1/platform/data/custody/net-kingdom/platform-root")
|
||||||
150
uv.lock
generated
150
uv.lock
generated
|
|
@ -1,6 +1,15 @@
|
||||||
version = 1
|
version = 1
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "attrs"
|
||||||
|
version = "26.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cffi"
|
name = "cffi"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
|
|
@ -136,6 +145,33 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429 },
|
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonschema"
|
||||||
|
version = "4.26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "attrs" },
|
||||||
|
{ name = "jsonschema-specifications" },
|
||||||
|
{ name = "referencing" },
|
||||||
|
{ name = "rpds-py" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonschema-specifications"
|
||||||
|
version = "2025.9.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "referencing" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psycopg"
|
name = "psycopg"
|
||||||
version = "3.3.5"
|
version = "3.3.5"
|
||||||
|
|
@ -217,6 +253,116 @@ crypto = [
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "referencing"
|
||||||
|
version = "0.37.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "attrs" },
|
||||||
|
{ name = "rpds-py" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpds-py"
|
||||||
|
version = "2026.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.16.0"
|
version = "4.16.0"
|
||||||
|
|
@ -239,6 +385,9 @@ wheels = [
|
||||||
name = "user-engine"
|
name = "user-engine"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jsonschema" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
oidc = [
|
oidc = [
|
||||||
|
|
@ -254,6 +403,7 @@ runtime = [
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "jsonschema", specifier = ">=4.10,<5" },
|
||||||
{ name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2,<4" },
|
{ name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2,<4" },
|
||||||
{ name = "psycopg", extras = ["binary"], marker = "extra == 'runtime'", specifier = ">=3.2,<4" },
|
{ name = "psycopg", extras = ["binary"], marker = "extra == 'runtime'", specifier = ">=3.2,<4" },
|
||||||
{ name = "pyjwt", extras = ["crypto"], marker = "extra == 'oidc'", specifier = ">=2.10,<3" },
|
{ name = "pyjwt", extras = ["crypto"], marker = "extra == 'oidc'", specifier = ">=2.10,<3" },
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ id: USER-WP-0037-T02
|
||||||
status: wait
|
status: wait
|
||||||
depends_on: [NK-WP-0045-T05]
|
depends_on: [NK-WP-0045-T05]
|
||||||
blocked_on: repo:railiance-platform
|
blocked_on: repo:railiance-platform
|
||||||
blocking_reason: "ADR and OpenBao design approved; awaits NK-WP-0045-T05 authenticated delivery plus consumer identity admission, durable baseline, canon policy packaging and live mapping/integration receipts."
|
blocking_reason: "Consumer transport, durable baseline, pinned canon and optional mapping implemented; awaits NK-WP-0045-T05 real provenance/admission plus owner image promotion, receipt-bound baseline initialization and live integration evidence."
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "23a5c211-277b-561e-a4b3-0f8e99537cac"
|
state_hub_task_id: "23a5c211-277b-561e-a4b3-0f8e99537cac"
|
||||||
```
|
```
|
||||||
|
|
@ -122,6 +122,44 @@ withheld due to changed informed-decision source. Reviewed current local source
|
||||||
README plus current NetKingdom companion and custodian build guidance; restricted
|
README plus current NetKingdom companion and custodian build guidance; restricted
|
||||||
work to offline source/tests, with no authority or live-readiness inference.
|
work to offline source/tests, with no authority or live-readiness inference.
|
||||||
|
|
||||||
|
2026-10-03 continued go-forward implementation: the source is now complete for
|
||||||
|
the confirmed OpenBao/Kubernetes interface. Added dedicated projected-JWT login,
|
||||||
|
exact-path fresh KV reader (256 KiB; bounded timeout; denied redirects/cache hits;
|
||||||
|
safe errors), persistent PostgreSQL baseline compare-and-swap in the existing
|
||||||
|
database, optional AccountAuthority.root_grant and disabled-by-default runtime
|
||||||
|
configuration. Owner check time is sampled after I/O; returned observation retains
|
||||||
|
the canon-derived expires_at. Embedded validator/schema/provenance are pinned to
|
||||||
|
NetKingdom `db319335b27b61b855382ee5af5d690f06eeb010`, with schema lookup only
|
||||||
|
relocated. No sibling checkout dependency in the runtime.
|
||||||
|
|
||||||
|
Confirmed deployment source in `rapp-user-engine/manifests/runtime.yaml`: namespace
|
||||||
|
and ServiceAccount user-engine; existing flex-auth audience is not reused. Platform
|
||||||
|
agreed dedicated auth/kubernetes role user-engine-root-custody-reader, audience
|
||||||
|
openbao, no default policy and 60-second maximum token lifetime. The packaging
|
||||||
|
owner prepares the separate projected token and disabled overlay. No live grant,
|
||||||
|
credential provisioning or deployment acceptance is inferred from source changes.
|
||||||
|
|
||||||
|
`python -m user_engine.custody_bootstrap --expected-sha256 <publisher-digest>` is
|
||||||
|
the concrete controlled initialization tool: fresh authenticated read, exact
|
||||||
|
source/record binding and validated canonical JSON digest from a reviewed owner
|
||||||
|
receipt; initialize absent baseline once, never reset. Restore/source changes
|
||||||
|
require external producer audit/high-water reconciliation before reactivation.
|
||||||
|
Complete procedures and configuration: `docs/root-custody-runtime.md`.
|
||||||
|
|
||||||
|
Validation: full make test passed296 tests (eight optional skips), with layer
|
||||||
|
conformance and diff checks passing; isolated disposable
|
||||||
|
PostgreSQL16 verified bootstrap CAS, competing-writer conflict and reconnect
|
||||||
|
persistence. Wheel build succeeded with embedded policy/schema/provenance. Offline
|
||||||
|
integration covers nested data.data.document to serialized AccountAuthority,
|
||||||
|
exact source/path/role mapping, post-read timing and emitted expires_at. No live
|
||||||
|
OpenBao or production database was accessed by these checks.
|
||||||
|
|
||||||
|
Founder reports no existing ceremony record; the approved initial document may
|
||||||
|
contain entries[] and therefore no grant. T02 remains wait for authenticated
|
||||||
|
publisher receipt (empty no-grant initialization is acceptable), platform reader admission,
|
||||||
|
owner image promotion, reviewed baseline initialization and live acceptance.
|
||||||
|
USER-WP-0037 stays blocked; T03 retains its independent Hub admission dependency.
|
||||||
|
|
||||||
## Authenticated workload transport and private acceptance
|
## Authenticated workload transport and private acceptance
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue