diff --git a/Makefile b/Makefile index 066d7b6..066b101 100644 --- a/Makefile +++ b/Makefile @@ -150,7 +150,11 @@ creds-agent-status: ## Show current v2 bootstrap state (agent mode) creds-emergency-reprint: ## Re-deliver emergency bundle (if lost/stolen — reprints, rotates nothing) @bash sso-mfa/bootstrap/emergency-bundle.sh --reprint +iam-profile-conformance-test: ## Run IAM Profile v0.2 conformance fixture tests + python3 -m pytest tools/iam-profile-conformance/tests + .PHONY: help hooks hooks-test sops-setup sops-edit sops-encrypt sops-decrypt sops-rotate \ check-secrets creds-init creds-generate creds-bundle creds-apply creds-verify \ creds-status creds-rotate \ - creds-agent-init creds-agent-status creds-emergency-reprint + creds-agent-init creds-agent-status creds-emergency-reprint \ + iam-profile-conformance-test diff --git a/SCOPE.md b/SCOPE.md index d0f61f3..0554c85 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -20,7 +20,8 @@ NetKingdom is a self-optimizing security platform for Kubernetes-based IT infras ## In Scope -- NetKingdom IAM Profile specification (versioned OIDC/PKCE contract) +- NetKingdom IAM Profile specification (versioned OIDC/PKCE contract; + canonical spec: `canon/standards/iam-profile_v0.2.md`) - SSO/MFA Platform: Keycloak with LDAP/Entra federation, enterprise identity (NK-WP-0001) - Local Identity: file-based user store + minimal OIDC server for bootstrap phase (NK-WP-0002) - Security bootstrapping: credential management, SOPS/age integration, OpenBao runtime secret authority @@ -91,7 +92,7 @@ NetKingdom is a self-optimizing security platform for Kubernetes-based IT infras ```capability type: security title: NetKingdom IAM Profile specification -description: Versioned OIDC/PKCE contract that all NetKingdom applications target — defines discovery, authorization, token, JWKS, and userinfo endpoints plus claim normalization. +description: Versioned OIDC/PKCE contract that all NetKingdom applications target — canonical v0.2 defines discovery, PKCE, token, JWKS, tenant, principal-type, assurance, and flex-auth claim inputs. keywords: [iam, oidc, pkce, profile, specification, identity, authentication] ``` diff --git a/canon/standards/iam-profile_v0.2.md b/canon/standards/iam-profile_v0.2.md new file mode 100644 index 0000000..f6d80a3 --- /dev/null +++ b/canon/standards/iam-profile_v0.2.md @@ -0,0 +1,391 @@ +--- +id: netkingdom-iam-profile +type: standard +title: "NetKingdom IAM Profile v0.2" +domain: netkingdom +status: accepted +version: "0.2" +created: "2026-05-22" +updated: "2026-05-22" +scope: core-platform +supersedes: + - the-custodian/canon/standards/iam-profile_v0.1.md +adr: + - docs/adr/ADR-0011-iam-profile-ownership-and-version-governance.md +--- + +# NetKingdom IAM Profile v0.2 + +## Purpose + +The NetKingdom IAM Profile is the provider-neutral OIDC contract that +identity implementations issue and applications consume. + +It defines: + +- OIDC discovery and endpoint requirements; +- Authorization Code + PKCE for human login; +- service-account and workload identity token requirements; +- human, service, and agent principal representation; +- tenant and platform-boundary claims; +- explicit assurance evidence; +- the identity-to-authorization claim contract consumed by flex-auth; +- local-development and emergency-access behavior; +- executable conformance expectations. + +Applications target this profile, not a concrete identity provider. +key-cape is the lightweight implementation. Keycloak is the expanded-mode +implementation. Both are interchangeable at the application and +authorization boundary when they conform to this document. + +## Ownership + +NetKingdom owns the core/platform profile. See ADR-0011. + +Downstream systems may define extension scopes, roles, resource names, +and tenant policy vocabularies. Those extensions are not part of the core +profile unless a future version explicitly adopts them. Extension +vocabularies must map back to the core claims in this document before +flex-auth or applications consume them. + +## Design Principles + +- Consumers trust signed OIDC tokens, not provider-specific sessions. +- Identity providers assert identity and authentication evidence; they do + not make final resource authorization decisions. +- The same profile works in lightweight key-cape mode and expanded + Keycloak mode. +- Tenancy is explicit. `tenant:platform` is distinct from tenant planes + such as `tenant:coulomb`. +- Human, service, and agent principals are distinguishable. +- Assurance evidence is explicit enough for flex-auth policy. +- Local-development issuers are useful but never accepted by production. +- Emergency access is auditable, time-bounded, and reviewable. + +## Discovery Contract + +Every IAM Profile implementation MUST expose OIDC discovery at: + +```text +GET /.well-known/openid-configuration +``` + +The discovery response MUST include: + +| Field | Requirement | +| --- | --- | +| `issuer` | Exact issuer identifier used in tokens | +| `authorization_endpoint` | Required for human Authorization Code + PKCE | +| `token_endpoint` | Required for token exchange and service accounts | +| `jwks_uri` | Required for signature validation | +| `userinfo_endpoint` | Required when userinfo is supported by the flow | +| `scopes_supported` | MUST include `openid`; SHOULD include `profile` and `email` | +| `response_types_supported` | MUST include `code` | +| `grant_types_supported` | MUST include `authorization_code`; MUST include `client_credentials` or a documented workload-token exchange for service identities | +| `id_token_signing_alg_values_supported` | MUST include the implementation signing algorithm; RS256 is required for v0.2 conformance | +| `code_challenge_methods_supported` | MUST include `S256` | + +The response SHOULD include `end_session_endpoint` where logout is +supported and `claims_supported` listing the core claims below. + +Consumers MUST discover endpoints and key material from the issuer +metadata instead of hardcoding provider-specific paths. + +## Required Flows + +### Human Interactive Flow + +Human users authenticate with Authorization Code + PKCE. + +Required properties: + +- PKCE with `S256` is mandatory for browser and CLI clients. +- Implicit flow is not part of the profile. +- MFA or equivalent strong assurance is mandatory for privileged, + destructive, platform-root, and emergency access in production. +- Access tokens are short-lived. +- Refresh tokens are allowed only for trusted clients with explicit + rotation and revocation. + +### Service Account Flow + +Service-to-service traffic uses client credentials or a deployment's +documented workload identity token-exchange equivalent. + +Required properties: + +- Service subjects are stable and named for service plus environment. +- Secrets or workload credentials are delivered through the + credential-management standard, not plaintext configuration. +- Tokens include an audience that identifies the target service. +- Tokens carry `principal_type: service`. +- Service accounts receive only required scopes and roles. +- Credentials are rotated and never shared between environments. + +### Agent Principal Flow + +Agents are automation principals that may act autonomously or under +delegated authority. + +Required properties: + +- Tokens carry `principal_type: agent`. +- Tokens include an `agent` object with `id` and `mode`. +- `agent.mode` is `autonomous` or `delegated`. +- Delegated agents MUST identify the delegating actor using `actor_sub` + or an equivalent `act.sub` claim. +- Agent tokens MUST carry the tenant they operate within. +- Agent tokens MUST include assurance evidence for both the agent + credential and any delegated human authority when policy needs it. + +## Core Claims + +Access tokens accepted by production consumers MUST provide the following +claims after provider mapping or normalization: + +| Claim | Type | Meaning | +| --- | --- | --- | +| `iss` | string | OIDC issuer URL or issuer identifier | +| `sub` | string | Stable subject identifier unique within `iss` | +| `aud` | string or array | Intended audience; MUST include the receiving service | +| `exp` | number | Expiry timestamp | +| `iat` | number | Issued-at timestamp | +| `nbf` | number | Not-before timestamp, recommended for production tokens | +| `jti` | string | Token identifier, recommended for audit and replay controls | +| `tenant` | string | Tenant identifier such as `tenant:platform` or `tenant:coulomb` | +| `principal_type` | string | `human`, `service`, or `agent` | +| `groups` | array | Group memberships, possibly empty | +| `roles` | array | Coarse identity roles, possibly empty | +| `scope` or `scp` | string or array | Granted OAuth scopes | +| `assurance` | object | Authentication and credential assurance evidence | + +Recommended human claims: + +| Claim | Meaning | +| --- | --- | +| `preferred_username` | Human-readable username | +| `email` | Contact identity | +| `name` | Display name | + +Recommended service claims: + +| Claim | Meaning | +| --- | --- | +| `azp` or `client_id` | Authorized client/service identifier | +| `service` | Object naming the service and environment | + +Recommended agent claims: + +| Claim | Meaning | +| --- | --- | +| `agent.id` | Stable agent identifier | +| `agent.mode` | `autonomous` or `delegated` | +| `actor_sub` or `act.sub` | Delegating subject for delegated agents | + +### Role Claim + +The canonical role claim is `roles`, an array of strings. + +Expanded-mode Keycloak deployments may also expose provider-native roles +such as `realm_access.roles`, but conforming tokens consumed by flex-auth +or applications MUST either emit `roles` directly or pass through a +normalizing adapter that produces `roles`. + +### Scope Vocabulary + +The core profile defines only OAuth/OIDC base scopes: + +| Scope | Meaning | +| --- | --- | +| `openid` | Required for OIDC login | +| `profile` | Basic profile claims | +| `email` | Email claim where appropriate | +| `offline_access` | Refresh-token capable access where explicitly allowed | + +Hub-, application-, and resource-specific scopes such as `hub:*`, +`ops:*`, `fin:*`, or storage actions are downstream extensions. They are +valid only when the consuming system defines them and maps them to +flex-auth resource/action semantics. + +## Tenant Claim + +`tenant` is required for every token accepted by profile consumers. + +Suggested identifiers: + +```text +tenant:platform +tenant:coulomb +tenant:sandbox: +tenant:customer: +``` + +`tenant:platform` is the platform control-plane tenant. Tenant +administration for `tenant:coulomb` or later tenants must never imply +platform-root authority. + +Subjects may have access to multiple tenants, but a token used for a +request MUST identify the tenant context for that request. If a client +needs to switch tenant context, it obtains a new token or uses an +approved token-exchange flow that records the target tenant. + +## Assurance Evidence + +The canonical assurance claim is `assurance`. + +It is an object with these fields: + +| Field | Type | Meaning | +| --- | --- | --- | +| `level` | string | `aal0`, `aal1`, `aal2`, `aal3`, or `break_glass` | +| `methods` | array | Authentication methods, e.g. `pwd`, `otp`, `webauthn`, `client_secret`, `workload_identity`, `upstream_mfa` | +| `mfa` | boolean | Whether the authentication included multiple factors or equivalent upstream evidence | +| `source` | string | Provider of the evidence, e.g. `key-cape`, `keycloak`, `privacyidea`, `entra`, `local-identity` | +| `at` | number | Authentication time, recommended | + +Level meanings: + +| Level | Meaning | +| --- | --- | +| `aal0` | Local/dev or unauthenticated bootstrap evidence; never production privileged | +| `aal1` | Single-factor or service credential evidence | +| `aal2` | MFA or equivalent strong upstream assurance | +| `aal3` | Phishing-resistant or hardware-backed assurance | +| `break_glass` | Time-bounded emergency access with post-event review | + +Privileged, destructive, platform-root, secret, credential-vending, and +emergency flows require `aal2` or stronger unless a policy explicitly +permits a narrower service or workload identity path. Emergency access +MUST use `break_glass` and short token lifetimes. + +Provider-native claims such as `acr` and `amr` may be present, but +consumers use `assurance` as the normalized profile claim. + +## Identity To Authorization Contract + +flex-auth consumes IAM Profile tokens as normative identity input. +flex-auth MUST NOT re-derive identity, tenant, group, role, or assurance +facts from provider-specific session state. + +The profile guarantees these inputs for authorization decision envelopes: + +| Decision input | Source claim | +| --- | --- | +| Subject | `sub` | +| Issuer | `iss` | +| Audience | `aud` | +| Tenant | `tenant` | +| Principal type | `principal_type` | +| Groups | `groups` | +| Roles | `roles` | +| Scopes | `scope` or `scp` | +| Assurance | `assurance` | +| Authorized client | `azp` or `client_id`, where present | +| Agent/delegation context | `agent`, `actor_sub`, or `act`, where present | +| Token lifetime/audit ids | `iat`, `nbf`, `exp`, `jti`, where present | + +Authorization decisions are made by flex-auth and its delegated PDP +adapters. Identity providers may assert roles or scopes, but those claims +are inputs to policy, not final permission to act on a resource. + +## Token Lifecycle + +Recommended production defaults: + +| Token | Lifetime | Notes | +| --- | --- | --- | +| Human access token | 5-15 minutes | Short-lived bearer token | +| Refresh token | 8-12 hours | Rotated and revoked on logout or suspicion | +| Service token | 5-30 minutes | Reissued by client credentials or workload identity | +| Agent token | 5-30 minutes | Shorter when delegated or platform-scoped | +| Emergency token | 5-15 minutes | Requires incident/review record | + +Consumers MUST reject expired tokens and tokens with invalid issuer, +audience, signature, `nbf`, or algorithm. Clock skew tolerance SHOULD be +small, normally no more than 60 seconds. + +JWKS material may be cached, but consumers MUST tolerate key rotation by +refreshing JWKS when a token uses an unknown `kid`. + +## Local Development Profile + +A local file-backed provider may be used for development, tests, and +bootstrap contexts where the full platform is unavailable. + +It MUST: + +- expose OIDC discovery; +- issue signed JWTs; +- support deterministic test users and service accounts; +- use local-only issuer URLs or a clearly local issuer identifier; +- mark tokens as local/development through issuer, audience, or + assurance evidence; +- be rejected by production consumers. + +Production consumers MUST reject: + +- issuer `local-identity`; +- `http://` issuers; +- loopback issuers such as `localhost` or `127.0.0.1`; +- tokens with `assurance.level: aal0`; +- tokens where the environment marks the issuer as local/dev. + +## Emergency And Break-Glass Access + +Emergency access is allowed only as a break-glass path. + +Requirements: + +- Emergency identities are disabled by default. +- Activation requires an incident, decision, or human-recorded review + reference. +- Tokens are short-lived and carry the `emergency` role. +- Tokens carry `assurance.level: break_glass`. +- Every emergency action emits an audit/progress/incident event. +- Emergency access is reviewed after use and then disabled again. + +Emergency access MUST NOT bypass audit logging or flex-auth policy. + +## Conformance + +An implementation conforms to IAM Profile v0.2 when it passes the +executable conformance suite in: + +```text +tools/iam-profile-conformance/ +``` + +The suite validates: + +- discovery document completeness; +- PKCE `S256` advertisement and rejection of authorization requests that + omit a code challenge; +- JWKS structure and key ids; +- token issuer, audience, expiry, `nbf`, `iat`, and RS256 signature; +- tenant, principal type, groups, roles, scopes, and assurance claim + shape; +- agent and delegated-agent claim shape; +- local-development issuer rejection in production mode. + +Conformance must be runnable against both key-cape lightweight issuers +and Keycloak expanded-mode issuers. Implementations may add provider +adapters, but the token consumed by applications and flex-auth must match +the core claim contract above. + +## Validation Checklist + +A service or implementation is profile-ready when: + +- it reads OIDC discovery rather than hardcoding endpoints; +- it validates issuer, audience, expiry, `nbf`, algorithm, and + signature; +- it refreshes JWKS on unknown `kid`; +- it supports Authorization Code + PKCE for human login; +- it supports service-account or workload identity tokens; +- it emits `tenant`, `principal_type`, `groups`, `roles`, + `scope`/`scp`, and `assurance`; +- it maps provider-native claims into the canonical core claims; +- it rejects local-development issuers in production; +- it logs emergency access with a durable audit trail; +- flex-auth receives identity facts from the profile, not from + provider-specific sessions. diff --git a/docs/adr/ADR-0011-iam-profile-ownership-and-version-governance.md b/docs/adr/ADR-0011-iam-profile-ownership-and-version-governance.md new file mode 100644 index 0000000..f22dad6 --- /dev/null +++ b/docs/adr/ADR-0011-iam-profile-ownership-and-version-governance.md @@ -0,0 +1,127 @@ +# ADR-0011 - NetKingdom IAM Profile Ownership And Version Governance + +**Status:** Accepted +**Date:** 2026-05-22 +**Deciders:** Bernd Worsch, Codex + +## Context + +The IAM Profile is the identity contract that applications, flex-auth, +key-cape, Keycloak, and bootstrap identity tooling all target. It defines +the OIDC discovery, flow, token, claim, assurance, tenant, and conformance +requirements that make lightweight and expanded identity modes +interchangeable at the application boundary. + +A draft IAM Profile v0.1 existed in the-custodian canon with an +all-hubs scope. That draft captured useful material: OIDC discovery, +Authorization Code + PKCE, service-account tokens, required claims, +token lifecycle, emergency access, and local-development behavior. +However, NetKingdom now owns the platform identity domain. SCOPE.md names +the NetKingdom IAM Profile as an in-scope, versioned standard, and +ADR-0006 requires key-cape and Keycloak to be implementations of the +profile rather than the canonical source of authorization semantics. + +The v0.1 draft also used hub-specific scope and role vocabulary. That +made sense for the Custodian hub landscape, but the core NetKingdom +profile must be platform-neutral so it can serve tenant, service, +application, and agent use cases without encoding one downstream system's +scope names. + +## Decision + +NetKingdom is the canonical owner of the IAM Profile. + +The profile is versioned under `canon/standards/` in this repository. The +first canonical NetKingdom version is +`canon/standards/iam-profile_v0.2.md`. + +The relationship to the earlier the-custodian draft is: + +- the-custodian IAM Profile v0.1 is superseded as a core/platform + standard; +- NetKingdom owns the provider-neutral core profile; +- downstream systems may define hub-, tenant-, or application-specific + scopes and roles as extensions, but those extensions must map back to + the core identity and authorization input contract; +- key-cape lightweight mode and Keycloak expanded mode are + interchangeable implementations of the same profile; +- flex-auth consumes the profile as normative identity input and must not + re-derive identity facts from provider-specific state. + +## Versioning + +The IAM Profile uses explicit document versions: + +- Patch/editorial changes clarify wording, examples, or non-normative + guidance without changing the token contract. +- Minor versions add optional claims, optional flows, or additional + conformance checks that existing implementations can pass unchanged. +- Major or breaking versions change required claims, claim meanings, + validation rules, flow requirements, assurance semantics, tenant + semantics, or token acceptance rules. + +Every versioned profile file remains immutable enough for downstream +references to cite. New versions are added as new files rather than +rewriting historical versions in place, except for clearly editorial +fixes that do not affect semantics. + +## Breaking-Change Governance + +A breaking profile change requires: + +1. a new ADR or ADR refinement that explains the change and migration + path; +2. a new versioned profile document; +3. an update to the executable conformance suite; +4. a coexistence window that lets at least one previous supported profile + version and the new version be accepted where practical; +5. notification in workplans or interface docs for known consumers, + especially key-cape, Keycloak/expanded-mode work, flex-auth, and + application integration docs. + +Breaking changes include: + +- removing or renaming a required claim; +- changing the meaning, type, or allowed values of required claims such + as `tenant`, `principal_type`, `roles`, `groups`, `scope`/`scp`, or + `assurance`; +- changing accepted issuer, audience, or signing validation rules; +- weakening PKCE, MFA/assurance, local-development rejection, or + emergency-access requirements; +- moving authorization decisions into an identity provider instead of + flex-auth. + +## Consequences + +- `canon/standards/iam-profile_v0.2.md` is the canonical profile. +- the-custodian's v0.1 draft should carry a relocation/deprecation note + pointing to this repository. +- Hub-specific scopes such as `hub:*`, `ops:*`, and `fin:*` are + downstream extensions, not core profile vocabulary. +- key-cape and Keycloak must emit or normalize to the same claim contract + before applications and flex-auth consume tokens. +- The conformance suite in `tools/iam-profile-conformance/` is the + executable contract for implementations. + +## Alternatives Considered + +### Keep The Custodian Draft As Canonical + +The draft is useful, but keeping ownership there would conflict with +NetKingdom's repository scope and with ADR-0006's responsibility split. +It would also leave the profile coupled to Custodian hub vocabulary. + +### Make Keycloak The Reference Provider + +Keycloak is the expanded-mode implementation and remains important for +enterprise federation. Making it the reference provider would make +lightweight mode, local bootstrap, and future identity adapters secondary +to one implementation. The accepted model keeps providers +interchangeable behind the profile. + +### Put Scope And Role Vocabulary In The Core Profile + +A shared vocabulary is useful, but core identity must stay stable across +applications and tenants. Downstream systems can define extension scopes +and roles as long as they map to the core claim shapes and flex-auth +decision inputs. diff --git a/docs/object-storage-sts-credential-vending.md b/docs/object-storage-sts-credential-vending.md index 14c859c..c6da30a 100644 --- a/docs/object-storage-sts-credential-vending.md +++ b/docs/object-storage-sts-credential-vending.md @@ -184,6 +184,11 @@ TTL policy: ## IAM Profile Requirements +The canonical token contract is NetKingdom IAM Profile v0.2 +(`canon/standards/iam-profile_v0.2.md`). The vending service consumes the +profile as normalized identity input and sends resource-specific +authorization questions to flex-auth. + Accepted issuers: - key-cape lightweight mode for local, sandbox, and small deployments; @@ -198,10 +203,9 @@ Required token properties: exchange audience; - `sub` is stable for the principal; - `exp`, `nbf`, and `iat` are present and within skew tolerance; -- `tenant` or equivalent tenant mapping is present for tenant-scoped - requests; -- service accounts and agents are distinguishable from humans; -- assurance/MFA claims are present when policy needs them; +- `tenant` is present for every request; +- `principal_type` distinguishes humans, service accounts, and agents; +- `assurance` is present, including MFA evidence where policy needs it; - groups or roles are mapped through IAM Profile semantics, not provider-specific bucket policy. diff --git a/docs/platform-identity-security-architecture.md b/docs/platform-identity-security-architecture.md index 83c866a..4a6f9e3 100644 --- a/docs/platform-identity-security-architecture.md +++ b/docs/platform-identity-security-architecture.md @@ -124,6 +124,12 @@ key-cape is the lightweight profile implementation. Keycloak is the expanded-mode profile implementation. privacyIDEA provides MFA/token capabilities where the deployment mode uses it. +The canonical profile is NetKingdom IAM Profile v0.2 +(`canon/standards/iam-profile_v0.2.md`). It requires explicit `tenant`, +`principal_type`, `groups`, `roles`, `scope`/`scp`, and `assurance` +claims so flex-auth receives normalized identity input regardless of +whether key-cape or Keycloak issued the token. + The choice between lightweight and expanded mode is **capability-driven, not scale-driven**. key-cape comfortably serves large internal user populations; expanded-mode Keycloak is introduced when a *capability* is @@ -333,9 +339,12 @@ Required implications: - Policy packages must distinguish `tenant:platform` policy from tenant-local packages such as `tenant:coulomb`. - Decision envelopes must carry subject, issuer, audience, tenant, - protected-system id, resource, action, requested TTL where relevant, - assurance evidence, obligations, deny reasons, and audit correlation - ids. + principal type, groups, roles, scopes, protected-system id, resource, + action, requested TTL where relevant, assurance evidence, obligations, + deny reasons, and audit correlation ids. Subject, issuer, audience, + tenant, principal type, groups, roles, scopes, and assurance come from + the IAM Profile v0.2 token contract rather than provider-specific + session state. - Topaz is a delegated PDP runtime behind flex-auth. It must not become the canonical policy model, identity provider, or platform control plane. @@ -428,7 +437,7 @@ an explicit check: | Area | Readiness check | | --- | --- | -| MFA and identity | key-cape or Keycloak issues IAM Profile-compatible tokens; privacyIDEA or the selected MFA provider enforces required assurance for privileged actions | +| MFA and identity | key-cape or Keycloak issues IAM Profile v0.2-compatible tokens and passes `tools/iam-profile-conformance/`; privacyIDEA or the selected MFA provider enforces required assurance for privileged actions | | Bootstrap and recovery | age/SOPS material, emergency bundle, and break-glass credentials are present, tested, and separated from tenant administration | | OpenBao runtime secrets | OpenBao is initialized, unsealed or auto-unsealed by the approved mechanism, backed up, audited, and using scoped auth methods and mounts | | Secret rotation | service, database, OpenBao-issued, and break-glass rotation paths have documented blast radius and verification steps | @@ -449,9 +458,7 @@ an explicit check: or via CSI-mounted secrets? - Which tenant metadata is required before a service can register resources with flex-auth? -- When does the platform switch from key-cape lightweight mode to - Keycloak expanded mode? (Answered as capability-driven — see Capability - Progression, tier C5. The remaining open part is the precise per-tenant - trigger and dual-issuer coexistence rule, owned by NK-WP-0011-T1.) +- What precise per-tenant trigger and dual-issuer coexistence rule should + NK-WP-0011-T1 use for Keycloak expanded mode? - Does Topaz run centrally for the platform, per tenant, or per service for the first production deployment? diff --git a/docs/responsibility-map.md b/docs/responsibility-map.md index a62db0e..6ee2400 100644 --- a/docs/responsibility-map.md +++ b/docs/responsibility-map.md @@ -77,8 +77,8 @@ and what NetKingdom is responsible for (meta-orchestration). | | | | --- | --- | | **Resources held** | users, groups, sessions, MFA tokens, OIDC clients, the directory | -| **Repo owns** | the lightweight IAM implementation conforming to the IAM Profile | -| **NetKingdom orchestrates** | the IAM Profile contract it must conform to; which identity/2FA capabilities are enabled (capability ladder C1–C2); user/group/role and OIDC-client provisioning policy; assurance requirements; identity-trust readiness and profile conformance | +| **Repo owns** | the lightweight IAM implementation conforming to the NetKingdom IAM Profile v0.2 | +| **NetKingdom orchestrates** | the IAM Profile contract in `canon/standards/iam-profile_v0.2.md`; which identity/2FA capabilities are enabled (capability ladder C1–C2); user/group/role and OIDC-client provisioning policy; tenant and assurance requirements; identity-trust readiness and profile conformance | ### `flex-auth` — authorization @@ -86,7 +86,7 @@ and what NetKingdom is responsible for (meta-orchestration). | --- | --- | | **Resources held** | roles, scopes, policies, protected-system registrations, resource/action vocabulary, decision/audit records | | **Repo owns** | the authorization registry, control plane, and PDP adapters | -| **NetKingdom orchestrates** | the decision-envelope contract; platform vs tenant policy boundaries; which protected systems/resources are registered; policy-package import and governance; audit retention; authorization-trust readiness | +| **NetKingdom orchestrates** | the decision-envelope contract fed by IAM Profile v0.2 claims; platform vs tenant policy boundaries; which protected systems/resources are registered; policy-package import and governance; audit retention; authorization-trust readiness | --- @@ -95,7 +95,8 @@ and what NetKingdom is responsible for (meta-orchestration). Across the orchestrated repos, NetKingdom is responsible for the coherent, cross-landscape management of: -- **Identities** — humans, service accounts, agents, groups, tenants +- **Identities** — humans, service accounts, agents, groups, tenants, + and assurance evidence as normalized by the IAM Profile - **Roles, scopes, and policies** — coarse claims through fine-grained authorization - **Secrets and credentials** — bootstrap material and runtime secret diff --git a/tools/iam-profile-conformance/README.md b/tools/iam-profile-conformance/README.md new file mode 100644 index 0000000..cd63212 --- /dev/null +++ b/tools/iam-profile-conformance/README.md @@ -0,0 +1,38 @@ +# IAM Profile Conformance + +Executable checks for `canon/standards/iam-profile_v0.2.md`. + +Runtime dependency: Python 3.11+ with `cryptography`. Fixture tests also +require `pytest`. + +Run a full check against a real issuer with a freshly minted access token: + +```bash +python3 tools/iam-profile-conformance/iam_profile_conformance.py \ + --issuer https://id.example.net/realms/platform \ + --audience my-service \ + --access-token "$(cat token.jwt)" \ + --client-id iam-profile-conformance \ + --redirect-uri http://localhost/callback \ + --environment production +``` + +The PKCE probe sends an authorization request without a +`code_challenge`; a conforming issuer rejects it. Use a dedicated public +test client for this check. + +For discovery-only smoke checks: + +```bash +python3 tools/iam-profile-conformance/iam_profile_conformance.py \ + --issuer https://id.example.net/realms/platform \ + --audience my-service \ + --discovery-only \ + --skip-pkce-probe +``` + +Run fixture tests: + +```bash +python3 -m pytest tools/iam-profile-conformance/tests +``` diff --git a/tools/iam-profile-conformance/iam_profile_conformance.py b/tools/iam-profile-conformance/iam_profile_conformance.py new file mode 100644 index 0000000..edfb1a6 --- /dev/null +++ b/tools/iam-profile-conformance/iam_profile_conformance.py @@ -0,0 +1,706 @@ +#!/usr/bin/env python3 +""" +Executable conformance checks for NetKingdom IAM Profile v0.2. + +The suite intentionally uses provider-neutral OIDC/JWT rules. It can run +against key-cape, Keycloak, or a fixture issuer as long as the issuer +exposes standard discovery and JWKS metadata. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa + + +PROFILE_VERSION = "0.2" +DEFAULT_SKEW_SECONDS = 60 +REQUIRED_DISCOVERY_FIELDS = { + "issuer", + "authorization_endpoint", + "token_endpoint", + "jwks_uri", + "userinfo_endpoint", + "scopes_supported", + "response_types_supported", + "grant_types_supported", + "id_token_signing_alg_values_supported", + "code_challenge_methods_supported", +} +PRINCIPAL_TYPES = {"human", "service", "agent"} +ASSURANCE_LEVELS = {"aal0", "aal1", "aal2", "aal3", "break_glass"} +HIGH_IMPACT_ROLES = { + "admin", + "platform-admin", + "platform_operator", + "steward", + "emergency", + "break-glass", +} + + +@dataclass +class Config: + issuer: str + audience: str + access_token: str | None = None + client_id: str | None = None + redirect_uri: str | None = None + environment: str = "production" + timeout: float = 10.0 + discovery_only: bool = False + skip_pkce_probe: bool = False + skew_seconds: int = DEFAULT_SKEW_SECONDS + + +@dataclass +class Result: + name: str + status: str + message: str + detail: dict[str, Any] | None = None + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: N802 + return None + + +def pass_result(name: str, message: str, detail: dict[str, Any] | None = None) -> Result: + return Result(name, "PASS", message, detail) + + +def warn_result(name: str, message: str, detail: dict[str, Any] | None = None) -> Result: + return Result(name, "WARN", message, detail) + + +def fail_result(name: str, message: str, detail: dict[str, Any] | None = None) -> Result: + return Result(name, "FAIL", message, detail) + + +def b64url_decode(value: str) -> bytes: + padding_len = (4 - len(value) % 4) % 4 + return base64.urlsafe_b64decode(value + ("=" * padding_len)) + + +def fetch_json(url: str, timeout: float) -> dict[str, Any]: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as response: + body = response.read() + data = json.loads(body) + if not isinstance(data, dict): + raise ValueError(f"expected JSON object from {url}") + return data + + +def discovery_url(issuer: str) -> str: + return issuer.rstrip("/") + "/.well-known/openid-configuration" + + +def normalize_issuer(value: str) -> str: + return value.rstrip("/") + + +def as_list(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, tuple): + return list(value) + return [value] + + +def normalize_scopes(payload: dict[str, Any]) -> list[str]: + scopes: list[str] = [] + scope_value = payload.get("scope") + if isinstance(scope_value, str): + scopes.extend(part for part in scope_value.split() if part) + elif isinstance(scope_value, list): + scopes.extend(str(part) for part in scope_value) + + scp_value = payload.get("scp") + if isinstance(scp_value, str): + scopes.extend(part for part in scp_value.split() if part) + elif isinstance(scp_value, list): + scopes.extend(str(part) for part in scp_value) + + return sorted(set(scopes)) + + +def normalize_roles(payload: dict[str, Any]) -> tuple[list[str], str]: + roles = payload.get("roles") + if isinstance(roles, list): + return [str(role) for role in roles], "roles" + + realm_access = payload.get("realm_access") + if isinstance(realm_access, dict) and isinstance(realm_access.get("roles"), list): + return [str(role) for role in realm_access["roles"]], "realm_access.roles" + + return [], "missing" + + +def is_local_issuer(issuer: str) -> bool: + if issuer == "local-identity": + return True + parsed = urllib.parse.urlparse(issuer) + host = (parsed.hostname or "").lower() + if parsed.scheme == "http": + return True + if host in {"localhost", "127.0.0.1", "::1"}: + return True + if host.endswith(".local"): + return True + return False + + +def check_discovery(config: Config, discovery: dict[str, Any]) -> list[Result]: + results: list[Result] = [] + + missing = sorted(REQUIRED_DISCOVERY_FIELDS - set(discovery)) + if missing: + results.append(fail_result("discovery-fields", "missing required metadata fields", {"missing": missing})) + else: + results.append(pass_result("discovery-fields", "required metadata fields are present")) + + advertised_issuer = str(discovery.get("issuer", "")) + if normalize_issuer(advertised_issuer) == normalize_issuer(config.issuer): + results.append(pass_result("discovery-issuer", "discovery issuer matches configured issuer")) + else: + results.append( + fail_result( + "discovery-issuer", + "discovery issuer does not match configured issuer", + {"configured": config.issuer, "advertised": advertised_issuer}, + ) + ) + + response_types = set(str(value) for value in as_list(discovery.get("response_types_supported"))) + if "code" in response_types: + results.append(pass_result("authorization-code-flow", "authorization code response type is advertised")) + else: + results.append(fail_result("authorization-code-flow", "response_types_supported must include code")) + + grants = set(str(value) for value in as_list(discovery.get("grant_types_supported"))) + if "authorization_code" in grants: + results.append(pass_result("authorization-code-grant", "authorization_code grant is advertised")) + else: + results.append(fail_result("authorization-code-grant", "grant_types_supported must include authorization_code")) + + service_grants = {"client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange"} + if grants & service_grants: + results.append(pass_result("service-account-flow", "service-account or workload-token grant is advertised")) + else: + results.append( + fail_result( + "service-account-flow", + "grant_types_supported must include client_credentials or a workload token-exchange grant", + ) + ) + + scopes = set(str(value) for value in as_list(discovery.get("scopes_supported"))) + if "openid" in scopes: + results.append(pass_result("openid-scope", "openid scope is advertised")) + else: + results.append(fail_result("openid-scope", "scopes_supported must include openid")) + missing_recommended_scopes = sorted({"profile", "email"} - scopes) + if missing_recommended_scopes: + results.append( + warn_result( + "recommended-scopes", + "profile/email are recommended for human profile claims", + {"missing": missing_recommended_scopes}, + ) + ) + else: + results.append(pass_result("recommended-scopes", "profile and email scopes are advertised")) + + algs = set(str(value) for value in as_list(discovery.get("id_token_signing_alg_values_supported"))) + if "RS256" in algs: + results.append(pass_result("signing-algorithm", "RS256 is advertised")) + else: + results.append(fail_result("signing-algorithm", "RS256 must be advertised for v0.2 conformance")) + + pkce_methods = set(str(value) for value in as_list(discovery.get("code_challenge_methods_supported"))) + if "S256" in pkce_methods: + results.append(pass_result("pkce-metadata", "PKCE S256 is advertised")) + else: + results.append(fail_result("pkce-metadata", "code_challenge_methods_supported must include S256")) + + return results + + +def check_local_issuer_policy(config: Config, issuer: str) -> Result: + if config.environment == "production" and is_local_issuer(issuer): + return fail_result( + "local-issuer-policy", + "production mode must reject local-development issuers", + {"issuer": issuer}, + ) + if is_local_issuer(issuer): + return pass_result( + "local-issuer-policy", + f"local issuer accepted only because environment={config.environment}", + {"issuer": issuer}, + ) + return pass_result("local-issuer-policy", "issuer is not a local-development issuer") + + +def check_jwks(jwks: dict[str, Any]) -> list[Result]: + results: list[Result] = [] + keys = jwks.get("keys") + if not isinstance(keys, list) or not keys: + return [fail_result("jwks-keys", "JWKS must contain at least one signing key")] + + missing_kids = [idx for idx, key in enumerate(keys) if not isinstance(key, dict) or not key.get("kid")] + if missing_kids: + results.append(fail_result("jwks-key-ids", "all JWKS keys must carry kid", {"indexes": missing_kids})) + else: + results.append(pass_result("jwks-key-ids", "all JWKS keys carry kid")) + + usable = [ + key for key in keys + if isinstance(key, dict) + and key.get("kty") == "RSA" + and key.get("n") + and key.get("e") + and (key.get("use") in {None, "sig"}) + ] + if usable: + results.append(pass_result("jwks-rsa", "JWKS contains usable RSA signing keys", {"count": len(usable)})) + else: + results.append(fail_result("jwks-rsa", "JWKS must contain RSA signing keys for RS256")) + + return results + + +def probe_pkce(config: Config, discovery: dict[str, Any]) -> Result: + if config.skip_pkce_probe: + return warn_result("pkce-probe", "PKCE probe skipped by operator request") + + if not config.client_id or not config.redirect_uri: + return fail_result( + "pkce-probe", + "client id and redirect URI are required to probe PKCE enforcement", + ) + + endpoint = discovery.get("authorization_endpoint") + if not isinstance(endpoint, str) or not endpoint: + return fail_result("pkce-probe", "authorization_endpoint is missing") + + query = urllib.parse.urlencode( + { + "response_type": "code", + "client_id": config.client_id, + "redirect_uri": config.redirect_uri, + "scope": "openid", + "state": "iam-profile-conformance", + "nonce": "iam-profile-conformance", + } + ) + url = endpoint + ("&" if "?" in endpoint else "?") + query + opener = urllib.request.build_opener(NoRedirect()) + req = urllib.request.Request(url, headers={"Accept": "application/json,text/html,*/*"}) + + try: + response = opener.open(req, timeout=config.timeout) + status = getattr(response, "status", 200) + location = response.headers.get("Location", "") + body = response.read(2048).decode("utf-8", errors="replace").lower() + except urllib.error.HTTPError as exc: + status = exc.code + location = exc.headers.get("Location", "") + body = exc.read(2048).decode("utf-8", errors="replace").lower() + except Exception as exc: # pragma: no cover - network diagnostics + return fail_result("pkce-probe", f"PKCE probe failed to reach authorization endpoint: {exc}") + + location_lower = location.lower() + rejection_text = " ".join([location_lower, body]) + rejected_for_pkce = ( + status in {302, 303, 307, 308, 400, 401} + and ("invalid_request" in rejection_text or "code_challenge" in rejection_text or "pkce" in rejection_text) + ) + if rejected_for_pkce: + return pass_result("pkce-probe", "authorization request without code_challenge was rejected") + + return fail_result( + "pkce-probe", + "authorization request without code_challenge was not clearly rejected", + {"status": status, "location": location}, + ) + + +def decode_jwt(token: str) -> tuple[dict[str, Any], dict[str, Any]]: + parts = token.split(".") + if len(parts) != 3: + raise ValueError("JWT must have three compact-serialization parts") + header = json.loads(b64url_decode(parts[0])) + payload = json.loads(b64url_decode(parts[1])) + return header, payload + + +def jwk_to_rsa_public_key(jwk: dict[str, Any]): + n = int.from_bytes(b64url_decode(str(jwk["n"])), "big") + e = int.from_bytes(b64url_decode(str(jwk["e"])), "big") + return rsa.RSAPublicNumbers(e, n).public_key() + + +def verify_signature(header: dict[str, Any], token: str, jwks: dict[str, Any]) -> Result: + if header.get("alg") != "RS256": + return fail_result("jwt-signature", "JWT alg must be RS256", {"alg": header.get("alg")}) + + kid = header.get("kid") + keys = jwks.get("keys") if isinstance(jwks.get("keys"), list) else [] + matching = [key for key in keys if isinstance(key, dict) and key.get("kid") == kid] + if not matching: + return fail_result("jwt-signature", "JWT kid was not found in JWKS", {"kid": kid}) + + parts = token.split(".") + signing_input = f"{parts[0]}.{parts[1]}".encode("ascii") + signature = b64url_decode(parts[2]) + try: + public_key = jwk_to_rsa_public_key(matching[0]) + public_key.verify(signature, signing_input, padding.PKCS1v15(), hashes.SHA256()) + except (InvalidSignature, ValueError, KeyError) as exc: + return fail_result("jwt-signature", f"JWT signature verification failed: {exc}") + + return pass_result("jwt-signature", "JWT signature verifies against JWKS", {"kid": kid}) + + +def audience_matches(audience_claim: Any, expected: str) -> bool: + if isinstance(audience_claim, str): + return audience_claim == expected + if isinstance(audience_claim, list): + return expected in [str(value) for value in audience_claim] + return False + + +def check_token_lifetime(payload: dict[str, Any], config: Config) -> list[Result]: + results: list[Result] = [] + now = int(time.time()) + skew = config.skew_seconds + + exp = payload.get("exp") + iat = payload.get("iat") + nbf = payload.get("nbf") + + if not isinstance(exp, int): + results.append(fail_result("token-expiry", "exp must be an integer timestamp")) + elif exp <= now - skew: + results.append(fail_result("token-expiry", "token is expired", {"exp": exp, "now": now})) + else: + results.append(pass_result("token-expiry", "token is not expired")) + + if not isinstance(iat, int): + results.append(fail_result("token-issued-at", "iat must be an integer timestamp")) + elif iat > now + skew: + results.append(fail_result("token-issued-at", "iat is in the future", {"iat": iat, "now": now})) + else: + results.append(pass_result("token-issued-at", "iat is valid")) + + if nbf is None: + results.append(warn_result("token-not-before", "nbf is recommended for production tokens")) + elif not isinstance(nbf, int): + results.append(fail_result("token-not-before", "nbf must be an integer timestamp")) + elif nbf > now + skew: + results.append(fail_result("token-not-before", "nbf is in the future", {"nbf": nbf, "now": now})) + else: + results.append(pass_result("token-not-before", "nbf is valid")) + + if isinstance(exp, int) and isinstance(iat, int): + ttl = exp - iat + if ttl > 3600: + results.append(warn_result("token-ttl", "access token TTL is longer than the profile default", {"ttl": ttl})) + elif ttl <= 0: + results.append(fail_result("token-ttl", "token TTL must be positive", {"ttl": ttl})) + else: + results.append(pass_result("token-ttl", "token TTL is within conformance tolerance", {"ttl": ttl})) + + return results + + +def check_claim_shape(payload: dict[str, Any]) -> list[Result]: + results: list[Result] = [] + required = {"iss", "sub", "aud", "exp", "iat", "tenant", "principal_type", "groups", "assurance"} + missing = sorted(required - set(payload)) + roles, role_source = normalize_roles(payload) + scopes = normalize_scopes(payload) + if not roles: + missing.append("roles") + if not scopes: + missing.append("scope/scp") + + if missing: + results.append(fail_result("claim-shape", "token is missing required IAM Profile claims", {"missing": missing})) + else: + results.append(pass_result("claim-shape", "required IAM Profile claims are present")) + + tenant = payload.get("tenant") + if isinstance(tenant, str) and tenant.startswith("tenant:") and len(tenant) > len("tenant:"): + results.append(pass_result("tenant-claim", "tenant claim is well formed", {"tenant": tenant})) + else: + results.append(fail_result("tenant-claim", "tenant must be a string like tenant:platform")) + + groups = payload.get("groups") + if isinstance(groups, list): + results.append(pass_result("groups-claim", "groups claim is a list", {"count": len(groups)})) + else: + results.append(fail_result("groups-claim", "groups must be a list, even when empty")) + + if role_source == "roles": + results.append(pass_result("roles-claim", "canonical roles claim is present", {"count": len(roles)})) + elif role_source == "realm_access.roles": + results.append( + warn_result( + "roles-claim", + "provider-native realm_access.roles found; emit canonical roles before production consumption", + {"count": len(roles)}, + ) + ) + else: + results.append(fail_result("roles-claim", "roles must be present as roles or normalized from provider-native roles")) + + if scopes: + results.append(pass_result("scope-claim", "scope/scp claim is present", {"scopes": scopes})) + else: + results.append(fail_result("scope-claim", "scope or scp must be present")) + + return results + + +def check_principal_shape(payload: dict[str, Any]) -> Result: + principal_type = payload.get("principal_type") + if principal_type not in PRINCIPAL_TYPES: + return fail_result("principal-shape", "principal_type must be human, service, or agent", {"principal_type": principal_type}) + + if principal_type == "human": + if payload.get("preferred_username"): + return pass_result("principal-shape", "human principal has preferred_username") + return fail_result("principal-shape", "human principals must include preferred_username") + + if principal_type == "service": + if payload.get("azp") or payload.get("client_id"): + return pass_result("principal-shape", "service principal has azp/client_id") + return fail_result("principal-shape", "service principals must include azp or client_id") + + agent = payload.get("agent") + if not isinstance(agent, dict): + return fail_result("principal-shape", "agent principals must include an agent object") + if not agent.get("id"): + return fail_result("principal-shape", "agent.id is required") + mode = agent.get("mode") + if mode not in {"autonomous", "delegated"}: + return fail_result("principal-shape", "agent.mode must be autonomous or delegated", {"mode": mode}) + if mode == "delegated": + act = payload.get("act") + has_actor = bool(payload.get("actor_sub")) or (isinstance(act, dict) and bool(act.get("sub"))) + if not has_actor: + return fail_result("principal-shape", "delegated agents must include actor_sub or act.sub") + return pass_result("principal-shape", "agent principal shape is valid") + + +def check_assurance(payload: dict[str, Any]) -> list[Result]: + results: list[Result] = [] + assurance = payload.get("assurance") + if not isinstance(assurance, dict): + return [fail_result("assurance-shape", "assurance must be an object")] + + level = assurance.get("level") + methods = assurance.get("methods") + mfa = assurance.get("mfa") + source = assurance.get("source") + missing = [ + name for name, value in { + "level": level, + "methods": methods, + "mfa": mfa, + "source": source, + }.items() + if value is None + ] + if missing: + results.append(fail_result("assurance-shape", "assurance is missing required fields", {"missing": missing})) + return results + + if level not in ASSURANCE_LEVELS: + results.append(fail_result("assurance-level", "assurance.level has an unsupported value", {"level": level})) + elif level == "aal0": + results.append(warn_result("assurance-level", "aal0 is local/dev only and not production privileged")) + else: + results.append(pass_result("assurance-level", "assurance.level is recognized", {"level": level})) + + if isinstance(methods, list) and all(isinstance(method, str) for method in methods): + results.append(pass_result("assurance-methods", "assurance.methods is a list")) + else: + results.append(fail_result("assurance-methods", "assurance.methods must be a list of strings")) + + if isinstance(mfa, bool): + results.append(pass_result("assurance-mfa", "assurance.mfa is boolean", {"mfa": mfa})) + else: + results.append(fail_result("assurance-mfa", "assurance.mfa must be boolean")) + + roles, _ = normalize_roles(payload) + has_high_impact_role = bool(HIGH_IMPACT_ROLES & set(roles)) + if has_high_impact_role and level not in {"aal2", "aal3", "break_glass"}: + results.append(fail_result("privileged-assurance", "high-impact roles require aal2, aal3, or break_glass")) + elif has_high_impact_role and mfa is not True and level != "break_glass": + results.append(fail_result("privileged-assurance", "high-impact roles require MFA evidence")) + else: + results.append(pass_result("privileged-assurance", "assurance is sufficient for asserted roles")) + + if "emergency" in roles or "break-glass" in roles: + if level != "break_glass": + results.append(fail_result("emergency-assurance", "emergency roles require assurance.level=break_glass")) + else: + results.append(pass_result("emergency-assurance", "emergency assurance level is explicit")) + exp = payload.get("exp") + iat = payload.get("iat") + if isinstance(exp, int) and isinstance(iat, int) and exp - iat > 900: + results.append(warn_result("emergency-ttl", "emergency token TTL should be 15 minutes or less")) + + return results + + +def check_token(config: Config, token: str, jwks: dict[str, Any]) -> list[Result]: + results: list[Result] = [] + try: + header, payload = decode_jwt(token) + except Exception as exc: + return [fail_result("jwt-structure", f"could not decode JWT: {exc}")] + + results.append(pass_result("jwt-structure", "JWT compact serialization decoded")) + results.append(verify_signature(header, token, jwks)) + + issuer = payload.get("iss") + if isinstance(issuer, str) and normalize_issuer(issuer) == normalize_issuer(config.issuer): + results.append(pass_result("token-issuer", "token issuer matches configured issuer")) + else: + results.append( + fail_result( + "token-issuer", + "token issuer does not match configured issuer", + {"configured": config.issuer, "token": issuer}, + ) + ) + + if audience_matches(payload.get("aud"), config.audience): + results.append(pass_result("token-audience", "token audience includes configured audience")) + else: + results.append( + fail_result( + "token-audience", + "token audience does not include configured audience", + {"expected": config.audience, "token": payload.get("aud")}, + ) + ) + + results.extend(check_token_lifetime(payload, config)) + results.extend(check_claim_shape(payload)) + results.append(check_principal_shape(payload)) + results.extend(check_assurance(payload)) + return results + + +def run_suite(config: Config) -> list[Result]: + results: list[Result] = [] + + try: + discovery = fetch_json(discovery_url(config.issuer), config.timeout) + except Exception as exc: + return [fail_result("discovery-fetch", f"failed to fetch discovery document: {exc}")] + results.append(pass_result("discovery-fetch", "discovery document fetched")) + results.extend(check_discovery(config, discovery)) + results.append(check_local_issuer_policy(config, str(discovery.get("issuer", config.issuer)))) + + try: + jwks_uri = str(discovery["jwks_uri"]) + jwks = fetch_json(jwks_uri, config.timeout) + except Exception as exc: + results.append(fail_result("jwks-fetch", f"failed to fetch JWKS: {exc}")) + jwks = {"keys": []} + else: + results.append(pass_result("jwks-fetch", "JWKS fetched")) + results.extend(check_jwks(jwks)) + + if config.discovery_only: + return results + + results.append(probe_pkce(config, discovery)) + + if not config.access_token: + results.append(fail_result("token-provided", "full conformance requires --access-token")) + else: + results.append(pass_result("token-provided", "access token provided")) + results.extend(check_token(config, config.access_token, jwks)) + + return results + + +def print_results(results: list[Result], as_json: bool) -> None: + if as_json: + print(json.dumps([result.__dict__ for result in results], indent=2, sort_keys=True)) + return + + for result in results: + print(f"{result.status:4} {result.name}: {result.message}") + if result.detail: + print(f" {json.dumps(result.detail, sort_keys=True)}") + + fail_count = sum(1 for result in results if result.status == "FAIL") + warn_count = sum(1 for result in results if result.status == "WARN") + print("") + print(f"IAM Profile v{PROFILE_VERSION} conformance: {fail_count} fail, {warn_count} warn, {len(results)} checks") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run NetKingdom IAM Profile v0.2 conformance checks.") + parser.add_argument("--issuer", required=True, help="OIDC issuer URL or local issuer base") + parser.add_argument("--audience", required=True, help="Expected audience for the supplied token") + parser.add_argument("--access-token", help="JWT access token to validate") + parser.add_argument("--client-id", help="Public test client id for PKCE probe") + parser.add_argument("--redirect-uri", help="Redirect URI registered for the test client") + parser.add_argument( + "--environment", + choices=["production", "nonproduction", "local"], + default="production", + help="Validation environment; production rejects local issuers", + ) + parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout in seconds") + parser.add_argument("--discovery-only", action="store_true", help="Only run discovery/JWKS checks") + parser.add_argument("--skip-pkce-probe", action="store_true", help="Skip active PKCE enforcement probe") + parser.add_argument("--json", action="store_true", help="Print machine-readable JSON results") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + config = Config( + issuer=args.issuer, + audience=args.audience, + access_token=args.access_token, + client_id=args.client_id, + redirect_uri=args.redirect_uri, + environment=args.environment, + timeout=args.timeout, + discovery_only=args.discovery_only, + skip_pkce_probe=args.skip_pkce_probe, + ) + results = run_suite(config) + print_results(results, args.json) + return 1 if any(result.status == "FAIL" for result in results) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/iam-profile-conformance/tests/test_iam_profile_conformance.py b/tools/iam-profile-conformance/tests/test_iam_profile_conformance.py new file mode 100644 index 0000000..0fa30a6 --- /dev/null +++ b/tools/iam-profile-conformance/tests/test_iam_profile_conformance.py @@ -0,0 +1,310 @@ +import base64 +import http.server +import importlib.util +import json +import sys +import threading +import time +import urllib.parse +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa + + +TOOL_PATH = Path(__file__).resolve().parents[1] / "iam_profile_conformance.py" +SPEC = importlib.util.spec_from_file_location("iam_profile_conformance", TOOL_PATH) +conformance = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = conformance +SPEC.loader.exec_module(conformance) + + +def b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def jwk_from_key(private_key, kid: str) -> dict: + numbers = private_key.public_key().public_numbers() + n = b64url(numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big")) + e = b64url(numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big")) + return {"kty": "RSA", "use": "sig", "alg": "RS256", "kid": kid, "n": n, "e": e} + + +def sign_jwt(private_key, kid: str, payload: dict) -> str: + header = {"alg": "RS256", "typ": "JWT", "kid": kid} + header_b64 = b64url(json.dumps(header, separators=(",", ":")).encode()) + payload_b64 = b64url(json.dumps(payload, separators=(",", ":")).encode()) + signing_input = f"{header_b64}.{payload_b64}".encode("ascii") + signature = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256()) + return f"{header_b64}.{payload_b64}.{b64url(signature)}" + + +def default_payload(issuer: str, audience: str, **overrides) -> dict: + now = int(time.time()) + payload = { + "iss": issuer, + "sub": "user:alice", + "aud": [audience, "profile-consumer"], + "exp": now + 600, + "iat": now, + "nbf": now - 5, + "jti": "test-token", + "tenant": "tenant:platform", + "principal_type": "human", + "preferred_username": "alice", + "email": "alice@example.test", + "groups": ["netkingdom-admins"], + "roles": ["admin"], + "scope": "openid profile email", + "assurance": { + "level": "aal2", + "methods": ["pwd", "otp"], + "mfa": True, + "source": "key-cape", + "at": now, + }, + } + payload.update(overrides) + return payload + + +@pytest.fixture +def issuer_fixture(): + servers = [] + + def start(name: str, discovery_overrides: dict | None = None): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + kid = f"{name}-kid" + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass + + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + base = f"http://127.0.0.1:{self.server.server_port}/{name}" + issuer = base + discovery = { + "issuer": issuer, + "authorization_endpoint": f"{base}/auth", + "token_endpoint": f"{base}/token", + "userinfo_endpoint": f"{base}/userinfo", + "jwks_uri": f"{base}/jwks", + "end_session_endpoint": f"{base}/logout", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "scopes_supported": ["openid", "profile", "email"], + "grant_types_supported": ["authorization_code", "client_credentials"], + "code_challenge_methods_supported": ["S256"], + "claims_supported": [ + "iss", + "sub", + "aud", + "exp", + "iat", + "tenant", + "principal_type", + "groups", + "roles", + "assurance", + ], + } + if discovery_overrides: + discovery.update(discovery_overrides) + + if parsed.path == f"/{name}/.well-known/openid-configuration": + self._json(discovery) + return + if parsed.path == f"/{name}/jwks": + self._json({"keys": [jwk_from_key(private_key, kid)]}) + return + if parsed.path == f"/{name}/auth": + query = urllib.parse.parse_qs(parsed.query) + redirect_uri = query.get("redirect_uri", ["http://localhost/callback"])[0] + if "code_challenge" not in query: + location = redirect_uri + "?error=invalid_request&error_description=pkce_code_challenge_required" + self.send_response(302) + self.send_header("Location", location) + self.end_headers() + return + self.send_response(302) + self.send_header("Location", redirect_uri + "?code=test-code") + self.end_headers() + return + self.send_response(404) + self.end_headers() + + def _json(self, payload): + data = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + httpd = http.server.HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + servers.append(httpd) + return f"http://127.0.0.1:{httpd.server_port}/{name}", private_key, kid + + yield start + + for server in servers: + server.shutdown() + + +def statuses(results): + return {result.name: result.status for result in results} + + +def messages(results): + return {result.name: result.message for result in results} + + +def test_keycape_fixture_passes_full_conformance(issuer_fixture): + issuer, private_key, kid = issuer_fixture("keycape") + token = sign_jwt(private_key, kid, default_payload(issuer, "service-a")) + config = conformance.Config( + issuer=issuer, + audience="service-a", + access_token=token, + client_id="iam-profile-conformance", + redirect_uri="http://localhost/callback", + environment="local", + ) + + results = conformance.run_suite(config) + + assert "FAIL" not in statuses(results).values(), messages(results) + assert statuses(results)["pkce-probe"] == "PASS" + assert statuses(results)["roles-claim"] == "PASS" + + +def test_keycloak_fixture_passes_with_canonical_roles_and_realm_roles(issuer_fixture): + issuer, private_key, kid = issuer_fixture("realms/platform") + payload = default_payload( + issuer, + "service-a", + assurance={ + "level": "aal2", + "methods": ["upstream_mfa"], + "mfa": True, + "source": "keycloak", + "at": int(time.time()), + }, + realm_access={"roles": ["admin", "offline_access"]}, + ) + token = sign_jwt(private_key, kid, payload) + config = conformance.Config( + issuer=issuer, + audience="service-a", + access_token=token, + client_id="iam-profile-conformance", + redirect_uri="http://localhost/callback", + environment="local", + ) + + results = conformance.run_suite(config) + + assert "FAIL" not in statuses(results).values(), messages(results) + assert statuses(results)["roles-claim"] == "PASS" + + +def test_provider_native_roles_warn_without_canonical_roles(issuer_fixture): + issuer, private_key, kid = issuer_fixture("realms/legacy") + payload = default_payload( + issuer, + "service-a", + realm_access={"roles": ["admin"]}, + ) + payload.pop("roles") + token = sign_jwt(private_key, kid, payload) + config = conformance.Config( + issuer=issuer, + audience="service-a", + access_token=token, + client_id="iam-profile-conformance", + redirect_uri="http://localhost/callback", + environment="local", + ) + + results = conformance.run_suite(config) + + assert "FAIL" not in statuses(results).values(), messages(results) + assert statuses(results)["roles-claim"] == "WARN" + + +def test_production_rejects_local_issuer(issuer_fixture): + issuer, private_key, kid = issuer_fixture("keycape") + token = sign_jwt(private_key, kid, default_payload(issuer, "service-a")) + config = conformance.Config( + issuer=issuer, + audience="service-a", + access_token=token, + client_id="iam-profile-conformance", + redirect_uri="http://localhost/callback", + environment="production", + ) + + results = conformance.run_suite(config) + + assert statuses(results)["local-issuer-policy"] == "FAIL" + + +def test_missing_tenant_fails_claim_contract(issuer_fixture): + issuer, private_key, kid = issuer_fixture("keycape") + payload = default_payload(issuer, "service-a") + payload.pop("tenant") + token = sign_jwt(private_key, kid, payload) + config = conformance.Config( + issuer=issuer, + audience="service-a", + access_token=token, + client_id="iam-profile-conformance", + redirect_uri="http://localhost/callback", + environment="local", + ) + + results = conformance.run_suite(config) + + assert statuses(results)["claim-shape"] == "FAIL" + assert statuses(results)["tenant-claim"] == "FAIL" + + +def test_delegated_agent_shape_passes(issuer_fixture): + issuer, private_key, kid = issuer_fixture("agent-issuer") + payload = default_payload( + issuer, + "service-a", + sub="agent:build-runner", + principal_type="agent", + preferred_username=None, + roles=["operator"], + agent={"id": "agent:build-runner", "mode": "delegated"}, + actor_sub="user:alice", + assurance={ + "level": "aal2", + "methods": ["workload_identity", "delegated_user_mfa"], + "mfa": True, + "source": "key-cape", + "at": int(time.time()), + }, + ) + token = sign_jwt(private_key, kid, payload) + config = conformance.Config( + issuer=issuer, + audience="service-a", + access_token=token, + client_id="iam-profile-conformance", + redirect_uri="http://localhost/callback", + environment="local", + ) + + results = conformance.run_suite(config) + + assert "FAIL" not in statuses(results).values(), messages(results) + assert statuses(results)["principal-shape"] == "PASS" diff --git a/workplans/NK-WP-0011-enterprise-federation-saml.md b/workplans/NK-WP-0011-enterprise-federation-saml.md index abc4f57..5c35204 100644 --- a/workplans/NK-WP-0011-enterprise-federation-saml.md +++ b/workplans/NK-WP-0011-enterprise-federation-saml.md @@ -197,8 +197,9 @@ priority: high conformance checks against the Keycloak issuer (discovery document, PKCE, token/claim shape, JWKS, userinfo). Verify an application configured for the IAM Profile can authenticate against either the KeyCape or the -Keycloak issuer per the T1 selection rule. Document per-tenant issuer -selection. +Keycloak issuer per the T1 selection rule. Use the canonical +`canon/standards/iam-profile_v0.2.md` contract and the executable suite in +`tools/iam-profile-conformance/`. Document per-tenant issuer selection. ```task id: NK-WP-0011-T7 @@ -254,5 +255,6 @@ production-readiness checklist. assurance evidence sourced from a federated token. - **railiance-platform**: OpenBao must expose a Keycloak auth role / ESO path before T3; unseal/break-glass story must be ready. -- **IAM Profile spec**: must be versioned and have an executable - conformance check before T6 can pass (see "Missing" below). +- **IAM Profile spec**: resolved by NK-WP-0012. T6 consumes + `canon/standards/iam-profile_v0.2.md` and + `tools/iam-profile-conformance/`. diff --git a/workplans/NK-WP-0012-iam-profile-specification.md b/workplans/NK-WP-0012-iam-profile-specification.md index f5f7833..b2fbaf0 100644 --- a/workplans/NK-WP-0012-iam-profile-specification.md +++ b/workplans/NK-WP-0012-iam-profile-specification.md @@ -4,13 +4,13 @@ type: workplan title: "NetKingdom IAM Profile Specification" domain: netkingdom repo: net-kingdom -status: proposed +status: finished owner: worsch topic_slug: netkingdom planning_priority: high planning_order: 12 created: "2026-05-21" -updated: "2026-05-21" +updated: "2026-05-22" depends_on: - NK-WP-0006 state_hub_workstream_id: 9b8e4afc-eb71-47d9-8750-799a082b320a @@ -86,7 +86,7 @@ Out of scope: ```task id: NK-WP-0012-T1 state_hub_task_id: 284dda38-b778-445a-a7dc-9b5a12fa380f -status: todo +status: done priority: high ``` @@ -101,7 +101,7 @@ breaking change is, how downstream is notified, how versions coexist). ```task id: NK-WP-0012-T2 state_hub_task_id: 0070398d-b0a4-4c11-a6fa-000166e1108f -status: todo +status: done priority: high ``` @@ -117,7 +117,7 @@ Remove hub-specific vocabulary from the core. ```task id: NK-WP-0012-T3 state_hub_task_id: 6fc2a5e1-1480-42f1-86a2-3e714359e1ba -status: todo +status: done priority: high ``` @@ -130,7 +130,7 @@ in the token). Align with NK-WP-0006 and the responsibility map. ```task id: NK-WP-0012-T4 state_hub_task_id: 0e52ed45-afa7-4832-9d6a-1ebbbab43872 -status: todo +status: done priority: high ``` @@ -143,7 +143,7 @@ identity. This is the contract named in the responsibility map. ```task id: NK-WP-0012-T5 state_hub_task_id: f0a62e77-b781-4625-b8bd-d191b48af58e -status: todo +status: done priority: high ``` @@ -157,7 +157,7 @@ consumes; it must run against both a key-cape and a Keycloak issuer. ```task id: NK-WP-0012-T6 state_hub_task_id: a1fd53a9-526f-4d87-89db-6073710c885d -status: todo +status: done priority: medium ``` @@ -183,6 +183,20 @@ interface/reference docs. - Downstream reference docs point at the canonical spec; the custodian v0.1 carries a deprecation/relocation note. +## Completion Notes + +- ADR: `docs/adr/ADR-0011-iam-profile-ownership-and-version-governance.md` +- Canonical profile: `canon/standards/iam-profile_v0.2.md` +- Executable conformance suite: + `tools/iam-profile-conformance/iam_profile_conformance.py` +- Fixture tests cover key-cape-like and Keycloak-like issuers, local-dev + rejection in production mode, tenant claim enforcement, provider-native + role normalization warnings, and delegated-agent claim shape. +- Cross-repo reference docs updated without touching downstream + `INTENT.md`: key-cape README/spec references now point at v0.2, and + flex-auth consumption docs plus claim fixtures now include v0.2 tenant, + principal, and assurance inputs. + ## Dependencies & Sequencing - **Depends on NK-WP-0006** for the recursive tenant model the claims encode. @@ -193,12 +207,17 @@ interface/reference docs. **key-cape**/**Keycloak** as the implementations the conformance check runs against — those repos implement, this workplan specifies and tests. -## Open Questions +## Resolved Questions -- Canonical role claim: `roles` vs `realm_access.roles`, or adapter - normalization of both (carried over from v0.1). -- Audience granularity: audience-per-service vs audience-per-endpoint. -- How agent principals differ from service accounts in claims and assurance - (delegated-authority agents vs plain workloads). -- Whether the conformance check is a standalone tool in net-kingdom or a - shared library other repos import. +- Canonical role claim: `roles` is canonical. `realm_access.roles` is a + transitional/provider-native source that must be mapped before + production consumption. +- Audience granularity: the core profile requires the receiving service + in `aud`; endpoint/resource granularity belongs to flex-auth + resource/action policy. +- Agent principals differ from service accounts through + `principal_type: agent`, an `agent` object, and delegated actor context + (`actor_sub` or `act.sub`) when applicable. +- The conformance check is a standalone tool in net-kingdom for v0.2. + Other repos consume it as an executable contract rather than importing + a shared library for now.