|
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
informed-decision and approval-engine both asked to hear problems with the human
approver registration now rather than at handover. Checking their requested shape
against the source rather than agreeing it on paper turned up three things.
Scope gap, accepted: [openid, approval:approve] cannot render a decision, since
GET /v1/approvals/{id} and /claim both need approval:read -- the surface could
submit an entry it was never able to display. Published
[openid, approval:read, approval:approve]. Reading through a service identity was
the alternative and is worse: it weakens the evidence-of-what-this-person-saw
claim the component exists to make. approval:consume stays excluded.
Assurance shape, published and a defect fixed. Both asked for a documented shape
and KeyCape already emitted one, so it is written down rather than renegotiated.
Writing it down surfaced that `at` was the token mint time rather than the
authentication time. Those differ by hours whenever a browser session is reused,
and approval-engine persists this object verbatim as the only downstream record
that MFA happened -- so a stored approval could have evidenced MFA at a moment
the person proved nothing. PKCESession.AuthTime now carries the original login
instant through session reuse, with mint time as the fallback.
Blocker found before anyone built on it: a human token cannot carry
tenant:platform. effectiveTenant resolves the human tenant from the directory
user, no adapter populates User.Tenant, and the per-client tenant field is read
only on the client_credentials path -- so every human token defaults to
tenant:coulomb, which approval-engine refuses by exact string equality. It would
have presented as a failed approval rather than a registration defect. Two
resolutions sent to the owners and neither implemented here: the choice decides
whether a human's tenant is a property of the person or of the registration, and
that is not KeyCape's alone to make.
Also recorded ops-warden's answers to KEY-WP-0014-T04, including their finding
that `warden plan` returns `autonomous` for a need containing generate and
CAS-write, because it has no read-versus-mutate intent. Their standing
instruction -- treat a warden plan verdict on any write, rotate or provision need
as unreliable until WARDEN-WP-0038 lands -- is recorded in the workplan rather
than left in an inbox.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV8zoCKpA1WRAxsKRYbdH
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
|
||
|---|---|---|
| .claude/rules | ||
| .forgejo/workflows | ||
| .gitea/workflows | ||
| .github/workflows | ||
| bin | ||
| capabilities/playbooks | ||
| config | ||
| docs | ||
| history | ||
| intakes | ||
| registry | ||
| scripts | ||
| spec | ||
| src | ||
| tools | ||
| wiki | ||
| workplans | ||
| .custodian-brief.md | ||
| .gitignore | ||
| .repo-classification.yaml | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| docker-compose.dev.yml | ||
| docker-compose.scenario-b.yml | ||
| docker-compose.scenario-c.yml | ||
| Dockerfile | ||
| INTENT.md | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
| SCOPE.md | ||
| WORK-RECORDS.md | ||
KeyCape
Prepare for Keycloak without Keycloak
KeyCape is the lightweight IAM component of NetKingdom. It
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 than a rewrite.
Status
Implementation complete (v0.1). All 23 workplan tasks implemented and tested.
21 test packages, all green. See workplans/KEY-WP-0001-keycape-implementation.md.
Architecture
Application
│ (NetKingdom IAM Profile v0.2)
▼
KeyCape ←── profile enforcement, claim normalization, telemetry
/ | \
Auth LLDAP privacyIDEA
elia
Expanded mode: Replace KeyCape with Keycloak. Same profile contract, same
conformance suite in ../net-kingdom/tools/iam-profile-conformance/.
Quick Start
# Start the dev stack (KeyCape + LLDAP + Authelia + privacyIDEA)
make dev
# Build the server binary
make build
# Run all tests
make test
Configuration
KeyCape uses a YAML config file. See config/dev-config.yaml for a full example.
issuer: "https://auth.netkingdom.local"
port: 8080
tokenLifetime: "15m"
privateKeyPem: "/etc/keycape/key.pem"
environment: "production"
lldap:
url: "ldap://lldap:389"
bindDN: "cn=admin,dc=netkingdom,dc=local"
bindPW: "secret"
baseDN: "dc=netkingdom,dc=local"
authelia:
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"
privacyidea:
baseURL: "https://privacyidea.local"
adminToken: "secret"
realm: "netkingdom"
clients:
- clientId: "my-app"
displayName: "My Application"
redirectUris: ["https://myapp.local/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"
Config is validated at startup — the server exits 1 with validation errors if config is invalid.
Confidential client_credentials registrations may set a per-client
tokenLifetime from 1 minute through 1 hour. This overrides the server default
for that client and lets machine-login consumers carry an explicit maximum TTL.
See config/service-clients.example.yaml and
docs/openbao-service-auth-contract.md for the non-secret OpenBao service
identity contracts. Client secret values remain outside Git and are resolved
only from the registered env: reference at startup.
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 |
|---|---|
GET /.well-known/openid-configuration |
OIDC discovery document |
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"} |
Profile Constraints
KeyCape enforces the NetKingdom IAM Profile. Violations return structured errors:
| Error type | Meaning |
|---|---|
feature_not_supported_by_profile |
Feature is outside the profile entirely |
available_in_keycloak_mode_only |
Available in expanded mode, not lightweight |
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. 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
KeyCape ships migration tools for the two orthogonal migration dimensions:
IAM migration (KeyCape → Keycloak):
# Export canonical data from LLDAP
./lldap-export --url ldap://lldap:389 --bind-dn cn=admin,... --output canonical-export.yaml
# Transform to Keycloak realm import
./keycape-to-keycloak --input canonical-export.yaml --realm netkingdom --output keycloak-realm-import.json
Directory migration (LLDAP → OpenLDAP / 389DS / AD):
./lldap-to-ldap --input canonical-export.yaml --target openldap --base-dn dc=netkingdom,dc=local --output migration.ldif
Both migrations are independent. Perform either or both without affecting privacyIDEA MFA enrollment.
LDAP Schema Validator
# Validate in CI mode (strict)
./validator --mode ci --input directory-snapshot.yaml
# Validate before provisioning
./validator --mode provisioning --input users.yaml
Validates: DN structure, required attributes, no unknown attributes, user references, no cyclic groups, username uniqueness, email format.
Repo Structure
src/
cmd/ # Binary entrypoints
keycape/ # Main server
validator/ # LDAP schema validator
lldap-export/ # Migration: LLDAP → canonical
keycape-to-keycloak/ # Migration: canonical → Keycloak
lldap-to-ldap/ # Migration: canonical → LDIF
internal/
config/ # Config loading and validation
domain/ # Canonical identity model (Go types)
errors/ # Profile error taxonomy
adapters/ # Backend adapters (Authelia, LLDAP, privacyIDEA)
server/ # OIDC handlers + telemetry + enforcement
migration/ # Migration logic
validator/ # LDAP schema validation
tests/
profile/ # Scenario A: lightweight baseline
negative/ # Scenario D: unsupported feature rejection
migration/ # Scenarios B & C: replacement tests
spec/
canonical-model.yaml # Source of truth for all identity data
ldap-schema.yaml # Canonical LDAP schema rules
docs/adr/ # Architecture Decision Records
workplans/ # Implementation workplans
wiki/ # Specifications
Key Documents
wiki/KeyCapeSpecification_v0.1.md— Architecture, design intent, objectiveswiki/KeyCapeSpecificationPack_v0.1.md— Normative implementation specdocs/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
docker pull 92.205.130.254:32166/coulomb/key-cape:latest
The registry runs over plain HTTP. Configure Docker to allow it:
// /etc/docker/daemon.json
{ "insecure-registries": ["92.205.130.254:32166"] }
Build and push locally
# 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
domain netkingdom, repo slug key-cape.
See CLAUDE.md for agent session protocol and workplan conventions.
Native authentication commands
keycape service-token exchanges a custody-injected client secret for a verified
JWT; keycape login performs public-client browser PKCE login. Both save tokens
to a new private file outside Git and never print token values. See
Native caller authentication for usage, supported
bindings, renewal, and the distinction from OpenBao login.