generated from coulomb/repo-seed
Implement NK-WP-0012 IAM profile specification
This commit is contained in:
6
Makefile
6
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
|
||||
|
||||
5
SCOPE.md
5
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]
|
||||
```
|
||||
|
||||
|
||||
391
canon/standards/iam-profile_v0.2.md
Normal file
391
canon/standards/iam-profile_v0.2.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
38
tools/iam-profile-conformance/README.md
Normal file
38
tools/iam-profile-conformance/README.md
Normal 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
|
||||
```
|
||||
706
tools/iam-profile-conformance/iam_profile_conformance.py
Normal file
706
tools/iam-profile-conformance/iam_profile_conformance.py
Normal 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())
|
||||
@@ -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"
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user