Implement NK-WP-0012 IAM profile specification

This commit is contained in:
2026-05-22 14:35:31 +02:00
parent 48cd174b00
commit c3f721397a
12 changed files with 1649 additions and 39 deletions

View File

@@ -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

View File

@@ -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]
```

View File

@@ -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 <issuer>/.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:<name>
tenant:customer:<name>
```
`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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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?

View File

@@ -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 C1C2); 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 C1C2); 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

View File

@@ -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
```

View File

@@ -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())

View File

@@ -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"

View File

@@ -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/`.

View File

@@ -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.