diff --git a/Makefile b/Makefile index 1880c2f..7af282b 100644 --- a/Makefile +++ b/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 \ diff --git a/canon/schemas/custody-record_v0.1.schema.json b/canon/schemas/custody-record_v0.1.schema.json new file mode 100644 index 0000000..5467043 --- /dev/null +++ b/canon/schemas/custody-record_v0.1.schema.json @@ -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"] } + } + } +} diff --git a/tools/custody-record/README.md b/tools/custody-record/README.md new file mode 100644 index 0000000..69808b4 --- /dev/null +++ b/tools/custody-record/README.md @@ -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. diff --git a/tools/custody-record/fixtures/invalid/cache-over-bound.json b/tools/custody-record/fixtures/invalid/cache-over-bound.json new file mode 100644 index 0000000..3506062 --- /dev/null +++ b/tools/custody-record/fixtures/invalid/cache-over-bound.json @@ -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 + } + } + ] +} diff --git a/tools/custody-record/fixtures/invalid/duplicate-active-subject.json b/tools/custody-record/fixtures/invalid/duplicate-active-subject.json new file mode 100644 index 0000000..43a462e --- /dev/null +++ b/tools/custody-record/fixtures/invalid/duplicate-active-subject.json @@ -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 + } + } + ] +} diff --git a/tools/custody-record/fixtures/invalid/other-tenant-grant.json b/tools/custody-record/fixtures/invalid/other-tenant-grant.json new file mode 100644 index 0000000..0886e4d --- /dev/null +++ b/tools/custody-record/fixtures/invalid/other-tenant-grant.json @@ -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 + } + } + ] +} diff --git a/tools/custody-record/fixtures/invalid/projection-only-grant.json b/tools/custody-record/fixtures/invalid/projection-only-grant.json new file mode 100644 index 0000000..df4e6ad --- /dev/null +++ b/tools/custody-record/fixtures/invalid/projection-only-grant.json @@ -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 + } + } + ] +} diff --git a/tools/custody-record/fixtures/invalid/recovery-code-field.json b/tools/custody-record/fixtures/invalid/recovery-code-field.json new file mode 100644 index 0000000..5cb5f56 --- /dev/null +++ b/tools/custody-record/fixtures/invalid/recovery-code-field.json @@ -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": [ + "" + ] + } + ] +} diff --git a/tools/custody-record/fixtures/invalid/revoked-and-superseded.json b/tools/custody-record/fixtures/invalid/revoked-and-superseded.json new file mode 100644 index 0000000..662adda --- /dev/null +++ b/tools/custody-record/fixtures/invalid/revoked-and-superseded.json @@ -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 + } + } + ] +} diff --git a/tools/custody-record/fixtures/invalid/secret-token-field.json b/tools/custody-record/fixtures/invalid/secret-token-field.json new file mode 100644 index 0000000..72402f9 --- /dev/null +++ b/tools/custody-record/fixtures/invalid/secret-token-field.json @@ -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": "" + } + ] +} diff --git a/tools/custody-record/fixtures/invalid/secret-value-in-reason.json b/tools/custody-record/fixtures/invalid/secret-value-in-reason.json new file mode 100644 index 0000000..d8824d0 --- /dev/null +++ b/tools/custody-record/fixtures/invalid/secret-value-in-reason.json @@ -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 + } + } + ] +} diff --git a/tools/custody-record/fixtures/invalid/tenant-admin-implication.json b/tools/custody-record/fixtures/invalid/tenant-admin-implication.json new file mode 100644 index 0000000..f88cab2 --- /dev/null +++ b/tools/custody-record/fixtures/invalid/tenant-admin-implication.json @@ -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 + } + } + ] +} diff --git a/tools/custody-record/fixtures/invalid/unseal-share-field.json b/tools/custody-record/fixtures/invalid/unseal-share-field.json new file mode 100644 index 0000000..ece772e --- /dev/null +++ b/tools/custody-record/fixtures/invalid/unseal-share-field.json @@ -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": [ + "" + ] +} diff --git a/tools/custody-record/fixtures/invalid/username-key.json b/tools/custody-record/fixtures/invalid/username-key.json new file mode 100644 index 0000000..be464f1 --- /dev/null +++ b/tools/custody-record/fixtures/invalid/username-key.json @@ -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 + } + } + ] +} diff --git a/tools/custody-record/fixtures/valid/grant.json b/tools/custody-record/fixtures/valid/grant.json new file mode 100644 index 0000000..1288bfc --- /dev/null +++ b/tools/custody-record/fixtures/valid/grant.json @@ -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 + } + } + ] +} diff --git a/tools/custody-record/fixtures/valid/revocation.json b/tools/custody-record/fixtures/valid/revocation.json new file mode 100644 index 0000000..38b0336 --- /dev/null +++ b/tools/custody-record/fixtures/valid/revocation.json @@ -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 + } + } + ] +} diff --git a/tools/custody-record/fixtures/valid/supersession.json b/tools/custody-record/fixtures/valid/supersession.json new file mode 100644 index 0000000..29684ba --- /dev/null +++ b/tools/custody-record/fixtures/valid/supersession.json @@ -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 + } + } + ] +} diff --git a/tools/custody-record/tests/test_custody_record.py b/tools/custody-record/tests/test_custody_record.py new file mode 100644 index 0000000..900e91b --- /dev/null +++ b/tools/custody-record/tests/test_custody_record.py @@ -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] = "" + 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() diff --git a/tools/custody-record/validate.py b/tools/custody-record/validate.py new file mode 100644 index 0000000..51ec93b --- /dev/null +++ b/tools/custody-record/validate.py @@ -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 "" + 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 "", 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()) diff --git a/workplans/NK-WP-0042-workload-mfa-step-up.md b/workplans/NK-WP-0042-workload-mfa-step-up.md index 9bf6037..ba36ab7 100644 --- a/workplans/NK-WP-0042-workload-mfa-step-up.md +++ b/workplans/NK-WP-0042-workload-mfa-step-up.md @@ -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. diff --git a/workplans/NK-WP-0045-root-grant-custody-record.md b/workplans/NK-WP-0045-root-grant-custody-record.md index 8737383..3a177f4 100644 --- a/workplans/NK-WP-0045-root-grant-custody-record.md +++ b/workplans/NK-WP-0045-root-grant-custody-record.md @@ -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