--- id: KEY-WP-0001 type: workplan title: "KeyCape Implementation — Lightweight IAM Profile" domain: netkingdom repo: key-cape status: done owner: Bernd topic_slug: netkingdom topic_id: a6c6e745-bf54-4465-9340-1534a2be493e repo_id: 8a99bb74-1ec0-4478-ac70-35a7cddb0e3c created: 2026-03-13 updated: 2026-03-13 spec_refs: - wiki/KeyCapeSpecification_v0.1.md - wiki/KeyCapeSpecificationPack_v0.1.md decisions: - id: ADR-0001 title: "Implementation language: Go" hub_decision_id: 620beb04-fa3f-4a9d-9806-02890a7a2b0d status: accepted ref: docs/adr/ADR-0001-choose-go-for-keycape.md state_hub_workstream_id: "0d34dfc1-7ccb-4bd5-b872-5c7379b9adce" --- # KEY-WP-0001 — KeyCape Implementation Implements the **NetKingdom IAM Profile** via KeyCape: a stateless, profile-constrained OIDC server orchestrating Authelia, LLDAP, and privacyIDEA. Replaceable by Keycloak without application changes. ## Language Decision **Go** — decided 2026-03-13 by Bernd. See `docs/adr/ADR-0001-choose-go-for-keycape.md`. KeyCape is an orchestrating boundary service (HTTP, adapters, JWT via library, CLI tooling) — Go's strongest domain. Rust revisitable if a subcomponent needs stronger guarantees later. Mandatory guardrails: typed domain models, narrow adapter interfaces, layered architecture, fuzz tests on validator/redirect/claim-mapping, no cleverness. ## Repo Structure (from spec §12) ``` src/ server/ oidc/ # Profile endpoints telemetry/ # Structured event emission errors/ # Error taxonomy + enforcement middleware adapters/ authelia/ # Auth flow delegation lldap/ # Identity directory reads privacyidea/ # MFA enforcement validator/ # Canonical LDAP schema validator binary migration/ lldap-export/ # LLDAP → canonical keycape-to-keycloak/ # Canonical → Keycloak realm import lldap-to-ldap/ # LLDAP → OpenLDAP/389DS/AD LDIF spec/ canonical-model.yaml # Source of truth for all identity data ldap-schema.yaml # Canonical LDAP schema rules tests/ profile/ # Scenario A — lightweight baseline negative/ # Scenario D — unsupported feature rejection migration/ # Scenarios B & C — replacement ``` ## Dependency Order ``` T01 (project setup) └─ T02 (canonical model) T04 (error taxonomy) └─ T03 (LDAP validator) └─ T13 (telemetry) └─ T10 (LLDAP adapter) └─ T14 (enforcement layer) └─ T11 (Authelia) │ └─ T12 (privacyIDEA) │ │ │ T05 ─ T06 ─ T07 ─ T08 ─┴─ T09 (OIDC server) │ T18 (profile tests / Scenario A) T21 (negative tests / Scenario D) │ T15 → T16 → T19 (Scenario B) T15 → T17 → T20 (Scenario C) │ T22 (dev stack) T23 (production packaging) ``` --- ## Phase 1 — Foundations ## T01 — Project setup: Go module, repo layout, CI skeleton ```task id: KEY-WP-0001-T01 hub_task_id: 25613e3f-2a65-409e-afaa-d23ded0bc256 priority: high status: done state_hub_task_id: "38822bc0-4189-4909-874e-ea40e5771250" ``` Initialise language module in `src/`. Create directory skeleton per spec §12. Add Makefile targets: `build`, `test`, `lint`. Set up CI (build + test on every push). Scaffolding only — no application code. **Agent must call `record_decision()` with chosen language (Go or Rust).** ## T02 — Canonical identity model: machine-readable schema ```task id: KEY-WP-0001-T02 hub_task_id: deee2929-9386-41db-bf91-fbd9ad646c28 priority: high status: done depends_on: [T01] state_hub_task_id: "940c118b-c1e6-4dda-bd4c-4fac105822be" ``` Write `spec/canonical-model.yaml`. Six entities: User, Group, Role, Client, Membership, MFAEnrollment (fields per spec §2). Include JSON Schema or CUE schema for programmatic validation. This file is the **source of truth** — all other code derives from it. ## T03 — Canonical LDAP schema + validator ```task id: KEY-WP-0001-T03 hub_task_id: 02592c65-db23-474b-b06b-019e95df8146 priority: high status: done depends_on: [T01, T02] state_hub_task_id: "c1715d70-f10f-45e9-b73a-b54a3d360342" ``` Write `spec/ldap-schema.yaml`: tree layout (`ou=users`, `ou=groups`, `ou=clients` under `dc=netkingdom,dc=local`), object classes (`inetOrgPerson`, `groupOfNames`), required/optional attributes. Implement `validator/` binary. Structural rules: valid DN, required attrs, no unknown attrs, valid group memberships. Semantic rules: referenced users exist, no cycles, usernames unique, email format valid. Validator runs in `--mode=ci|provisioning|migration`. Emits machine-readable report. ## T04 — Error taxonomy: types, JSON format, middleware ```task id: KEY-WP-0001-T04 hub_task_id: 46870fd6-0672-432b-8824-6bc2e24811b3 priority: high status: done depends_on: [T01] state_hub_task_id: "6e3b6b97-ac77-44c5-959e-be12751f1b63" ``` Implement four error types (spec §5): - `feature_not_supported_by_profile` - `available_in_keycloak_mode_only` - `rejected_for_profile_safety` - `invalid_profile_usage` JSON format: `{"error": "...", "description": "...", "feature": "..."}`. HTTP middleware wraps all handler errors. Error type strings are stable and test-assertable. --- ## Phase 2 — OIDC Server ## T05 — OIDC discovery endpoint (/.well-known/openid-configuration) ```task id: KEY-WP-0001-T05 hub_task_id: 92eb8916-cb22-4786-9f16-a8a07272f818 priority: high status: done depends_on: [T04] state_hub_task_id: "0dbc08e3-c465-4c37-a219-832a580bedfd" ``` `GET /.well-known/openid-configuration`. Advertise **only** profile-supported features: `authorization_code`, S256 PKCE, RS256, static scopes. Must NOT advertise: dynamic registration, implicit flow. Issuer configurable. Cacheable response. ## T06 — Authorization endpoint with PKCE and redirect URI validation ```task id: KEY-WP-0001-T06 hub_task_id: c3df620b-9864-4ff1-ba6d-26057c6f4d59 priority: high status: done depends_on: [T04, T11, T12, T13, T14] state_hub_task_id: "cdb4b06d-3d54-49dd-ac05-ca9ed6d7322f" ``` `GET/POST /authorize`. Validate: `client_id` (static config), `redirect_uri` (exact match — wildcard → `rejected_for_profile_safety`), `response_type=code`, `scope` contains `openid`, `code_challenge` present (missing → `invalid_profile_usage`), `code_challenge_method=S256`. Delegate to Authelia adapter. Store PKCE state server-side. No implicit or hybrid flow. ## T07 — Token endpoint: JWT/RS256, canonical claim mapping ```task id: KEY-WP-0001-T07 hub_task_id: d3248f4d-e0e9-4144-9844-c9768dc896d6 priority: high status: done depends_on: [T06, T08, T10] state_hub_task_id: "534d8616-90de-4d32-961c-c2ef719642e4" ``` `POST /token`. Validate PKCE `code_verifier`. Issue RS256 JWT via standard library (no custom crypto). Mandatory claims: `iss`, `sub` (canonical user ID), `aud`, `exp`, `iat`. Optional claims: `preferred_username` (LDAP `uid`), `email` (LDAP `mail`), `groups` (groupOfNames), `roles`. Short, explicitly configured token lifetime. Any other grant type → `feature_not_supported_by_profile`. ## T08 — JWKS endpoint (/jwks) ```task id: KEY-WP-0001-T08 hub_task_id: 58a1d705-b788-4d66-8c0a-33edff63a885 priority: high status: done depends_on: [T01] state_hub_task_id: "7e2167be-bcc7-49c2-8681-e518abd5bc0c" ``` `GET /jwks`. RS256 public key in JWK Set format. Key loaded from config. Key rotation: serve multiple keys during rotation window, keyed by `kid`. Standard library key generation only. ## T09 — Userinfo endpoint (/userinfo) ```task id: KEY-WP-0001-T09 hub_task_id: 742d3924-21e9-4304-86e9-0400af0e81ee priority: medium status: done depends_on: [T07, T10] state_hub_task_id: "78094ca5-a831-4443-9ccf-fc476ff87b91" ``` `GET /userinfo`. Optional per spec — implement if any registered client requires it. Validate Bearer token (RS256 + expiry). Return claim subset scoped to granted scopes. Claims must be identical to ID token for same scopes. If no client needs it: stub returning `available_in_keycloak_mode_only`. --- ## Phase 3 — Backend Adapters ## T10 — LLDAP adapter: user and group reads ```task id: KEY-WP-0001-T10 hub_task_id: 2043b10a-6822-45f8-abcc-4e233d918fb0 priority: high status: done depends_on: [T02, T03] state_hub_task_id: "97d19662-f482-4ea5-84fd-9fccb84ff317" ``` `adapters/lldap`. LDAP protocol connection to LLDAP. Interface: `LookupUser(username) → canonical User`, `LookupGroups(userDN) → []Group`, `ValidatePassword(username, password) → bool`. Attribute map: `uid→username`, `cn→displayName`, `mail→email`, `memberOf→groups`. Run canonical LDAP schema validator on every read. No LDAP internals exposed to `server/`. ## T11 — Authelia adapter: session and auth flow delegation ```task id: KEY-WP-0001-T11 hub_task_id: ad129a14-1552-4717-b1dd-b529d18ce681 priority: high status: done depends_on: [T04, T13] state_hub_task_id: "6461865b-f57c-4591-9cf3-68c79af22723" ``` `adapters/authelia`. Initiate auth redirect to Authelia, receive callback, extract authenticated user identity, hand off to MFA check (T12). Must not leak Authelia session tokens/cookies into profile layer. Unavailable Authelia → fail closed (`auth_failure` event). ## T12 — privacyIDEA adapter: MFA enforcement ```task id: KEY-WP-0001-T12 hub_task_id: 1ef196e6-2304-4cf6-b205-47ac1da879ec priority: high status: done depends_on: [T02, T13] state_hub_task_id: "e403a783-c856-4d6d-b859-a9cad7545fe1" ``` `adapters/privacyidea`. **KeyCape must NOT implement MFA logic.** Interface: `CheckMFARequired(user) → bool`, `ValidateMFAToken(user, token) → bool`. MFA failure → no token issued + `auth_failure` telemetry. MFA enrollment state from canonical `MFAEnrollment` entity. privacyIDEA remains stable across lightweight → expanded migration. --- ## Phase 4 — Telemetry & Enforcement ## T13 — Telemetry pipeline: structured event emission ```task id: KEY-WP-0001-T13 hub_task_id: 704146bf-cd60-4922-b18b-3d209cff3ac3 priority: high status: done depends_on: [T01] state_hub_task_id: "4df7bda1-5b84-4b4c-9b16-bcb1d3cca096" ``` `server/telemetry`. Event types (spec §6.1): `auth_start`, `auth_success`, `auth_failure`, `token_issued`, `unsupported_feature`, `invalid_request`, `migration_event`. Required fields (spec §6.2): `timestamp`, `client_id`, `endpoint`, `feature`, `result`, `error_type`, `scopes`, `grant_type`, `environment`, `trace_id`. Pluggable outputs: structured log (default), Prometheus metrics endpoint. Every auth and error path emits an event — **no silent paths**. ## T14 — Unsupported feature enforcement layer ```task id: KEY-WP-0001-T14 hub_task_id: 71f44886-ab61-4160-a435-72b35af472a0 priority: high status: done depends_on: [T04, T13] state_hub_task_id: "ae16fba9-5bb4-4780-ac77-558e3ed7e1dd" ``` `server/errors` enforcement middleware. Intercept any parameter, grant type, scope, or client config exceeding the profile. Return correct error type + emit `unsupported_feature` telemetry. Maintain a **registry** of unsupported features (adding new ones requires no handler changes): `dynamic_client_registration`, `identity_broker`, `wildcard_redirect_uri`, `implicit_flow`, etc. Every registry entry must have a corresponding test in T21. --- ## Phase 5 — Migration Tooling ## T15 — Migration: LLDAP → canonical export ```task id: KEY-WP-0001-T15 hub_task_id: 1bd13f76-2d62-429d-b230-d785ef6a3f2f priority: medium status: done depends_on: [T02, T03, T10] state_hub_task_id: "f7549cd7-33f0-4407-a656-ab8f5a184e64" ``` `migration/lldap-export` tool. Read all users, groups, memberships, attributes from LLDAP. Map to canonical model. Validate against LDAP schema validator before writing. Output: `canonical-export.yaml`. Emit `migration_event` telemetry. Idempotent. Include incompatibility report for unmappable LLDAP data. ## T16 — Migration: canonical → Keycloak import ```task id: KEY-WP-0001-T16 hub_task_id: f3d50e80-f6b5-4c0c-a5f3-08308ea1a95e priority: medium status: done depends_on: [T15] state_hub_task_id: "96486c41-9f33-42a5-b7b6-ad0a9eb2bdee" ``` `migration/keycape-to-keycloak` tool. Read canonical export (T15). Transform to Keycloak realm import format: users, groups, clients, roles, scope mappings. Preserve: same issuer, same claims, same scopes, same client behavior. Output: `keycloak-realm-import.json`. Emit `migration_event`. Include round-trip validation report. ## T17 — Migration: LLDAP → full LDAP (OpenLDAP / 389DS / AD) ```task id: KEY-WP-0001-T17 hub_task_id: 044d99c8-39cb-4f35-9308-912ae829bd22 priority: medium status: done depends_on: [T15] state_hub_task_id: "1ec335a2-80ca-4c34-b08e-211f537e4214" ``` `migration/lldap-to-ldap` tool. Export via T15 canonical export. Generate LDIF for target (`--target=openldap|389ds|ad`). Migrate: users, groups, memberships, attributes. Run validator on output LDIF before import. Produce validation report. **Orthogonal to T16** — the two migration dimensions are independent (spec §14.1). --- ## Phase 6 — Replacement Tests ## T18 — Profile test suite: Scenario A (lightweight baseline) ```task id: KEY-WP-0001-T18 hub_task_id: 76abc3f6-9c4e-4aca-9995-72b728925812 priority: high status: done depends_on: [T05, T06, T07, T08, T09, T22] state_hub_task_id: "1b0e9f26-d441-42b8-b532-1eb713fb355d" ``` `tests/profile`. Provision canonical fixtures into LLDAP + Authelia + KeyCape. Test categories (spec §15.3): discovery, login flow (PKCE), token claim assertions (all mandatory + optional), redirect validation, client config, MFA policy, logout (if implemented). Tests are **backend-agnostic** — same suite runs in T19 and T20. Must pass for Scenario A conformance. ## T19 — Replacement test suite: Scenario B (IAM swap, same directory) ```task id: KEY-WP-0001-T19 hub_task_id: 56d03e89-934b-4992-bfe4-b32f275882e3 priority: medium status: done depends_on: [T18, T16] state_hub_task_id: "a02d24e7-32de-4be6-935c-896c10dde020" ``` Run T18 suite against Keycloak + LLDAP (configured from T16 canonical export). **No test code changes allowed.** Migration successful only if all T18 tests pass. Proves IAM replaceability without directory migration. ## T20 — Replacement test suite: Scenario C (full expansion) ```task id: KEY-WP-0001-T20 hub_task_id: ec3cae5c-9942-4be7-acc0-1eb9f02aba45 priority: medium status: done depends_on: [T19, T17] state_hub_task_id: "545f319f-053d-48bd-8d94-c8c05cd56736" ``` Apply T17 LLDAP→OpenLDAP migration, then T16 Keycloak import. Run T18 suite. Migration successful only if all tests pass. privacyIDEA must remain stable (no MFA re-enrollment required). ## T21 — Negative profile tests: Scenario D (unsupported feature rejection) ```task id: KEY-WP-0001-T21 hub_task_id: a5112b63-121f-4d17-ac1a-fb46d160413e priority: high status: done depends_on: [T14] state_hub_task_id: "5856afe0-2a9e-4489-b057-35e59f86c359" ``` `tests/negative`. For every entry in T14 unsupported-feature registry: attempt usage, assert correct `error.error` string, assert `unsupported_feature` telemetry event emitted. Covered cases: `dynamic_client_registration`, `implicit_flow`, wildcard redirect URIs, identity brokering, missing PKCE, unknown scopes, unknown grant types. **Must run in CI.** Proves enforcement layer is complete. --- ## Phase 7 — Operations ## T22 — Dev mode stack: docker-compose with LLDAP + Authelia ```task id: KEY-WP-0001-T22 hub_task_id: e840963f-cd19-4b38-857a-7c40df165d3d priority: medium status: done depends_on: [T01] state_hub_task_id: "b98f2671-a20a-4438-99c9-fbe0e5324534" ``` `docker-compose.dev.yml`: KeyCape, LLDAP, Authelia, privacyIDEA (or stub). Pre-seeded with canonical fixtures from T02. Makefile targets: `make dev` (start + verify basic auth flow), `make seed` (re-apply fixtures without full restart). Deterministic: same seed → same state. Test environment for T18 and T21. ## T23 — Slim production packaging: binary + config + health ```task id: KEY-WP-0001-T23 hub_task_id: d5723683-f739-4362-b62e-71213dc5a89e priority: low status: done depends_on: [T18, T21] state_hub_task_id: "8c1752c2-7fb3-4da5-aab3-6b7acf12ea64" ``` Single stateless binary. Declarative YAML config: profile version, client definitions, backend connections, LDAP schema rules, privacyIDEA settings, telemetry destination, token lifetime, issuer URL. Static config validation on startup. `/healthz` endpoint. Minimal container image (distroless or Alpine). Config environment-promotable via env var overrides only. --- ## Acceptance Criteria (from spec §15.4 and §20) A release is conformant when: - [x] Scenario A tests pass (T18) — `src/tests/profile/profile_test.go` (8 tests) - [x] Scenario D tests pass (T21) — `src/tests/negative/negative_test.go` (8 tests) - [x] Scenario B tests pass (T19) — `src/tests/migration/scenario_b_test.go` (7 tests) - [x] Scenario C tests pass (T20) — `src/tests/migration/scenario_c_test.go` (6 tests) - [x] All error responses use taxonomy types from spec §5 — `internal/errors/taxonomy.go` - [x] All auth/error paths emit structured telemetry (T13) — `internal/server/telemetry/` - [x] Canonical LDAP schema validator passes on all fixtures (T03) — `internal/validator/` - [x] No handwritten cryptography anywhere in the codebase — stdlib `crypto/rsa` only - [x] Config is statically validated at startup (T23) — `internal/config/validate.go`