Compare commits

..

27 Commits

Author SHA1 Message Date
2fd69f0374 Normalize agent instructions and workplan frontmatter (STATE-WP-0067)
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
- Align agent files with on-disk workplan prefixes (infer from workplan ids)
- Set workplan domain to registered domain_slug; add topic_slug where applicable
- Repair frontmatter delimiter formatting; migrate legacy task status literals
- Regenerate AGENTS.md, CLAUDE.md, and .claude/rules from State Hub templates
2026-06-22 23:16:27 +02:00
afc01456a5 Fixed workplan frontmatter
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-06-22 18:40:55 +02:00
d076e7ee7b chore(consistency): sync task status from DB [auto]
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for key-cape
2026-06-22 18:02:26 +02:00
c4f281a376 Human-review .repo-classification.yaml (CUST-WP-0050 follow-up)
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-06-22 17:56:17 +02:00
bee021735c Add .repo-classification.yaml (CUST-WP-0050 T11 agent first-pass) 2026-06-22 17:47:37 +02:00
c9838a4811 Add credential routing instructions for all agent runtimes
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Propagate shared credential-routing section (Codex, Claude, Grok, llm-connect)
from state-hub template via scripts/propagate_credential_routing.py.
2026-06-18 22:48:38 +02:00
593b5af8dc Add capability registry scaffold (REUSE-WP-0014-T05 B03)
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-06-16 01:53:59 +02:00
d6d41dd84f Fix OpenBao OIDC token exchange compatibility
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-06-01 21:20:54 +02:00
06d20c3379 Load LLDAP organizational unit config
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-25 00:28:33 +02:00
937cb39de6 Require MFA during bootstrap mode
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-25 00:09:40 +02:00
56d279a8e6 Use basic auth for Authelia token exchange
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-24 18:04:28 +02:00
1d68639225 Align KeyCape image namespace with deployment
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-24 17:17:37 +02:00
7e22fcf3c7 bootrapping support
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-24 17:03:01 +02:00
393abf3e0e Reference IAM Profile v0.2
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-22 14:35:29 +02:00
f45784f951 Make INTENT.md self-coherent
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Remove external reference points so the intent stands on its own at the
abstract, stable level. The IAM profile this repo implements is described
as a versioned profile contract rather than attributed to an external
owner, and the heavier comparison mode is described generically instead of
by product name. All of KeyCape's own substance is preserved — purpose,
primary utility, intended users, strategic role and boundaries, design
principles, maturity target, and stability note.

Relationships to other systems belong in interface contracts and the
orchestration responsibility map, not in intent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:50:08 +02:00
465a778c1f Refresh agent instruction files
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-18 16:55:43 +02:00
10868739a8 Added INTENT.md file
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-03 17:37:45 +02:00
a626dd5d4e Scope update from repo-scoping refactor
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
2026-05-01 12:26:34 +02:00
926adfb3aa chore(session): read .custodian-brief.md before MCP call in session init
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:48:52 +01:00
cfa12e978d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-26:
  - update .custodian-brief.md for key-cape
2026-03-26 17:47:47 +01:00
a6af43b332 fix(authelia): use adapter's own client_id/redirect_uri in AuthorizeURL
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
The adapter was forwarding the downstream client's client_id and
redirect_uri to Authelia, which would always be rejected — Authelia
only recognises client_id=keycape and its registered callback URI.
Also removed downstream PKCE forwarding: KeyCape is a confidential
OIDC client to Authelia and authenticates via client_secret instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 03:15:36 +00:00
18dbad68ed feat(close): mark KEY-WP-0002 done — all 6 tasks complete
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:33:28 +00:00
7822ba0703 feat(image): KEY-WP-0002 T01/T02/T06 — Makefile image targets, Gitea Actions workflow, README CI docs
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
- Makefile: add IMAGE_REGISTRY/IMAGE_REPO/IMAGE_TAG vars + image, push, image-tag targets
- .gitea/workflows/image.yaml: build+push on main push and v* tags via metadata-action
- README: Container Image section with pull/build/push/CI secret docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:27:39 +00:00
393ef3ca76 feat(workplan): KEY-WP-0002 — build & publish KeyCape image to Gitea OCI registry
Some checks failed
CI / Build and Test (push) Has been cancelled
Adds workplan for containerising KeyCape and publishing to the self-hosted
Gitea registry on CoulombCore (92.205.130.254:32166) instead of GHCR. Covers
Makefile targets, Gitea Actions workflow, k3s insecure registry config, machine
account/token management, and a smoke test round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:18:12 +01:00
303663e48b Enhanced scope with provided capabilities
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-03-19 21:41:24 +01:00
80bf79de46 docs: add SCOPE.md for rapid orientation
Some checks failed
CI / Build and Test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:10:44 +01:00
ece58bc363 feat(close): mark KEY-WP-0001 done — all 23 tasks complete, tests passing
Some checks failed
CI / Build and Test (push) Has been cancelled
All implementation phases complete: OIDC server (Authorization Code + PKCE),
canonical identity model + LDAP validator, backend adapters (Authelia/LLDAP/
privacyIDEA), telemetry, enforcement middleware, migration tooling, and all
four replacement test scenarios (A–D). Tests pass with Go 1.23.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 02:49:13 +01:00
41 changed files with 2147 additions and 241 deletions

20
.claude/rules/agents.md Normal file
View File

@@ -0,0 +1,20 @@
## Kaizen Agents
Specialized agent personas available on demand via the state-hub MCP.
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
Common agents:
| Agent | Category | When to use |
|-------|----------|-------------|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
| `test-maintenance` | testing | Diagnose and fix failing tests |
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
| `keepaTodofile` | process | Maintain TODO.md during work |
| `project-management` | process | Track status, determine next steps |
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
All 17 agents: call `list_kaizen_agents()` for the full list.

View File

@@ -0,0 +1,8 @@
## Architecture
<!-- TODO: Describe the key design decisions and component structure.
Key modules, data flows, external integrations, state machines, etc. -->
## Quick Reference
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference

View File

@@ -0,0 +1,50 @@
# Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=key-cape` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes**`warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`

View File

@@ -0,0 +1,38 @@
## First Session Protocol
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work**
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
**Step 3 — Propose workstreams to Bernd**
Propose 13 workstreams — each a coherent strand, weeks to months, anchored to a
roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)**
```
workplans/KEY-WP-NNNN-<slug>.md ← write this first
```
Then register in the hub:
```
create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
```
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured infotech into N workstreams, M tasks",
event_type="milestone",
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
detail={"workstreams": [...], "tasks_created": M}
)
```
<!-- Delete or archive this file once past first session -->

View File

@@ -0,0 +1,8 @@
## Repo boundary
This repo owns **KeyCape** only. It does not own:
<!-- TODO: List what belongs in adjacent repos, e.g.:
- SSH key management → railiance-infra/
- State hub code → state-hub/
-->

View File

@@ -0,0 +1,5 @@
**Purpose:** Lightweight IAM profile implementation for NetKingdom — "prepare for Keycloak without Keycloak". Implements the NetKingdom IAM Profile (OIDC/PKCE) via Authelia + LLDAP + privacyIDEA, with migration path to Keycloak in expanded mode.
**Domain:** infotech
**Repo slug:** key-cape
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a

View File

@@ -0,0 +1,85 @@
## Session Protocol
Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient**
Read the offline-safe brief first — it works without a live hub connection:
```bash
cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("infotech")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
**Step 2 — Check inbox**
With MCP tools:
```
get_messages(to_agent="key-cape", unread_only=True)
```
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
requests before proceeding.
Without MCP tools:
```bash
curl -s "http://127.0.0.1:8000/messages/?to_agent=key-cape&unread_only=true" \
| python3 -m json.tool
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
**Step 3 — Scan workplans**
```bash
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`wait`/`todo`/`progress` tasks.
**Step 4 — Present brief**
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:key-cape]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
- `alignment_warnings`: flag if active work is not aligned with current goal
4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
If no workstreams: follow First Session Protocol (`first-session.md`).
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
**Session close:**
With MCP tools:
```
add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="<uuid>")
```
Without MCP tools:
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"topic_id":"cee7bedf-2b48-46ef-8601-006474f2ad7a","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
```
If workplan files were modified, ensure the local copy is up to date first:
```bash
git -C <repo_path> pull --ff-only
cd ~/state-hub && make fix-consistency REPO=key-cape
```
For repos where implementation runs on a remote machine (e.g. CoulombCore),
use the combined target which pulls before fixing:
```bash
cd ~/state-hub && make fix-consistency-remote REPO=key-cape
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.

View File

@@ -0,0 +1,19 @@
## Stack
<!-- TODO: Fill in language, frameworks, and key dependencies -->
- **Language:**
- **Key deps:**
## Dev Commands
```bash
# TODO: Fill in the standard commands for this repo
# Install dependencies
# Run tests
# Lint / type check
# Build / package (if applicable)
```

View File

@@ -0,0 +1,40 @@
## Workplan Convention (ADR-001)
File location: `workplans/KEY-WP-NNNN-<slug>.md`
ID prefix: `KEY-WP-`
Work items originate as files in this repo **before** being registered in the hub.
Canonical workplan/workstream frontmatter statuses are:
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
Use `proposed` for a newly drafted plan, `ready` after review against current
repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-KEY-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
directly. Promote anything requiring analysis, design, approval, dependencies, or
multiple planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:key-cape]` hub tasks —
visible at session start. Pick one up by creating the workplan file, then registering
the workstream.
Task blocks use this shape:
```task
id: KEY-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
```
Status progression is `todo``progress``done`; use `wait` for waiting or
blocked work and `cancel` for stopped work.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

18
.custodian-brief.md Normal file
View File

@@ -0,0 +1,18 @@
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
# Custodian Brief — key-cape
**Domain:** communication
**Last synced:** 2026-06-22 16:02 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
*(none — repo may need first-session setup)*
---
## MCP Orientation (when available)
If the state-hub MCP server is reachable, call:
`get_domain_summary("communication")`
This provides richer cross-domain context.
If the MCP call fails, use this file as your orientation source.

View File

@@ -0,0 +1,51 @@
name: Build and Publish Container Image
on:
push:
branches:
- main
tags:
- "v*"
env:
REGISTRY: 92.205.130.254:32166
IMAGE_NAME: coulomb/key-cape
jobs:
build-and-push:
runs-on: act_runner
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=main-,format=short,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

26
.repo-classification.yaml Normal file
View File

@@ -0,0 +1,26 @@
repo_classification:
standard: Repo Classification Standard
version: '1.0'
classified_at: '2026-06-22'
classified_by: human
category: product
domain: infotech
secondary_domains:
- communication
capability_tags:
- identity
- access-control
- security
- platform
- operations
business_stake:
- technology
- operations
- legal
- product
business_mechanics:
- control
- operation
- adaptation
notes: NetKingdom IAM Profile lightweight mode (Authelia/LLDAP/privacyIDEA); human
corrected domain from communication→infotech.

219
AGENTS.md Normal file
View File

@@ -0,0 +1,219 @@
# KeyCape — Agent Instructions
## Repo Identity
**Purpose:** Lightweight IAM profile implementation for NetKingdom — "prepare for Keycloak without Keycloak". Implements the NetKingdom IAM Profile (OIDC/PKCE) via Authelia + LLDAP + privacyIDEA, with migration path to Keycloak in expanded mode.
**Domain:** infotech
**Repo slug:** key-cape
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
**Workplan prefix:** `KEY-WP-`
---
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
there is no MCP server for Codex agents.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
### Orient at session start
```bash
# Offline brief — works without hub connection
cat .custodian-brief.md
# Active workstreams for this domain
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
| python3 -m json.tool
# Check inbox
curl -s "http://127.0.0.1:8000/messages/?to_agent=key-cape&unread_only=true" \
| python3 -m json.tool
```
Mark a message read:
```bash
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
### Log progress (required at session close)
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{
"summary": "what was done",
"event_type": "note",
"author": "codex",
"workstream_id": "<uuid>",
"task_id": "<uuid>"
}'
```
Omit `workstream_id` / `task_id` when not applicable.
### Update task status
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "progress"}'
# values: wait | todo | progress | done | cancel
```
### Flag a task for human review
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"needs_human": true, "intervention_note": "reason"}'
```
---
## Session Protocol
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=key-cape&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
**During work:**
- Update task statuses in workplan files as tasks progress
- Record significant decisions via `POST /decisions/`
**Close:**
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. Note for the custodian operator: after workplan file changes, run from
`~/state-hub`:
```bash
make fix-consistency REPO=key-cape
```
This syncs task status from files into the hub DB.
---
## Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=key-cape` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
<!-- REPO-AGENTS-EXTENSIONS -->
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->
---
## Workplan Convention (ADR-001)
Work items originate as files in this repo — not in the hub. The hub is a
read/cache/index layer that rebuilds from files.
**File location:** `workplans/KEY-WP-NNNN-<slug>.md`
**Archived location:** finished workplans may move to
`workplans/archived/YYMMDD-KEY-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
the completion/archive date; the frontmatter `id` does not change.
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
this only for low-risk work completed directly; create a normal workplan for
anything needing analysis, design, approval, dependencies, or multiple phases.
**Frontmatter:**
```yaml
---
id: KEY-WP-NNNN
type: workplan
title: "..."
domain: infotech
repo: key-cape
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
---
```
Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses.
**Task block format** (one per `##` section):
```
## Task Title
` ` `task
id: KEY-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` `
Task description text.
```
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan:
1. Write the file following the format above
2. Notify the custodian operator to run `make fix-consistency REPO=key-cape`
(or send a message to the hub agent via `POST /messages/`)

144
CLAUDE.md
View File

@@ -1,136 +1,12 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# KeyCape — Claude Code Instructions
## What This Repo Is
**KeyCape** is the lightweight IAM component of NetKingdom.
> *"Prepare for Keycloak without Keycloak"*
KeyCape implements the **NetKingdom IAM Profile** — a versioned OIDC/PKCE contract
that NetKingdom applications integrate against. It orchestrates:
| Component | Role |
|--------------|-------------------------------|
| Authelia | OIDC provider / session / tokens |
| LLDAP | Lightweight identity directory |
| privacyIDEA | MFA authority |
Keycape is intentionally replaceable by **Keycloak** in expanded mode. All apps
must target the profile, not Keycape or Keycloak incidentals.
## Custodian State Hub Integration
- **Domain:** `netkingdom`
- **Repo ID:** `8a99bb74-1ec0-4478-ac70-35a7cddb0e3c`
- **State Hub API:** `http://127.0.0.1:8000` (run `cd ~/the-custodian/state-hub && make api` if offline)
### Session Protocol
**Start of every session:**
```
get_domain_summary("netkingdom")
```
This gives the full picture of active workstreams, blocking decisions, and recent
progress for the NetKingdom domain at ~10% of the cost of `get_state_summary()`.
**During work:**
- `record_decision()` for any architectural choice (profile extensions, backend selection, etc.)
- `add_progress_event()` for milestones, blockers, discoveries
- `resolve_decision()` once a decision is closed
**End of every session:**
```
add_progress_event(summary="...", event_type="...", workstream_id="<active ws id>")
```
After modifying workplan files, run:
```
cd ~/the-custodian/state-hub && make fix-consistency REPO=key-cape
```
## Key Documents
| Document | Path | Purpose |
|---|---|---|
| Keycape Specification v0.1 | `wiki/KeyCapeSpecification_v0.1.md` | Architecture, design intent, objectives |
| Normative Specification Pack v0.1 | `wiki/KeyCapeSpecificationPack_v0.1.md` | Normative spec for implementation agents: identity model, LDAP schema, error taxonomy, telemetry, migration contract, acceptance test matrix |
## Architecture
```
key-cape/
wiki/ # Specifications (read before implementing)
workplans/ # Implementation workplans (ADR-001 convention)
src/ # Implementation (to be created)
tests/ # Test suite (to be created)
```
### Lightweight mode stack
```
Application ──→ NetKingdom IAM Profile
KeyCape ←── config translation, claim normalization
/ | \
Authelia LLDAP privacyIDEA
```
### Expanded mode stack (Keycape → Keycloak)
```
Application ──→ NetKingdom IAM Profile
Keycloak (same profile, different runtime)
/ \
LDAP privacyIDEA
```
## Implementation Priorities (from spec)
1. **Profile endpoints** — OIDC discovery, authorization, token, JWKS, userinfo
2. **Canonical identity model** — product-neutral user/group/client schema
3. **Claim normalization** — stable claim set regardless of backend quirks
4. **Unsupported-feature enforcement** — structured errors, never silent emulation
5. **Telemetry** — demand visibility for unsupported features and auth events
6. **Migration tooling** — export/validate for LLDAP → Keycloak path
## Normative Constraints (from spec — binding on implementation)
**Never silently emulate unsupported features.** Any request outside the profile MUST fail with a structured error from this taxonomy:
- `feature_not_supported_by_profile` — outside the NetKingdom IAM Profile entirely
- `available_in_keycloak_mode_only` — exists in expanded mode, absent here by design
- `rejected_for_profile_safety` — would weaken profile guarantees or security discipline
- `invalid_profile_usage` — supported endpoint/feature used incorrectly
**Security hard rules:** No handwritten cryptography. No handwritten password hashing. Use established protocol and crypto libraries. Strict redirect URI validation. Strict issuer consistency.
**Canonical identity model** is the source of truth for test fixtures, provisioning, migration, and validation — not any backend's native schema.
**Spec Pack structure** (`wiki/KeyCapeSpecificationPack_v0.1.md`) contains 7 normative components agents must read before implementing:
1. Normative Specification — OIDC/PKCE contract, endpoints, scopes, claims, client model, MFA
2. Canonical Identity Schema — User, Group, Membership, Client, Role, MFAEnrollmentReference, etc.
3. Canonical LDAP Schema + Validator Rules — restricted LDAP expression of identity model
4. Error Taxonomy — machine-readable/human-readable/loggable structured errors
5. Telemetry Schema — event types, required fields (timestamp, env, client_id, endpoint, feature_category, correlation_id, …)
6. Migration Contract — LLDAP → full LDAP, KeyCape → Keycloak migration paths
7. Acceptance Test Matrix — lightweight baseline, IAM replacement, full expansion, negative profile tests
## Workplan Convention (ADR-001)
Workplans live in `workplans/<id>-<slug>.md` with YAML frontmatter:
```yaml
id: KEY-WP-0001
type: workplan
title: "..."
domain: netkingdom
repo: key-cape
status: todo|active|done
owner: Bernd
topic_slug: netkingdom
```
Tasks are embedded as `## Task Title\n```task\nid: ...\nstatus: todo\n```\n` blocks.
@SCOPE.md
@.claude/rules/repo-identity.md
@.claude/rules/session-protocol.md
@.claude/rules/first-session.md
@.claude/rules/workplan-convention.md
@.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md

96
INTENT.md Normal file
View File

@@ -0,0 +1,96 @@
# INTENT
## Purpose
This repository exists to provide a **lightweight, profile-conformant identity and access management (IAM) system**.
It ensures that applications can rely on a **stable, versioned authentication contract** independent of the underlying IAM implementation.
---
## Primary Utility
The repository provides an implementation of a **versioned IAM profile** that:
* Delivers OIDC/PKCE-based authentication with strong security constraints
* Normalizes identity data across heterogeneous backend systems
* Enforces strict adherence to the defined IAM contract
* Enables seamless migration between lightweight and expanded IAM modes
It transforms IAM from a system dependency into a **replaceable, contract-driven capability**.
---
## Intended Users
* Application developers integrating against the IAM profile
* Infrastructure operators (`adm`) deploying IAM in constrained environments
* Automation systems (`atm`) managing identity, migration, and validation workflows
* LLM agents (`agt`) interacting with authenticated services
---
## Strategic Role in the System
This repository serves as the **lightweight IAM layer**:
* It provides a **resource-efficient implementation** of the IAM profile for environments with limited resources
* It anchors IAM around a **profile contract rather than a specific implementation**
* It enables a **two-mode architecture**:
* Lightweight mode (this implementation)
* Expanded mode (a heavier, full-featured implementation)
The profile ensures that both modes are **interchangeable without application changes**.
---
## Strategic Boundaries
This repository is **not** intended to:
* Become a full-featured, general-purpose IAM platform
* Extend beyond the defined IAM profile
* Support features that weaken security guarantees (e.g., implicit flow, wildcard redirects)
* Replace or wrap the heavier expanded-mode implementation
Its responsibility is limited to **strict, secure, and transparent profile implementation**.
---
## Design Principles
* **Contract over implementation**
Applications depend on the IAM profile, not on KeyCape internals
* **Security through constraint**
Only explicitly allowed features are supported; unsafe patterns are rejected
* **Explicitness over convenience**
Unsupported features must fail clearly and predictably
* **Replaceability by design**
The system must be swappable with a heavier profile implementation without breaking integrations
* **Canonical identity model**
Identity data must be normalized and consistent across all backends
---
## Maturity Target
A mature version of this repository should:
* Fully implement and enforce the **IAM profile** with zero ambiguity
* Provide **complete migration pathways** between lightweight and expanded modes
* Offer **deterministic and testable behavior** across all supported scenarios
* Act as a **reference implementation** of the IAM profile
* Enable IAM deployments that are **minimal, secure, and operationally efficient**
---
## Stability Note
Changes to this file represent a **deliberate shift in the IAM contract, scope, or architectural role** of this repository.
Such changes must be made with explicit intent, as they directly affect all dependent applications.

View File

@@ -1,4 +1,9 @@
.PHONY: dev seed build test lint
IMAGE_REGISTRY ?= 92.205.130.254:32166
IMAGE_REPO ?= coulomb/key-cape
IMAGE_TAG ?= latest
IMAGE := $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)
.PHONY: dev seed build test lint image push image-tag
dev:
docker compose -f docker-compose.dev.yml up
@@ -14,3 +19,12 @@ test:
lint:
cd src && go vet ./...
image:
docker build -t $(IMAGE) .
push: image
docker push $(IMAGE)
image-tag:
docker tag $(IMAGE) $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)

View File

@@ -3,9 +3,11 @@
*Prepare for Keycloak without Keycloak*
KeyCape is the lightweight IAM component of [NetKingdom](../net-kingdom/). It
implements the **NetKingdom IAM Profile** — a versioned OIDC/PKCE contract —
by orchestrating Authelia, LLDAP, and privacyIDEA. The same profile is
implemented by Keycloak in expanded-mode deployments.
implements lightweight mode for the **NetKingdom IAM Profile** — a versioned
OIDC/PKCE contract whose canonical core is now
`../net-kingdom/canon/standards/iam-profile_v0.2.md` — by orchestrating
Authelia, LLDAP, and privacyIDEA. The same profile is implemented by Keycloak
in expanded-mode deployments.
Applications integrate against the profile, not against Keycape internals. This
makes the lightweight → expanded migration a tested, automated operation rather
@@ -20,7 +22,7 @@ than a rewrite.
```
Application
│ (NetKingdom IAM Profile)
│ (NetKingdom IAM Profile v0.2)
KeyCape ←── profile enforcement, claim normalization, telemetry
/ | \
@@ -28,7 +30,8 @@ Auth LLDAP privacyIDEA
elia
```
**Expanded mode:** Replace KeyCape with Keycloak. Same profile, same tests pass.
**Expanded mode:** Replace KeyCape with Keycloak. Same profile contract, same
conformance suite in `../net-kingdom/tools/iam-profile-conformance/`.
## Quick Start
@@ -61,7 +64,9 @@ lldap:
baseDN: "dc=netkingdom,dc=local"
authelia:
baseURL: "https://authelia.local"
baseURL: "http://authelia.sso.svc.cluster.local:9091"
browserBaseURL: "https://authelia.local"
tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091"
clientId: "keycape"
clientSecret: "secret"
redirectURI: "https://auth.netkingdom.local/authorize/callback"
@@ -78,10 +83,22 @@ clients:
allowedScopes: ["openid", "profile", "email", "groups"]
grantTypes: ["authorization_code"]
clientType: "public"
- clientId: "netkingdom-bootstrap-console"
displayName: "NetKingdom Bootstrap Console"
redirectUris:
- "http://127.0.0.1:8876/oidc/callback"
- "http://localhost:8876/oidc/callback"
allowedScopes: ["openid", "profile", "email", "groups"]
grantTypes: ["authorization_code"]
clientType: "public"
```
Config is validated at startup — the server exits 1 with validation errors if config is invalid.
`browserBaseURL` is used only for the human browser redirect to Authelia.
`tokenBaseURL` is used for server-side code exchange. If either is omitted,
KeyCape falls back to `baseURL`.
## Endpoints
| Endpoint | Description |
@@ -90,6 +107,7 @@ Config is validated at startup — the server exits 1 with validation errors if
| `GET /jwks` | RS256 public key in JWK Set format |
| `GET /authorize` | Authorization endpoint (PKCE required) |
| `GET /authorize/callback` | Authelia callback handler |
| `POST /authorize/callback` | privacyIDEA MFA challenge submission |
| `POST /token` | Token exchange (authorization_code only) |
| `GET /userinfo` | Userinfo endpoint (Bearer token required) |
| `GET /healthz` | Health check → `{"status":"ok","version":"0.1.0"}` |
@@ -105,8 +123,10 @@ KeyCape enforces the NetKingdom IAM Profile. Violations return structured errors
| `rejected_for_profile_safety` | Would weaken security guarantees |
| `invalid_profile_usage` | Supported feature used incorrectly |
Enforced boundaries: no implicit flow, no wildcard redirect URIs, no dynamic client
registration, no identity brokering, PKCE S256 required.
Enforced boundaries: no implicit flow, no wildcard redirect URIs, no dynamic
client registration, no identity brokering, PKCE S256 required. Profile v0.2
also requires normalized tenant, principal type, groups, roles, scopes, and
assurance evidence in tokens consumed by applications and flex-auth.
## Migration Tools
@@ -177,6 +197,62 @@ wiki/ # Specifications
- `wiki/KeyCapeSpecificationPack_v0.1.md` — Normative implementation spec
- `docs/adr/ADR-0001-choose-go-for-keycape.md` — Language decision (Go vs Rust)
## Container Image
The KeyCape image is published to the Gitea OCI registry on CoulombCore.
**Registry:** `92.205.130.254:32166`
**Image:** `92.205.130.254:32166/coulomb/key-cape`
### Pull
```bash
docker pull 92.205.130.254:32166/coulomb/key-cape:latest
```
The registry runs over plain HTTP. Configure Docker to allow it:
```json
// /etc/docker/daemon.json
{ "insecure-registries": ["92.205.130.254:32166"] }
```
### Build and push locally
```bash
# Build with default tag (latest)
make image
# Build with a specific tag
IMAGE_TAG=dev make image
# Push to registry (requires prior docker login)
docker login 92.205.130.254:32166
make push
# Push with a specific tag
IMAGE_TAG=v1.0.0 make push
```
### Tags
| Trigger | Tags |
|---------|------|
| Push to `main` | `latest`, `main-<short-sha>` |
| Tag `v1.2.3` | `1.2.3`, `1.2`, `1`, `latest` |
### CI (Gitea Actions)
The workflow at `.gitea/workflows/image.yaml` builds and publishes automatically
on every push to `main` and on semver tags (`v*`).
Required Gitea Actions secrets on the `key-cape` repo:
| Secret | Value |
|--------|-------|
| `REGISTRY_USER` | Gitea username or machine account (e.g. `ci-netkingdom`) |
| `REGISTRY_TOKEN` | Gitea personal access token with `write:packages` scope |
## Domain
Part of the **NetKingdom** domain. Tracked in the Custodian State Hub under

115
SCOPE.md Normal file
View File

@@ -0,0 +1,115 @@
# SCOPE
> This file helps you quickly understand what this repository is about,
> when it is relevant, and when it is not.
> It is intentionally lightweight and may be incomplete.
---
## One-liner
Lightweight IAM implementation of the NetKingdom IAM Profile — orchestrates Authelia, LLDAP, and privacyIDEA to provide OIDC/PKCE authentication as a drop-in Keycloak alternative.
---
## Core Idea
NetKingdom applications target the "NetKingdom IAM Profile" — a versioned OIDC/PKCE contract. KeyCape implements that profile in lightweight mode (Authelia + LLDAP + privacyIDEA) with intentional constraints: no implicit flow, no wildcard redirects, no dynamic client registration. The same profile is implemented in expanded mode by Keycloak, so applications can migrate between modes without code changes.
---
## In Scope
- OIDC profile endpoints (discovery, authorization, token, JWKS, userinfo) per NetKingdom IAM Profile
- Canonical identity model: users, groups, clients, MFA
- Claim normalization across Authelia/LLDAP/privacyIDEA backend quirks
- Profile enforcement with structured error taxonomy (no silent emulation of unsupported features)
- Telemetry for unsupported-feature requests
- Migration tooling: LLDAP export, Keycloak import, LDIF generation
- LDAP schema validation
- Full acceptance test suite (profile baseline, migration scenarios, negative tests)
---
## Out of Scope
- General-purpose IAM (profile-specific only; no out-of-profile extensions)
- Dynamic client registration
- Implicit flow
- Wildcard redirect URIs
- Identity brokering beyond OIDC
- Keycloak operations (KeyCape is the lightweight alternative, not a Keycloak wrapper)
---
## Relevant When
- Deploying NetKingdom IAM in lightweight mode (no Keycloak license/resources needed)
- Applications need OIDC authentication with MFA in a constrained environment
- Migrating from lightweight (KeyCape) to expanded (Keycloak) mode
- Validating LDAP schema or generating migration artifacts
---
## Not Relevant When
- Expanded-mode Keycloak is already running (applications use the same profile; no code changes needed)
- Need out-of-profile IAM features (dynamic client registration, implicit flow, etc.)
- Non-NetKingdom OIDC deployments
---
## Current State
- Status: stable (v0.1 complete)
- Implementation: complete — all 23 workplan tasks implemented and tested
- Stability: high — profile-constrained; no silent failures; acceptance tests passing
- Usage: internal NetKingdom stack; replaces Keycloak in lightweight deployments
---
## How It Fits
- Upstream dependencies: Authelia (OIDC provider/sessions), LLDAP (identity directory), privacyIDEA (MFA)
- Downstream consumers: all NetKingdom applications; net-kingdom (parent domain)
- Often used with: net-kingdom (SSO/MFA workplan), railiance (deployed on Railiance infrastructure)
---
## Terminology
- Preferred terms: NetKingdom IAM Profile, lightweight mode, expanded mode, profile enforcement, canonical model
- Also known as: "KeyCape", "key-cape"
- Potentially confusing terms: "lightweight mode" = KeyCape stack; "expanded mode" = Keycloak stack; both implement the same OIDC profile
---
## Related / Overlapping
- `net-kingdom` — parent platform domain; KeyCape is the lightweight IAM implementation of its IAM Profile
---
## Provided Capabilities
```capability
type: security
title: OIDC/PKCE authentication (lightweight mode)
description: Provides OIDC/PKCE endpoints conforming to the NetKingdom IAM Profile via Authelia + LLDAP + privacyIDEA — a drop-in Keycloak alternative for constrained environments.
keywords: [oidc, pkce, authentication, iam, sso, authelia, lldap, mfa, identity]
```
```capability
type: security
title: Identity migration tooling
description: Migrate identities between lightweight (KeyCape) and expanded (Keycloak) IAM modes — LLDAP export, Keycloak import, LDIF generation.
keywords: [migration, identity, lldap, keycloak, ldif, iam]
```
---
## Getting Oriented
- Start with: `wiki/KeyCapeSpecification_v0.1.md` (architecture and design intent)
- Key files / directories: `wiki/KeyCapeSpecificationPack_v0.1.md` (normative spec), `src/cmd/` (binary entrypoints), `src/internal/` (implementation), `tests/` (acceptance suite)
- Entry points: `keycape server` binary; `keycape migrate` for migration tooling

View File

@@ -10,6 +10,8 @@ lldap:
baseDN: "dc=netkingdom,dc=local"
authelia:
baseURL: "http://authelia:9091"
browserBaseURL: "http://localhost:9091"
tokenBaseURL: "http://authelia:9091"
clientId: "keycape"
clientSecret: "changeme"
redirectURI: "http://localhost:8080/authorize/callback"
@@ -22,6 +24,16 @@ clients:
displayName: "Demo Application"
redirectUris:
- "http://localhost:3000/callback"
- "http://127.0.0.1:8876/oidc/callback"
- "http://localhost:8876/oidc/callback"
allowedScopes: ["openid", "profile", "email", "groups"]
grantTypes: ["authorization_code"]
clientType: "public"
- clientId: "netkingdom-bootstrap-console"
displayName: "NetKingdom Bootstrap Console"
redirectUris:
- "http://127.0.0.1:8876/oidc/callback"
- "http://localhost:8876/oidc/callback"
allowedScopes: ["openid", "profile", "email", "groups"]
grantTypes: ["authorization_code"]
clientType: "public"

12
registry/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Capability Registry
Markdown-first capability index for federation and reuse planning.
## Authoring
1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`).
2. Add the row to `indexes/capabilities.yaml`.
3. Run `reuse-surface validate` from a checkout with the CLI installed.
4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`.
Federation contract: reuse-surface `docs/RegistryFederation.md`.

View File

View File

@@ -0,0 +1,4 @@
version: 1
updated: '2026-06-16'
domain: helix_forge
capabilities: []

View File

@@ -37,26 +37,20 @@ func New(cfg Config, httpClient HTTPClient) *AutheliaAdapter {
// AuthorizeURL builds the Authelia OIDC authorization URL to which the user
// should be redirected.
//
// KeyCape is a confidential OIDC client to Authelia. The adapter always uses
// its own registered client_id and redirect_uri — NOT the downstream client's
// values — and requests the full fixed scope set. PKCE is omitted because
// the confidential client_secret authenticates the token exchange instead.
func (a *AutheliaAdapter) AuthorizeURL(_ context.Context, req domain.AuthRequest) (string, error) {
base := strings.TrimRight(a.cfg.BaseURL, "/") + "/api/oidc/authorization"
base := strings.TrimRight(a.authorizeBaseURL(), "/") + "/api/oidc/authorization"
q := url.Values{}
q.Set("client_id", req.ClientID)
q.Set("redirect_uri", req.RedirectURI)
q.Set("client_id", a.cfg.ClientID)
q.Set("redirect_uri", a.cfg.RedirectURI)
q.Set("response_type", "code")
q.Set("state", req.State)
if req.Nonce != "" {
q.Set("nonce", req.Nonce)
}
if len(req.Scopes) > 0 {
q.Set("scope", strings.Join(req.Scopes, " "))
} else {
q.Set("scope", "openid profile")
}
if req.PKCEChallenge != "" {
q.Set("code_challenge", req.PKCEChallenge)
q.Set("code_challenge_method", req.PKCEChallengeMethod)
}
q.Set("scope", "openid profile email groups")
return base + "?" + q.Encode(), nil
}
@@ -142,20 +136,20 @@ type tokenResponse struct {
// exchangeCode sends a POST to Authelia's token endpoint and returns the
// parsed token response. On any HTTP or status error it returns a non-nil error.
func (a *AutheliaAdapter) exchangeCode(_ context.Context, code string) (*tokenResponse, error) {
tokenURL := strings.TrimRight(a.cfg.BaseURL, "/") + "/api/oidc/token"
tokenURL := strings.TrimRight(a.tokenBaseURL(), "/") + "/api/oidc/token"
body := url.Values{}
body.Set("grant_type", "authorization_code")
body.Set("code", code)
body.Set("redirect_uri", a.cfg.RedirectURI)
body.Set("client_id", a.cfg.ClientID)
body.Set("client_secret", a.cfg.ClientSecret)
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(body.Encode()))
if err != nil {
return nil, fmt.Errorf("authelia: build token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(a.cfg.ClientID, a.cfg.ClientSecret)
resp, err := a.client.Do(req)
if err != nil {
@@ -179,6 +173,20 @@ func (a *AutheliaAdapter) exchangeCode(_ context.Context, code string) (*tokenRe
return &tr, nil
}
func (a *AutheliaAdapter) authorizeBaseURL() string {
if a.cfg.BrowserBaseURL != "" {
return a.cfg.BrowserBaseURL
}
return a.cfg.BaseURL
}
func (a *AutheliaAdapter) tokenBaseURL() string {
if a.cfg.TokenBaseURL != "" {
return a.cfg.TokenBaseURL
}
return a.cfg.BaseURL
}
// parseIDTokenClaims extracts the JWT payload claims without verifying the
// signature. This is intentional — the token is received directly from the
// upstream OIDC provider over a server-to-server TLS connection.

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
@@ -75,6 +76,7 @@ func jsonResponse(body string) *http.Response {
func TestAuthorizeURL_ContainsRequiredParams(t *testing.T) {
adapter := authelia.New(testConfig(), &mockHTTPClient{})
// Downstream client values — must NOT appear in the Authelia URL.
req := domain.AuthRequest{
ClientID: "myapp",
RedirectURI: "https://myapp.local/cb",
@@ -90,22 +92,29 @@ func TestAuthorizeURL_ContainsRequiredParams(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
checks := []string{
"client_id=myapp",
// Must use adapter's own client_id and redirect_uri, not the downstream client's.
required := []string{
"client_id=keycape",
"redirect_uri=",
"response_type=code",
"state=state-abc",
"nonce=nonce-xyz",
"code_challenge=challenge123",
"code_challenge_method=S256",
"scope=",
"openid",
}
for _, want := range checks {
for _, want := range required {
if !strings.Contains(u, want) {
t.Errorf("AuthorizeURL missing %q in: %s", want, u)
}
}
// Downstream client_id must NOT be forwarded to Authelia.
if strings.Contains(u, "client_id=myapp") {
t.Errorf("AuthorizeURL must not forward downstream client_id to Authelia, got: %s", u)
}
// PKCE must NOT be forwarded — confidential client uses client_secret instead.
if strings.Contains(u, "code_challenge") {
t.Errorf("AuthorizeURL must not include PKCE params for confidential client, got: %s", u)
}
}
func TestAuthorizeURL_UsesBaseURL(t *testing.T) {
@@ -128,6 +137,33 @@ func TestAuthorizeURL_UsesBaseURL(t *testing.T) {
}
}
func TestAuthorizeURL_UsesBrowserBaseURLWhenConfigured(t *testing.T) {
cfg := testConfig()
cfg.BaseURL = "http://authelia.sso.svc.cluster.local:9091"
cfg.BrowserBaseURL = "https://auth.coulomb.social"
adapter := authelia.New(cfg, &mockHTTPClient{})
req := domain.AuthRequest{
ClientID: "app",
RedirectURI: "https://app.local/cb",
State: "s",
PKCEChallenge: "c",
PKCEChallengeMethod: "S256",
Scopes: []string{"openid"},
}
u, err := adapter.AuthorizeURL(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.HasPrefix(u, "https://auth.coulomb.social") {
t.Errorf("expected URL to start with BrowserBaseURL, got: %s", u)
}
if strings.Contains(u, "authelia.sso.svc.cluster.local") {
t.Errorf("browser redirect must not use internal service URL, got: %s", u)
}
}
// ---------------------------------------------------------------------------
// HandleCallback — successful token exchange
// ---------------------------------------------------------------------------
@@ -144,6 +180,27 @@ func TestHandleCallback_Success_PreferredUsername(t *testing.T) {
if req.Method != http.MethodPost {
t.Errorf("expected POST, got %s", req.Method)
}
gotID, gotSecret, ok := req.BasicAuth()
if !ok {
t.Error("expected client_secret_basic authentication")
}
if gotID != "keycape" || gotSecret != "test-secret" {
t.Errorf("unexpected basic auth credentials for client %q", gotID)
}
rawBody, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
form, err := url.ParseQuery(string(rawBody))
if err != nil {
t.Fatalf("parse request body: %v", err)
}
if form.Get("client_secret") != "" {
t.Error("client_secret must not be sent in the form body")
}
if form.Get("client_id") != "keycape" {
t.Errorf("client_id: want keycape, got %q", form.Get("client_id"))
}
return jsonResponse(tokenBody), nil
},
}
@@ -164,6 +221,32 @@ func TestHandleCallback_Success_PreferredUsername(t *testing.T) {
}
}
func TestHandleCallback_UsesTokenBaseURLWhenConfigured(t *testing.T) {
tokenBody := buildTokenResponse(map[string]interface{}{
"sub": "user-uuid-123",
"preferred_username": "alice",
})
var tokenURL string
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
tokenURL = req.URL.String()
return jsonResponse(tokenBody), nil
},
}
cfg := testConfig()
cfg.BaseURL = "https://auth.coulomb.social"
cfg.TokenBaseURL = "http://authelia.sso.svc.cluster.local:9091"
adapter := authelia.New(cfg, client)
if _, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{Code: "code"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.HasPrefix(tokenURL, "http://authelia.sso.svc.cluster.local:9091") {
t.Errorf("expected token exchange to use TokenBaseURL, got: %s", tokenURL)
}
}
func TestHandleCallback_Success_FallsBackToSub(t *testing.T) {
tokenBody := buildTokenResponse(map[string]interface{}{
"sub": "user-uuid-456",

View File

@@ -8,17 +8,25 @@ import "net/http"
// Config holds all connection parameters for the Authelia adapter.
type Config struct {
// BaseURL is the Authelia server base URL, e.g. "https://authelia.local".
BaseURL string
BaseURL string `yaml:"baseURL"`
// BrowserBaseURL is the public Authelia URL used for browser redirects.
// If empty, BaseURL is used.
BrowserBaseURL string `yaml:"browserBaseURL,omitempty"`
// TokenBaseURL is the server-side Authelia URL used for token exchange.
// If empty, BaseURL is used.
TokenBaseURL string `yaml:"tokenBaseURL,omitempty"`
// ClientID is the client ID registered in Authelia for KeyCape.
ClientID string
ClientID string `yaml:"clientId"`
// ClientSecret is the client secret for the KeyCape client registration.
ClientSecret string
ClientSecret string `yaml:"clientSecret"`
// RedirectURI is the callback URL registered in Authelia that points back
// to KeyCape's callback handler.
RedirectURI string
RedirectURI string `yaml:"redirectURI"`
}
// HTTPClient is a minimal interface over net/http.Client for test injection.

View File

@@ -125,11 +125,16 @@ func (a *LDAPAdapter) LookupUser(ctx context.Context, username string) (*domain.
entry := result.Entries[0]
user := mapEntryToUser(entry)
// Run the canonical LDAP schema validator.
// Runtime login should not fail because a live directory entry is missing
// provisioning metadata such as cn/sn. Keep the warning visible for
// diagnostics, but return the resolved user so token issuance can proceed.
snap := validator.Snapshot{Users: []domain.User{user}}
report := validator.Validate(snap, validator.ModeProvisioning)
if !report.Passed {
return nil, fmt.Errorf("lldap: validation failed for user %q: %s", username, validationSummary(report))
if user.LDAPAttributes == nil {
user.LDAPAttributes = make(map[string]string)
}
user.LDAPAttributes["_validation_warning"] = validationSummary(report)
}
return &user, nil

View File

@@ -154,16 +154,20 @@ func TestLookupUser_NotFound(t *testing.T) {
}
}
func TestLookupUser_ValidationFailure(t *testing.T) {
// Return an entry with an empty DisplayName and empty sn — will fail validator.
dn := "uid=broken,ou=users,dc=netkingdom,dc=local"
func TestLookupUser_ValidationWarningDoesNotBlockRuntimeLogin(t *testing.T) {
// Return an entry with an empty DisplayName and empty sn. Runtime login
// should still resolve the user; provisioning validators report the warning.
dn := "uid=platform-root,ou=people,dc=netkingdom,dc=local"
conn := &mockConn{
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
if req.BaseDN != "ou=people,dc=netkingdom,dc=local" {
t.Fatalf("BaseDN: want ou=people,dc=netkingdom,dc=local, got %q", req.BaseDN)
}
attrs := []*ldap.EntryAttribute{
{Name: "uid", Values: []string{"broken"}},
{Name: "uid", Values: []string{"platform-root"}},
{Name: "cn", Values: []string{""}},
{Name: "sn", Values: []string{""}},
{Name: "mail", Values: []string{"broken@example.com"}},
{Name: "mail", Values: []string{"bernd.worsch@gmail.com"}},
}
return &ldap.SearchResult{
Entries: []*ldap.Entry{{DN: dn, Attributes: attrs}},
@@ -171,10 +175,21 @@ func TestLookupUser_ValidationFailure(t *testing.T) {
},
}
adapter := makeAdapter(testConfig(), conn)
_, err := adapter.LookupUser(context.Background(), "broken")
if err == nil {
t.Fatal("expected validation error, got nil")
cfg := testConfig()
cfg.UserOU = "ou=people"
adapter := makeAdapter(cfg, conn)
user, err := adapter.LookupUser(context.Background(), "platform-root")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.ID != dn {
t.Errorf("ID: want %q, got %q", dn, user.ID)
}
if user.Username != "platform-root" {
t.Errorf("Username: want platform-root, got %q", user.Username)
}
if user.LDAPAttributes["_validation_warning"] == "" {
t.Error("expected validation warning for missing displayName")
}
}

View File

@@ -6,26 +6,26 @@ package lldap
// Config holds all connection parameters for the LLDAP adapter.
type Config struct {
// URL is the LDAP server address, e.g. "ldap://lldap:389" or "ldaps://lldap:636".
URL string
URL string `yaml:"url"`
// BindDN is the distinguished name used for the service account bind,
// e.g. "cn=admin,dc=netkingdom,dc=local".
BindDN string
BindDN string `yaml:"bindDN"`
// BindPW is the service account password.
BindPW string
BindPW string `yaml:"bindPW"`
// BaseDN is the search base, e.g. "dc=netkingdom,dc=local".
BaseDN string
BaseDN string `yaml:"baseDN"`
// UserOU is the organisational unit for users. Defaults to "ou=users" when empty.
UserOU string
UserOU string `yaml:"userOU,omitempty"`
// GroupOU is the organisational unit for groups. Defaults to "ou=groups" when empty.
GroupOU string
GroupOU string `yaml:"groupOU,omitempty"`
// TLSSkipVerify disables TLS certificate verification. For development only.
TLSSkipVerify bool
TLSSkipVerify bool `yaml:"tlsSkipVerify,omitempty"`
}
// userOU returns the effective UserOU, falling back to the default.

View File

@@ -38,6 +38,10 @@ func New(cfg Config, httpClient HTTPClient) *PrivacyIDEAAdapter {
// registered in privacyIDEA. Fails closed: any infrastructure error returns
// (false, err) so callers cannot bypass the check.
func (a *PrivacyIDEAAdapter) CheckMFARequired(ctx context.Context, userID string) (bool, error) {
if a.cfg.RequireForAll {
return true, nil
}
endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/"
q := url.Values{}

View File

@@ -101,6 +101,27 @@ func TestCheckMFARequired_ActiveTokenPresent_ReturnsTrue(t *testing.T) {
}
}
func TestCheckMFARequired_RequireForAll_ReturnsTrueWithoutTokenList(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
t.Fatal("token-list endpoint must not be called when RequireForAll is enabled")
return nil, nil
},
}
cfg := testConfig()
cfg.RequireForAll = true
adapter := privacyidea.New(cfg, client)
required, err := adapter.CheckMFARequired(context.Background(), "alice")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !required {
t.Error("expected MFA required=true when RequireForAll is enabled")
}
}
func TestCheckMFARequired_InactiveTokenOnly_ReturnsFalse(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {

View File

@@ -8,15 +8,20 @@ import "net/http"
// Config holds all connection parameters for the privacyIDEA adapter.
type Config struct {
// BaseURL is the privacyIDEA server base URL, e.g. "https://privacyidea.local".
BaseURL string
BaseURL string `yaml:"baseURL"`
// AdminToken is the service-account JWT used to authenticate requests to the
// privacyIDEA admin API.
AdminToken string
AdminToken string `yaml:"adminToken"`
// Realm is the privacyIDEA realm to scope token and validate requests.
// Defaults to "netkingdom" when empty.
Realm string
Realm string `yaml:"realm"`
// RequireForAll skips privacyIDEA token-list discovery and requires MFA for
// every authenticated upstream user. This is useful during bootstrap when
// token-list admin credentials may not be durable yet.
RequireForAll bool `yaml:"requireForAll,omitempty"`
}
// realm returns the effective realm, falling back to "netkingdom".

View File

@@ -81,6 +81,122 @@ clients:
}
}
func TestLoad_AutheliaSplitURLs(t *testing.T) {
keyPath := writeTempFile(t, "placeholder-key")
yaml := `
issuer: "https://kc.example.com"
port: 8080
tokenLifetime: "15m"
privateKeyPem: "` + keyPath + `"
environment: "dev"
authelia:
baseURL: "http://authelia.sso.svc.cluster.local:9091"
browserBaseURL: "https://auth.example.com"
tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091"
clientId: "keycape"
clientSecret: "secret"
redirectURI: "https://kc.example.com/authorize/callback"
clients:
- clientId: "netkingdom-bootstrap-console"
displayName: "NetKingdom Bootstrap Console"
redirectUris:
- "http://127.0.0.1:8876/oidc/callback"
- "http://localhost:8876/oidc/callback"
clientType: "public"
`
cfgPath := writeTempFile(t, yaml)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load: unexpected error: %v", err)
}
if cfg.Authelia.BaseURL != "http://authelia.sso.svc.cluster.local:9091" {
t.Errorf("Authelia.BaseURL: got %q", cfg.Authelia.BaseURL)
}
if cfg.Authelia.BrowserBaseURL != "https://auth.example.com" {
t.Errorf("Authelia.BrowserBaseURL: got %q", cfg.Authelia.BrowserBaseURL)
}
if cfg.Authelia.TokenBaseURL != "http://authelia.sso.svc.cluster.local:9091" {
t.Errorf("Authelia.TokenBaseURL: got %q", cfg.Authelia.TokenBaseURL)
}
if len(cfg.Clients) != 1 || cfg.Clients[0].ClientID != "netkingdom-bootstrap-console" {
t.Fatalf("bootstrap client not loaded: %+v", cfg.Clients)
}
if got := cfg.Clients[0].RedirectURIs; len(got) != 2 || got[0] != "http://127.0.0.1:8876/oidc/callback" {
t.Errorf("bootstrap redirect URIs not loaded: %+v", got)
}
}
func TestLoad_PrivacyIDEARequireForAll(t *testing.T) {
keyPath := writeTempFile(t, "placeholder-key")
yaml := `
issuer: "https://kc.example.com"
port: 8080
tokenLifetime: "15m"
privateKeyPem: "` + keyPath + `"
environment: "dev"
privacyidea:
baseURL: "http://privacyidea.mfa.svc.cluster.local:8080"
adminToken: "service-token"
realm: "coulomb"
requireForAll: true
clients:
- clientId: "netkingdom-bootstrap-console"
displayName: "NetKingdom Bootstrap Console"
redirectUris:
- "http://127.0.0.1:8876/oidc/callback"
clientType: "public"
`
cfgPath := writeTempFile(t, yaml)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load: unexpected error: %v", err)
}
if cfg.PrivacyIDEA.Realm != "coulomb" {
t.Errorf("PrivacyIDEA.Realm: got %q", cfg.PrivacyIDEA.Realm)
}
if !cfg.PrivacyIDEA.RequireForAll {
t.Error("PrivacyIDEA.RequireForAll should load from YAML")
}
}
func TestLoad_LLDAPOrganisationalUnits(t *testing.T) {
keyPath := writeTempFile(t, "placeholder-key")
yaml := `
issuer: "https://kc.example.com"
port: 8080
tokenLifetime: "15m"
privateKeyPem: "` + keyPath + `"
environment: "dev"
lldap:
url: "ldap://lldap.sso.svc.cluster.local:3890"
bindDN: "uid=admin,ou=people,dc=netkingdom,dc=local"
bindPW: "secret"
baseDN: "dc=netkingdom,dc=local"
userOU: "ou=people"
groupOU: "ou=groups"
clients:
- clientId: "netkingdom-bootstrap-console"
displayName: "NetKingdom Bootstrap Console"
redirectUris:
- "http://127.0.0.1:8876/oidc/callback"
clientType: "public"
`
cfgPath := writeTempFile(t, yaml)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load: unexpected error: %v", err)
}
if cfg.LLDAP.UserOU != "ou=people" {
t.Errorf("LLDAP.UserOU: got %q", cfg.LLDAP.UserOU)
}
if cfg.LLDAP.GroupOU != "ou=groups" {
t.Errorf("LLDAP.GroupOU: got %q", cfg.LLDAP.GroupOU)
}
}
func TestLoad_FileNotFound(t *testing.T) {
_, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml"))
if err == nil {

View File

@@ -1,7 +1,10 @@
package oidc
import (
"context"
"html/template"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -20,8 +23,10 @@ type PendingState struct {
PKCEChallenge string
PKCEChallengeMethod string
State string
Nonce string
Scopes []string
ExpiresAt time.Time
AuthenticatedUser string
}
// pendingStateStore is a thread-safe map of state → PendingState.
@@ -99,6 +104,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
responseType := q.Get("response_type")
scope := q.Get("scope")
state := q.Get("state")
nonce := q.Get("nonce")
codeChallenge := q.Get("code_challenge")
codeChallengeMethod := q.Get("code_challenge_method")
@@ -187,6 +193,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
PKCEChallenge: codeChallenge,
PKCEChallengeMethod: codeChallengeMethod,
State: state,
Nonce: nonce,
Scopes: strings.Fields(scope),
ExpiresAt: time.Now().Add(10 * time.Minute),
})
@@ -212,6 +219,17 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Request) {
h.init()
ctx := r.Context()
if r.Method == http.MethodPost {
h.serveMFASubmission(w, r)
return
}
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
q := r.URL.Query()
state := q.Get("state")
@@ -229,7 +247,6 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
http.Error(w, "authorization request expired", http.StatusBadRequest)
return
}
h.pending.Delete(state)
// Handle upstream callback.
result, err := h.Auth.HandleCallback(ctx, domain.CallbackParams{
@@ -248,42 +265,110 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
if result == nil || result.Username == "" {
h.pending.Delete(state)
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthFailure,
ClientID: ps.ClientID,
Endpoint: "/authorize/callback",
Result: "failure",
ErrorType: "auth_failed",
})
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
// Check MFA requirement.
mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username)
if err != nil {
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthFailure,
ClientID: ps.ClientID,
Endpoint: "/authorize/callback",
Result: "failure",
ErrorType: "mfa_check_error",
})
http.Error(w, "mfa check error", http.StatusInternalServerError)
return
}
if mfaRequired {
if mfaToken == "" {
ps.AuthenticatedUser = result.Username
h.pending.Store(state, ps)
h.renderMFAChallenge(w, ps, "")
return
}
if err := h.MFA.ValidateMFAToken(ctx, result.Username, mfaToken); err != nil {
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthFailure,
ClientID: ps.ClientID,
Endpoint: "/authorize/callback",
Result: "failure",
ErrorType: "mfa_failed",
})
h.pending.Delete(state)
h.emitMFAFailure(ctx, ps.ClientID)
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
return
}
}
h.pending.Delete(state)
h.completeAuthorization(w, r, ps, result.Username)
}
func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
state := r.Form.Get("state")
mfaToken := r.Form.Get("mfa_token")
ps, ok := h.pending.Load(state)
if !ok {
http.Error(w, "unknown or expired state", http.StatusBadRequest)
return
}
if time.Now().After(ps.ExpiresAt) {
h.pending.Delete(state)
http.Error(w, "authorization request expired", http.StatusBadRequest)
return
}
if ps.AuthenticatedUser == "" {
h.pending.Delete(state)
http.Error(w, "mfa challenge not active", http.StatusBadRequest)
return
}
if strings.TrimSpace(mfaToken) == "" {
h.renderMFAChallenge(w, ps, "Enter the one-time code.")
return
}
if err := h.MFA.ValidateMFAToken(ctx, ps.AuthenticatedUser, mfaToken); err != nil {
h.pending.Delete(state)
h.emitMFAFailure(ctx, ps.ClientID)
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
return
}
h.pending.Delete(state)
h.completeAuthorization(w, r, ps, ps.AuthenticatedUser)
}
func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.Request, ps *PendingState, username string) {
// Generate authorization code and store PKCE session.
sess := &PKCESession{
ClientID: ps.ClientID,
RedirectURI: ps.RedirectURI,
PKCEChallenge: ps.PKCEChallenge,
PKCEChallengeMethod: ps.PKCEChallengeMethod,
State: state,
Username: result.Username,
State: ps.State,
Nonce: ps.Nonce,
Username: username,
Scopes: ps.Scopes,
ExpiresAt: time.Now().Add(10 * time.Minute),
}
authCode := h.Sessions.Create(sess)
h.Emitter.Emit(ctx, telemetry.Event{
h.Emitter.Emit(r.Context(), telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthSuccess,
ClientID: ps.ClientID,
@@ -293,14 +378,94 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
})
// Redirect to client with code and state.
redirectTo := ps.RedirectURI + "?code=" + authCode + "&state=" + state
http.Redirect(w, r, redirectTo, http.StatusFound)
redirectTo, err := url.Parse(ps.RedirectURI)
if err != nil {
http.Error(w, "invalid redirect_uri", http.StatusInternalServerError)
return
}
q := redirectTo.Query()
q.Set("code", authCode)
q.Set("state", ps.State)
redirectTo.RawQuery = q.Encode()
http.Redirect(w, r, redirectTo.String(), http.StatusFound)
}
func (h *AuthorizeHandler) emitMFAFailure(ctx context.Context, clientID string) {
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthFailure,
ClientID: clientID,
Endpoint: "/authorize/callback",
Result: "failure",
ErrorType: "mfa_failed",
})
}
func (h *AuthorizeHandler) renderMFAChallenge(w http.ResponseWriter, ps *PendingState, errorMessage string) {
clientName := ps.ClientID
if client, ok := h.ClientConfig[ps.ClientID]; ok && client.DisplayName != "" {
clientName = client.DisplayName
}
status := http.StatusOK
if errorMessage != "" {
status = http.StatusBadRequest
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_ = mfaChallengeTemplate.Execute(w, struct {
State string
Username string
ClientName string
ErrorMessage string
}{
State: ps.State,
Username: ps.AuthenticatedUser,
ClientName: clientName,
ErrorMessage: errorMessage,
})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
var mfaChallengeTemplate = template.Must(template.New("mfa-challenge").Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KeyCape MFA</title>
<style>
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f6f7f9; color: #17202a; }
main { width: min(420px, calc(100vw - 32px)); background: #fff; border: 1px solid #dfe4ea; border-radius: 8px; padding: 28px; box-shadow: 0 18px 45px rgba(23, 32, 42, .08); }
h1 { margin: 0 0 6px; font-size: 22px; font-weight: 650; letter-spacing: 0; }
p { margin: 0 0 20px; color: #52606d; line-height: 1.45; }
label { display: block; margin: 0 0 8px; font-size: 13px; font-weight: 650; color: #344054; }
input[type="text"] { width: 100%; box-sizing: border-box; height: 44px; border: 1px solid #c9d3df; border-radius: 6px; padding: 0 12px; font: inherit; background: #fff; }
input[type="text"]:focus { outline: 2px solid #2f80ed; outline-offset: 2px; border-color: #2f80ed; }
button { width: 100%; height: 44px; border: 0; border-radius: 6px; margin-top: 16px; background: #17324d; color: #fff; font: inherit; font-weight: 650; cursor: pointer; }
button:focus { outline: 2px solid #2f80ed; outline-offset: 2px; }
.meta { font-size: 13px; color: #667085; }
.error { margin: 0 0 12px; color: #b42318; font-size: 13px; font-weight: 650; }
</style>
</head>
<body>
<main>
<h1>Verify sign-in</h1>
<p class="meta">{{.Username}} for {{.ClientName}}</p>
{{if .ErrorMessage}}<p class="error">{{.ErrorMessage}}</p>{{end}}
<form method="post" action="/authorize/callback" autocomplete="off">
<input type="hidden" name="state" value="{{.State}}">
<label for="mfa_token">One-time code</label>
<input id="mfa_token" name="mfa_token" type="text" inputmode="numeric" autocomplete="one-time-code" required autofocus>
<button type="submit">Verify</button>
</form>
</main>
</body>
</html>`))
func uriRegistered(registered []string, target string) bool {
for _, u := range registered {
if u == target {

View File

@@ -45,14 +45,20 @@ type mockMFAProvider struct {
required bool
requiredErr error
validateErr error
validateErr error
validateCalls int
validatedUser string
validatedToken string
}
func (m *mockMFAProvider) CheckMFARequired(_ context.Context, _ string) (bool, error) {
return m.required, m.requiredErr
}
func (m *mockMFAProvider) ValidateMFAToken(_ context.Context, _, _ string) error {
func (m *mockMFAProvider) ValidateMFAToken(_ context.Context, user, token string) error {
m.validateCalls++
m.validatedUser = user
m.validatedToken = token
return m.validateErr
}
@@ -80,10 +86,21 @@ func testClient() map[string]*domain.Client {
return map[string]*domain.Client{
"test-client": {
ClientID: "test-client",
DisplayName: "Test Client",
RedirectURIs: []string{"https://app.example.com/callback"},
AllowedScopes: []string{"openid", "profile", "email"},
ClientType: "public",
},
"netkingdom-bootstrap-console": {
ClientID: "netkingdom-bootstrap-console",
DisplayName: "NetKingdom Bootstrap Console",
RedirectURIs: []string{
"http://127.0.0.1:8876/oidc/callback",
"http://localhost:8876/oidc/callback",
},
AllowedScopes: []string{"openid", "profile", "email", "groups"},
ClientType: "public",
},
}
}
@@ -146,6 +163,28 @@ func TestAuthorizeHandler_ValidRequest_RedirectsToAuthelia(t *testing.T) {
}
}
func TestAuthorizeHandler_BootstrapConsoleRedirectURI_RedirectsToAuthelia(t *testing.T) {
auth := &mockAuthProvider{authorizeURL: "https://authelia.example.com/auth?state=bootstrap"}
mfa := &mockMFAProvider{}
emitter := &captureEmitter{}
h := newAuthorizeHandler(auth, mfa, emitter)
params := validAuthorizeParams()
params.Set("client_id", "netkingdom-bootstrap-console")
params.Set("redirect_uri", "http://127.0.0.1:8876/oidc/callback")
req := authorizeRequest(params)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Errorf("expected 302 redirect, got %d (body: %s)", w.Code, w.Body.String())
}
if loc := w.Header().Get("Location"); loc != "https://authelia.example.com/auth?state=bootstrap" {
t.Errorf("expected Authelia redirect, got %q", loc)
}
}
func TestAuthorizeHandler_EmitsAuthStart(t *testing.T) {
auth := &mockAuthProvider{authorizeURL: "https://authelia.example.com/auth"}
mfa := &mockMFAProvider{}
@@ -449,6 +488,164 @@ func TestAuthorizeCallback_MFAFailed_AuthFailure(t *testing.T) {
}
}
func TestAuthorizeCallback_MFARequired_RendersChallengeWithoutToken(t *testing.T) {
auth := &mockAuthProvider{
callbackResult: &domain.AuthResult{Username: "alice"},
}
mfa := &mockMFAProvider{required: true}
emitter := &captureEmitter{}
sessions := oidc.NewSessionStore()
h := &oidc.AuthorizeHandler{
ClientConfig: testClient(),
Auth: auth,
MFA: mfa,
Sessions: sessions,
Emitter: emitter,
}
h.PendingStates().Store("random-state", &oidc.PendingState{
ClientID: "test-client",
RedirectURI: "https://app.example.com/callback",
PKCEChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
PKCEChallengeMethod: "S256",
State: "random-state",
Scopes: []string{"openid"},
ExpiresAt: time.Now().Add(5 * time.Minute),
})
req := httptest.NewRequest(http.MethodGet,
"/authorize/callback?code=authelia-code&state=random-state", nil)
w := httptest.NewRecorder()
h.ServeHTTPCallback(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 challenge page, got %d (body: %s)", w.Code, w.Body.String())
}
body := w.Body.String()
for _, want := range []string{"Verify sign-in", "alice", "Test Client", `name="mfa_token"`} {
if !strings.Contains(body, want) {
t.Errorf("challenge page missing %q in body: %s", want, body)
}
}
if mfa.validateCalls != 0 {
t.Errorf("MFA token should not be validated until form submission, got %d calls", mfa.validateCalls)
}
ps, ok := h.PendingStates().Load("random-state")
if !ok {
t.Fatal("expected pending state to remain for MFA form submission")
}
if ps.AuthenticatedUser != "alice" {
t.Errorf("AuthenticatedUser: want alice, got %q", ps.AuthenticatedUser)
}
}
func TestAuthorizeCallback_MFASubmission_ValidToken_RedirectsWithCode(t *testing.T) {
auth := &mockAuthProvider{}
mfa := &mockMFAProvider{required: true}
emitter := &captureEmitter{}
sessions := oidc.NewSessionStore()
h := &oidc.AuthorizeHandler{
ClientConfig: testClient(),
Auth: auth,
MFA: mfa,
Sessions: sessions,
Emitter: emitter,
}
h.PendingStates().Store("random-state", &oidc.PendingState{
ClientID: "test-client",
RedirectURI: "https://app.example.com/callback?from=bootstrap",
PKCEChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
PKCEChallengeMethod: "S256",
State: "random-state",
Scopes: []string{"openid"},
ExpiresAt: time.Now().Add(5 * time.Minute),
AuthenticatedUser: "alice",
})
form := url.Values{"state": {"random-state"}, "mfa_token": {"123456"}}
req := httptest.NewRequest(http.MethodPost, "/authorize/callback", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTPCallback(w, req)
if w.Code != http.StatusFound {
t.Errorf("expected 302 redirect, got %d (body: %s)", w.Code, w.Body.String())
}
if mfa.validatedUser != "alice" || mfa.validatedToken != "123456" {
t.Errorf("validated MFA: want alice/123456, got %q/%q", mfa.validatedUser, mfa.validatedToken)
}
loc := w.Header().Get("Location")
parsed, err := url.Parse(loc)
if err != nil {
t.Fatalf("invalid Location header: %v", err)
}
if parsed.Query().Get("from") != "bootstrap" {
t.Errorf("expected original redirect query to be preserved, got %q", loc)
}
if parsed.Query().Get("code") == "" {
t.Error("expected code param in redirect, got empty")
}
if parsed.Query().Get("state") != "random-state" {
t.Errorf("expected state=random-state, got %q", parsed.Query().Get("state"))
}
if _, ok := h.PendingStates().Load("random-state"); ok {
t.Error("expected pending MFA state to be deleted after successful submission")
}
}
func TestAuthorizeCallback_MFASubmission_InvalidToken_AuthFailure(t *testing.T) {
auth := &mockAuthProvider{}
mfa := &mockMFAProvider{
required: true,
validateErr: domain.ErrMFAFailed,
}
emitter := &captureEmitter{}
h := &oidc.AuthorizeHandler{
ClientConfig: testClient(),
Auth: auth,
MFA: mfa,
Sessions: oidc.NewSessionStore(),
Emitter: emitter,
}
h.PendingStates().Store("random-state", &oidc.PendingState{
ClientID: "test-client",
RedirectURI: "https://app.example.com/callback",
PKCEChallenge: "abc",
PKCEChallengeMethod: "S256",
State: "random-state",
Scopes: []string{"openid"},
ExpiresAt: time.Now().Add(5 * time.Minute),
AuthenticatedUser: "alice",
})
form := url.Values{"state": {"random-state"}, "mfa_token": {"wrong"}}
req := httptest.NewRequest(http.MethodPost, "/authorize/callback", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTPCallback(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
if _, ok := h.PendingStates().Load("random-state"); ok {
t.Error("expected pending MFA state to be deleted after invalid submission")
}
found := false
for _, ev := range emitter.events {
if ev.EventType == telemetry.EventAuthFailure && ev.ErrorType == "mfa_failed" {
found = true
break
}
}
if !found {
t.Error("expected mfa_failed auth_failure telemetry event")
}
}
func TestAuthorizeCallback_AuthProviderFailed_AuthFailure(t *testing.T) {
auth := &mockAuthProvider{
callbackErr: domain.ErrAuthFailed,

View File

@@ -15,6 +15,7 @@ type PKCESession struct {
PKCEChallenge string // S256 challenge
PKCEChallengeMethod string // always "S256"
State string
Nonce string
Username string // set after auth
Scopes []string
ExpiresAt time.Time

View File

@@ -111,6 +111,9 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
"exp": exp.Unix(),
"iat": now.Unix(),
}
if sess.Nonce != "" {
claims["nonce"] = sess.Nonce
}
scopeSet := make(map[string]bool)
for _, s := range sess.Scopes {

View File

@@ -107,6 +107,7 @@ func seededSession(sessions *oidc.SessionStore, verifier string) (code string) {
PKCEChallenge: challenge,
PKCEChallengeMethod: "S256",
State: "state1",
Nonce: "nonce1",
Username: "alice",
Scopes: []string{"openid", "profile", "email", "groups"},
ExpiresAt: time.Now().Add(10 * time.Minute),
@@ -323,6 +324,9 @@ func TestTokenHandler_JWTClaims_CorrectSubAndIssuer(t *testing.T) {
if claims["aud"] != "test-client" {
t.Errorf("aud: expected test-client, got %v", claims["aud"])
}
if claims["nonce"] != "nonce1" {
t.Errorf("nonce: expected nonce1, got %v", claims["nonce"])
}
}
func TestTokenHandler_ScopeFiltering_ProfileScope(t *testing.T) {

View File

@@ -224,9 +224,13 @@ The lightweight stack shall be considered valid production infrastructure where
---
## 8. NetKingdom IAM Profile v0.1
## 8. NetKingdom IAM Profile
This section defines the initial minimum profile to be supported.
This section defines the initial minimum profile supported by the KeyCape v0.1
specification. The canonical NetKingdom profile has since moved to
`net-kingdom/canon/standards/iam-profile_v0.2.md`; KeyCape conformance should
be measured against that profile and the executable suite in
`net-kingdom/tools/iam-profile-conformance/`.
## 8.1 Supported authentication model
@@ -282,11 +286,15 @@ Initial standard claims may include:
* `email` if present
* `name` if present
Optional NetKingdom-specific claims may include:
NetKingdom profile v0.2 requires these normalized claims before applications
or flex-auth consume a token:
* groups
* roles
* tenant or environment markers if explicitly defined
* `tenant`
* `principal_type`
* `groups`
* `roles`
* `scope` or `scp`
* `assurance`
Claim names, types, and semantics must be fixed by the profile and validated in tests.
@@ -786,9 +794,11 @@ Canonical fixtures conform if they pass canonical model and LDAP schema validati
The following implementation artifacts should be created next:
### 21.1 NetKingdom IAM Profile v0.1
### 21.1 NetKingdom IAM Profile
A more formal profile document with endpoint-by-endpoint detail.
A formal canonical profile document now exists in net-kingdom as
`canon/standards/iam-profile_v0.2.md`, with endpoint-by-endpoint detail,
tenant/principal/assurance claims, and executable conformance checks.
### 21.2 Canonical identity model schema

View File

@@ -2,15 +2,15 @@
id: KEY-WP-0001
type: workplan
title: "KeyCape Implementation — Lightweight IAM Profile"
domain: netkingdom
domain: infotech
repo: key-cape
status: active
status: done
owner: Bernd
topic_slug: netkingdom
workstream_id: 2c9caad8-2ced-492d-9d63-376387b4b9b0
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
@@ -20,6 +20,7 @@ decisions:
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
@@ -95,7 +96,8 @@ T01 (project setup)
id: KEY-WP-0001-T01
hub_task_id: 25613e3f-2a65-409e-afaa-d23ded0bc256
priority: high
status: todo
status: done
state_hub_task_id: "38822bc0-4189-4909-874e-ea40e5771250"
```
Initialise language module in `src/`. Create directory skeleton per spec §12. Add Makefile
@@ -108,8 +110,9 @@ no application code. **Agent must call `record_decision()` with chosen language
id: KEY-WP-0001-T02
hub_task_id: deee2929-9386-41db-bf91-fbd9ad646c28
priority: high
status: todo
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,
@@ -122,8 +125,9 @@ validation. This file is the **source of truth** — all other code derives from
id: KEY-WP-0001-T03
hub_task_id: 02592c65-db23-474b-b06b-019e95df8146
priority: high
status: todo
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
@@ -139,8 +143,9 @@ machine-readable report.
id: KEY-WP-0001-T04
hub_task_id: 46870fd6-0672-432b-8824-6bc2e24811b3
priority: high
status: todo
status: done
depends_on: [T01]
state_hub_task_id: "6e3b6b97-ac77-44c5-959e-be12751f1b63"
```
Implement four error types (spec §5):
@@ -162,8 +167,9 @@ all handler errors. Error type strings are stable and test-assertable.
id: KEY-WP-0001-T05
hub_task_id: 92eb8916-cb22-4786-9f16-a8a07272f818
priority: high
status: todo
status: done
depends_on: [T04]
state_hub_task_id: "0dbc08e3-c465-4c37-a219-832a580bedfd"
```
`GET /.well-known/openid-configuration`. Advertise **only** profile-supported features:
@@ -176,8 +182,9 @@ implicit flow. Issuer configurable. Cacheable response.
id: KEY-WP-0001-T06
hub_task_id: c3df620b-9864-4ff1-ba6d-26057c6f4d59
priority: high
status: todo
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 —
@@ -191,8 +198,9 @@ Delegate to Authelia adapter. Store PKCE state server-side. No implicit or hybri
id: KEY-WP-0001-T07
hub_task_id: d3248f4d-e0e9-4144-9844-c9768dc896d6
priority: high
status: todo
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
@@ -207,8 +215,9 @@ claims: `preferred_username` (LDAP `uid`), `email` (LDAP `mail`), `groups` (grou
id: KEY-WP-0001-T08
hub_task_id: 58a1d705-b788-4d66-8c0a-33edff63a885
priority: high
status: todo
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
@@ -220,8 +229,9 @@ multiple keys during rotation window, keyed by `kid`. Standard library key gener
id: KEY-WP-0001-T09
hub_task_id: 742d3924-21e9-4304-86e9-0400af0e81ee
priority: medium
status: todo
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
@@ -239,8 +249,9 @@ identical to ID token for same scopes. If no client needs it: stub returning
id: KEY-WP-0001-T10
hub_task_id: 2043b10a-6822-45f8-abcc-4e233d918fb0
priority: high
status: todo
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
@@ -254,8 +265,9 @@ validator on every read. No LDAP internals exposed to `server/`.
id: KEY-WP-0001-T11
hub_task_id: ad129a14-1552-4717-b1dd-b529d18ce681
priority: high
status: todo
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
@@ -268,8 +280,9 @@ profile layer. Unavailable Authelia → fail closed (`auth_failure` event).
id: KEY-WP-0001-T12
hub_task_id: 1ef196e6-2304-4cf6-b205-47ac1da879ec
priority: high
status: todo
status: done
depends_on: [T02, T13]
state_hub_task_id: "e403a783-c856-4d6d-b859-a9cad7545fe1"
```
`adapters/privacyidea`. **KeyCape must NOT implement MFA logic.** Interface:
@@ -287,8 +300,9 @@ privacyIDEA remains stable across lightweight → expanded migration.
id: KEY-WP-0001-T13
hub_task_id: 704146bf-cd60-4922-b18b-3d209cff3ac3
priority: high
status: todo
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`,
@@ -303,8 +317,9 @@ metrics endpoint. Every auth and error path emits an event — **no silent paths
id: KEY-WP-0001-T14
hub_task_id: 71f44886-ab61-4160-a435-72b35af472a0
priority: high
status: todo
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
@@ -323,8 +338,9 @@ Every registry entry must have a corresponding test in T21.
id: KEY-WP-0001-T15
hub_task_id: 1bd13f76-2d62-429d-b230-d785ef6a3f2f
priority: medium
status: todo
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
@@ -338,8 +354,9 @@ report for unmappable LLDAP data.
id: KEY-WP-0001-T16
hub_task_id: f3d50e80-f6b5-4c0c-a5f3-08308ea1a95e
priority: medium
status: todo
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
@@ -353,8 +370,9 @@ Include round-trip validation report.
id: KEY-WP-0001-T17
hub_task_id: 044d99c8-39cb-4f35-9308-912ae829bd22
priority: medium
status: todo
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
@@ -372,8 +390,9 @@ migration dimensions are independent (spec §14.1).
id: KEY-WP-0001-T18
hub_task_id: 76abc3f6-9c4e-4aca-9995-72b728925812
priority: high
status: todo
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
@@ -387,8 +406,9 @@ redirect validation, client config, MFA policy, logout (if implemented). Tests a
id: KEY-WP-0001-T19
hub_task_id: 56d03e89-934b-4992-bfe4-b32f275882e3
priority: medium
status: todo
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
@@ -401,8 +421,9 @@ without directory migration.
id: KEY-WP-0001-T20
hub_task_id: ec3cae5c-9942-4be7-acc0-1eb9f02aba45
priority: medium
status: todo
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
@@ -414,8 +435,9 @@ only if all tests pass. privacyIDEA must remain stable (no MFA re-enrollment req
id: KEY-WP-0001-T21
hub_task_id: a5112b63-121f-4d17-ac1a-fb46d160413e
priority: high
status: todo
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
@@ -434,8 +456,9 @@ is complete.
id: KEY-WP-0001-T22
hub_task_id: e840963f-cd19-4b38-857a-7c40df165d3d
priority: medium
status: todo
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
@@ -449,8 +472,9 @@ Test environment for T18 and T21.
id: KEY-WP-0001-T23
hub_task_id: d5723683-f739-4362-b62e-71213dc5a89e
priority: low
status: todo
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

View File

@@ -0,0 +1,245 @@
---
id: KEY-WP-0002
type: workplan
title: "KeyCape Container Image — Build & Publish to Gitea OCI Registry"
domain: infotech
repo: key-cape
status: done
owner: netkingdom
topic_slug: netkingdom
created: "2026-03-22"
updated: "2026-03-21"
capability_request_id: ""
state_hub_workstream_id: "c8843c7a-460a-47a2-b45a-b8d3940f9aa2"
---
# KEY-WP-0002 — KeyCape Container Image — Build & Publish to Gitea OCI Registry
## Problem
KeyCape has a `Dockerfile` but no automated build pipeline and no published
image. Other services (k3s deployments, local dev) that need to run KeyCape
must build locally from source. There is no versioned artefact to reference
in Helm charts or manifests.
The capability request for this work was originally misrouted to railiance.
It belongs here: KeyCape owns its own image.
## Goal
Produce a versioned OCI image for KeyCape, published to the Gitea container
registry on CoulombCore (`92.205.130.254:32166`), triggered automatically on
every merge to `main` and on semver tags (`v*`).
**Gitea OCI registry endpoint:** `92.205.130.254:32166`
**Image name:** `92.205.130.254:32166/coulomb/key-cape`
> **Why Gitea, not GHCR?**
> The net-kingdom cluster is self-hosted. Keeping images in Gitea (also
> self-hosted on CoulombCore) avoids any external registry dependency and
> keeps image pulls within the cluster network. GHCR is a future option
> once public distribution is needed.
## Design
### Image naming & tagging
| Trigger | Tags applied |
|---------|-------------|
| push to `main` | `latest`, `main-<short-sha>` |
| tag `v1.2.3` | `1.2.3`, `1.2`, `1`, `latest` |
### Build
Multi-stage Dockerfile already present — no changes needed to the build
itself. The image builds to a distroless static binary (~10 MB).
### Registry auth
Gitea issues a personal access token (or machine account token) with
`write:packages` scope. Stored as Gitea Actions secret `REGISTRY_TOKEN`;
username stored as `REGISTRY_USER`.
For local `make push`, credentials are passed via `docker login` before
the push target runs.
### Makefile targets
```makefile
IMAGE_REGISTRY ?= 92.205.130.254:32166
IMAGE_REPO ?= coulomb/key-cape
IMAGE_TAG ?= latest
IMAGE := $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)
image:
docker build -t $(IMAGE) .
push: image
docker push $(IMAGE)
image-tag:
docker tag $(IMAGE) $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)
```
### Gitea Actions workflow
`.gitea/workflows/image.yaml` — triggers on push to `main` and on `v*` tags:
- Checkout
- Set up Docker Buildx
- Login to `92.205.130.254:32166` using secrets
- Build and push with metadata-action tags
- (Optional) sign with cosign if available
### k3s insecure registry
Gitea runs over plain HTTP on port 32166 (NodePort). k3s must be configured
to treat this endpoint as an insecure registry so image pulls work from
within the cluster:
```yaml
# /etc/rancher/k3s/registries.yaml (on CoulombCore)
mirrors:
"92.205.130.254:32166":
endpoint:
- "http://92.205.130.254:32166"
```
k3s picks this up on restart (or SIGHUP). Worker nodes (if any) need the
same file.
---
## Tasks
### T01 — Makefile: image, push, image-tag targets
```task
id: KEY-WP-0002-T01
status: done
priority: high
state_hub_task_id: "749472fc-edb9-4948-9ebc-58d5f38327ee"
```
Add `image`, `push`, and `image-tag` targets to `Makefile` with
`IMAGE_REGISTRY`, `IMAGE_REPO`, `IMAGE_TAG` variables defaulting to the
Gitea endpoint and `coulomb/key-cape:latest`.
Gate: `make image` builds successfully locally; `IMAGE_TAG=dev make image`
produces a differently-tagged image.
---
### T02 — Gitea Actions workflow
```task
id: KEY-WP-0002-T02
status: done
priority: high
state_hub_task_id: "8ecf18cc-a3bb-4ede-a09c-fcd0d26d7f9d"
```
Create `.gitea/workflows/image.yaml`:
- Trigger: `push` to `main`, `push` tags matching `v*`
- Runner: `act_runner` label (or `ubuntu-latest` if configured)
- Steps: checkout → docker buildx → login → build+push
- Tags via `docker/metadata-action`: `latest` on main, semver on tags
Secrets required (document in README.md under "CI"):
- `REGISTRY_USER` — Gitea username or machine account
- `REGISTRY_TOKEN` — Gitea personal access token with `write:packages`
Gate: workflow file is syntactically valid; documented in README.
---
### T03 — k3s insecure registry config on CoulombCore
```task
id: KEY-WP-0002-T03
status: done
priority: high
state_hub_task_id: "2dde67f9-944f-418d-a2e9-7367bc556425"
```
On CoulombCore, create/update `/etc/rancher/k3s/registries.yaml` to add
the Gitea NodePort as an HTTP mirror. Restart k3s (or send SIGHUP) and
verify `crictl pull 92.205.130.254:32166/coulomb/key-cape:latest` works.
Gate: image pull from within the cluster succeeds without TLS errors.
---
### T04 — Create Gitea machine account & token
```task
id: KEY-WP-0002-T04
status: done
priority: medium
state_hub_task_id: "25775e10-3164-4adb-9c41-835c86fde5f8"
```
In Gitea (http://92.205.130.254:32166), create a machine account
`ci-netkingdom` (or reuse an existing service account) with access to
the `netkingdom` organisation. Generate a token with `write:packages`
scope and store it in:
- Gitea Actions secrets on the `key-cape` repo: `REGISTRY_USER`, `REGISTRY_TOKEN`
- The net-kingdom credential store (SOPS-encrypted) under
`credentials/gitea-ci-token.enc.yaml`
Gate: `docker login 92.205.130.254:32166` succeeds with the token;
secret is in the credential store.
---
### T05 — Smoke test: push and pull a dev image
```task
id: KEY-WP-0002-T05
status: done
priority: medium
state_hub_task_id: "0f6ab38f-6d34-41af-9180-f19c687947b5"
```
Manually trigger a build-and-push:
```bash
docker login 92.205.130.254:32166
IMAGE_TAG=dev make push
```
Then verify the image is pullable from CoulombCore:
```bash
# on CoulombCore
crictl pull 92.205.130.254:32166/coulomb/key-cape:dev
```
Gate: pull succeeds; image is listed in Gitea -> Packages -> coulomb/key-cape.
---
### T06 — Update README with registry & CI docs
```task
id: KEY-WP-0002-T06
status: done
priority: low
state_hub_task_id: "946cd34d-94da-4fa9-a781-ed36f6c827a3"
```
Add a "Container Image" section to `README.md` documenting:
- Registry URL and image name
- How to pull (`docker pull 92.205.130.254:32166/coulomb/key-cape:latest`)
- How to build and push locally (Makefile targets)
- CI secrets required for the Actions workflow
Gate: README section present and accurate.
---
## Done Criteria
- [ ] `make image` and `make push` work locally
- [ ] `.gitea/workflows/image.yaml` present and documented
- [ ] k3s can pull the image from Gitea without TLS errors
- [ ] Machine account token stored in credential store
- [ ] Smoke test: `dev` image pushed and pulled successfully
- [ ] README updated

View File

@@ -0,0 +1,200 @@
---
id: KEY-WP-0003
type: workplan
title: "Bootstrap Console OIDC Login and MFA Verification"
domain: infotech
repo: key-cape
status: finished
owner: codex
topic_slug: netkingdom
created: "2026-05-24"
updated: "2026-05-24"
state_hub_workstream_id: "02990009-a2b3-44f6-a579-487fbacae41a"
---
# KEY-WP-0003 - Bootstrap Console OIDC Login and MFA Verification
## Problem
The NetKingdom security bootstrap console now acts as a local OIDC client
callback so the operator can verify the dedicated platform-root login before
approving custody mode. The current live KeyCape deployment rejects that flow
with:
```json
{
"error": "invalid_profile_usage",
"description": "redirect_uri does not match any registered URI",
"feature": "redirect_uri"
}
```
That error is correct profile enforcement: KeyCape only accepts exact
registered redirect URIs. The live `demo-app` registration has not yet been
updated to allow the local bootstrap console callback:
- `http://127.0.0.1:8876/oidc/callback`
- `http://localhost:8876/oidc/callback`
After that is fixed, there is a second usability/security gap. KeyCape checks
privacyIDEA MFA after the Authelia callback, but the browser flow currently
expects an `mfa_token` query parameter instead of presenting a proper OTP
challenge page to the human operator.
## Goal
Make the bootstrap console's "Start demo OIDC login" button a real
end-to-end verification path for the current lightweight IAM stack:
1. KeyCape accepts the bootstrap console callback URI by exact registration.
2. The browser leaves KeyCape for the public Authelia login URL.
3. After password login, KeyCape presents a minimal MFA challenge when
privacyIDEA requires one.
4. KeyCape issues an OIDC authorization code to the bootstrap console callback.
5. The console can exchange the code and let the operator mark
`OIDC login verified` without exposing tokens or secrets.
This keeps KeyCape's security posture intact: no wildcard redirect URIs, no
dynamic client registration, no token display, and no storage of OTP material.
## Design Notes
- Prefer a dedicated public client named `netkingdom-bootstrap-console` for
long-lived clarity. Reusing `demo-app` is acceptable for the immediate
unblock only if the deployment/runbook clearly labels it as a bootstrap test
client.
- The bootstrap callback is local-only and operator-attended. It must be an
exact URI in config, not a wildcard or dynamic registration exception.
- Browser-facing Authelia redirects must use the public Authelia base URL
(`https://auth.coulomb.social`) so the human login page opens correctly.
- KeyCape may still need an internal service URL for back-channel token
exchange. If so, split the current single Authelia URL into browser-facing
authorize URL and internal token URL instead of making the browser use an
in-cluster hostname.
- The MFA prompt should collect only a one-time code, post it back to KeyCape,
validate with privacyIDEA, and then continue the normal OIDC code flow.
- This work unblocks the NetKingdom custody gate in
`NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap`.
## Implementation Notes
**2026-05-24:** Implemented in source:
- added `netkingdom-bootstrap-console` as a public OIDC client in the sample
KeyCape config, while keeping the local callback registered on `demo-app`
for compatibility,
- split Authelia browser redirects from server-side token exchange via
`browserBaseURL` and `tokenBaseURL`,
- added a browser MFA challenge page at `POST /authorize/callback` that
validates the one-time code with privacyIDEA before issuing the downstream
OIDC authorization code,
- updated NetKingdom's `keycape-config` generation template and bootstrap
console to use the dedicated client,
- added regression tests for callback registration, split Authelia URLs, MFA
challenge rendering, valid OTP continuation, and invalid OTP failure.
Live use still requires deployment: build/publish the updated KeyCape image,
refresh the live `keycape-config` Secret through the custodian age-key unlock
ceremony, and restart the KeyCape deployment.
---
## T01 - Register the bootstrap console callback client
```task
id: KEY-WP-0003-T01
status: done
priority: high
state_hub_task_id: "b396c99f-d711-475a-9cba-4f03a1db561d"
```
Add a KeyCape client registration for the bootstrap console. Either create a
dedicated `netkingdom-bootstrap-console` public client or update `demo-app`
temporarily with these exact redirect URIs:
- `http://127.0.0.1:8876/oidc/callback`
- `http://localhost:8876/oidc/callback`
Update the sample config, tests, and deployment/runbook references so the
registered client is reproducible and not just a live-cluster patch.
Gate: an authorize request using the local callback no longer returns
`invalid_profile_usage` for `redirect_uri`.
## T02 - Separate browser-facing and internal Authelia URLs if needed
```task
id: KEY-WP-0003-T02
status: done
priority: high
state_hub_task_id: "46172e6d-3e11-493c-b223-79c2fc321aec"
```
Confirm whether the current `authelia.baseURL` is safe to use for both browser
redirects and server-side token exchange. If not, add explicit configuration
for the browser authorize base URL and internal token/userinfo base URL.
Gate: the first browser redirect leaves `https://kc.coulomb.social` for
`https://auth.coulomb.social/...`; server-side token exchange still works from
inside the deployment.
## T03 - Add a browser MFA challenge step
```task
id: KEY-WP-0003-T03
status: done
priority: high
state_hub_task_id: "92fca4d0-6215-4ea6-9f80-9178ae183acb"
```
When `CheckMFARequired` returns true after the Authelia callback, render a
minimal KeyCape MFA challenge page instead of requiring `mfa_token` in the
callback query string. The page should:
- show the authenticated username and client display name,
- collect only the OTP code,
- preserve the pending OIDC state server-side,
- validate with privacyIDEA,
- continue to issue the normal authorization code on success,
- fail closed with the existing telemetry on invalid MFA.
Gate: a user enrolled in privacyIDEA can complete password + OTP in the
browser and is returned to the registered downstream callback.
## T04 - Add end-to-end profile tests for the bootstrap login path
```task
id: KEY-WP-0003-T04
status: done
priority: medium
state_hub_task_id: "079a5929-1864-4461-a64c-746cebca469d"
```
Add tests that cover:
- local bootstrap callback registration,
- rejection of unregistered callbacks remains intact,
- Authelia browser redirect uses the expected public URL,
- MFA-required login presents a challenge instead of immediate failure,
- invalid OTP fails closed,
- valid OTP produces an authorization code bound to the original PKCE session.
Gate: `make test` passes and the negative redirect URI tests remain green.
## T05 - Document the live rollout ceremony
```task
id: KEY-WP-0003-T05
status: done
priority: medium
state_hub_task_id: "1d67225d-a20b-4e36-9b2e-20836be2f439"
```
Document the deployment path for updating live KeyCape config without
regenerating unrelated secrets. The runbook must fit the NetKingdom custodian
age-key model: decrypt or unlock only during an attended ceremony, apply the
updated client registration/config, restart KeyCape, and remove plaintext
secret material afterward.
Gate: an operator can update the live `keycape-config` Secret and verify the
bootstrap console OIDC login without printing or committing secrets.