diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 0ab53c5..e4ddeec 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -311,7 +311,7 @@ | 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 | done | — | workplans/NK-WP-0054-service-identity-policy.md | -| task | NK-WP-0054-T03b | todo | — | workplans/NK-WP-0054-service-identity-policy.md | +| task | NK-WP-0054-T03b | 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 index fe1c4b8..7d0a83c 100644 --- a/canon/schemas/service-identity_v0.1.schema.json +++ b/canon/schemas/service-identity_v0.1.schema.json @@ -2,7 +2,7 @@ "$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.", + "description": "One service declares every account it holds (NetKingdom Service Identity and Privilege v0.1, draft-3: an estate profile of SecurityCanon). Accounts carry an imported principal_type and authority grants written MODE @ SCOPE with activation and environment. Holds no credential and no secret-shaped value. Facts a declarer cannot establish are 'unknown', never guessed.", "type": "object", "additionalProperties": false, "required": [ @@ -114,9 +114,9 @@ "additionalProperties": false, "required": [ "name", - "classes", + "principal_type", "purpose", - "privilege_level", + "grants", "interactive_login", "second_factor", "custody", @@ -129,38 +129,45 @@ "name": { "$ref": "#/$defs/name" }, - "classes": { + "principal_type": { + "enum": [ + "HUMAN", + "WORKLOAD", + "SERVICE", + "AGENT", + "CONTROLLER", + "DEVICE", + "unknown" + ], + "description": "Imported from InfoTechCanon via SecurityCanon. 'unknown' is a declarer's placeholder." + }, + "conventions": { "type": "array", - "minItems": 1, "uniqueItems": true, "items": { "enum": [ - "human", - "break-glass", "bind", - "service-admin", - "workload", - "agent", - "infrastructure" + "service-admin" ] - } + }, + "description": "Estate naming conventions for recurring grant shapes (standard section 2). More than one is itself a finding." }, "purpose": { "type": "string", "minLength": 3, "maxLength": 300 }, - "privilege_level": { - "enum": [ - "none", - "read", - "write_scoped", - "admin", - "unknown" - ] + "grants": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "items": { + "$ref": "#/$defs/grant" + } }, - "grants_admin_policy": { - "type": "boolean" + "federated_role": { + "type": "boolean", + "description": "A role mapping federated principals to authority; holds no credential." }, "assurance_claim_bound": { "type": "boolean" @@ -346,14 +353,12 @@ { "if": { "properties": { - "classes": { - "contains": { - "const": "agent" - } + "principal_type": { + "const": "AGENT" } }, "required": [ - "classes" + "principal_type" ] }, "then": { @@ -374,7 +379,8 @@ "reason", "owner", "expires", - "compensating_control" + "compensating_control", + "elimination_path" ], "properties": { "id": { @@ -387,7 +393,7 @@ "uniqueItems": true, "items": { "type": "string", - "pattern": "^(SI-(1[0-3]|[1-9])|NAMING)$" + "pattern": "^(SI-(1[0-5]|[1-9])|NAMING)$" } }, "identity": { @@ -411,6 +417,71 @@ "type": "string", "minLength": 10, "maxLength": 400 + }, + "elimination_path": { + "type": "string", + "minLength": 10, + "maxLength": 400, + "description": "What change ends the exception (DSM-EXC-004)." + } + } + }, + "grant": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "scope" + ], + "description": "MODE @ SCOPE with SecurityCanon dimensions. 'unknown' is a declarer's placeholder, not a canon mode.", + "properties": { + "mode": { + "enum": [ + "USE", + "OBSERVE", + "OPERATE", + "ADMINISTER", + "GOVERN", + "CONSTRUCT", + "SOVEREIGN", + "unknown" + ] + }, + "scope": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "activation": { + "enum": [ + "STANDING", + "JUST_IN_TIME", + "SCHEDULED", + "APPROVED", + "INCIDENT", + "BREAK_GLASS", + "RECOVERY" + ] + }, + "environments": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": [ + "DEVELOPMENT", + "TEST", + "STAGING", + "PRODUCTION", + "RECOVERY" + ] + } + }, + "read_only": { + "type": "boolean" + }, + "note": { + "type": "string", + "maxLength": 200 } } } diff --git a/canon/standards/service-identity-privilege_v0.1.md b/canon/standards/service-identity-privilege_v0.1.md index 22960b9..b390e48 100644 --- a/canon/standards/service-identity-privilege_v0.1.md +++ b/canon/standards/service-identity-privilege_v0.1.md @@ -41,8 +41,8 @@ related: rules it profiles and states only what NetKingdom adds. New: the estate identities in canon terms (§3) and SI-15, accountable automation. All founder decisions and every rule id from draft-2 are kept. -The offline validator (`tools/service-identity`) still implements the draft-2 field model. Its migration to -grants is NK-WP-0054-T03b. The live health check (T04) does not exist yet. +The offline validator (`tools/service-identity`) implements draft-3 (principal type and grants, NK-WP-0054-T03b). +The live health check (T04) does not exist yet. ## 1. Purpose, scope and authority @@ -216,9 +216,9 @@ Per account the result is `conformant`, `non-conformant`, `excepted`, `unknown` finding, not a pass. These match the companion's conformance states: `excepted` is a declared gap, `non-conformant` is an undeclared violation. -The offline validator decides from a declaration alone: SI-1, SI-2, SI-3, SI-6, SI-8, SI-9, SI-11, SI-12, SI-14 -and naming. SI-4 is an advisory. SI-5, SI-7, SI-10, SI-13 and SI-15 are process rules, checked by evidence rather -than by declaration. Dates are explicit (`--as-of`). +The offline validator decides from a declaration alone: SI-1, SI-2, SI-3, SI-4, SI-6, SI-7, SI-8, SI-9, SI-11, +SI-12, SI-14 and naming; more than one service-admin account per service is an advisory. SI-5, SI-10, SI-13 and +SI-15 are process rules, checked by evidence rather than by declaration. Dates are explicit (`--as-of`). The health check (T04) is SEC-DSM's automated discovery (DSM-INV-004) applied to accounts. It compares declared against observed state from owner-provided read-only metadata and repository manifests. It MUST NOT read a Secret. @@ -232,7 +232,7 @@ control), expiring or revalidated, and carrying an **elimination path**: what ch | Id | Rules | Account | Reason | Elimination path | | --- | --- | --- | --- | --- | -| SI-X1 | SI-1, SI-2, SI-3, SI-9, naming | LLDAP `admin` | The human, the super-admin and the shared bind of four services in one account; password in a person's safe and a Kubernetes Secret | NK-WP-0054-T05: `svc-*` binds, provisioner account, `ld-admin`, `admin` human-only | +| SI-X1 | SI-1, SI-2, SI-3, SI-4, SI-9, naming | LLDAP `admin` | The human, the super-admin and the shared bind of four services in one account; password in a person's safe and a Kubernetes Secret | NK-WP-0054-T05: `svc-*` binds, provisioner account, `ld-admin`, `admin` human-only | | SI-X2 | SI-10 | LDAP bind accounts | Static long-lived credential; LDAP has no lease | Governed residual secret (DSM-TRANS-002) until a federated directory lookup exists | | SI-X3 | naming, SI-11 | `trigger-admin` (privacyIDEA) | Legacy account, consumers uninventoried | NK-WP-0049-T22 disposition | | SI-X4 | SI-8 | OpenBao `netkingdom-platform-admin` | Role binds the group claim only | railiance-platform binds `aal2` (decided 2026-10-24) | @@ -287,7 +287,7 @@ interactively (SI-3). ## 11. Adoption -Structure first, tooling second. Ratify the vocabulary and rules; migrate the declarations and validator to -grants (T03b); confirm the declarations with their owners; run the health check read-only (T04) and publish the +Structure first, tooling second. Ratify the vocabulary and rules (the declarations and validator already follow +draft-3); confirm the declarations with their owners; run the health check read-only (T04) and publish the first report; then eliminate the exceptions one by one, starting with SI-X1 (T05). No exception is closed by writing a declaration. Each is closed by the live change and an evidence run. diff --git a/docs/service-identity-inventory.md b/docs/service-identity-inventory.md index 2b8d8fc..9cff280 100644 --- a/docs/service-identity-inventory.md +++ b/docs/service-identity-inventory.md @@ -11,6 +11,10 @@ live state is T04's job. ## Classes used +These are draft-1 classes. Draft-3 of the standard replaces them with the canon's `principal_type` and grants +`MODE @ SCOPE` (standard §2 has the mapping); the declarations in `tools/service-identity/declarations/` use draft-3. + + | Class | Meaning | | --- | --- | | human | A person's login | diff --git a/tools/service-identity/README.md b/tools/service-identity/README.md index ff57502..9735d6f 100644 --- a/tools/service-identity/README.md +++ b/tools/service-identity/README.md @@ -4,7 +4,7 @@ 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. It now follows draft-2. +so a rule change in the standard changes this tool. It follows draft-3. ```bash make service-identity-test # unit tests @@ -30,14 +30,20 @@ Alongside the results, **advisories** report declarations not asserted complete declarations not confirmed by the owning service, stale exceptions, more than one service-admin and more than one break-glass identity. -## Rules decided here +## Model and 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, SI-14 -(an agent must present as an agent, with a token lifetime at or below the 3600-second, 60-minute ceiling) 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. +Draft-3 of the standard is an estate profile of SecurityCanon. Each account declares an imported +`principal_type` (`HUMAN`, `SERVICE`, `WORKLOAD`, `AGENT`, `CONTROLLER`, `DEVICE`), its authority as grants +`MODE @ SCOPE` with `activation` and `environments`, and optional estate `conventions` (`bind`, +`service-admin`). Break-glass is `activation: BREAK_GLASS`, not a kind of account. `unknown` is allowed where a +fact is not established and is reported as a finding. Exceptions carry an `elimination_path` (DSM-EXC-004). + +Decided from a declaration: SI-1, SI-2, SI-3 (including a credential shared by more than one service), SI-4 +(a service-admin holds `ADMINISTER` over one service only), SI-6 (`SOVEREIGN` is offline and break-glass in +production), SI-7 (`CONSTRUCT` never in production), SI-8 (second factor; admin roles bind the assurance +claim), SI-9, SI-11 (overdue legacy disposition), SI-12, SI-14 (an agent presents as an agent, tokens at most +3600 seconds) and naming. More than one service-admin account per service is an advisory. SI-5, SI-10, SI-13 and +SI-15 are process rules. Observed state is the health check, NK-WP-0054-T04. ## Declarations diff --git a/tools/service-identity/declarations/authelia.yaml b/tools/service-identity/declarations/authelia.yaml index 5a74877..bf5a136 100644 --- a/tools/service-identity/declarations/authelia.yaml +++ b/tools/service-identity/declarations/authelia.yaml @@ -9,9 +9,9 @@ attestation: complete: true identities: - name: keycape - classes: [workload] + principal_type: SERVICE purpose: "The only registered OIDC client: KeyCape's upstream sign-in" - privilege_level: none + grants: [{mode: USE, scope: authelia, activation: STANDING, environments: [PRODUCTION], note: "authorization_code, one_factor"}] interactive_login: false second_factor: not_applicable custody: [delivered-secret] diff --git a/tools/service-identity/declarations/identity-provisioner.yaml b/tools/service-identity/declarations/identity-provisioner.yaml index e52ab2c..461f2f0 100644 --- a/tools/service-identity/declarations/identity-provisioner.yaml +++ b/tools/service-identity/declarations/identity-provisioner.yaml @@ -9,9 +9,9 @@ attestation: complete: false identities: - name: identity-provisioner-token - classes: [workload] + principal_type: SERVICE purpose: "Service token that user-engine presents; the NetworkPolicy admits only user-engine pods" - privilege_level: write_scoped + grants: [{mode: USE, scope: identity-provisioner, activation: STANDING, environments: [PRODUCTION]}] interactive_login: false second_factor: not_applicable custody: [delivered-secret] diff --git a/tools/service-identity/declarations/keycape.yaml b/tools/service-identity/declarations/keycape.yaml index d02df0f..f88b916 100644 --- a/tools/service-identity/declarations/keycape.yaml +++ b/tools/service-identity/declarations/keycape.yaml @@ -9,9 +9,11 @@ attestation: complete: false identities: - name: secrets-engine-openbao - classes: [workload] - purpose: "Confidential OIDC client; scopes openbao:login only (deny openbao:admin)" - privilege_level: unknown + principal_type: SERVICE + purpose: "Confidential OIDC client; obtains a login token whose only scope is openbao:login" + grants: + - {mode: USE, scope: keycape, activation: STANDING, environments: [PRODUCTION], note: "client credentials"} + - {mode: USE, scope: openbao, activation: STANDING, environments: [PRODUCTION], note: "scope openbao:login only; openbao:admin denied"} interactive_login: false second_factor: not_applicable custody: [delivered-secret] @@ -20,9 +22,11 @@ identities: 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 + principal_type: SERVICE + purpose: "Confidential OIDC client; its token scopes are not established in the repository" + grants: + - {mode: USE, scope: keycape, activation: STANDING, environments: [PRODUCTION], note: "client credentials"} + - {mode: unknown, scope: "token scopes", note: "not established in the repository"} interactive_login: false second_factor: not_applicable custody: [delivered-secret] @@ -31,9 +35,11 @@ identities: 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 + principal_type: SERVICE + purpose: "Confidential OIDC client; its token scopes are not established in the repository" + grants: + - {mode: USE, scope: keycape, activation: STANDING, environments: [PRODUCTION], note: "client credentials"} + - {mode: unknown, scope: "token scopes", note: "not established in the repository"} interactive_login: false second_factor: not_applicable custody: [delivered-secret] @@ -42,9 +48,11 @@ identities: 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 + principal_type: SERVICE + purpose: "Confidential OIDC client; its token scopes are not established in the repository" + grants: + - {mode: USE, scope: keycape, activation: STANDING, environments: [PRODUCTION], note: "client credentials"} + - {mode: unknown, scope: "token scopes", note: "not established in the repository"} interactive_login: false second_factor: not_applicable custody: [delivered-secret] @@ -53,9 +61,11 @@ identities: 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 + principal_type: SERVICE + purpose: "Confidential OIDC client; its token scopes are not established in the repository" + grants: + - {mode: USE, scope: keycape, activation: STANDING, environments: [PRODUCTION], note: "client credentials"} + - {mode: unknown, scope: "token scopes", note: "not established in the repository"} interactive_login: false second_factor: not_applicable custody: [delivered-secret] @@ -64,9 +74,11 @@ identities: 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 + principal_type: SERVICE + purpose: "Confidential OIDC client; its token scopes are not established in the repository" + grants: + - {mode: USE, scope: keycape, activation: STANDING, environments: [PRODUCTION], note: "client credentials"} + - {mode: unknown, scope: "token scopes", note: "not established in the repository"} interactive_login: false second_factor: not_applicable custody: [delivered-secret] diff --git a/tools/service-identity/declarations/lldap.yaml b/tools/service-identity/declarations/lldap.yaml index 99a1311..a10ae12 100644 --- a/tools/service-identity/declarations/lldap.yaml +++ b/tools/service-identity/declarations/lldap.yaml @@ -9,9 +9,12 @@ attestation: 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 + principal_type: HUMAN + conventions: [bind, service-admin] + purpose: "The human platform administrator; with the same password also the directory super-admin and the bind of four services" + grants: + - {mode: ADMINISTER, scope: directory, activation: STANDING, environments: [PRODUCTION]} + - {mode: USE, scope: directory, activation: STANDING, environments: [PRODUCTION], read_only: true, note: "service binds"} interactive_login: true second_factor: required custody: [delivered-secret, personal-safe] @@ -28,9 +31,11 @@ identities: status: active recovery: {needed: true, works_with_sso_down: false, in_crisis_list: true} - name: ld-admin - classes: [service-admin] - purpose: "LLDAP only: the directory's configured super-admin, separate from every bind and every person; introduced by configuration after a rehearsal" - privilege_level: admin + principal_type: HUMAN + conventions: [service-admin] + purpose: "LLDAP only: the directory's configured super-admin, a sealed fallback; introduced by configuration after a rehearsal" + grants: + - {mode: ADMINISTER, scope: lldap, activation: BREAK_GLASS, environments: [PRODUCTION]} interactive_login: true second_factor: unknown custody: [unknown] @@ -39,9 +44,10 @@ identities: rotation: {path: "To be decided in NK-WP-0054-T06", coupling: []} status: planned - name: svc-authelia - classes: [bind] + principal_type: SERVICE + conventions: [bind] purpose: "Target: Authelia's read-only directory lookup" - privilege_level: read + grants: [{mode: USE, scope: directory, activation: STANDING, environments: [PRODUCTION], read_only: true}] interactive_login: false second_factor: not_applicable custody: [delivered-secret] @@ -50,9 +56,10 @@ identities: rotation: {path: "Secret rotation with Authelia only", coupling: [authelia]} status: planned - name: svc-keycape - classes: [bind] + principal_type: SERVICE + conventions: [bind] purpose: "Target: KeyCape's read-only directory lookup" - privilege_level: read + grants: [{mode: USE, scope: directory, activation: STANDING, environments: [PRODUCTION], read_only: true}] interactive_login: false second_factor: not_applicable custody: [delivered-secret] @@ -61,9 +68,10 @@ identities: rotation: {path: "Secret rotation with KeyCape only", coupling: [keycape]} status: planned - name: svc-privacyidea - classes: [bind] + principal_type: SERVICE + conventions: [bind] purpose: "Target: the privacyIDEA resolver's read-only directory lookup" - privilege_level: read + grants: [{mode: USE, scope: directory, activation: STANDING, environments: [PRODUCTION], read_only: true}] interactive_login: false second_factor: not_applicable custody: [delivered-secret] @@ -72,9 +80,9 @@ identities: rotation: {path: "Resolver bind password, stored encrypted in the privacyIDEA database", coupling: [privacyidea]} status: planned - name: svc-provisioner - classes: [workload] + principal_type: SERVICE purpose: "Target: identity-provisioner, narrowest working write scope for users and groups, not the super-admin (scope to be tested)" - privilege_level: write_scoped + grants: [{mode: ADMINISTER, scope: "directory/people,groups", activation: STANDING, environments: [PRODUCTION]}] interactive_login: false second_factor: not_applicable custody: [delivered-secret] @@ -84,9 +92,10 @@ identities: status: planned exceptions: - id: SI-X1 - rules: [SI-1, SI-2, SI-3, SI-9, NAMING] + rules: [SI-1, SI-2, SI-3, SI-4, 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" + compensating_control: "Web UI restricted by IP allowlist; OTP on the human login" + elimination_path: "NK-WP-0054-T05: svc-* binds, a provisioner account, ld-admin as the configured super-admin, admin human-only" diff --git a/tools/service-identity/declarations/openbao.yaml b/tools/service-identity/declarations/openbao.yaml index ae6677d..b954ae6 100644 --- a/tools/service-identity/declarations/openbao.yaml +++ b/tools/service-identity/declarations/openbao.yaml @@ -9,10 +9,10 @@ attestation: complete: false identities: - name: netkingdom-platform-admin - classes: [human] + principal_type: HUMAN + federated_role: true purpose: "OIDC role: group net-kingdom-admins receives policies platform-admin and operator-custody, one-hour token" - privilege_level: admin - grants_admin_policy: true + grants: [{mode: ADMINISTER, scope: openbao, activation: STANDING, environments: [PRODUCTION]}] assurance_claim_bound: false interactive_login: true second_factor: required @@ -23,10 +23,10 @@ identities: status: active recovery: {needed: true, works_with_sso_down: false, in_crisis_list: true} - name: credential-broker-keycape-client-secret-generate - classes: [human] + principal_type: HUMAN + federated_role: true purpose: "OIDC role: net-kingdom-admins with aal2 may generate a KeyCape client secret in custody" - privilege_level: write_scoped - grants_admin_policy: false + grants: [{mode: ADMINISTER, scope: "openbao/platform/workloads/keycape", activation: STANDING, environments: [PRODUCTION]}] assurance_claim_bound: true interactive_login: true second_factor: required @@ -36,10 +36,10 @@ identities: rotation: {path: "Federated; policy change by reviewed change", coupling: []} status: active - name: fluid-telegram-operators - classes: [human] + principal_type: HUMAN + federated_role: true purpose: "OIDC role: fluid-telegram operators with aal2" - privilege_level: unknown - grants_admin_policy: false + grants: [{mode: unknown, scope: openbao, note: "policy not established in the repository"}] assurance_claim_bound: true interactive_login: true second_factor: required @@ -49,9 +49,10 @@ identities: rotation: {path: "Federated; policy change by reviewed change", coupling: []} status: active - name: secrets-engine-openbao - classes: [workload] + principal_type: SERVICE + federated_role: true purpose: "JWT role: subject service:secrets-engine, scope openbao:login, policy secrets-engine-login-self, five-minute token" - privilege_level: read + grants: [{mode: USE, scope: openbao, activation: STANDING, environments: [PRODUCTION], note: "login-self only"}] interactive_login: false second_factor: not_applicable custody: [none] @@ -60,10 +61,11 @@ identities: rotation: {path: "Federated; the KeyCape client secret rotates in the keycape declaration", coupling: [keycape]} status: active - name: coding-agent - classes: [agent] + principal_type: AGENT + federated_role: true agent: {mode: unknown, presents_principal_type: service, max_token_ttl_seconds: 900, boundary_policy: agent-high-risk-boundary} 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 + grants: [{mode: unknown, scope: openbao, note: "boundary policy content not established"}] interactive_login: false second_factor: not_applicable custody: [none] @@ -78,4 +80,5 @@ exceptions: 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" + compensating_control: "KeyCape requires a second factor for every login (requireForAll)" + elimination_path: "railiance-platform binds aal2 in the role (decided 2026-10-24)" diff --git a/tools/service-identity/declarations/platform.yaml b/tools/service-identity/declarations/platform.yaml index 83ce399..28ac1c1 100644 --- a/tools/service-identity/declarations/platform.yaml +++ b/tools/service-identity/declarations/platform.yaml @@ -1,18 +1,20 @@ -# People and the break-glass identity. Provisional location (standard section 10, question 6). +# People and the break-glass authority, in SecurityCanon terms (standard section 3). 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] + basis: [docs/service-identity-inventory.md, docs/platform-root-custody.md, canon/standards/service-identity-privilege_v0.1.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 + principal_type: HUMAN + purpose: "King credential: final authority, break-glass in production; build-mode authority outside production" + grants: + - {mode: SOVEREIGN, scope: platform, activation: BREAK_GLASS, environments: [PRODUCTION, RECOVERY]} + - {mode: CONSTRUCT, scope: platform, activation: STANDING, environments: [DEVELOPMENT, TEST]} interactive_login: true second_factor: required custody: [offline-key] @@ -22,9 +24,10 @@ identities: 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 + principal_type: HUMAN + purpose: "Developer identity (CONSTRUCT @ product); its actual production reach is not established" + grants: + - {mode: CONSTRUCT, scope: product, activation: STANDING, environments: [DEVELOPMENT, TEST]} interactive_login: true second_factor: unknown custody: [unknown] diff --git a/tools/service-identity/declarations/postgresql.yaml b/tools/service-identity/declarations/postgresql.yaml index 4efc664..629fcb0 100644 --- a/tools/service-identity/declarations/postgresql.yaml +++ b/tools/service-identity/declarations/postgresql.yaml @@ -9,9 +9,11 @@ attestation: complete: false identities: - name: privacyidea - classes: [infrastructure] - purpose: "Owner of privacyidea_db in the net-kingdom-pg cluster" - privilege_level: write_scoped + principal_type: SERVICE + purpose: "Database role owning privacyidea_db in the net-kingdom-pg cluster" + grants: + - {mode: USE, scope: privacyidea_db, activation: STANDING, environments: [PRODUCTION]} + - {mode: ADMINISTER, scope: privacyidea_db, activation: STANDING, environments: [PRODUCTION], note: "database owner"} interactive_login: false second_factor: not_applicable custody: [delivered-secret] diff --git a/tools/service-identity/declarations/privacyidea.yaml b/tools/service-identity/declarations/privacyidea.yaml index afb294b..e32877e 100644 --- a/tools/service-identity/declarations/privacyidea.yaml +++ b/tools/service-identity/declarations/privacyidea.yaml @@ -9,9 +9,10 @@ attestation: complete: false identities: - name: pi-admin - classes: [service-admin] + principal_type: HUMAN + conventions: [service-admin] purpose: "privacyIDEA's local administrator; policy pi-admin-all-rights restricted to adminuser=pi-admin" - privilege_level: admin + grants: [{mode: ADMINISTER, scope: privacyidea, activation: STANDING, environments: [PRODUCTION]}] interactive_login: true second_factor: unknown custody: [personal-safe] @@ -21,9 +22,10 @@ identities: 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 + principal_type: unknown + conventions: [service-admin] + purpose: "Legacy Keycloak-era administrator account, policy and Secret; who presents it is not established" + grants: [{mode: ADMINISTER, scope: privacyidea, activation: STANDING, environments: [PRODUCTION]}] interactive_login: false second_factor: unknown custody: [delivered-secret] @@ -33,9 +35,9 @@ identities: status: legacy disposition: {action: defer, owner: "Founder", date: 2026-12-31} - name: keycape-factor-reader - classes: [workload] + principal_type: SERVICE purpose: "KeyCape's access to read factor state; delivered by railiance-platform (RPF-WP-0040)" - privilege_level: unknown + grants: [{mode: unknown, scope: privacyidea, note: "exact rights not established"}] interactive_login: false second_factor: not_applicable custody: [delivered-secret] @@ -50,4 +52,5 @@ exceptions: 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" + compensating_control: "Not provisioned by the current bootstrap" + elimination_path: "Revocation or acceptance 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 index 2d7bbe0..2194d30 100644 --- a/tools/service-identity/fixtures/invalid/secret-value.json +++ b/tools/service-identity/fixtures/invalid/secret-value.json @@ -3,12 +3,50 @@ "service": "example", "owner": "Example owners", "declared_at": "2026-10-24", - "attestation": {"authored_by": "fixture", "basis": ["fixture"], "owner_confirmed": true}, + "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"} + { + "name": "svc-example", + "purpose": "Lookup with hvs.ABCDEFGH12345678abcdef smuggled in", + "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", + "principal_type": "SERVICE", + "conventions": [ + "bind" + ], + "grants": [ + { + "mode": "USE", + "scope": "directory", + "activation": "STANDING", + "environments": [ + "PRODUCTION" + ], + "read_only": true + } + ] + } ] } diff --git a/tools/service-identity/fixtures/valid/conformant.json b/tools/service-identity/fixtures/valid/conformant.json index ac133a9..5e5c575 100644 --- a/tools/service-identity/fixtures/valid/conformant.json +++ b/tools/service-identity/fixtures/valid/conformant.json @@ -3,16 +3,87 @@ "service": "example", "owner": "Example owners", "declared_at": "2026-10-24", - "attestation": {"authored_by": "fixture", "basis": ["fixture"], "owner_confirmed": true}, + "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"} + { + "name": "svc-example", + "purpose": "Read-only lookup", + "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", + "principal_type": "SERVICE", + "conventions": [ + "bind" + ], + "grants": [ + { + "mode": "USE", + "scope": "directory", + "activation": "STANDING", + "environments": [ + "PRODUCTION" + ], + "read_only": true + } + ] + }, + { + "name": "ex-admin", + "purpose": "Administer the example service", + "interactive_login": true, + "second_factor": "required", + "custody": [ + "personal-safe" + ], + "consumers": [ + { + "name": "operator", + "kind": "person" + } + ], + "owner": "Example owners", + "rotation": { + "path": "Owner-managed", + "coupling": [] + }, + "status": "active", + "principal_type": "HUMAN", + "conventions": [ + "service-admin" + ], + "grants": [ + { + "mode": "ADMINISTER", + "scope": "example", + "activation": "STANDING", + "environments": [ + "PRODUCTION" + ] + } + ] + } ] } diff --git a/tools/service-identity/tests/test_validate.py b/tools/service-identity/tests/test_validate.py index ab94298..fccf676 100644 --- a/tools/service-identity/tests/test_validate.py +++ b/tools/service-identity/tests/test_validate.py @@ -21,8 +21,16 @@ SCHEMA = V.load_schema() NOW = dt.date(2026, 10, 24) +READ = {"mode": "USE", "scope": "directory", "activation": "STANDING", "environments": ["PRODUCTION"], "read_only": True} + + +def grant(mode, scope="example", **over): + return {"mode": mode, "scope": scope, "activation": "STANDING", "environments": ["PRODUCTION"], **over} + + def identity(**over): - base = {"name": "svc-x", "classes": ["bind"], "purpose": "Read-only lookup", "privilege_level": "read", + base = {"name": "svc-x", "principal_type": "SERVICE", "conventions": ["bind"], "purpose": "Read-only lookup", + "grants": [dict(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"} @@ -41,7 +49,8 @@ def declaration(*identities, exceptions=None, complete=True, confirmed=True): 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"} + "owner": "Owners", "expires": "2027-01-31", "compensating_control": "A compensating control", + "elimination_path": "The change that ends it"} base.update(over) return base @@ -53,6 +62,11 @@ def result_of(doc, as_of=NOW, name=None): return found[0] +HUMAN_ADMIN = dict(name="ex-admin", principal_type="HUMAN", conventions=["service-admin"], grants=[grant("ADMINISTER")], + interactive_login=True, second_factor="required", custody=["personal-safe"], + consumers=[{"name": "p", "kind": "person"}]) + + class StructureTests(unittest.TestCase): def test_conformant_fixture_is_valid_and_conformant(self): doc = json.loads((FIXTURES / "valid/conformant.json").read_text()) @@ -65,17 +79,21 @@ class StructureTests(unittest.TestCase): 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_unknown_field_mode_and_principal_are_rejected(self): + self.assertTrue(V.structural_errors(declaration(identity(password="x")), SCHEMA)) + self.assertTrue(V.structural_errors(declaration(identity(grants=[grant("ROOT")])), SCHEMA)) + self.assertTrue(V.structural_errors(declaration(identity(principal_type="ROBOT")), 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_exception_needs_an_elimination_path(self): + bad = exception() + del bad["elimination_path"] + self.assertTrue(V.structural_errors(declaration(identity(), exceptions=[bad]), 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"}) @@ -86,58 +104,71 @@ 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_si1_a_human_account_presented_by_a_service_or_two_conventions(self): + self.assertIn("SI-1", self.rules(**{**HUMAN_ADMIN, "consumers": [{"name": "s", "kind": "service"}]})) + self.assertIn("SI-1", self.rules(conventions=["bind", "service-admin"])) 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")) + def test_si3_bind_is_a_read_only_use_grant_with_one_consumer(self): + self.assertEqual(self.rules(), []) + self.assertIn("SI-3", self.rules(grants=[grant("ADMINISTER", "directory")])) + self.assertIn("SI-3", self.rules(grants=[{**READ, "read_only": False}])) self.assertIn("SI-3", self.rules(interactive_login=True)) + self.assertIn("SI-3", self.rules(principal_type="HUMAN", consumers=[{"name": "x", "kind": "service"}])) 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_si4_service_admin_holds_administer_over_one_service(self): + self.assertEqual(self.rules(**HUMAN_ADMIN), []) + self.assertIn("SI-4", self.rules(**{**HUMAN_ADMIN, "grants": [grant("ADMINISTER"), grant("USE")]})) + self.assertIn("SI-4", self.rules(**{**HUMAN_ADMIN, "grants": [grant("ADMINISTER", "a"), grant("ADMINISTER", "b")]})) - def test_si9_a_person_safe_must_not_hold_a_service_credential(self): + def test_si6_sovereign_is_offline_and_break_glass_in_production(self): + root = dict(name="platform-root", principal_type="HUMAN", conventions=[], interactive_login=True, + second_factor="required", custody=["offline-key"], consumers=[{"name": "p", "kind": "person"}]) + ok = grant("SOVEREIGN", "platform", activation="BREAK_GLASS", environments=["PRODUCTION", "RECOVERY"]) + self.assertEqual(self.rules(grants=[ok], **root), []) + self.assertIn("SI-6", self.rules(grants=[{**ok, "activation": "STANDING"}], **root)) + self.assertIn("SI-6", self.rules(grants=[ok], **{**root, "custody": ["personal-safe"]})) + self.assertIn("SI-6", self.rules(grants=[ok], **{**root, "consumers": [{"name": "s", "kind": "service"}]})) + + def test_si7_construct_is_never_available_in_production(self): + dev = dict(name="dev", principal_type="HUMAN", conventions=[], interactive_login=True, second_factor="required", + custody=["personal-safe"], consumers=[{"name": "p", "kind": "person"}]) + self.assertEqual(self.rules(grants=[grant("CONSTRUCT", "product", environments=["DEVELOPMENT", "TEST"])], **dev), []) + self.assertIn("SI-7", self.rules(grants=[grant("CONSTRUCT", "product")], **dev)) + + def test_si8_second_factor_and_assurance_on_admin_roles(self): + self.assertIn("SI-8", self.rules(**{**HUMAN_ADMIN, "second_factor": "not_enforced"})) + role = dict(name="admins-role", principal_type="HUMAN", conventions=[], federated_role=True, grants=[grant("ADMINISTER")], + interactive_login=True, second_factor="required", custody=["none"], consumers=[{"name": "g", "kind": "person"}]) + self.assertIn("SI-8", self.rules(**role)) + self.assertEqual(self.rules(**role, assurance_claim_bound=True), []) + self.assertEqual(self.rules(**{**role, "grants": [grant("USE")]}), []) + + def test_si9_a_persons_safe_never_holds_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): + def test_naming_is_reserved_by_convention(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"}])) + self.assertIn("NAMING", self.rules(**{**HUMAN_ADMIN, "name": "administrator"})) + self.assertIn("NAMING", self.rules(**{**HUMAN_ADMIN, "name": "admin"})) # `admin` is the human's name everywhere + self.assertEqual(self.rules(**{**HUMAN_ADMIN, "name": "ld-admin"}), []) + person = dict(principal_type="HUMAN", conventions=[], grants=[grant("OPERATE", "platform")], interactive_login=True, + second_factor="required", consumers=[{"name": "p", "kind": "person"}]) + self.assertIn("NAMING", self.rules(name="x-admin", custody=["personal-safe"], **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.assertIn("NAMING", self.rules(name="admin", **admin)) # `admin` is the human's name everywhere - self.assertEqual(self.rules(name="ld-admin", **admin), []) + self.assertNotIn("NAMING", self.rules(name="group-admin", custody=["none"], **person)) 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"}) + legacy = identity(**{**HUMAN_ADMIN, "name": "old-admin", "consumers": [], "status": "legacy", + "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"]) @@ -148,11 +179,12 @@ class AgentTests(unittest.TestCase): agent = {"mode": "autonomous", "presents_principal_type": "agent", "max_token_ttl_seconds": 900, "boundary_policy": "agent-high-risk-boundary"} agent.update(agent_over) - return identity(name="coding-agent", classes=["agent"], agent=agent, privilege_level="read", - custody=["none"], consumers=[{"name": "agent-host", "kind": "service"}]) + return identity(name="coding-agent", principal_type="AGENT", conventions=[], agent=agent, + grants=[grant("USE", "openbao")], custody=["none"], + consumers=[{"name": "agent-host", "kind": "service"}]) - def test_agent_class_needs_the_agent_object(self): - doc = declaration(identity(name="a", classes=["agent"], custody=["none"])) + def test_agent_principal_needs_the_agent_object(self): + doc = declaration(identity(name="a", principal_type="AGENT", conventions=[], custody=["none"])) self.assertTrue(V.structural_errors(doc, SCHEMA)) def test_conformant_agent(self): @@ -162,8 +194,9 @@ class AgentTests(unittest.TestCase): r = result_of(declaration(self.agent(presents_principal_type="service"))) self.assertEqual((r["result"], r["rules"]), ("non-conformant", ["SI-14"])) - def test_agent_token_lifetime_above_the_ceiling_breaks_si14(self): - self.assertIn("SI-14", result_of(declaration(self.agent(max_token_ttl_seconds=7200)))["rules"]) + def test_agent_token_lifetime_above_sixty_minutes_breaks_si14(self): + self.assertIn("SI-14", result_of(declaration(self.agent(max_token_ttl_seconds=3601)))["rules"]) + self.assertNotIn("SI-14", result_of(declaration(self.agent(max_token_ttl_seconds=3600)))["rules"]) def test_unknown_agent_mode_is_unknown_not_a_pass(self): r = result_of(declaration(self.agent(mode="unknown"))) @@ -172,25 +205,25 @@ class AgentTests(unittest.TestCase): def test_agent_must_not_live_in_a_persons_safe_or_serve_a_person(self): doc = declaration({**self.agent(), "custody": ["personal-safe"], "consumers": [{"name": "p", "kind": "person"}]}) - self.assertLessEqual({"SI-9", "SI-2"}, set(result_of(doc)["rules"])) + self.assertLessEqual({"SI-9", "SI-1"}, set(result_of(doc)["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_unknown_is_a_finding_for_every_account(self): + for over, fact in ((dict(grants=[grant("unknown")]), "grants"), (dict(custody=["unknown"]), "custody"), + (dict(principal_type="unknown", conventions=[]), "principal_type")): + r = result_of(declaration(identity(name="client", **{"conventions": [], **over}))) + self.assertEqual((r["result"], r["unknown"]), ("unknown", [fact])) def test_violation_outranks_unknown(self): - r = result_of(declaration(identity(privilege_level="unknown", interactive_login=True))) + r = result_of(declaration(identity(custody=["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()]) + doc = declaration(identity(interactive_login=True), 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)) @@ -198,7 +231,7 @@ class ResultTests(unittest.TestCase): 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"])]) + doc = declaration(identity(interactive_login=True), exceptions=[exception(rules=["SI-9"])]) self.assertEqual(result_of(doc)["result"], "non-conformant") def test_stale_exception_and_unconfirmed_declaration_are_advisories(self): @@ -209,7 +242,7 @@ class ResultTests(unittest.TestCase): 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")]) + doc = declaration(identity(interactive_login=True), 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") @@ -236,7 +269,7 @@ class CheckedInDeclarations(unittest.TestCase): 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"}) + self.assertEqual(set(today["excepted"]), {"SI-1", "SI-2", "SI-3", "SI-4", "SI-9", "NAMING"}) after = admin(dt.date(2027, 2, 1)) self.assertEqual(after["result"], "non-conformant") @@ -282,20 +315,21 @@ class CommandLineTests(unittest.TestCase): 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, + def test_more_than_one_sovereign_account_is_an_advisory(self): + def root(name): + return declaration(identity(name=name, principal_type="HUMAN", conventions=[], interactive_login=True, second_factor="required", custody=["offline-key"], + grants=[grant("SOVEREIGN", "platform", activation="BREAK_GLASS")], 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))) + p.write_text(json.dumps(root(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"])) + self.assertTrue(any("more than one SOVEREIGN" in a for r in reports for a in r["advisories"])) if __name__ == "__main__": diff --git a/tools/service-identity/validate.py b/tools/service-identity/validate.py index 61ba19f..1c463eb 100644 --- a/tools/service-identity/validate.py +++ b/tools/service-identity/validate.py @@ -5,7 +5,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. +NetKingdom Service Identity and Privilege v0.1 (draft-3, a SecurityCanon profile) that a declaration alone can decide. 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. @@ -93,43 +93,74 @@ def structural_errors(document: Any, schema: dict[str, Any]) -> list[str]: return out +ADMIN_MODES = {"ADMINISTER", "GOVERN", "CONSTRUCT", "SOVEREIGN"} # not a ladder: kinds that change rules or definitions +NON_HUMAN = {"SERVICE", "WORKLOAD", "AGENT", "CONTROLLER", "DEVICE"} + + def _violations(identity: dict[str, Any]) -> tuple[set[str], list[str]]: - """Rules broken by one identity on declared facts, and the facts still unknown.""" + """Rules broken by one account on declared facts, and the facts still unknown (standard draft-3).""" broken: set[str] = set() unknown: list[str] = [] - classes = identity["classes"] + principal = identity["principal_type"] + conventions = identity.get("conventions", []) + grants = identity["grants"] 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"] + second, custody = identity["second_factor"], identity["custody"] + modes = {g["mode"] for g in grants} - if len(classes) != 1: + # Unknown is a finding for every account: a fact nobody has established is not a pass. + if principal == "unknown": + unknown.append("principal_type") + if "unknown" in modes: + unknown.append("grants") + if "unknown" in custody: + unknown.append("custody") + + # SI-1 one principal type, one purpose; SI-2 people and services never share a credential. + if len(conventions) > 1 or (principal == "HUMAN" and services) or (principal in NON_HUMAN and persons): broken.add("SI-1") - if (persons and services) or ("human" in classes and services): + if persons 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"): + # SI-3 bind accounts: SERVICE, read-only USE, one consumer, never interactive. + if "bind" in conventions: + reads_only = all(g["mode"] == "USE" and g.get("read_only") is True for g in grants) + if principal != "SERVICE" or not reads_only or identity["interactive_login"] or persons or len(services) != 1: 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: + # SI-4 a service-admin account holds ADMINISTER over one service only. + if "service-admin" in conventions: + if {g["mode"] for g in grants} - {"ADMINISTER", "unknown"} or len({g["scope"] for g in grants}) != 1: + broken.add("SI-4") + # SI-6 SOVEREIGN is offline and break-glass in production; break-glass serves no automation. + for grant in grants: + prod = "PRODUCTION" in grant.get("environments", []) + if grant["mode"] == "SOVEREIGN": + if "unknown" not in custody and custody != ["offline-key"]: + broken.add("SI-6") + if prod and grant.get("activation") not in ("BREAK_GLASS", "RECOVERY"): + broken.add("SI-6") + if grant.get("activation") == "BREAK_GLASS" and services: + broken.add("SI-6") + # SI-7 CONSTRUCT (build mode, development) is never available in production. + if grant["mode"] == "CONSTRUCT" and prod: + broken.add("SI-7") + # SI-8 assurance: people and service admins need a second factor; admin roles bind the claim. + if principal == "HUMAN" or "service-admin" in conventions: 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"): + if identity.get("federated_role") and modes & ADMIN_MODES and not identity.get("assurance_claim_bound"): broken.add("SI-8") - if ("bind" in classes or "workload" in classes or "agent" in classes) and "personal-safe" in custody: - broken.add("SI-9") # a person's safe must not be the home of a service or agent credential - if "agent" in classes: + # SI-9 a person's safe never holds a service, workload or agent credential. + if (principal in NON_HUMAN or "bind" in conventions) and "personal-safe" in custody: + broken.add("SI-9") + # SI-14 agents present as agents, with short-lived tokens. + if principal == "AGENT": agent = identity["agent"] if agent["presents_principal_type"] == "unknown": unknown.append("agent.presents_principal_type") @@ -139,28 +170,21 @@ def _violations(identity: dict[str, Any]) -> tuple[set[str], list[str]]: unknown.append("agent.mode") if agent["max_token_ttl_seconds"] > AGENT_MAX_TTL_SECONDS: broken.add("SI-14") - if persons: - broken.add("SI-2") - if "break-glass" in classes: - if "unknown" not in custody and custody != ["offline-key"]: - broken.add("SI-6") - if services: - broken.add("SI-6") + # SI-12 recovery accounts are in the crisis custody list. 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: + # Naming (section 5). Reserved names belong to their convention; `admin` is the human's name. + if "bind" in conventions and not BIND_NAME.match(name): broken.add("NAMING") - if "service-admin" in classes and not reserved_admin: + if "service-admin" in conventions and not ADMIN_NAME.match(name): 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 + if principal == "HUMAN" and holds_credential and not conventions and (name.startswith("svc-") or name.endswith("-admin")): + broken.add("NAMING") return broken, sorted(set(unknown)) @@ -204,7 +228,7 @@ def evaluate(document: dict[str, Any], as_of: dt.date) -> dict[str, Any]: 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 "service-admin" in i.get("conventions", []) 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"]: @@ -231,10 +255,11 @@ def run(paths: list[pathlib.Path], as_of: dt.date) -> tuple[list[dict[str, Any]] 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"]} + breakglass |= {i["name"] for i in document["identities"] + if any(g["mode"] == "SOVEREIGN" for g in i["grants"])} if len(breakglass) > 1: reports.append({"service": "", "as_of": as_of.isoformat(), "identities": [], - "advisories": ["SI-6: more than one break-glass identity across declarations: " + "advisories": ["SI-6: more than one SOVEREIGN account across declarations: " + ", ".join(sorted(breakglass))]}) return reports, rejected diff --git a/workplans/NK-WP-0054-service-identity-policy.md b/workplans/NK-WP-0054-service-identity-policy.md index a5214be..46839e6 100644 --- a/workplans/NK-WP-0054-service-identity-policy.md +++ b/workplans/NK-WP-0054-service-identity-policy.md @@ -142,7 +142,7 @@ SI-10 and SI-13 are not decidable from a declaration. ```task id: NK-WP-0054-T03b -status: todo +status: done priority: medium assignee: claude-code depends_on: [NK-WP-0054-T03] @@ -158,6 +158,23 @@ same non-conformant, excepted and unknown accounts unless a difference is explai **Done when:** the schema expresses principal type and grants, the declarations follow §3 of the standard, the tests pass, and a before/after comparison is recorded. +Completed 2026-10-24. Schema, validator, fixtures, declarations and tests (40 pass) follow draft-3: imported +`principal_type`, grants `MODE @ SCOPE` with activation and environments, estate conventions `bind` and +`service-admin`, `unknown` placeholders, and an `elimination_path` on every exception. SI-4 and SI-7 became +decidable. Before/after, as of 2026-10-24 over the same eight declarations: + +| Result | Draft-2 fields | Draft-3 grants | +| --- | --- | --- | +| non-conformant | 1 | 1 (`openbao/coding-agent`, SI-14) | +| unknown | 11 | 10 | +| excepted | 2 | 2 (`lldap/admin` SI-X1, `openbao/netkingdom-platform-admin` SI-X4) | +| planned | 5 | 5 | +| conformant | 6 | 7 | + +The only account that moved is KeyCape's `secrets-engine-openbao` client, unknown to conformant: its scope +(`openbao:login` only) is established, so it can be stated as a grant instead of an unknown privilege level. +`lldap/admin` is now also excepted under SI-4, which grants made decidable; SI-X1 lists it. + ## Live conformance health check ```task