NK-WP-0054-T03: service identity schema, offline validator and first declarations
Schema, validator (30 tests), make targets and eight declarations authored from the inventory, all unconfirmed by their owners. First run: 0 non-conformant, 12 unknown, 2 excepted, 5 planned, 6 conformant. Provisional: built against draft-1 before the founder's review. Building it changed the draft: custody is a list, SI-X1 now covers SI-9 and NAMING, and the reserved-name rule applies only to credential-holding identities (open question 7). Unknown is a finding for every class. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 328925@bnt-lap001 Assistant-Session: 14b795fd-c632-4c01-8d28-e7e490c7b0ed
This commit is contained in:
parent
aa96128d73
commit
48385816fd
18 changed files with 1413 additions and 8 deletions
51
tools/service-identity/README.md
Normal file
51
tools/service-identity/README.md
Normal file
|
|
@ -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.
|
||||
21
tools/service-identity/declarations/authelia.yaml
Normal file
21
tools/service-identity/declarations/authelia.yaml
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
76
tools/service-identity/declarations/keycape.yaml
Normal file
76
tools/service-identity/declarations/keycape.yaml
Normal file
|
|
@ -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
|
||||
92
tools/service-identity/declarations/lldap.yaml
Normal file
92
tools/service-identity/declarations/lldap.yaml
Normal file
|
|
@ -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"
|
||||
80
tools/service-identity/declarations/openbao.yaml
Normal file
80
tools/service-identity/declarations/openbao.yaml
Normal file
|
|
@ -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"
|
||||
34
tools/service-identity/declarations/platform.yaml
Normal file
34
tools/service-identity/declarations/platform.yaml
Normal file
|
|
@ -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
|
||||
21
tools/service-identity/declarations/postgresql.yaml
Normal file
21
tools/service-identity/declarations/postgresql.yaml
Normal file
|
|
@ -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
|
||||
53
tools/service-identity/declarations/privacyidea.yaml
Normal file
53
tools/service-identity/declarations/privacyidea.yaml
Normal file
|
|
@ -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"
|
||||
14
tools/service-identity/fixtures/invalid/secret-value.json
Normal file
14
tools/service-identity/fixtures/invalid/secret-value.json
Normal file
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
18
tools/service-identity/fixtures/valid/conformant.json
Normal file
18
tools/service-identity/fixtures/valid/conformant.json
Normal file
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
264
tools/service-identity/tests/test_validate.py
Normal file
264
tools/service-identity/tests/test_validate.py
Normal file
|
|
@ -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()
|
||||
267
tools/service-identity/validate.py
Normal file
267
tools/service-identity/validate.py
Normal file
|
|
@ -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 '<root>'}:{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": "<run>", "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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue