CUST-WP-0075: NK-WP-0045 custody-record schema and validator; NK-WP-0042 reply
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 3775@bnt-lap001 Assistant-Session: cfacb6a5-ad5b-4f79-ac67-053e3b0d34a3
This commit is contained in:
parent
a8cebcfc94
commit
17f92c9f11
21 changed files with 1196 additions and 2 deletions
4
Makefile
4
Makefile
|
|
@ -214,6 +214,9 @@ security-scenario-compose-lightweight: ## Compose plan-only KeyCape C1+C2b from
|
|||
posture-feedback-test: ## Run deterministic posture-feedback tests
|
||||
python3 -m pytest tools/posture-feedback/tests tools/tenancy-posture/test_validate.py
|
||||
|
||||
custody-record-test: ## Run custody-record schema/validator tests (NK-WP-0045-T03)
|
||||
python3 -m pytest tools/custody-record/tests
|
||||
|
||||
posture-feedback-reference: ## Emit the proposal-only reference report at its fixed example time
|
||||
python3 tools/posture-feedback/posture_feedback.py \
|
||||
--as-of 2026-08-23T12:00:00Z \
|
||||
|
|
@ -384,6 +387,7 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a
|
|||
creds-agent-init creds-agent-status creds-emergency-reprint \
|
||||
openbao-init-unseal openbao-init-unseal-dry-run \
|
||||
iam-profile-conformance-test playbook-contract-test posture-feedback-test \
|
||||
custody-record-test \
|
||||
emission-cadence-profile-test \
|
||||
posture-feedback-reference security-scenario-compose-lightweight \
|
||||
security-bootstrap-console-test security-bootstrap-scripts-syntax \
|
||||
|
|
|
|||
135
canon/schemas/custody-record_v0.1.schema.json
Normal file
135
canon/schemas/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 proposed; 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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
23
tools/custody-record/README.md
Normal file
23
tools/custody-record/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Custody Record Validator
|
||||
|
||||
Validates the non-secret platform-root custody record defined by
|
||||
`canon/schemas/custody-record_v0.1.schema.json` (NK-ADR-0017, status
|
||||
proposed; NK-WP-0045-T02/T03). Offline and read-only.
|
||||
|
||||
```bash
|
||||
uv run tools/custody-record/validate.py tools/custody-record/fixtures/valid/*.json
|
||||
uv run pytest tools/custody-record/tests
|
||||
```
|
||||
|
||||
Beyond the schema it checks: one active grant per exact `(issuer, subject)`,
|
||||
revision ordering and supersession links inside a record, append-only
|
||||
successors across reads (`successor_errors`: an older revision is a stale read,
|
||||
a removed entry is not a revocation), and secret-looking values.
|
||||
`observe_root_grant` is the consumer rule from ADR-0017 section 3: a grant only
|
||||
for tenant `platform`, from a valid record read at most 15 minutes ago,
|
||||
otherwise no grant (fail closed).
|
||||
|
||||
Fixtures: `valid/` covers grant, revocation and supersession; `invalid/`
|
||||
covers projection-only grants, tenant-admin implication, other tenants,
|
||||
secret-bearing fields and values, username keys, an over-long cache bound,
|
||||
duplicate active grants and an entry both revoked and superseded.
|
||||
40
tools/custody-record/fixtures/invalid/cache-over-bound.json
Normal file
40
tools/custody-record/fixtures/invalid/cache-over-bound.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 1,
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 3600,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 1,
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 900,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"entry_id": "king-1b",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 1,
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 900,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "acme",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 1,
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 900,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "projection",
|
||||
"ref": "lldap group platform-root"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 1,
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 900,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
},
|
||||
"recovery_codes": [
|
||||
"<redacted-fixture>"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 2,
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 900,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
},
|
||||
"revoked": {
|
||||
"at": "2026-10-02T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "x",
|
||||
"revision": 2
|
||||
},
|
||||
"superseded_by": {
|
||||
"entry_id": "king-1",
|
||||
"revision": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 1,
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 900,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
},
|
||||
"root_token": "<redacted-fixture>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 1,
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 900,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "handover hvs.FIXTURE-NOT-A-TOKEN",
|
||||
"revision": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 1,
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 900,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "tenant-admin",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "tenant-admin",
|
||||
"ref": "tenant:platform admin role"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 1,
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 900,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"unseal_shares": [
|
||||
"<redacted-fixture>"
|
||||
]
|
||||
}
|
||||
40
tools/custody-record/fixtures/invalid/username-key.json
Normal file
40
tools/custody-record/fixtures/invalid/username-key.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 1,
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 900,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"username": "tegwick"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
40
tools/custody-record/fixtures/valid/grant.json
Normal file
40
tools/custody-record/fixtures/valid/grant.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 1,
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 900,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
49
tools/custody-record/fixtures/valid/revocation.json
Normal file
49
tools/custody-record/fixtures/valid/revocation.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 2,
|
||||
"updated_at": "2026-10-02T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 900,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
},
|
||||
"revoked": {
|
||||
"at": "2026-10-02T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "Custodian stepped down; break-glass under king custody unaffected",
|
||||
"revision": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
70
tools/custody-record/fixtures/valid/supersession.json
Normal file
70
tools/custody-record/fixtures/valid/supersession.json
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
{
|
||||
"schema_version": "0.1",
|
||||
"kind": "netkingdom-custody-record",
|
||||
"record_id": "nk-custody:platform-root",
|
||||
"revision": 2,
|
||||
"updated_at": "2026-10-02T09:00:00Z",
|
||||
"owner_system": "net-kingdom-custody",
|
||||
"freshness": {
|
||||
"max_cache_seconds": 900,
|
||||
"on_unreadable": "no-grant"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "king-1",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000001"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-01T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "King credential assigned at custody ceremony",
|
||||
"revision": 1
|
||||
},
|
||||
"superseded_by": {
|
||||
"entry_id": "king-2",
|
||||
"revision": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"entry_id": "king-2",
|
||||
"subject": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000002"
|
||||
},
|
||||
"grant": {
|
||||
"scope_type": "tenant",
|
||||
"scope_id": "platform",
|
||||
"relation": "platform-root-custodian",
|
||||
"owner_system": "net-kingdom-custody"
|
||||
},
|
||||
"evidence": {
|
||||
"basis": "custody-ceremony",
|
||||
"ref": "docs/platform-root-custody.md#king-credential-signoff"
|
||||
},
|
||||
"granted": {
|
||||
"at": "2026-10-02T09:00:00Z",
|
||||
"actor": {
|
||||
"issuer": "https://kc.coulomb.social/realms/netkingdom",
|
||||
"subject": "00000000-0000-4000-8000-000000000009"
|
||||
},
|
||||
"reason": "Custody handed over to the successor king credential",
|
||||
"revision": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
140
tools/custody-record/tests/test_custody_record.py
Normal file
140
tools/custody-record/tests/test_custody_record.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import datetime as dt
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
import jsonschema
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
MODULE_PATH = HERE.parent / "validate.py"
|
||||
SPEC = importlib.util.spec_from_file_location("custody_record_validate", MODULE_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
V = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(V)
|
||||
FIXTURES = HERE.parent / "fixtures"
|
||||
ISS = "https://kc.coulomb.social/realms/netkingdom"
|
||||
KING_1 = "00000000-0000-4000-8000-000000000001"
|
||||
KING_2 = "00000000-0000-4000-8000-000000000002"
|
||||
READ_AT = dt.datetime(2026, 10, 3, 9, 0, tzinfo=dt.timezone.utc)
|
||||
|
||||
|
||||
def load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class SchemaTests(unittest.TestCase):
|
||||
def test_schema_is_valid_draft_2020_12(self) -> None:
|
||||
jsonschema.Draft202012Validator.check_schema(V.load_schema())
|
||||
|
||||
def test_valid_fixtures_pass(self) -> None:
|
||||
for name in ("grant", "revocation", "supersession"):
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(V.validate(load(f"valid/{name}.json")), [])
|
||||
|
||||
def test_every_invalid_fixture_fails_for_its_reason(self) -> None:
|
||||
expected = {
|
||||
"projection-only-grant": "custody-ceremony",
|
||||
"tenant-admin-implication": "platform-root-custodian",
|
||||
"other-tenant-grant": "'platform' was expected",
|
||||
"secret-token-field": "root_token",
|
||||
"recovery-code-field": "recovery_codes",
|
||||
"unseal-share-field": "unseal_shares",
|
||||
"secret-value-in-reason": "secret material",
|
||||
"username-key": "username",
|
||||
"cache-over-bound": "maximum of 900",
|
||||
"duplicate-active-subject": "second active grant",
|
||||
"revoked-and-superseded": "should not be valid",
|
||||
}
|
||||
found = {path.stem for path in (FIXTURES / "invalid").glob("*.json")}
|
||||
self.assertEqual(found, set(expected))
|
||||
for name, needle in expected.items():
|
||||
with self.subTest(name=name):
|
||||
errors = V.validate(load(f"invalid/{name}.json"))
|
||||
self.assertTrue(errors)
|
||||
self.assertIn(needle, "\n".join(errors))
|
||||
|
||||
def test_secret_names_rejected_at_any_depth(self) -> None:
|
||||
for field in ("api_key", "privateKey", "reset_link", "otp_seed", "password"):
|
||||
with self.subTest(field=field):
|
||||
record = load("valid/grant.json")
|
||||
record["entries"][0]["evidence"][field] = "<redacted-fixture>"
|
||||
self.assertTrue(V.validate(record))
|
||||
|
||||
def test_revision_ordering_inside_a_record(self) -> None:
|
||||
record = load("valid/revocation.json")
|
||||
record["entries"][0]["revoked"]["revision"] = 1
|
||||
self.assertIn("revoked.revision", "\n".join(V.validate(record)))
|
||||
record = load("valid/grant.json")
|
||||
record["entries"][0]["granted"]["revision"] = 2
|
||||
self.assertIn("after record revision", "\n".join(V.validate(record)))
|
||||
|
||||
def test_supersession_must_point_at_the_successor_grant(self) -> None:
|
||||
record = load("valid/supersession.json")
|
||||
record["entries"][0]["superseded_by"]["entry_id"] = "missing"
|
||||
self.assertIn("names no other entry", "\n".join(V.validate(record)))
|
||||
|
||||
|
||||
class SuccessorTests(unittest.TestCase):
|
||||
def test_revocation_is_a_newer_append_only_revision(self) -> None:
|
||||
self.assertEqual(V.successor_errors(load("valid/grant.json"), load("valid/revocation.json")), [])
|
||||
self.assertEqual(V.successor_errors(load("valid/grant.json"), load("valid/supersession.json")), [])
|
||||
|
||||
def test_older_revision_is_a_stale_read(self) -> None:
|
||||
errors = V.successor_errors(load("valid/revocation.json"), load("valid/grant.json"))
|
||||
self.assertIn("stale read", errors[0])
|
||||
|
||||
def test_same_revision_different_content_rejected(self) -> None:
|
||||
later = load("valid/grant.json")
|
||||
later["entries"][0]["granted"]["reason"] = "rewritten"
|
||||
self.assertTrue(V.successor_errors(load("valid/grant.json"), later))
|
||||
|
||||
def test_deleting_an_entry_is_not_a_revocation(self) -> None:
|
||||
later = load("valid/grant.json")
|
||||
later["revision"] = 2
|
||||
later["entries"] = []
|
||||
self.assertIn("never delete", "\n".join(V.successor_errors(load("valid/grant.json"), later)))
|
||||
|
||||
|
||||
class ObservationTests(unittest.TestCase):
|
||||
def observe(self, record, subject=KING_1, tenant="platform", age=0, issuer=ISS):
|
||||
return V.observe_root_grant(
|
||||
record, issuer, subject, tenant, READ_AT, READ_AT + dt.timedelta(seconds=age)
|
||||
)
|
||||
|
||||
def test_active_entry_yields_adr_0017_observation(self) -> None:
|
||||
observation = self.observe(load("valid/grant.json"))
|
||||
self.assertEqual(observation["grant"]["scope_id"], "platform")
|
||||
self.assertEqual(observation["grant"]["relation"], "platform-root-custodian")
|
||||
self.assertEqual(observation["source"], {"record_id": "nk-custody:platform-root", "revision": 1})
|
||||
self.assertEqual(set(observation), {"subject", "grant", "source", "observed_at"})
|
||||
|
||||
def test_fail_closed_cases(self) -> None:
|
||||
grant = load("valid/grant.json")
|
||||
cases = {
|
||||
"other tenant": dict(record=grant, tenant="acme"),
|
||||
"unreadable record": dict(record=None),
|
||||
"stale beyond 15 minutes": dict(record=grant, age=901),
|
||||
"unknown subject": dict(record=grant, subject=KING_2),
|
||||
"different issuer": dict(record=grant, issuer="https://other.example"),
|
||||
"revoked": dict(record=load("valid/revocation.json")),
|
||||
"superseded": dict(record=load("valid/supersession.json")),
|
||||
"invalid record": dict(record=load("invalid/projection-only-grant.json")),
|
||||
}
|
||||
for name, kwargs in cases.items():
|
||||
with self.subTest(name=name):
|
||||
self.assertIsNone(self.observe(**kwargs))
|
||||
|
||||
def test_cache_bound_edge_and_successor(self) -> None:
|
||||
self.assertIsNotNone(self.observe(load("valid/grant.json"), age=900))
|
||||
self.assertIsNotNone(self.observe(load("valid/supersession.json"), subject=KING_2))
|
||||
tighter = copy.deepcopy(load("valid/grant.json"))
|
||||
tighter["freshness"]["max_cache_seconds"] = 60
|
||||
self.assertIsNone(self.observe(tighter, age=61))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
207
tools/custody-record/validate.py
Normal file
207
tools/custody-record/validate.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
#!/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
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
SCHEMA = ROOT / "canon/schemas/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."""
|
||||
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"]}
|
||||
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,
|
||||
) -> 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
|
||||
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):
|
||||
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"),
|
||||
}
|
||||
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:
|
||||
errors = validate(json.loads(path.read_text(encoding="utf-8")), schema)
|
||||
for error in errors:
|
||||
print(f"{path}: {error}", file=sys.stderr)
|
||||
failed = failed or bool(errors)
|
||||
if not errors:
|
||||
print(f"{path}: ok")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -79,6 +79,18 @@ cannot decide it unilaterally.
|
|||
|
||||
2026-10-03: human gate routed to hub decision cef97ccd-4ee7-40fe-859e-1df9c9360ecb (D10: Identity record and MFA: accept NK-ADR-0017, choose the record read surface, agree the MFA user experience) by CUST-WP-0075; resolving that decision raises C-40 here.
|
||||
|
||||
2026-10-03 (CUST-WP-0075 inbox sweep): gate re-checked, unchanged. The unread
|
||||
user-engine message of 2026-10-03 (`db1b7d6e`, USER-WP-0026-T03) was about the
|
||||
workload catalogue/admission and access-decision owner, not this journey; it
|
||||
was answered (reply `19a0cbcd`: access-engine owns decisions, grants,
|
||||
revocation and the protected-system registry; the catalogue is the onboarding
|
||||
binding of access-engine protected system, key-cape OIDC client and user-engine
|
||||
application record; NetKingdom only orchestrates). It does not settle T02. Our
|
||||
2026-09-27 request `892fd489` (pilot workload, U06 touchpoints) is still unread
|
||||
by user-engine, so `blocked_on: message-from:user-engine` stays real, and the
|
||||
founder's acceptance under decision cef97ccd stays the human gate. The reply
|
||||
reminds user-engine of `892fd489`.
|
||||
|
||||
## Infrastructure review — 2026-09-28
|
||||
|
||||
Build T02 on the already completed USER-WP-0033 and KEY-WP-0035 P06 work.
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ ADR to `accepted`.
|
|||
|
||||
```task
|
||||
id: NK-WP-0045-T02
|
||||
status: todo
|
||||
status: done
|
||||
depends_on: [NK-WP-0045-T01]
|
||||
priority: high
|
||||
state_hub_task_id: "49d8dd25-b555-55e9-a481-037d2b98fcc5"
|
||||
|
|
@ -52,11 +52,27 @@ Add `canon/schemas/custody-record_v0.1.schema.json`: record id and revision,
|
|||
granted-at, superseded-by, revoked-at with actor and reason. Non-secret only;
|
||||
the schema rejects token, key, share and recovery-code fields.
|
||||
|
||||
**Done 2026-10-03** (CUST-WP-0075, build mode; ADR-0017 stays `proposed`,
|
||||
acceptance remains T01 under decision cef97ccd). Added
|
||||
`canon/schemas/custody-record_v0.1.schema.json`: `record_id` +
|
||||
monotonic per-record `revision`, `owner_system: net-kingdom-custody`, and
|
||||
entries keyed by exact `(issuer, subject)` (no username/email/group key), each
|
||||
with the fixed grant `{scope_type: tenant, scope_id: platform, relation:
|
||||
platform-root-custodian, owner_system: net-kingdom-custody}`, ceremony evidence
|
||||
(`basis: custody-ceremony` only, so projections cannot ground an entry),
|
||||
`granted`, optional `superseded_by` (entry id + revision) or `revoked` (at,
|
||||
actor, reason, revision), never both. A `freshness` block caps consumer cache at
|
||||
900 s with `on_unreadable: no-grant`. Field names matching token, key, secret,
|
||||
share, recovery, password, reset, seed, private, otp or credential are rejected
|
||||
at every level. Incorporates user-engine's 2026-10-03 confirmation (message
|
||||
`60acf176`): exact `(issuer, subject)` keying, monotonic revision, 15-minute
|
||||
cache bound, fail closed. If T01 amends the ADR, revise this schema to match.
|
||||
|
||||
## Add a validator and fixtures
|
||||
|
||||
```task
|
||||
id: NK-WP-0045-T03
|
||||
status: todo
|
||||
status: done
|
||||
depends_on: [NK-WP-0045-T02]
|
||||
priority: medium
|
||||
state_hub_task_id: "6372f282-a12b-54b4-b2ea-327b20e58c8f"
|
||||
|
|
@ -66,6 +82,18 @@ Validator with grant, revocation and supersession fixtures, plus negative cases:
|
|||
projection-only grants, tenant-admin implication, secret-bearing fields.
|
||||
Follow the existing tool layout under `tools/`.
|
||||
|
||||
**Done 2026-10-03** (CUST-WP-0075). `tools/custody-record/validate.py` (schema
|
||||
plus semantics: one active grant per `(issuer, subject)`, revision ordering,
|
||||
supersession links, secret-looking values; `successor_errors` for append-only,
|
||||
strictly newer revisions so a stale read is distinguishable from a revocation;
|
||||
`observe_root_grant` for the ADR-0017 section 3 fail-closed read rule).
|
||||
Fixtures under `tools/custody-record/fixtures/`: valid grant, revocation,
|
||||
supersession; invalid projection-only grant, tenant-admin implication, other
|
||||
tenant, root-token / recovery-code / unseal-share fields, secret-looking value,
|
||||
username key, cache over 900 s, duplicate active grant, revoked-and-superseded.
|
||||
Tests `tools/custody-record/tests/test_custody_record.py` (13 passed;
|
||||
`make custody-record-test`); full `pytest tests tools`: 131 passed.
|
||||
|
||||
## Decide the read surface
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue