Compare commits

...

10 commits

Author SHA1 Message Date
2fd69f0374 Normalize agent instructions and workplan frontmatter (STATE-WP-0067)
Some checks are pending
Build and Publish Container Image / build-and-push (push) Waiting to run
- 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 2026-06-22 18:40:55 +02:00
d076e7ee7b chore(consistency): sync task status from DB [auto]
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) 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
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) 2026-06-16 01:53:59 +02:00
d6d41dd84f Fix OpenBao OIDC token exchange compatibility 2026-06-01 21:20:54 +02:00
06d20c3379 Load LLDAP organizational unit config 2026-05-25 00:28:33 +02:00
937cb39de6 Require MFA during bootstrap mode 2026-05-25 00:09:40 +02:00
26 changed files with 363 additions and 54 deletions

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

@ -1,11 +1,11 @@
## First Session Protocol ## First Session Protocol
Triggered when `get_domain_summary("netkingdom")` shows **no workstreams**. Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
The project is registered but work has not yet been structured. The project is registered but work has not yet been structured.
**Step 1 — Read, don't write** **Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/netkingdom/project_charter_v0.1.md` — purpose, scope - `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/netkingdom/roadmap_v0.1.md` — planned phases - `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs - Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work** **Step 2 — Survey in-progress work**
@ -17,20 +17,20 @@ roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)** **Step 4 — Create workplan file first, then DB record (ADR-001)**
``` ```
workplans/key-cape-WP-NNNN-<slug>.md ← write this first workplans/KEY-WP-NNNN-<slug>.md ← write this first
``` ```
Then register in the hub: Then register in the hub:
``` ```
create_workstream(topic_id="a6c6e745-bf54-4465-9340-1534a2be493e", title="...", owner="...", description="...") create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low") create_task(workstream_id="<id>", title="...", priority="high|medium|low")
``` ```
**Step 5 — Record the setup** **Step 5 — Record the setup**
``` ```
add_progress_event( add_progress_event(
summary="First session: structured netkingdom into N workstreams, M tasks", summary="First session: structured infotech into N workstreams, M tasks",
event_type="milestone", event_type="milestone",
topic_id="a6c6e745-bf54-4465-9340-1534a2be493e", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
detail={"workstreams": [...], "tasks_created": M} detail={"workstreams": [...], "tasks_created": M}
) )
``` ```

View file

@ -1,5 +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. **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:** netkingdom **Domain:** infotech
**Repo slug:** key-cape **Repo slug:** key-cape
**Topic ID:** a6c6e745-bf54-4465-9340-1534a2be493e **Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a

View file

@ -1,6 +1,7 @@
## Session Protocol ## Session Protocol
State Hub: http://127.0.0.1:8000 Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient** **Step 1 — Orient**
@ -10,7 +11,7 @@ cat .custodian-brief.md
``` ```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed: Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
``` ```
get_domain_summary("netkingdom") get_domain_summary("infotech")
``` ```
If MCP tools are unavailable in the current agent session, use the REST API: If MCP tools are unavailable in the current agent session, use the REST API:
```bash ```bash
@ -39,11 +40,11 @@ curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
ls workplans/ ls workplans/
``` ```
For each file with `status: ready`, `active`, or `blocked`, note pending For each file with `status: ready`, `active`, or `blocked`, note pending
`todo`/`in_progress` tasks. `wait`/`todo`/`progress` tasks.
**Step 4 — Present brief** **Step 4 — Present brief**
1. **Active workstreams** for `netkingdom` — title, task counts, blocking decisions 1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:key-cape]` hub tasks 2. **Pending tasks** from `workplans/` + any `[repo:key-cape]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary: 3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"* - `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
@ -61,13 +62,13 @@ If no workstreams: follow First Session Protocol (`first-session.md`).
**Session close:** **Session close:**
With MCP tools: With MCP tools:
``` ```
add_progress_event(summary="...", topic_id="a6c6e745-bf54-4465-9340-1534a2be493e", workstream_id="<uuid>") add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="<uuid>")
``` ```
Without MCP tools: Without MCP tools:
```bash ```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \ curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"topic_id":"a6c6e745-bf54-4465-9340-1534a2be493e","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}' -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: If workplan files were modified, ensure the local copy is up to date first:
```bash ```bash

View file

