diff --git a/Makefile b/Makefile index a780363..a75fa67 100644 --- a/Makefile +++ b/Makefile @@ -197,6 +197,12 @@ posture-feedback-test: ## Run deterministic posture-feedback tests custody-record-test: ## Run custody-record schema/validator tests (NK-WP-0045-T03) python3 -m pytest tools/custody-record/tests +service-identity-test: ## Run service-identity schema/validator tests (NK-WP-0054-T03) + python3 -m pytest tools/service-identity/tests + +service-identity-check: ## Evaluate the checked-in service identity declarations (read-only, offline) + python3 tools/service-identity/validate.py tools/service-identity/declarations/*.yaml + 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 \ @@ -365,7 +371,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 \ + custody-record-test service-identity-test service-identity-check \ emission-cadence-profile-test \ posture-feedback-reference security-scenario-compose-lightweight \ security-bootstrap-console-test security-bootstrap-scripts-syntax \ diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 80ee50a..182cee9 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -310,7 +310,7 @@ | task | NK-WP-0053-T04 | todo | — | workplans/NK-WP-0053-identity-restore-drill-cadence.md | | task | NK-WP-0054-T01 | done | — | workplans/NK-WP-0054-service-identity-policy.md | | task | NK-WP-0054-T02 | wait | — | workplans/NK-WP-0054-service-identity-policy.md | -| task | NK-WP-0054-T03 | wait | — | workplans/NK-WP-0054-service-identity-policy.md | +| task | NK-WP-0054-T03 | done | — | workplans/NK-WP-0054-service-identity-policy.md | | task | NK-WP-0054-T04 | wait | — | workplans/NK-WP-0054-service-identity-policy.md | | task | NK-WP-0054-T05 | wait | — | workplans/NK-WP-0054-service-identity-policy.md | | task | NK-WP-0054-T06 | wait | — | workplans/NK-WP-0054-service-identity-policy.md | diff --git a/canon/schemas/service-identity_v0.1.schema.json b/canon/schemas/service-identity_v0.1.schema.json new file mode 100644 index 0000000..860609f --- /dev/null +++ b/canon/schemas/service-identity_v0.1.schema.json @@ -0,0 +1,361 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://policy.coulomb.social/schemas/service-identity/v0.1", + "title": "NetKingdom service identity declaration v0.1", + "description": "One service declares every identity it holds (NetKingdom Service Identity and Privilege v0.1, proposed). Holds no credential and no secret-shaped value. Facts a declarer cannot establish are written as 'unknown', never guessed.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "service", + "owner", + "declared_at", + "attestation", + "complete", + "identities" + ], + "properties": { + "schema_version": { + "const": "0.1" + }, + "service": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,40}$" + }, + "owner": { + "type": "string", + "minLength": 3, + "maxLength": 200 + }, + "declared_at": { + "type": "string", + "format": "date" + }, + "attestation": { + "type": "object", + "additionalProperties": false, + "required": [ + "authored_by", + "basis", + "owner_confirmed" + ], + "properties": { + "authored_by": { + "type": "string", + "minLength": 2, + "maxLength": 100 + }, + "basis": { + "type": "array", + "minItems": 1, + "maxItems": 40, + "items": { + "type": "string", + "minLength": 3, + "maxLength": 200 + } + }, + "owner_confirmed": { + "type": "boolean" + } + } + }, + "complete": { + "type": "boolean", + "description": "True only when the owner asserts that every identity the service holds is listed." + }, + "identities": { + "type": "array", + "minItems": 1, + "maxItems": 200, + "items": { + "$ref": "#/$defs/identity" + } + }, + "exceptions": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/$defs/exception" + } + } + }, + "$defs": { + "name": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$" + }, + "unknownable": { + "description": "A fact that may be unknown rather than guessed." + }, + "consumer": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "kind" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "kind": { + "enum": [ + "person", + "service" + ] + } + } + }, + "identity": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "classes", + "purpose", + "privilege_level", + "interactive_login", + "second_factor", + "custody", + "consumers", + "owner", + "rotation", + "status" + ], + "properties": { + "name": { + "$ref": "#/$defs/name" + }, + "classes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "human", + "break-glass", + "bind", + "service-admin", + "workload", + "infrastructure" + ] + } + }, + "purpose": { + "type": "string", + "minLength": 3, + "maxLength": 300 + }, + "privilege_level": { + "enum": [ + "none", + "read", + "write_scoped", + "admin", + "unknown" + ] + }, + "grants_admin_policy": { + "type": "boolean" + }, + "assurance_claim_bound": { + "type": "boolean" + }, + "interactive_login": { + "type": "boolean" + }, + "second_factor": { + "enum": [ + "required", + "not_enforced", + "not_supported", + "not_applicable", + "unknown" + ] + }, + "custody": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "offline-key", + "personal-safe", + "openbao-lease", + "delivered-secret", + "none", + "unknown" + ] + }, + "description": "Every place the credential lives. One credential in two places is two entries." + }, + "consumers": { + "type": "array", + "maxItems": 50, + "items": { + "$ref": "#/$defs/consumer" + } + }, + "owner": { + "type": "string", + "minLength": 3, + "maxLength": 200 + }, + "rotation": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "coupling" + ], + "properties": { + "path": { + "type": "string", + "minLength": 3, + "maxLength": 300 + }, + "coupling": { + "type": "array", + "maxItems": 20, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + } + } + }, + "status": { + "enum": [ + "active", + "legacy", + "planned" + ] + }, + "disposition": { + "type": "object", + "additionalProperties": false, + "required": [ + "action", + "owner", + "date" + ], + "properties": { + "action": { + "enum": [ + "retire", + "accept", + "defer" + ] + }, + "owner": { + "type": "string", + "minLength": 3, + "maxLength": 200 + }, + "date": { + "type": "string", + "format": "date" + } + } + }, + "recovery": { + "type": "object", + "additionalProperties": false, + "required": [ + "needed", + "works_with_sso_down", + "in_crisis_list" + ], + "properties": { + "needed": { + "type": "boolean" + }, + "works_with_sso_down": { + "enum": [ + true, + false, + "unknown" + ] + }, + "in_crisis_list": { + "type": "boolean" + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "legacy" + } + }, + "required": [ + "status" + ] + }, + "then": { + "required": [ + "disposition" + ] + } + } + ] + }, + "exception": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "rules", + "identity", + "reason", + "owner", + "expires", + "compensating_control" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^SI-X[0-9]{1,3}$" + }, + "rules": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^(SI-(1[0-3]|[1-9])|NAMING)$" + } + }, + "identity": { + "$ref": "#/$defs/name" + }, + "reason": { + "type": "string", + "minLength": 10, + "maxLength": 400 + }, + "owner": { + "type": "string", + "minLength": 3, + "maxLength": 200 + }, + "expires": { + "type": "string", + "format": "date" + }, + "compensating_control": { + "type": "string", + "minLength": 10, + "maxLength": 400 + } + } + } + } +} diff --git a/canon/standards/service-identity-privilege_v0.1.md b/canon/standards/service-identity-privilege_v0.1.md index b9b17b3..ba73efa 100644 --- a/canon/standards/service-identity-privilege_v0.1.md +++ b/canon/standards/service-identity-privilege_v0.1.md @@ -142,7 +142,9 @@ subject, and MUST record which identity ran each stage. | `human` | the person's own login | `tegwick`, `admin` | | `workload` | the token subject form already in use | for example `service:secrets-engine`; IAM Profile v0.3 governs the token | -Names are reserved for their class. A `svc-` or `-admin` name MUST NOT be used for a human. +Names are reserved for their class. A `svc-` or `-admin` name MUST NOT be used for a human account. A +federated role that holds no credential (custody `none`), such as an OpenBao role that maps a group to a +policy, is not an account and is not bound by this rule (see §10, question 7). ## 5. Target shape for today's services (informative) @@ -170,6 +172,12 @@ secret-shaped value. For each identity the result is one of `conformant`, `non-conformant` (with the rule ids), `excepted` (with the exception id) or `unknown`. Unknown is a finding, not a pass. +The offline validator `tools/service-identity/validate.py` (NK-WP-0054-T03, provisional) evaluates the +rules that a declaration alone can decide: SI-1, SI-2, SI-3, SI-6, SI-8, SI-9, SI-11, SI-12 and the naming +rules. SI-4, SI-5, SI-7, SI-10 and SI-13 are advisory or process rules and are not decided offline; SI-4 +appears as an advisory. Unknown facts are reported as `unknown` for every class. Dates are explicit: the +validator takes `--as-of`. + The health check (NK-WP-0054-T04) compares declared against observed state: for example, which DN each service binds as, which groups the bind identities belong to, and which policies an admin role carries. It uses read-only metadata that each owner provides and repository @@ -187,12 +195,16 @@ Initial exceptions at adoption: | Id | Rule | Identity | Reason | Until | | --- | --- | --- | --- | --- | -| SI-X1 | SI-1, SI-2, SI-3 | LLDAP `admin` | One account is the human, the super-admin and the shared bind of five consumers | NK-WP-0054-T05 completes | +| SI-X1 | SI-1, SI-2, SI-3, SI-9, NAMING | LLDAP `admin` | One account is the human, the super-admin and the shared bind of four services, and its password is in a person's safe and in a Kubernetes Secret | NK-WP-0054-T05 completes | | SI-X2 | SI-10 | LDAP binds | Static long-lived credential; LDAP has no lease | Tenancy Posture §9 gap review | -| SI-X3 | SI-11 | `trigger-admin` (privacyIDEA) | Legacy account with uninventoried consumers | NK-WP-0049-T22 disposition | +| SI-X3 | NAMING, SI-11 | `trigger-admin` (privacyIDEA) | Legacy account, not named in the admin form, with uninventoried consumers; its disposition is tracked | NK-WP-0049-T22 disposition | | SI-X4 | SI-8 | OpenBao `netkingdom-platform-admin` | Role binds on the group claim only in the repository file | Decision on §10 question 1 | | SI-X5 | SI-5 | privacyIDEA | Only a local administrator is supported | Upstream capability | +SI-X2 and SI-X5 concern SI-10 and SI-5, which cannot be decided from a declaration. They are recorded +here and are not encoded in the declarations; the offline validator would call them stale. The +declarations carry the exceptions the validator can evaluate (SI-X1, SI-X3, SI-X4). + ## 9. Relationship to other documents - **IAM Profile v0.3** defines the principal types in tokens. This standard governs the @@ -221,6 +233,9 @@ Initial exceptions at adoption: standard only requires that each is declared, owned and not shared. Is that enough? 6. **Where the declarations live.** In each service's repository (preferred, owners are accountable) or in this repository (simpler to validate). +7. **Roles named like accounts.** The reserved-name rule is scoped to identities that hold a credential, so + OpenBao's `netkingdom-platform-admin` role is not flagged. Is that the intended reading, or should such + roles be renamed too? ## 11. Adoption diff --git a/tools/service-identity/README.md b/tools/service-identity/README.md new file mode 100644 index 0000000..0105dbc --- /dev/null +++ b/tools/service-identity/README.md @@ -0,0 +1,51 @@ +# Service Identity Declaration Validator + +NK-WP-0054-T03. Offline and read-only. Checks declarations against +`canon/schemas/service-identity_v0.1.schema.json` and evaluates the rules of the +proposed [Service Identity and Privilege standard](../../canon/standards/service-identity-privilege_v0.1.md) +that a declaration alone can decide. It was built against draft-1, before the founder's review, +so a rule change in the standard changes this tool. + +```bash +make service-identity-test # unit tests +make service-identity-check # evaluate the checked-in declarations +uv run tools/service-identity/validate.py tools/service-identity/declarations/*.yaml --as-of 2026-10-24 +``` + +Options: `--as-of YYYY-MM-DD` (evaluation date, so a run is reproducible and exceptions expire on a +stated day), `--json`, `--fail-on-findings` (exit 2 when anything is non-conformant or unknown). +Exit 1 means invalid input. + +## Results + +| Result | Meaning | +| --- | --- | +| `conformant` | Every checked rule holds on declared facts | +| `non-conformant` | At least one rule is broken and not covered by a live exception, or an exception has expired | +| `excepted` | Every broken rule has a live, time-boxed exception | +| `unknown` | A fact nobody has established (`unknown` in the declaration). A finding, not a pass | +| `planned` | A target identity that does not exist yet; never counted as conformant | + +Alongside the results, **advisories** report declarations not asserted complete by their owner, +declarations not confirmed by the owning service, stale exceptions, more than one service-admin +and more than one break-glass identity. + +## Rules decided here + +SI-1, SI-2, SI-3 (including a credential shared by more than one service), SI-6, SI-8 (including an +admin policy without a bound assurance claim), SI-9, SI-11 (overdue legacy disposition), SI-12 and the +naming rules (`NAMING`). SI-4 (one admin per service) is an advisory. SI-5, SI-7, SI-10 and SI-13 are +not decidable from a declaration. Observed state (what is really configured) is the health check, +NK-WP-0054-T04. + +## Declarations + +`declarations/` holds the first set, authored from `docs/service-identity-inventory.md`. The location is +provisional (standard section 10, question 6). Every one says `owner_confirmed: false`: they describe what +the repository establishes, not what the owning service has attested. Facts the repository cannot +establish are `unknown`, never guessed. + +## Safety + +A declaration holds no credential. Secret-shaped values are rejected, and schema messages are never +printed because they can echo the rejected value; invalid input reports only a count. diff --git a/tools/service-identity/declarations/authelia.yaml b/tools/service-identity/declarations/authelia.yaml new file mode 100644 index 0000000..5a74877 --- /dev/null +++ b/tools/service-identity/declarations/authelia.yaml @@ -0,0 +1,21 @@ +schema_version: "0.1" +service: authelia +owner: "SSO owners (NetKingdom)" +declared_at: 2026-10-24 +attestation: + authored_by: claude-code + basis: [sso-mfa/k8s/authelia/configmap.yaml, sso-mfa/k8s/authelia/deployment.yaml, docs/service-identity-inventory.md] + owner_confirmed: false +complete: true +identities: + - name: keycape + classes: [workload] + purpose: "The only registered OIDC client: KeyCape's upstream sign-in" + privilege_level: none + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: keycape, kind: service}] + owner: "SSO owners (NetKingdom)" + rotation: {path: "Client secret: Authelia verifier and KeyCape together (Credential Management section 6)", coupling: [authelia, keycape]} + status: active diff --git a/tools/service-identity/declarations/identity-provisioner.yaml b/tools/service-identity/declarations/identity-provisioner.yaml new file mode 100644 index 0000000..e52ab2c --- /dev/null +++ b/tools/service-identity/declarations/identity-provisioner.yaml @@ -0,0 +1,21 @@ +schema_version: "0.1" +service: identity-provisioner +owner: "NetKingdom directory and User Engine owners" +declared_at: 2026-10-24 +attestation: + authored_by: claude-code + basis: [sso-mfa/k8s/identity-provisioner/deployment.yaml, docs/service-identity-inventory.md] + owner_confirmed: false +complete: false +identities: + - name: identity-provisioner-token + classes: [workload] + purpose: "Service token that user-engine presents; the NetworkPolicy admits only user-engine pods" + privilege_level: write_scoped + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: user-engine, kind: service}] + owner: "NetKingdom directory and User Engine owners" + rotation: {path: "Kubernetes Secret identity-provisioner-token; owner rotation", coupling: [user-engine, identity-provisioner]} + status: active diff --git a/tools/service-identity/declarations/keycape.yaml b/tools/service-identity/declarations/keycape.yaml new file mode 100644 index 0000000..d02df0f --- /dev/null +++ b/tools/service-identity/declarations/keycape.yaml @@ -0,0 +1,76 @@ +schema_version: "0.1" +service: keycape +owner: "key-cape owners" +declared_at: 2026-10-24 +attestation: + authored_by: claude-code + basis: [tools/bootstrap-recovery/identity-backup-profile.json, sso-mfa/k8s/keycape/deployment.yaml, sso-mfa/k8s/keycape/bootstrap-config.json, docs/service-identity-inventory.md] + owner_confirmed: false +complete: false +identities: + - name: secrets-engine-openbao + classes: [workload] + purpose: "Confidential OIDC client; scopes openbao:login only (deny openbao:admin)" + privilege_level: unknown + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: secrets-engine, kind: service}] + owner: "key-cape owners and the consuming service owner" + rotation: {path: "Mint in custody, then deliver (credential broker); KeyCape reads it at start", coupling: [keycape, secrets-engine]} + status: active + - name: secrets-engine-approval + classes: [workload] + purpose: "Confidential OIDC client; scopes not established in the repository" + privilege_level: unknown + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: secrets-engine, kind: service}] + owner: "key-cape owners and the consuming service owner" + rotation: {path: "Mint in custody, then deliver (credential broker); KeyCape reads it at start", coupling: [keycape, secrets-engine]} + status: active + - name: secrets-engine-requester + classes: [workload] + purpose: "Confidential OIDC client; scopes not established in the repository" + privilege_level: unknown + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: secrets-engine, kind: service}] + owner: "key-cape owners and the consuming service owner" + rotation: {path: "Mint in custody, then deliver (credential broker); KeyCape reads it at start", coupling: [keycape, secrets-engine]} + status: active + - name: approval-engine-operator + classes: [workload] + purpose: "Confidential OIDC client; scopes not established in the repository" + privilege_level: unknown + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: approval-engine, kind: service}] + owner: "key-cape owners and the consuming service owner" + rotation: {path: "Mint in custody, then deliver (credential broker); KeyCape reads it at start", coupling: [keycape, approval-engine]} + status: active + - name: informed-decision-sitting-requester + classes: [workload] + purpose: "Confidential OIDC client; scopes not established in the repository" + privilege_level: unknown + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: informed-decision-sitting, kind: service}] + owner: "key-cape owners and the consuming service owner" + rotation: {path: "Mint in custody, then deliver (credential broker); KeyCape reads it at start", coupling: [keycape, informed-decision-sitting]} + status: active + - name: rapp-qonto + classes: [workload] + purpose: "Confidential OIDC client; scopes not established in the repository" + privilege_level: unknown + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: rapp-qonto, kind: service}] + owner: "key-cape owners and the consuming service owner" + rotation: {path: "Mint in custody, then deliver (credential broker); KeyCape reads it at start", coupling: [keycape, rapp-qonto]} + status: active diff --git a/tools/service-identity/declarations/lldap.yaml b/tools/service-identity/declarations/lldap.yaml new file mode 100644 index 0000000..f2921f6 --- /dev/null +++ b/tools/service-identity/declarations/lldap.yaml @@ -0,0 +1,92 @@ +schema_version: "0.1" +service: lldap +owner: "NetKingdom directory and User Engine owners" +declared_at: 2026-10-24 +attestation: + authored_by: claude-code + basis: [docs/service-identity-inventory.md, sso-mfa/k8s/authelia/configmap.yaml, sso-mfa/k8s/keycape/bootstrap-config.json, sso-mfa/k8s/privacyidea/realm_bootstrap.py, sso-mfa/k8s/identity-provisioner/deployment.yaml, sso-mfa/k8s/lldap/OPERATOR-GROUPS.md] + owner_confirmed: false +complete: false +identities: + - name: admin + classes: [human, bind, service-admin] + purpose: "Human platform administrator and, with the same password, the directory super-admin and the bind of four services" + privilege_level: admin + interactive_login: true + second_factor: required + custody: [delivered-secret, personal-safe] + consumers: + - {name: founder, kind: person} + - {name: authelia, kind: service} + - {name: keycape, kind: service} + - {name: privacyidea, kind: service} + - {name: identity-provisioner, kind: service} + owner: "NetKingdom directory and User Engine owners" + rotation: + path: "LLDAP_LDAP_USER_PASS in lldap-secrets (Credential Management section 6)" + coupling: [authelia, keycape, privacyidea-resolver, identity-provisioner, lldap-web-ui] + status: active + recovery: {needed: true, works_with_sso_down: false, in_crisis_list: true} + - name: ld-admin + classes: [service-admin] + purpose: "Proposed: LLDAP's own administrator, separate from every bind and every person (renaming needs a rehearsal)" + privilege_level: admin + interactive_login: true + second_factor: unknown + custody: [unknown] + consumers: [] + owner: "NetKingdom directory and User Engine owners" + rotation: {path: "To be decided in NK-WP-0054-T06", coupling: []} + status: planned + - name: svc-authelia + classes: [bind] + purpose: "Target: Authelia's read-only directory lookup" + privilege_level: read + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: authelia, kind: service}] + owner: "NetKingdom directory and User Engine owners" + rotation: {path: "Secret rotation with Authelia only", coupling: [authelia]} + status: planned + - name: svc-keycape + classes: [bind] + purpose: "Target: KeyCape's read-only directory lookup" + privilege_level: read + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: keycape, kind: service}] + owner: "NetKingdom directory and User Engine owners" + rotation: {path: "Secret rotation with KeyCape only", coupling: [keycape]} + status: planned + - name: svc-privacyidea + classes: [bind] + purpose: "Target: the privacyIDEA resolver's read-only directory lookup" + privilege_level: read + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: privacyidea, kind: service}] + owner: "NetKingdom directory and User Engine owners" + rotation: {path: "Resolver bind password, stored encrypted in the privacyIDEA database", coupling: [privacyidea]} + status: planned + - name: svc-provisioner + classes: [workload] + purpose: "Target: identity-provisioner, narrowest working write scope for users and groups, not the super-admin (scope to be tested)" + privilege_level: write_scoped + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: identity-provisioner, kind: service}] + owner: "NetKingdom directory and User Engine owners" + rotation: {path: "Secret rotation with the provisioner only", coupling: [identity-provisioner]} + status: planned +exceptions: + - id: SI-X1 + rules: [SI-1, SI-2, SI-3, SI-9, NAMING] + identity: admin + reason: "One account is the human, the super-admin and the shared bind of four services, and its password is in a person's safe and in a Kubernetes Secret" + owner: "Founder" + expires: 2027-01-31 + compensating_control: "Web UI restricted by IP allowlist; OTP on the human login; NK-WP-0054-T05 removes the sharing" diff --git a/tools/service-identity/declarations/openbao.yaml b/tools/service-identity/declarations/openbao.yaml new file mode 100644 index 0000000..901f16e --- /dev/null +++ b/tools/service-identity/declarations/openbao.yaml @@ -0,0 +1,80 @@ +schema_version: "0.1" +service: openbao +owner: "railiance-platform" +declared_at: 2026-10-24 +attestation: + authored_by: claude-code + basis: [railiance-platform openbao/auth/netkingdom-platform-admin-role.json, railiance-platform openbao/auth/credential-broker-keycape-client-secret-generate-oidc-role.json, railiance-platform openbao/auth/secrets-engine-jwt-role.json, railiance-platform openbao/auth/coding-agent-jwt-role.json, railiance-platform openbao/auth/fluid-telegram-hall-of-helix-oidc-role.json] + owner_confirmed: false +complete: false +identities: + - name: netkingdom-platform-admin + classes: [human] + purpose: "OIDC role: group net-kingdom-admins receives policies platform-admin and operator-custody, one-hour token" + privilege_level: admin + grants_admin_policy: true + assurance_claim_bound: false + interactive_login: true + second_factor: required + custody: [none] + consumers: [{name: members of net-kingdom-admins, kind: person}] + owner: "railiance-platform" + rotation: {path: "Federated; membership and policy change by reviewed change", coupling: []} + status: active + recovery: {needed: true, works_with_sso_down: false, in_crisis_list: true} + - name: credential-broker-keycape-client-secret-generate + classes: [human] + purpose: "OIDC role: net-kingdom-admins with aal2 may generate a KeyCape client secret in custody" + privilege_level: write_scoped + grants_admin_policy: false + assurance_claim_bound: true + interactive_login: true + second_factor: required + custody: [none] + consumers: [{name: members of net-kingdom-admins, kind: person}] + owner: "railiance-platform" + rotation: {path: "Federated; policy change by reviewed change", coupling: []} + status: active + - name: fluid-telegram-operators + classes: [human] + purpose: "OIDC role: fluid-telegram operators with aal2" + privilege_level: unknown + grants_admin_policy: false + assurance_claim_bound: true + interactive_login: true + second_factor: required + custody: [none] + consumers: [{name: members of fluid-telegram-operators, kind: person}] + owner: "railiance-platform" + rotation: {path: "Federated; policy change by reviewed change", coupling: []} + status: active + - name: secrets-engine-openbao + classes: [workload] + purpose: "JWT role: subject service:secrets-engine, scope openbao:login, policy secrets-engine-login-self, five-minute token" + privilege_level: read + interactive_login: false + second_factor: not_applicable + custody: [none] + consumers: [{name: secrets-engine, kind: service}] + owner: "railiance-platform" + rotation: {path: "Federated; the KeyCape client secret rotates in the keycape declaration", coupling: [keycape]} + status: active + - name: coding-agent + classes: [workload] + purpose: "JWT role and AppRole for the coding agent; policies agent-high-risk-boundary and workload-kv-read-issue-core-runtime, fifteen minutes" + privilege_level: unknown + interactive_login: false + second_factor: not_applicable + custody: [none] + consumers: [{name: codex-railiance-platform, kind: service}] + owner: "railiance-platform" + rotation: {path: "Federated for the JWT role; AppRole secret rotation owned by railiance-platform", coupling: []} + status: active +exceptions: + - id: SI-X4 + rules: [SI-8] + identity: netkingdom-platform-admin + reason: "The role file binds on the group claim only; the aal2 claim is not bound while two sibling roles bind it" + owner: "railiance-platform and founder" + expires: 2027-01-31 + compensating_control: "KeyCape requires a second factor for every login (requireForAll); decision on standard section 10 question 1" diff --git a/tools/service-identity/declarations/platform.yaml b/tools/service-identity/declarations/platform.yaml new file mode 100644 index 0000000..83ce399 --- /dev/null +++ b/tools/service-identity/declarations/platform.yaml @@ -0,0 +1,34 @@ +# People and the break-glass identity. Provisional location (standard section 10, question 6). +schema_version: "0.1" +service: platform +owner: "Founder (Bernd Worsch)" +declared_at: 2026-10-24 +attestation: + authored_by: claude-code + basis: [docs/service-identity-inventory.md, docs/platform-root-custody.md, canon/standards/credential-management_v0.2.md] + owner_confirmed: false +complete: false +identities: + - name: platform-root + classes: [break-glass] + purpose: "Final authority (king credential); not for routine or production use" + privilege_level: admin + interactive_login: true + second_factor: required + custody: [offline-key] + consumers: [{name: founder, kind: person}] + owner: "Founder" + rotation: {path: "Break-glass material: explicit maintenance window (Credential Management section 6)", coupling: []} + status: active + recovery: {needed: true, works_with_sso_down: false, in_crisis_list: true} + - name: tegwick + classes: [human] + purpose: "Developer identity: builds and tests the platform, Git and tooling" + privilege_level: unknown + interactive_login: true + second_factor: unknown + custody: [unknown] + consumers: [{name: founder, kind: person}] + owner: "Founder" + rotation: {path: "Personal credential; owner-managed", coupling: []} + status: active diff --git a/tools/service-identity/declarations/postgresql.yaml b/tools/service-identity/declarations/postgresql.yaml new file mode 100644 index 0000000..4efc664 --- /dev/null +++ b/tools/service-identity/declarations/postgresql.yaml @@ -0,0 +1,21 @@ +schema_version: "0.1" +service: postgresql +owner: "railiance-platform (platform data services)" +declared_at: 2026-10-24 +attestation: + authored_by: claude-code + basis: [sso-mfa/k8s/postgresql/cluster.yaml] + owner_confirmed: false +complete: false +identities: + - name: privacyidea + classes: [infrastructure] + purpose: "Owner of privacyidea_db in the net-kingdom-pg cluster" + privilege_level: write_scoped + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: privacyidea, kind: service}] + owner: "railiance-platform" + rotation: {path: "PI_DB_PASSWORD: atomic update of database and pod (Credential Management section 6)", coupling: [privacyidea]} + status: active diff --git a/tools/service-identity/declarations/privacyidea.yaml b/tools/service-identity/declarations/privacyidea.yaml new file mode 100644 index 0000000..afb294b --- /dev/null +++ b/tools/service-identity/declarations/privacyidea.yaml @@ -0,0 +1,53 @@ +schema_version: "0.1" +service: privacyidea +owner: "MFA and privacyIDEA owners (NetKingdom)" +declared_at: 2026-10-24 +attestation: + authored_by: claude-code + basis: [sso-mfa/k8s/privacyidea/README.md, sso-mfa/k8s/privacyidea/bootstrap-admin.py, sso-mfa/k8s/keycape/deployment.yaml, docs/service-identity-inventory.md] + owner_confirmed: false +complete: false +identities: + - name: pi-admin + classes: [service-admin] + purpose: "privacyIDEA's local administrator; policy pi-admin-all-rights restricted to adminuser=pi-admin" + privilege_level: admin + interactive_login: true + second_factor: unknown + custody: [personal-safe] + consumers: [{name: founder, kind: person}] + owner: "Founder and MFA owners" + rotation: {path: "bootstrap-admin custody entry; password changes are a separate procedure", coupling: []} + status: active + recovery: {needed: true, works_with_sso_down: true, in_crisis_list: true} + - name: trigger-admin + classes: [service-admin] + purpose: "Legacy Keycloak-era administrator account, policy and Secret; consumers not inventoried" + privilege_level: admin + interactive_login: false + second_factor: unknown + custody: [delivered-secret] + consumers: [] + owner: "MFA owners; revocation decided under NK-WP-0049-T22" + rotation: {path: "None established; revocation needs a separate approval", coupling: []} + status: legacy + disposition: {action: defer, owner: "Founder", date: 2026-12-31} + - name: keycape-factor-reader + classes: [workload] + purpose: "KeyCape's access to read factor state; delivered by railiance-platform (RPF-WP-0040)" + privilege_level: unknown + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: keycape, kind: service}] + owner: "railiance-platform and key-cape" + rotation: {path: "Renewed by policy keycape-factor-renewer through ESO", coupling: [keycape]} + status: active +exceptions: + - id: SI-X3 + rules: [NAMING] + identity: trigger-admin + reason: "Legacy account whose name does not follow the admin naming form and whose consumers are uninventoried" + owner: "Founder" + expires: 2026-12-31 + compensating_control: "Not provisioned by the current bootstrap; revocation tracked under NK-WP-0049-T22" diff --git a/tools/service-identity/fixtures/invalid/secret-value.json b/tools/service-identity/fixtures/invalid/secret-value.json new file mode 100644 index 0000000..2d7bbe0 --- /dev/null +++ b/tools/service-identity/fixtures/invalid/secret-value.json @@ -0,0 +1,14 @@ +{ + "schema_version": "0.1", + "service": "example", + "owner": "Example owners", + "declared_at": "2026-10-24", + "attestation": {"authored_by": "fixture", "basis": ["fixture"], "owner_confirmed": true}, + "complete": true, + "identities": [ + {"name": "svc-example", "classes": ["bind"], "purpose": "Lookup with hvs.ABCDEFGH12345678abcdef smuggled in", "privilege_level": "read", + "interactive_login": false, "second_factor": "not_applicable", "custody": ["delivered-secret"], + "consumers": [{"name": "example", "kind": "service"}], "owner": "Example owners", + "rotation": {"path": "Rotate with the consumer", "coupling": []}, "status": "active"} + ] +} diff --git a/tools/service-identity/fixtures/valid/conformant.json b/tools/service-identity/fixtures/valid/conformant.json new file mode 100644 index 0000000..ac133a9 --- /dev/null +++ b/tools/service-identity/fixtures/valid/conformant.json @@ -0,0 +1,18 @@ +{ + "schema_version": "0.1", + "service": "example", + "owner": "Example owners", + "declared_at": "2026-10-24", + "attestation": {"authored_by": "fixture", "basis": ["fixture"], "owner_confirmed": true}, + "complete": true, + "identities": [ + {"name": "svc-example", "classes": ["bind"], "purpose": "Read-only lookup", "privilege_level": "read", + "interactive_login": false, "second_factor": "not_applicable", "custody": ["delivered-secret"], + "consumers": [{"name": "example", "kind": "service"}], "owner": "Example owners", + "rotation": {"path": "Rotate with the consumer", "coupling": ["example"]}, "status": "active"}, + {"name": "ex-admin", "classes": ["service-admin"], "purpose": "Administer the example service", "privilege_level": "admin", + "interactive_login": true, "second_factor": "required", "custody": ["personal-safe"], + "consumers": [{"name": "operator", "kind": "person"}], "owner": "Example owners", + "rotation": {"path": "Owner-managed", "coupling": []}, "status": "active"} + ] +} diff --git a/tools/service-identity/tests/test_validate.py b/tools/service-identity/tests/test_validate.py new file mode 100644 index 0000000..63a9037 --- /dev/null +++ b/tools/service-identity/tests/test_validate.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import contextlib +import copy +import datetime as dt +import importlib.util +import io +import json +import pathlib +import tempfile +import unittest + +HERE = pathlib.Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location("service_identity_validate", HERE.parent / "validate.py") +assert SPEC and SPEC.loader +V = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(V) +FIXTURES = HERE.parent / "fixtures" +DECLARATIONS = HERE.parent / "declarations" +SCHEMA = V.load_schema() +NOW = dt.date(2026, 10, 24) + + +def identity(**over): + base = {"name": "svc-x", "classes": ["bind"], "purpose": "Read-only lookup", "privilege_level": "read", + "interactive_login": False, "second_factor": "not_applicable", "custody": ["delivered-secret"], + "consumers": [{"name": "x", "kind": "service"}], "owner": "Owners", + "rotation": {"path": "Rotate with the consumer", "coupling": ["x"]}, "status": "active"} + base.update(over) + return base + + +def declaration(*identities, exceptions=None, complete=True, confirmed=True): + doc = {"schema_version": "0.1", "service": "example", "owner": "Owners", "declared_at": "2026-10-24", + "attestation": {"authored_by": "test", "basis": ["test"], "owner_confirmed": confirmed}, + "complete": complete, "identities": list(identities or [identity()])} + if exceptions: + doc["exceptions"] = exceptions + return doc + + +def exception(**over): + base = {"id": "SI-X9", "rules": ["SI-3"], "identity": "svc-x", "reason": "Reason for the test", + "owner": "Owners", "expires": "2027-01-31", "compensating_control": "A compensating control"} + base.update(over) + return base + + +def result_of(doc, as_of=NOW, name=None): + assert V.structural_errors(doc, SCHEMA) == [], V.structural_errors(doc, SCHEMA) + report = V.evaluate(doc, as_of) + found = [i for i in report["identities"] if name in (None, i["identity"])] + return found[0] + + +class StructureTests(unittest.TestCase): + def test_conformant_fixture_is_valid_and_conformant(self): + doc = json.loads((FIXTURES / "valid/conformant.json").read_text()) + self.assertEqual(V.structural_errors(doc, SCHEMA), []) + self.assertEqual({i["result"] for i in V.evaluate(doc, NOW)["identities"]}, {"conformant"}) + + def test_secret_shaped_value_is_rejected_without_echo(self): + doc = json.loads((FIXTURES / "invalid/secret-value.json").read_text()) + errors = V.structural_errors(doc, SCHEMA) + self.assertEqual(errors, ["value looks like secret material"]) + self.assertNotIn("hvs.", " ".join(errors)) + + def test_unknown_field_and_class_are_rejected(self): + doc = declaration(identity(password="x")) + self.assertTrue(V.structural_errors(doc, SCHEMA)) + doc = declaration(identity(classes=["wizard"])) + self.assertTrue(V.structural_errors(doc, SCHEMA)) + + def test_duplicate_identity_and_undeclared_exception_target(self): + self.assertIn("duplicate identity name", V.structural_errors(declaration(identity(), identity()), SCHEMA)) + doc = declaration(identity(), exceptions=[exception(identity="ghost")]) + self.assertIn("exception names an undeclared identity", V.structural_errors(doc, SCHEMA)) + + def test_legacy_identity_needs_a_disposition(self): + self.assertTrue(V.structural_errors(declaration(identity(status="legacy")), SCHEMA)) + ok = identity(status="legacy", disposition={"action": "retire", "owner": "Owners", "date": "2026-12-31"}) + self.assertEqual(V.structural_errors(declaration(ok), SCHEMA), []) + + +class RuleTests(unittest.TestCase): + def rules(self, **over): + return result_of(declaration(identity(**over)))["rules"] + + def test_si1_more_than_one_class(self): + self.assertIn("SI-1", self.rules(name="admin", classes=["human", "bind"])) + + def test_si2_person_and_service_share_a_credential(self): + consumers = [{"name": "p", "kind": "person"}, {"name": "s", "kind": "service"}] + self.assertIn("SI-2", self.rules(consumers=consumers)) + + def test_si3_bind_must_be_read_only_non_interactive_and_unshared(self): + self.assertIn("SI-3", self.rules(privilege_level="admin")) + self.assertIn("SI-3", self.rules(interactive_login=True)) + shared = [{"name": "a", "kind": "service"}, {"name": "b", "kind": "service"}] + self.assertIn("SI-3", self.rules(consumers=shared)) + self.assertEqual(self.rules(), []) + + def test_si8_admin_without_enforced_second_factor_or_assurance(self): + admin = dict(name="ex-admin", classes=["service-admin"], privilege_level="admin", interactive_login=True, + consumers=[{"name": "p", "kind": "person"}], custody=["personal-safe"]) + self.assertIn("SI-8", self.rules(second_factor="not_enforced", **admin)) + self.assertEqual(self.rules(second_factor="required", **admin), []) + self.assertIn("SI-8", self.rules(second_factor="required", grants_admin_policy=True, **admin)) + self.assertEqual(self.rules(second_factor="required", grants_admin_policy=True, assurance_claim_bound=True, **admin), []) + + def test_si9_a_person_safe_must_not_hold_a_service_credential(self): + self.assertIn("SI-9", self.rules(custody=["delivered-secret", "personal-safe"])) + + def test_si6_break_glass_is_offline_and_has_no_service_consumer(self): + bg = dict(name="platform-root", classes=["break-glass"], privilege_level="admin", interactive_login=True, + second_factor="required", consumers=[{"name": "p", "kind": "person"}]) + self.assertEqual(self.rules(custody=["offline-key"], **bg), []) + self.assertIn("SI-6", self.rules(custody=["personal-safe"], **bg)) + self.assertIn("SI-6", self.rules(custody=["offline-key"], **{**bg, "consumers": [{"name": "s", "kind": "service"}]})) + + def test_si12_recovery_identity_must_be_in_the_crisis_list(self): + self.assertIn("SI-12", self.rules(recovery={"needed": True, "works_with_sso_down": True, "in_crisis_list": False})) + + def test_naming_is_reserved_by_class(self): + self.assertIn("NAMING", self.rules(name="lookup")) + self.assertIn("NAMING", self.rules(name="x-admin", classes=["human"], second_factor="required", interactive_login=True, + consumers=[{"name": "p", "kind": "person"}])) + # A federated role holds no credential and cannot be mistaken for an account. + self.assertNotIn("NAMING", self.rules(name="group-admin", classes=["human"], second_factor="required", interactive_login=True, + custody=["none"], consumers=[{"name": "p", "kind": "person"}])) + admin = dict(classes=["service-admin"], privilege_level="admin", second_factor="required", interactive_login=True, + consumers=[{"name": "p", "kind": "person"}], custody=["personal-safe"]) + self.assertIn("NAMING", self.rules(name="administrator", **admin)) + self.assertEqual(self.rules(name="ld-admin", **admin), []) + + def test_overdue_legacy_disposition_is_a_violation(self): + legacy = identity(status="legacy", name="old-admin", classes=["service-admin"], privilege_level="admin", + second_factor="required", interactive_login=True, consumers=[], + disposition={"action": "defer", "owner": "Owners", "date": "2026-10-01"}) + self.assertIn("SI-11", result_of(declaration(legacy))["rules"]) + legacy["disposition"]["date"] = "2026-12-31" + self.assertNotIn("SI-11", result_of(declaration(legacy))["rules"]) + + +class ResultTests(unittest.TestCase): + def test_unknown_is_a_finding_for_every_class(self): + for fact, value in (("privilege_level", "unknown"), ("custody", ["unknown"])): + r = result_of(declaration(identity(classes=["workload"], name="client", **{fact: value}))) + self.assertEqual(r["result"], "unknown") + self.assertEqual(r["unknown"], [fact]) + + def test_violation_outranks_unknown(self): + r = result_of(declaration(identity(privilege_level="unknown", interactive_login=True))) + self.assertEqual(r["result"], "non-conformant") + + def test_planned_identity_is_not_counted_as_conformant(self): + self.assertEqual(result_of(declaration(identity(status="planned")))["result"], "planned") + + def test_live_exception_excepts_and_expired_one_does_not(self): + doc = declaration(identity(privilege_level="admin"), exceptions=[exception()]) + r = result_of(doc) + self.assertEqual((r["result"], r["excepted"]), ("excepted", {"SI-3": "SI-X9"})) + r = result_of(doc, as_of=dt.date(2027, 2, 1)) + self.assertEqual(r["result"], "non-conformant") + self.assertIn("SI-EXC-EXPIRED", r["rules"]) + + def test_exception_for_another_rule_does_not_cover_the_violation(self): + doc = declaration(identity(privilege_level="admin"), exceptions=[exception(rules=["SI-9"])]) + self.assertEqual(result_of(doc)["result"], "non-conformant") + + def test_stale_exception_and_unconfirmed_declaration_are_advisories(self): + doc = declaration(identity(), exceptions=[exception()], complete=False, confirmed=False) + advisories = V.evaluate(doc, NOW)["advisories"] + self.assertTrue(any("stale exception" in a for a in advisories)) + self.assertTrue(any("not asserted complete" in a for a in advisories)) + self.assertTrue(any("not confirmed" in a for a in advisories)) + + def test_evaluation_date_is_explicit(self): + doc = declaration(identity(privilege_level="admin"), exceptions=[exception(expires="2026-10-30")]) + self.assertEqual(result_of(doc, as_of=dt.date(2026, 10, 30))["result"], "excepted") + self.assertEqual(result_of(doc, as_of=dt.date(2026, 10, 31))["result"], "non-conformant") + + +class CheckedInDeclarations(unittest.TestCase): + def paths(self): + found = sorted(DECLARATIONS.glob("*.yaml")) + self.assertGreaterEqual(len(found), 7) + return found + + def test_all_declarations_are_valid(self): + reports, rejected = V.run(self.paths(), NOW) + self.assertEqual(rejected, []) + self.assertEqual({r["service"] for r in reports} >= {"lldap", "keycape", "privacyidea", "openbao", "platform"}, True) + + def test_the_shared_admin_credential_is_excepted_today_and_fails_after_expiry(self): + def admin(as_of): + reports, _ = V.run(self.paths(), as_of) + return [i for r in reports for i in r["identities"] if r["service"] == "lldap" and i["identity"] == "admin"][0] + today = admin(NOW) + self.assertEqual(today["result"], "excepted") + self.assertEqual(set(today["excepted"]), {"SI-1", "SI-2", "SI-3", "SI-9", "NAMING"}) + after = admin(dt.date(2027, 2, 1)) + self.assertEqual(after["result"], "non-conformant") + + def test_no_declaration_claims_owner_confirmation_yet(self): + for path in self.paths(): + doc = V.load_declaration(path) + self.assertFalse(doc["attestation"]["owner_confirmed"], path.name) + + +class CommandLineTests(unittest.TestCase): + def run_cli(self, *argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = V.main(list(argv)) + return code, out.getvalue(), err.getvalue() + + def test_invalid_input_reports_only_a_count_and_exits_1(self): + code, out, err = self.run_cli(str(FIXTURES / "invalid/secret-value.json")) + self.assertEqual(code, 1) + self.assertIn("rejected (1 errors)", err) + self.assertNotIn("hvs.", out + err) + + def test_valid_fixture_exits_0_and_prints_summary(self): + code, out, _ = self.run_cli(str(FIXTURES / "valid/conformant.json"), "--as-of", "2026-10-24") + self.assertEqual(code, 0) + self.assertIn("summary: 0 non-conformant, 0 unknown, 0 excepted, 0 planned, 2 conformant", out) + + def test_fail_on_findings_exits_2_for_unknowns(self): + paths = [str(p) for p in sorted(DECLARATIONS.glob("*.yaml"))] + code, _, _ = self.run_cli(*paths, "--as-of", "2026-10-24", "--fail-on-findings") + self.assertEqual(code, 2) + code, _, _ = self.run_cli(*paths, "--as-of", "2026-10-24") + self.assertEqual(code, 0) + + def test_json_output_has_counts_and_no_values(self): + code, out, _ = self.run_cli(str(FIXTURES / "valid/conformant.json"), "--json", "--as-of", "2026-10-24") + self.assertEqual(code, 0) + data = json.loads(out) + self.assertEqual(data["counts"]["conformant"], 2) + + def test_bad_as_of_is_refused(self): + code, _, err = self.run_cli(str(FIXTURES / "valid/conformant.json"), "--as-of", "yesterday") + self.assertEqual(code, 1) + self.assertIn("invalid --as-of", err) + + def test_more_than_one_break_glass_identity_is_an_advisory(self): + def bg(name): + return declaration(identity(name=name, classes=["break-glass"], privilege_level="admin", interactive_login=True, + second_factor="required", custody=["offline-key"], + consumers=[{"name": "p", "kind": "person"}])) + with tempfile.TemporaryDirectory() as d: + paths = [] + for n in ("platform-root", "king"): + p = pathlib.Path(d) / f"{n}.json" + p.write_text(json.dumps(bg(n))) + paths.append(p) + reports, rejected = V.run(paths, NOW) + self.assertEqual(rejected, []) + self.assertTrue(any("more than one break-glass" in a for r in reports for a in r["advisories"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/service-identity/validate.py b/tools/service-identity/validate.py new file mode 100644 index 0000000..00a6c39 --- /dev/null +++ b/tools/service-identity/validate.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = ["jsonschema>=4.10,<5", "PyYAML>=6,<7"] +# /// +"""Check service identity declarations against the proposed standard (NK-WP-0054-T03). + +Validates `canon/schemas/service-identity_v0.1.schema.json`, then evaluates the rules of +NetKingdom Service Identity and Privilege v0.1 that can be decided from a declaration alone. +Each identity ends as `conformant`, `non-conformant`, `excepted` or `unknown`; unknown is a +finding, not a pass. Observed state is the health check's job (T04), not this tool's. + +Offline and read-only. It never contacts a service and never prints a declared value: schema +messages can contain a rejected secret, so invalid input reports only a count. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import pathlib +import re +import sys +from typing import Any + +import jsonschema +import yaml + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SCHEMA = ROOT / "canon/schemas/service-identity_v0.1.schema.json" + +# Value shapes that look like secret material. Field names are limited 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-----|AGE-SECRET-KEY-1[A-Z0-9]{10,}" + r"|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\." + r"|://[^/\s:@]+:[^/\s@]{3,}@)" +) + +BIND_NAME = re.compile(r"^svc-[a-z0-9]+(-[a-z0-9]+)*$") +ADMIN_NAME = re.compile(r"^[a-z]{2,6}-admin$") +ORDER = {"non-conformant": 0, "unknown": 1, "excepted": 2, "planned": 3, "conformant": 4} + + +def load_schema() -> dict[str, Any]: + return json.loads(SCHEMA.read_text(encoding="utf-8")) + + +def load_declaration(path: pathlib.Path) -> Any: + text = path.read_text(encoding="utf-8") + data = json.loads(text) if path.suffix == ".json" else yaml.safe_load(text) + # YAML turns dates into date objects; the schema speaks JSON. + return json.loads(json.dumps(data, default=str)) + + +def parse_date(value: str) -> dt.date: + return dt.date.fromisoformat(value) + + +def _strings(value: Any): + if isinstance(value, str): + yield value + elif isinstance(value, dict): + for item in value.values(): + yield from _strings(item) + elif isinstance(value, list): + for item in value: + yield from _strings(item) + + +def structural_errors(document: Any, schema: dict[str, Any]) -> list[str]: + """Locations only. Messages can echo the rejected value, so they are never returned.""" + validator = jsonschema.Draft202012Validator(schema, format_checker=jsonschema.FormatChecker()) + errors = [f"schema:{'/'.join(str(p) for p in e.absolute_path) or ''}:{e.validator}" + for e in validator.iter_errors(document)] + if errors: + return sorted(errors) + out = [] + names = [i["name"] for i in document["identities"]] + if len(set(names)) != len(names): + out.append("duplicate identity name") + ids = [e["id"] for e in document.get("exceptions", [])] + if len(set(ids)) != len(ids): + out.append("duplicate exception id") + known = set(names) + for exception in document.get("exceptions", []): + if exception["identity"] not in known: + out.append("exception names an undeclared identity") + if any(SECRET_VALUE.search(s) for s in _strings(document)): + out.append("value looks like secret material") + return out + + +def _violations(identity: dict[str, Any]) -> tuple[set[str], list[str]]: + """Rules broken by one identity on declared facts, and the facts still unknown.""" + broken: set[str] = set() + unknown: list[str] = [] + classes = identity["classes"] + consumers = identity["consumers"] + persons = [c for c in consumers if c["kind"] == "person"] + services = [c for c in consumers if c["kind"] == "service"] + name = identity["name"] + privilege, second, custody = identity["privilege_level"], identity["second_factor"], identity["custody"] + + if len(classes) != 1: + broken.add("SI-1") + if (persons and services) or ("human" in classes and services): + broken.add("SI-2") + if len(services) > 1: + broken.add("SI-3") # a credential used by more than one consumer + # Unknown is a finding for every class: a fact nobody has established is not a pass. + if privilege == "unknown": + unknown.append("privilege_level") + if "unknown" in custody: + unknown.append("custody") + if "bind" in classes: + if privilege not in ("read", "unknown"): + broken.add("SI-3") + if identity["interactive_login"] or persons or len(services) != 1: + broken.add("SI-3") + if "human" in classes or "service-admin" in classes: + if second == "unknown": + unknown.append("second_factor") + elif second == "not_enforced": + broken.add("SI-8") + if identity.get("grants_admin_policy") and not identity.get("assurance_claim_bound"): + broken.add("SI-8") + if ("bind" in classes or "workload" in classes) and "personal-safe" in custody: + broken.add("SI-9") # a person's safe must not be the home of a service credential + if "break-glass" in classes: + if "unknown" not in custody and custody != ["offline-key"]: + broken.add("SI-6") + if services: + broken.add("SI-6") + recovery = identity.get("recovery") + if recovery and recovery["needed"]: + if not recovery["in_crisis_list"]: + broken.add("SI-12") + if recovery["works_with_sso_down"] == "unknown": + unknown.append("recovery.works_with_sso_down") + # Naming (section 4). Reserved names belong to their class. + reserved_bind, reserved_admin = BIND_NAME.match(name), ADMIN_NAME.match(name) + if "bind" in classes and not reserved_bind: + broken.add("NAMING") + if "service-admin" in classes and not reserved_admin: + broken.add("NAMING") + holds_credential = custody != ["none"] + if "human" in classes and holds_credential and (name.startswith("svc-") or name.endswith("-admin")): + broken.add("NAMING") # svc- and -admin names are reserved for their classes; a federated role holds no credential + return broken, sorted(set(unknown)) + + +def evaluate(document: dict[str, Any], as_of: dt.date) -> dict[str, Any]: + """Evaluate a structurally valid declaration as of a stated date (never the wall clock).""" + exceptions = document.get("exceptions", []) + findings: list[dict[str, Any]] = [] + used: set[str] = set() + advisories: list[str] = [] + for identity in document["identities"]: + broken, unknown = _violations(identity) + if identity["status"] == "legacy" and parse_date(identity["disposition"]["date"]) < as_of: + broken.add("SI-11") + excepted: dict[str, str] = {} + open_rules: set[str] = set() + expired_ids: list[str] = [] + for rule in sorted(broken): + match = [e for e in exceptions if e["identity"] == identity["name"] and rule in e["rules"]] + live = [e for e in match if as_of <= parse_date(e["expires"])] + if live: + excepted[rule] = live[0]["id"] + used.add(live[0]["id"]) + else: + open_rules.add(rule) + expired_ids += [e["id"] for e in match] + if expired_ids: + open_rules.add("SI-EXC-EXPIRED") + if identity["status"] == "planned" and not open_rules: + result = "planned" + elif open_rules: + result = "non-conformant" + elif unknown: + result = "unknown" + elif excepted: + result = "excepted" + else: + result = "conformant" + findings.append({"identity": identity["name"], "result": result, "rules": sorted(open_rules), + "excepted": excepted, "unknown": unknown}) + for exception in exceptions: + if exception["id"] not in used and as_of <= parse_date(exception["expires"]): + advisories.append(f"{exception['id']}: no matching violation (stale exception)") + admins = [i["name"] for i in document["identities"] + if "service-admin" in i["classes"] and i["status"] != "planned"] + if len(admins) > 1: + advisories.append("SI-4: more than one service-admin identity: " + ", ".join(sorted(admins))) + if not document["complete"]: + advisories.append("SI-11: declaration is not asserted complete by the owner") + if not document["attestation"]["owner_confirmed"]: + advisories.append("declaration is not confirmed by the owning service") + return {"service": document["service"], "as_of": as_of.isoformat(), "identities": findings, + "advisories": advisories} + + +def run(paths: list[pathlib.Path], as_of: dt.date) -> tuple[list[dict[str, Any]], list[str]]: + schema = load_schema() + reports: list[dict[str, Any]] = [] + rejected: list[str] = [] + breakglass: set[str] = set() + for path in paths: + try: + document = load_declaration(path) + except (OSError, ValueError, yaml.YAMLError): + rejected.append(f"{path.name}: unreadable input") + continue + errors = structural_errors(document, schema) + if errors: + rejected.append(f"{path.name}: rejected ({len(errors)} errors)") + continue + reports.append(evaluate(document, as_of)) + breakglass |= {i["name"] for i in document["identities"] if "break-glass" in i["classes"]} + if len(breakglass) > 1: + reports.append({"service": "", "as_of": as_of.isoformat(), "identities": [], + "advisories": ["SI-6: more than one break-glass identity across declarations: " + + ", ".join(sorted(breakglass))]}) + return reports, rejected + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("paths", nargs="+", type=pathlib.Path) + parser.add_argument("--as-of", default=dt.date.today().isoformat(), help="evaluation date, YYYY-MM-DD") + parser.add_argument("--json", action="store_true", help="machine-readable output") + parser.add_argument("--fail-on-findings", action="store_true", help="exit 2 when anything is non-conformant or unknown") + args = parser.parse_args(argv) + try: + as_of = parse_date(args.as_of) + except ValueError: + print("service identity: invalid --as-of", file=sys.stderr) + return 1 + reports, rejected = run(args.paths, as_of) + for line in rejected: + print(f"service identity: {line}", file=sys.stderr) + counts: dict[str, int] = {k: 0 for k in ORDER} + for report in reports: + for item in report["identities"]: + counts[item["result"]] += 1 + if args.json: + print(json.dumps({"as_of": as_of.isoformat(), "counts": counts, "reports": reports, "rejected": len(rejected)}, indent=2)) + else: + for report in reports: + for item in sorted(report["identities"], key=lambda i: (ORDER[i["result"]], i["identity"])): + detail = (item["rules"] or item["unknown"] if item["result"] in ("non-conformant", "unknown", "planned") + else sorted(item["excepted"])) + print(f"{report['service']}/{item['identity']}: {item['result']}" + (f" [{', '.join(detail)}]" if detail else "")) + for note in report["advisories"]: + print(f"{report['service']}: advisory: {note}") + print("summary: " + ", ".join(f"{counts[k]} {k}" for k in ORDER) + f" (as of {as_of.isoformat()})") + if rejected: + return 1 + if args.fail_on_findings and (counts["non-conformant"] or counts["unknown"]): + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/workplans/NK-WP-0054-service-identity-policy.md b/workplans/NK-WP-0054-service-identity-policy.md index 6794e7b..825de34 100644 --- a/workplans/NK-WP-0054-service-identity-policy.md +++ b/workplans/NK-WP-0054-service-identity-policy.md @@ -103,11 +103,10 @@ founder's, then the service owners named in the inventory. ```task id: NK-WP-0054-T03 -status: wait +status: done priority: medium assignee: claude-code depends_on: [NK-WP-0054-T02] -blocking_reason: "Needs the reviewed T02 standard." state_hub_task_id: "a5c51d69-28ff-5458-83eb-10a35c8c5492" ``` @@ -119,6 +118,18 @@ declaration against the standard, refuses secret-shaped values and reports count **Done when:** valid and invalid fixtures pass and fail as expected, and the declarations for the current services are checked in. +Completed 2026-10-24, **provisionally**: built against draft-1 before the founder's review, so a rule +change in the standard changes the tool. `canon/schemas/service-identity_v0.1.schema.json`; +`tools/service-identity/validate.py` (30 tests); `make service-identity-test` and +`make service-identity-check`; eight declarations in `tools/service-identity/declarations/`, authored from +the inventory and all marked `owner_confirmed: false`. First run as of 2026-10-24: 0 non-conformant, 12 +unknown, 2 excepted, 5 planned, 6 conformant. Unknown outnumbers conformant because the repository +cannot establish privileges for the KeyCape clients and others; that is the honest result, not a pass. +Building it changed the draft in three ways: custody became a list (the `admin` password lives in a +Secret and a person's safe), the SI-X1 exception grew to include SI-9 and NAMING, and the reserved-name +rule was scoped to credential-holding identities (standard section 10, question 7). SI-4, SI-5, SI-7, +SI-10 and SI-13 are not decidable from a declaration. + ## Live conformance health check ```task @@ -127,7 +138,7 @@ status: wait priority: medium assignee: claude-code depends_on: [NK-WP-0054-T03] -blocking_reason: "Needs the validator and owner agreement on what read-only metadata each service owner can provide." +blocking_reason: "Validator delivered (T03, provisional). Await the T02 review and owner agreement on what read-only metadata each service can provide." state_hub_task_id: "c8db4607-9306-572b-a3cb-c35ca5989ec7" ```