@ -1,7 +1,7 @@
## Workplan Convention (ADR-001) ## Workplan Convention (ADR-001)
File location: `workplans/key-cape-WP-NNNN-<slug>.md` File location: `workplans/KEY-WP-NNNN-<slug>.md`
ID prefix: `KEY-WP` ID prefix: `KEY-WP-`
Work items originate as files in this repo **before** being registered in the hub. Work items originate as files in this repo **before** being registered in the hub.
@ -12,7 +12,7 @@ repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses. `needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-key-cape-WP-NNNN-<slug>.md`. The frontmatter id remains prefix: `YYMMDD-KEY-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference. unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**: Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
@ -25,4 +25,16 @@ 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 visible at session start. Pick one up by creating the workplan file, then registering
the workstream. 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 --> <!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

View file

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

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.

View file

@ -4,9 +4,9 @@
**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. **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:** netkingdom **Domain:** infotech
**Repo slug:** key-cape **Repo slug:** key-cape
**Topic ID:** `a6c6e745-bf54-4465-9340-1534a2be493e` **Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
**Workplan prefix:** `KEY-WP-` **Workplan prefix:** `KEY-WP-`
--- ---
@ -28,7 +28,7 @@ there is no MCP server for Codex agents.
cat .custodian-brief.md cat .custodian-brief.md
# Active workstreams for this domain # Active workstreams for this domain
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=a6c6e745-bf54-4465-9340-1534a2be493e&status=active" \ curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
| python3 -m json.tool | python3 -m json.tool
# Check inbox # Check inbox
@ -63,8 +63,8 @@ Omit `workstream_id` / `task_id` when not applicable.
```bash ```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"status": "in_progress"}' -d '{"status": "progress"}'
# values: todo | in_progress | done | blocked # values: wait | todo | progress | done | cancel
``` ```
### Flag a task for human review ### Flag a task for human review
@ -83,7 +83,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe) 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 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 3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check blocked tasks: `GET /tasks/?needs_human=true` 4. Check human-needed tasks: `GET /tasks/?needs_human=true`
**During work:** **During work:**
- Update task statuses in workplan files as tasks progress - Update task statuses in workplan files as tasks progress
@ -101,6 +101,63 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
--- ---
## 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) ## Workplan Convention (ADR-001)
Work items originate as files in this repo — not in the hub. The hub is a Work items originate as files in this repo — not in the hub. The hub is a
@ -124,7 +181,7 @@ anything needing analysis, design, approval, dependencies, or multiple phases.
id: KEY-WP-NNNN id: KEY-WP-NNNN
type: workplan type: workplan
title: "..." title: "..."
domain: netkingdom domain: infotech
repo: key-cape repo: key-cape
status: proposed | ready | active | blocked | backlog | finished | archived status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex owner: codex
@ -146,7 +203,7 @@ derived health labels, not frontmatter statuses.
` ` `task ` ` `task
id: KEY-WP-NNNN-T01 id: KEY-WP-NNNN-T01
status: todo | in_progress | done | blocked status: wait | todo | progress | done | cancel
priority: high | medium | low priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` ` ` ` `
@ -154,7 +211,7 @@ state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
Task description text. Task description text.
``` ```
Status progression: `todo``in_progress` → `done` (or `blocked`) Status progression: `todo``progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan: To create a new workplan:
1. Write the file following the format above 1. Write the file following the format above

View file

@ -8,4 +8,5 @@
@.claude/rules/stack-and-commands.md @.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md @.claude/rules/architecture.md
@.claude/rules/repo-boundary.md @.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md @.claude/rules/agents.md

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

@ -125,11 +125,16 @@ func (a *LDAPAdapter) LookupUser(ctx context.Context, username string) (*domain.
entry := result.Entries[0] entry := result.Entries[0]
user := mapEntryToUser(entry) 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}} snap := validator.Snapshot{Users: []domain.User{user}}
report := validator.Validate(snap, validator.ModeProvisioning) report := validator.Validate(snap, validator.ModeProvisioning)
if !report.Passed { 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 return &user, nil

View file

@ -154,16 +154,20 @@ func TestLookupUser_NotFound(t *testing.T) {
} }
} }
func TestLookupUser_ValidationFailure(t *testing.T) { func TestLookupUser_ValidationWarningDoesNotBlockRuntimeLogin(t *testing.T) {
// Return an entry with an empty DisplayName and empty sn — will fail validator. // Return an entry with an empty DisplayName and empty sn. Runtime login
dn := "uid=broken,ou=users,dc=netkingdom,dc=local" // should still resolve the user; provisioning validators report the warning.
dn := "uid=platform-root,ou=people,dc=netkingdom,dc=local"
conn := &mockConn{ conn := &mockConn{
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { 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{ attrs := []*ldap.EntryAttribute{
{Name: "uid", Values: []string{"broken"}}, {Name: "uid", Values: []string{"platform-root"}},
{Name: "cn", Values: []string{""}}, {Name: "cn", Values: []string{""}},
{Name: "sn", Values: []string{""}}, {Name: "sn", Values: []string{""}},
{Name: "mail", Values: []string{"broken@example.com"}}, {Name: "mail", Values: []string{"bernd.worsch@gmail.com"}},
} }
return &ldap.SearchResult{ return &ldap.SearchResult{
Entries: []*ldap.Entry{{DN: dn, Attributes: attrs}}, Entries: []*ldap.Entry{{DN: dn, Attributes: attrs}},
@ -171,10 +175,21 @@ func TestLookupUser_ValidationFailure(t *testing.T) {
}, },
} }
adapter := makeAdapter(testConfig(), conn) cfg := testConfig()
_, err := adapter.LookupUser(context.Background(), "broken") cfg.UserOU = "ou=people"
if err == nil { adapter := makeAdapter(cfg, conn)
t.Fatal("expected validation error, got nil") 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. // Config holds all connection parameters for the LLDAP adapter.
type Config struct { type Config struct {
// URL is the LDAP server address, e.g. "ldap://lldap:389" or "ldaps://lldap:636". // 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, // BindDN is the distinguished name used for the service account bind,
// e.g. "cn=admin,dc=netkingdom,dc=local". // e.g. "cn=admin,dc=netkingdom,dc=local".
BindDN string BindDN string `yaml:"bindDN"`
// BindPW is the service account password. // BindPW is the service account password.
BindPW string BindPW string `yaml:"bindPW"`
// BaseDN is the search base, e.g. "dc=netkingdom,dc=local". // 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 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 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 disables TLS certificate verification. For development only.
TLSSkipVerify bool TLSSkipVerify bool `yaml:"tlsSkipVerify,omitempty"`
} }
// userOU returns the effective UserOU, falling back to the default. // 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 // registered in privacyIDEA. Fails closed: any infrastructure error returns
// (false, err) so callers cannot bypass the check. // (false, err) so callers cannot bypass the check.
func (a *PrivacyIDEAAdapter) CheckMFARequired(ctx context.Context, userID string) (bool, error) { 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/" endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/"
q := url.Values{} 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) { func TestCheckMFARequired_InactiveTokenOnly_ReturnsFalse(t *testing.T) {
client := &mockHTTPClient{ client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) { 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. // Config holds all connection parameters for the privacyIDEA adapter.
type Config struct { type Config struct {
// BaseURL is the privacyIDEA server base URL, e.g. "https://privacyidea.local". // 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 // AdminToken is the service-account JWT used to authenticate requests to the
// privacyIDEA admin API. // privacyIDEA admin API.
AdminToken string AdminToken string `yaml:"adminToken"`
// Realm is the privacyIDEA realm to scope token and validate requests. // Realm is the privacyIDEA realm to scope token and validate requests.
// Defaults to "netkingdom" when empty. // 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". // realm returns the effective realm, falling back to "netkingdom".

View file

@ -127,6 +127,76 @@ clients:
} }
} }
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) { func TestLoad_FileNotFound(t *testing.T) {
_, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml")) _, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml"))
if err == nil { if err == nil {

View file

@ -23,6 +23,7 @@ type PendingState struct {
PKCEChallenge string PKCEChallenge string
PKCEChallengeMethod string PKCEChallengeMethod string
State string State string
Nonce string
Scopes []string Scopes []string
ExpiresAt time.Time ExpiresAt time.Time
AuthenticatedUser string AuthenticatedUser string
@ -103,6 +104,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
responseType := q.Get("response_type") responseType := q.Get("response_type")
scope := q.Get("scope") scope := q.Get("scope")
state := q.Get("state") state := q.Get("state")
nonce := q.Get("nonce")
codeChallenge := q.Get("code_challenge") codeChallenge := q.Get("code_challenge")
codeChallengeMethod := q.Get("code_challenge_method") codeChallengeMethod := q.Get("code_challenge_method")
@ -191,6 +193,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
PKCEChallenge: codeChallenge, PKCEChallenge: codeChallenge,
PKCEChallengeMethod: codeChallengeMethod, PKCEChallengeMethod: codeChallengeMethod,
State: state, State: state,
Nonce: nonce,
Scopes: strings.Fields(scope), Scopes: strings.Fields(scope),
ExpiresAt: time.Now().Add(10 * time.Minute), ExpiresAt: time.Now().Add(10 * time.Minute),
}) })
@ -279,6 +282,14 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
// Check MFA requirement. // Check MFA requirement.
mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username) mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username)
if err != nil { 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) http.Error(w, "mfa check error", http.StatusInternalServerError)
return return
} }
@ -350,6 +361,7 @@ func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.
PKCEChallenge: ps.PKCEChallenge, PKCEChallenge: ps.PKCEChallenge,
PKCEChallengeMethod: ps.PKCEChallengeMethod, PKCEChallengeMethod: ps.PKCEChallengeMethod,
State: ps.State, State: ps.State,
Nonce: ps.Nonce,
Username: username, Username: username,
Scopes: ps.Scopes, Scopes: ps.Scopes,
ExpiresAt: time.Now().Add(10 * time.Minute), ExpiresAt: time.Now().Add(10 * time.Minute),

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
id: KEY-WP-0001 id: KEY-WP-0001
type: workplan type: workplan
title: "KeyCape Implementation — Lightweight IAM Profile" title: "KeyCape Implementation — Lightweight IAM Profile"
domain: netkingdom domain: infotech
repo: key-cape repo: key-cape
status: done status: done
owner: Bernd owner: Bernd

View file

@ -2,7 +2,7 @@
id: KEY-WP-0002 id: KEY-WP-0002
type: workplan type: workplan
title: "KeyCape Container Image — Build & Publish to Gitea OCI Registry" title: "KeyCape Container Image — Build & Publish to Gitea OCI Registry"
domain: netkingdom domain: infotech
repo: key-cape repo: key-cape
status: done status: done
owner: netkingdom owner: netkingdom

View file

@ -2,13 +2,14 @@
id: KEY-WP-0003 id: KEY-WP-0003
type: workplan type: workplan
title: "Bootstrap Console OIDC Login and MFA Verification" title: "Bootstrap Console OIDC Login and MFA Verification"
domain: netkingdom domain: infotech
repo: key-cape repo: key-cape
status: finished status: finished
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
created: "2026-05-24" created: "2026-05-24"
updated: "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 # KEY-WP-0003 - Bootstrap Console OIDC Login and MFA Verification
@ -104,6 +105,7 @@ ceremony, and restart the KeyCape deployment.
id: KEY-WP-0003-T01 id: KEY-WP-0003-T01
status: done status: done
priority: high priority: high
state_hub_task_id: "b396c99f-d711-475a-9cba-4f03a1db561d"
``` ```
Add a KeyCape client registration for the bootstrap console. Either create a Add a KeyCape client registration for the bootstrap console. Either create a
@ -125,6 +127,7 @@ Gate: an authorize request using the local callback no longer returns
id: KEY-WP-0003-T02 id: KEY-WP-0003-T02
status: done status: done
priority: high priority: high
state_hub_task_id: "46172e6d-3e11-493c-b223-79c2fc321aec"
``` ```
Confirm whether the current `authelia.baseURL` is safe to use for both browser Confirm whether the current `authelia.baseURL` is safe to use for both browser
@ -141,6 +144,7 @@ inside the deployment.
id: KEY-WP-0003-T03 id: KEY-WP-0003-T03
status: done status: done
priority: high priority: high
state_hub_task_id: "92fca4d0-6215-4ea6-9f80-9178ae183acb"
``` ```
When `CheckMFARequired` returns true after the Authelia callback, render a When `CheckMFARequired` returns true after the Authelia callback, render a
@ -163,6 +167,7 @@ browser and is returned to the registered downstream callback.
id: KEY-WP-0003-T04 id: KEY-WP-0003-T04
status: done status: done
priority: medium priority: medium
state_hub_task_id: "079a5929-1864-4461-a64c-746cebca469d"
``` ```
Add tests that cover: Add tests that cover:
@ -182,6 +187,7 @@ Gate: `make test` passes and the negative redirect URI tests remain green.
id: KEY-WP-0003-T05 id: KEY-WP-0003-T05
status: done status: done
priority: medium priority: medium
state_hub_task_id: "1d67225d-a20b-4e36-9b2e-20836be2f439"
``` ```
Document the deployment path for updating live KeyCape config without Document the deployment path for updating live KeyCape config without