generated from coulomb/repo-seed
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow - T02: spec/canonical-model.yaml with 6 entities + Go domain types - T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules - T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers 28 tests pass, go vet clean, go build clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
33
.claude/ralph-loop.local.md
Normal file
33
.claude/ralph-loop.local.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
active: true
|
||||||
|
iteration: 1
|
||||||
|
session_id:
|
||||||
|
max_iterations: 30
|
||||||
|
completion_promise: "HEUREKA"
|
||||||
|
started_at: "2026-03-13T00:07:01Z"
|
||||||
|
---
|
||||||
|
|
||||||
|
Do a high quality implementation of workplan KEY-WP-0001-keycape-implementation.md as follows:
|
||||||
|
|
||||||
|
Requirements and Boundaries
|
||||||
|
- Keep the scope of the workplan
|
||||||
|
- Use documents in spec/ and wiki/ folder to understand the big picture
|
||||||
|
- Document important architecture decisions as ADRs in the architecture/ folder
|
||||||
|
- Save relevant parts of documentation you need to grab online to the research/ folder
|
||||||
|
- Keep the state-hub up to date with task completions
|
||||||
|
- If stuck and when running out of iterations document what went wrong in problems/ folder
|
||||||
|
|
||||||
|
WHILE TDD-Loop for all tasks:
|
||||||
|
1) RED: write test → run → fail
|
||||||
|
2) GREEN: implement minimal code → run → pass
|
||||||
|
3) REFACTOR: refactor → run → pass
|
||||||
|
REPEAT
|
||||||
|
|
||||||
|
Success Criteria:
|
||||||
|
- No linter errors
|
||||||
|
- All tasks and requirements of workplan implemented
|
||||||
|
- Documentation updated showing how the application works
|
||||||
|
- Check Workstream DoD from the state-hub policies
|
||||||
|
|
||||||
|
Refine until all succes-criteria have been met, otherwise
|
||||||
|
output <promise>HEUREKA</promise> when done!
|
||||||
43
.github/workflows/ci.yml
vendored
Normal file
43
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
name: Build and Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.22"
|
||||||
|
|
||||||
|
- name: Verify dependencies
|
||||||
|
working-directory: src
|
||||||
|
run: go mod verify
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
working-directory: src
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
working-directory: src
|
||||||
|
run: go test -v -race ./...
|
||||||
|
|
||||||
|
- name: Build all binaries
|
||||||
|
working-directory: src
|
||||||
|
run: |
|
||||||
|
mkdir -p ../bin
|
||||||
|
go build -o ../bin/keycape ./cmd/keycape/
|
||||||
|
go build -o ../bin/validator ./cmd/validator/
|
||||||
|
go build -o ../bin/lldap-export ./cmd/lldap-export/
|
||||||
|
go build -o ../bin/keycape-to-keycloak ./cmd/keycape-to-keycloak/
|
||||||
|
go build -o ../bin/lldap-to-ldap ./cmd/lldap-to-ldap/
|
||||||
25
CLAUDE.md
25
CLAUDE.md
@@ -1,3 +1,7 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
# KeyCape — Claude Code Instructions
|
# KeyCape — Claude Code Instructions
|
||||||
|
|
||||||
## What This Repo Is
|
## What This Repo Is
|
||||||
@@ -94,6 +98,27 @@ Application ──→ NetKingdom IAM Profile
|
|||||||
5. **Telemetry** — demand visibility for unsupported features and auth events
|
5. **Telemetry** — demand visibility for unsupported features and auth events
|
||||||
6. **Migration tooling** — export/validate for LLDAP → Keycloak path
|
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)
|
## Workplan Convention (ADR-001)
|
||||||
|
|
||||||
Workplans live in `workplans/<id>-<slug>.md` with YAML frontmatter:
|
Workplans live in `workplans/<id>-<slug>.md` with YAML frontmatter:
|
||||||
|
|||||||
119
docs/adr/ADR-0001-choose-go-for-keycape.md
Normal file
119
docs/adr/ADR-0001-choose-go-for-keycape.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
id: ADR-0001
|
||||||
|
title: "Implementation language for KeyCape: Go"
|
||||||
|
status: accepted
|
||||||
|
date: 2026-03-13
|
||||||
|
decided_by: Bernd
|
||||||
|
hub_decision_id: 620beb04-fa3f-4a9d-9806-02890a7a2b0d
|
||||||
|
workstream: KEY-WP-0001 (keycape-implementation)
|
||||||
|
alternatives_considered: [Rust]
|
||||||
|
---
|
||||||
|
|
||||||
|
# ADR-0001 — Implementation Language: Go
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
KeyCape must be implemented in a language that satisfies the requirements of spec §11:
|
||||||
|
|
||||||
|
> Keycape SHOULD be implemented in Go or Rust.
|
||||||
|
> Key requirements: stateless, small memory footprint, simple deployment, clear logging,
|
||||||
|
> structured telemetry.
|
||||||
|
|
||||||
|
Both Go and Rust are valid per spec. A decision was needed before T01 (project setup).
|
||||||
|
|
||||||
|
### What KeyCape actually does
|
||||||
|
|
||||||
|
KeyCape is an **orchestrating boundary service**, not a protocol or security engine:
|
||||||
|
|
||||||
|
- delegates authentication UI and session to **Authelia**
|
||||||
|
- delegates identity storage to **LLDAP**
|
||||||
|
- delegates MFA enforcement to **privacyIDEA**
|
||||||
|
- its own code is: HTTP endpoints, config loading, JWT signing (via library), JSON/JWKS handling,
|
||||||
|
structured telemetry, adapter glue, migration CLI tooling, LDAP schema validator
|
||||||
|
|
||||||
|
The main risks are **understandability and clean integration boundaries**, not memory safety in
|
||||||
|
hot loops or complex parser internals.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Go.**
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
### Why Go fits KeyCape
|
||||||
|
|
||||||
|
Go is especially strong for the actual implementation surface of KeyCape:
|
||||||
|
|
||||||
|
| What KeyCape does | Go fit |
|
||||||
|
|---|---|
|
||||||
|
| HTTP API server | excellent |
|
||||||
|
| Config loading and static validation | excellent |
|
||||||
|
| Adapter code to Authelia, LLDAP, privacyIDEA | excellent |
|
||||||
|
| JWT/JWKS/JSON handling | excellent |
|
||||||
|
| Structured logging and Prometheus metrics | excellent |
|
||||||
|
| CLI tooling (migration, validator, export) | excellent |
|
||||||
|
| Integration tests in containers | excellent |
|
||||||
|
|
||||||
|
Go also provides:
|
||||||
|
|
||||||
|
- faster iteration to a working, testable v1
|
||||||
|
- simpler dependency and build model
|
||||||
|
- easy static binaries and minimal container images
|
||||||
|
- low enough runtime overhead for the stated lightweight target
|
||||||
|
- straightforward output for coding agents across a growing infra codebase
|
||||||
|
|
||||||
|
### Why not Rust for this scope
|
||||||
|
|
||||||
|
Rust's advantages are real — stronger compile-time safety, better memory control, excellent for
|
||||||
|
security-critical infrastructure — but they pay back most clearly when:
|
||||||
|
|
||||||
|
- substantial protocol machinery is implemented internally
|
||||||
|
- complex async concurrency or parser-heavy code is required
|
||||||
|
- the service is intended as a long-lived, standalone security product for others
|
||||||
|
|
||||||
|
KeyCape's design **intentionally avoids** all of that. It stays narrow and delegates to existing
|
||||||
|
components. For a façade/orchestrator, developer friction matters more than theoretical maximal
|
||||||
|
correctness.
|
||||||
|
|
||||||
|
### Decision rule
|
||||||
|
|
||||||
|
> **Pick Go if KeyCape is primarily an orchestrating boundary service.**
|
||||||
|
> **Pick Rust if KeyCape starts becoming a real protocol/security engine.**
|
||||||
|
|
||||||
|
Based on the v0.1 spec, KeyCape is clearly the first.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- fastest path to a working, testable, operationally simple implementation
|
||||||
|
- simple build, deploy, and CI story
|
||||||
|
- lower friction for coding agents producing coherent infra code
|
||||||
|
|
||||||
|
### Negative / risks
|
||||||
|
|
||||||
|
- weaker compiler-enforced invariants than Rust
|
||||||
|
- easier to write sloppy code if discipline lapses
|
||||||
|
- error handling and domain modeling can drift if not designed carefully
|
||||||
|
|
||||||
|
### Compensating guardrails (mandatory)
|
||||||
|
|
||||||
|
To recover the rigor that Rust would provide via the type system:
|
||||||
|
|
||||||
|
1. **Typed domain models** for the profile contract — no raw maps or untyped JSON in business logic
|
||||||
|
2. **Narrow adapter interfaces** — `server/` layer never sees LDAP, Authelia, or privacyIDEA types directly
|
||||||
|
3. **Layered architecture** — protocol layer | domain layer | adapter layer | migration layer (hard boundaries)
|
||||||
|
4. **Strict schema/config validation** at startup and in CI
|
||||||
|
5. **Fuzz and property tests** around the LDAP schema validator, redirect URI checker, and claim mapping
|
||||||
|
6. **No cleverness** — small, deterministic functions
|
||||||
|
|
||||||
|
## Revisit trigger
|
||||||
|
|
||||||
|
Reconsider this decision if a subcomponent (e.g. LDAP schema validator, token normalization
|
||||||
|
engine, or a future high-throughput policy evaluator) demonstrably needs stronger guarantees.
|
||||||
|
That subcomponent could be redesigned in Rust without regretting the overall Go choice — Go and
|
||||||
|
Rust interop via CGo or separate binaries is feasible.
|
||||||
174
spec/canonical-model.yaml
Normal file
174
spec/canonical-model.yaml
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
version: "0.1"
|
||||||
|
description: >
|
||||||
|
Canonical Identity Model for KeyCape / NetKingdom IAM Profile.
|
||||||
|
This file is the source of truth for all identity entities.
|
||||||
|
All provisioning, tests, and migrations derive from these definitions.
|
||||||
|
|
||||||
|
entities:
|
||||||
|
User:
|
||||||
|
description: "A person or service account in the identity directory."
|
||||||
|
fields:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: "Stable internal identifier. Immutable after creation."
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: "Unique login name. Maps to LDAP uid."
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: "Human-readable full name. Maps to LDAP cn."
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
format: email
|
||||||
|
description: "Primary email address. Maps to LDAP mail."
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
description: "Whether the account is active."
|
||||||
|
groups:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
ref: Group.id
|
||||||
|
description: "Group memberships by group ID."
|
||||||
|
roles:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
ref: Role.id
|
||||||
|
description: "Role assignments by role ID."
|
||||||
|
mfaEnrollment:
|
||||||
|
type: object
|
||||||
|
ref: MFAEnrollment
|
||||||
|
nullable: true
|
||||||
|
description: "MFA enrollment record if the user has enrolled."
|
||||||
|
ldapAttributes:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
description: "Raw LDAP attributes not covered by the canonical model."
|
||||||
|
|
||||||
|
Group:
|
||||||
|
description: "A named collection of users."
|
||||||
|
fields:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: "Stable internal identifier."
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: "Unique group name. Maps to LDAP cn."
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
description: "Human-readable description."
|
||||||
|
members:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
ref: User.id
|
||||||
|
description: "User IDs belonging to this group."
|
||||||
|
|
||||||
|
Role:
|
||||||
|
description: "A named permission set assigned to users."
|
||||||
|
fields:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: "Stable internal identifier."
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: "Unique role name."
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
description: "Human-readable description."
|
||||||
|
|
||||||
|
Client:
|
||||||
|
description: "A registered OIDC client. Registration is static in v0.1."
|
||||||
|
fields:
|
||||||
|
clientId:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: "OAuth2 client_id."
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: "Human-readable client name."
|
||||||
|
redirectUris:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
required: true
|
||||||
|
minItems: 1
|
||||||
|
description: "Allowed redirect URIs. Wildcards are NEVER permitted."
|
||||||
|
allowedScopes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: "Scopes this client may request."
|
||||||
|
grantTypes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum: [authorization_code]
|
||||||
|
required: true
|
||||||
|
description: "Allowed OAuth2 grant types. Only authorization_code in v0.1."
|
||||||
|
clientType:
|
||||||
|
type: string
|
||||||
|
enum: [confidential, public]
|
||||||
|
required: true
|
||||||
|
description: "confidential = server-side app; public = SPA or native."
|
||||||
|
secretRef:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: "Reference to the client secret (confidential clients only)."
|
||||||
|
tokenProfile:
|
||||||
|
type: string
|
||||||
|
description: "Optional: token configuration profile name."
|
||||||
|
environments:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: "Environments this client is registered for (e.g. prod, staging)."
|
||||||
|
|
||||||
|
Membership:
|
||||||
|
description: "Explicit link between a user and a group."
|
||||||
|
fields:
|
||||||
|
userId:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
ref: User.id
|
||||||
|
groupId:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
ref: Group.id
|
||||||
|
|
||||||
|
MFAEnrollment:
|
||||||
|
description: "Records MFA enrollment state for a user via privacyIDEA."
|
||||||
|
fields:
|
||||||
|
userId:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
ref: User.id
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
enum: [privacyidea]
|
||||||
|
description: "MFA provider. Only privacyidea is supported in v0.1."
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
enum: [enabled, disabled, pending]
|
||||||
|
description: "Current enrollment state."
|
||||||
|
enrolledAt:
|
||||||
|
type: string
|
||||||
|
format: datetime
|
||||||
|
description: "ISO 8601 timestamp of enrollment."
|
||||||
91
spec/ldap-schema.yaml
Normal file
91
spec/ldap-schema.yaml
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
version: "0.1"
|
||||||
|
description: >
|
||||||
|
Canonical LDAP Schema for KeyCape / NetKingdom IAM Profile.
|
||||||
|
Expresses the canonical identity model in LDAP terms.
|
||||||
|
Portable across LLDAP, OpenLDAP, 389DS, and Active Directory.
|
||||||
|
|
||||||
|
base_dn: "dc=netkingdom,dc=local"
|
||||||
|
|
||||||
|
organization_units:
|
||||||
|
users:
|
||||||
|
dn: "ou=users,dc=netkingdom,dc=local"
|
||||||
|
description: "User accounts"
|
||||||
|
object_classes:
|
||||||
|
required:
|
||||||
|
- inetOrgPerson
|
||||||
|
- organizationalPerson
|
||||||
|
- person
|
||||||
|
- top
|
||||||
|
attributes:
|
||||||
|
required:
|
||||||
|
- uid # canonical: username
|
||||||
|
- cn # canonical: displayName
|
||||||
|
- sn # canonical: surname (may be set to displayName if absent)
|
||||||
|
optional:
|
||||||
|
- mail # canonical: email
|
||||||
|
- memberOf # back-reference to group membership
|
||||||
|
forbidden: []
|
||||||
|
naming_attr: uid
|
||||||
|
examples:
|
||||||
|
- dn: "uid=alice,ou=users,dc=netkingdom,dc=local"
|
||||||
|
uid: alice
|
||||||
|
cn: "Alice Example"
|
||||||
|
sn: Example
|
||||||
|
mail: alice@example.com
|
||||||
|
|
||||||
|
groups:
|
||||||
|
dn: "ou=groups,dc=netkingdom,dc=local"
|
||||||
|
description: "User groups"
|
||||||
|
object_classes:
|
||||||
|
required:
|
||||||
|
- groupOfNames
|
||||||
|
- top
|
||||||
|
attributes:
|
||||||
|
required:
|
||||||
|
- cn # canonical: name
|
||||||
|
- member # list of member DNs
|
||||||
|
optional:
|
||||||
|
- description
|
||||||
|
forbidden: []
|
||||||
|
naming_attr: cn
|
||||||
|
examples:
|
||||||
|
- dn: "cn=admins,ou=groups,dc=netkingdom,dc=local"
|
||||||
|
cn: admins
|
||||||
|
member:
|
||||||
|
- "uid=alice,ou=users,dc=netkingdom,dc=local"
|
||||||
|
|
||||||
|
clients:
|
||||||
|
dn: "ou=clients,dc=netkingdom,dc=local"
|
||||||
|
description: "OIDC client registrations"
|
||||||
|
object_classes:
|
||||||
|
required:
|
||||||
|
- inetOrgPerson
|
||||||
|
- top
|
||||||
|
attributes:
|
||||||
|
required:
|
||||||
|
- uid # canonical: clientId
|
||||||
|
- cn # canonical: displayName
|
||||||
|
optional:
|
||||||
|
- description
|
||||||
|
forbidden: []
|
||||||
|
naming_attr: uid
|
||||||
|
|
||||||
|
validation_rules:
|
||||||
|
structural:
|
||||||
|
- name: valid_dn_structure
|
||||||
|
description: "All DNs must conform to the base_dn and OU layout above."
|
||||||
|
- name: required_attributes_present
|
||||||
|
description: "Every entry must carry all required attributes for its OU."
|
||||||
|
- name: no_unknown_attributes
|
||||||
|
description: "No attributes outside the allowed set may appear."
|
||||||
|
- name: valid_group_memberships
|
||||||
|
description: "All member values must be non-empty valid DNs."
|
||||||
|
semantic:
|
||||||
|
- name: referenced_users_exist
|
||||||
|
description: "Every user ID referenced in group members must exist."
|
||||||
|
- name: no_cyclic_groups
|
||||||
|
description: "Groups may not contain other group IDs as members."
|
||||||
|
- name: usernames_unique
|
||||||
|
description: "The uid attribute must be unique across ou=users."
|
||||||
|
- name: email_format_valid
|
||||||
|
description: "mail, when present, must be a valid RFC 5322 address."
|
||||||
29
src/Makefile
Normal file
29
src/Makefile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
GOBIN ?= $(shell go env GOPATH)/bin
|
||||||
|
BINDIR = ../bin
|
||||||
|
|
||||||
|
.PHONY: all build test lint vet clean
|
||||||
|
|
||||||
|
all: vet lint test build
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o $(BINDIR)/keycape ./cmd/keycape/
|
||||||
|
go build -o $(BINDIR)/validator ./cmd/validator/
|
||||||
|
go build -o $(BINDIR)/lldap-export ./cmd/lldap-export/
|
||||||
|
go build -o $(BINDIR)/keycape-to-keycloak ./cmd/keycape-to-keycloak/
|
||||||
|
go build -o $(BINDIR)/lldap-to-ldap ./cmd/lldap-to-ldap/
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@if command -v golangci-lint >/dev/null 2>&1; then \
|
||||||
|
golangci-lint run ./...; \
|
||||||
|
else \
|
||||||
|
echo "golangci-lint not installed, skipping (run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf $(BINDIR)
|
||||||
13
src/cmd/keycape-to-keycloak/main.go
Normal file
13
src/cmd/keycape-to-keycloak/main.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// keycape-to-keycloak migrates a KeyCape canonical snapshot to a Keycloak
|
||||||
|
// realm export format. Part of the NetKingdom IAM migration contract.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Fprintln(os.Stderr, "keycape-to-keycloak: not yet implemented (T06+)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
14
src/cmd/keycape/main.go
Normal file
14
src/cmd/keycape/main.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// keycape is the main server binary for the KeyCape IAM profile service.
|
||||||
|
// It orchestrates Authelia, LLDAP, and privacyIDEA to implement the
|
||||||
|
// NetKingdom IAM Profile (OIDC/PKCE Authorization Code Flow).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Fprintln(os.Stderr, "keycape server: not yet implemented (T05+)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
13
src/cmd/lldap-export/main.go
Normal file
13
src/cmd/lldap-export/main.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// lldap-export exports the LLDAP directory as a canonical YAML snapshot
|
||||||
|
// for use with the validator and migration tools.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Fprintln(os.Stderr, "lldap-export: not yet implemented (T06+)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
13
src/cmd/lldap-to-ldap/main.go
Normal file
13
src/cmd/lldap-to-ldap/main.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// lldap-to-ldap migrates LLDAP directory data to standard LDAP (LDIF format).
|
||||||
|
// Part of the NetKingdom IAM migration contract.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Fprintln(os.Stderr, "lldap-to-ldap: not yet implemented (T06+)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
69
src/cmd/validator/main.go
Normal file
69
src/cmd/validator/main.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// validator is the CLI binary for the KeyCape canonical LDAP schema validator.
|
||||||
|
// It reads a YAML directory snapshot and emits a machine-readable JSON report.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// validator --mode=ci|provisioning|migration --input=<snapshot.yaml>
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"keycape/internal/domain"
|
||||||
|
"keycape/internal/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mode := flag.String("mode", "ci", "validation mode: ci, provisioning, or migration")
|
||||||
|
input := flag.String("input", "", "path to YAML directory snapshot (required)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *input == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "error: --input is required")
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := validator.Mode(*mode)
|
||||||
|
switch m {
|
||||||
|
case validator.ModeCI, validator.ModeProvisioning, validator.ModeMigration:
|
||||||
|
// valid
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "error: unknown mode %q (must be ci, provisioning, or migration)\n", *mode)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(*input)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error reading input: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dir domain.Directory
|
||||||
|
if err := yaml.Unmarshal(data, &dir); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error parsing YAML: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: dir.Users,
|
||||||
|
Groups: dir.Groups,
|
||||||
|
}
|
||||||
|
report := validator.Validate(snap, m)
|
||||||
|
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(report); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error encoding report: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !report.Passed {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/go.mod
Normal file
5
src/go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module keycape
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
4
src/go.sum
Normal file
4
src/go.sum
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
68
src/internal/domain/model.go
Normal file
68
src/internal/domain/model.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Package domain contains the canonical identity model for KeyCape.
|
||||||
|
// This is the source of truth for all user, group, client, and MFA data.
|
||||||
|
// All provisioning, tests, and migrations derive from these types.
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// User is the canonical identity entity — source of truth for all user data.
|
||||||
|
type User struct {
|
||||||
|
ID string `yaml:"id" json:"id"`
|
||||||
|
Username string `yaml:"username" json:"username"`
|
||||||
|
DisplayName string `yaml:"displayName" json:"displayName"`
|
||||||
|
Email string `yaml:"email" json:"email"`
|
||||||
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
|
Groups []string `yaml:"groups" json:"groups"`
|
||||||
|
Roles []string `yaml:"roles" json:"roles"`
|
||||||
|
MFAEnrollment *MFAEnrollment `yaml:"mfaEnrollment,omitempty" json:"mfaEnrollment,omitempty"`
|
||||||
|
LDAPAttributes map[string]string `yaml:"ldapAttributes,omitempty" json:"ldapAttributes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group is a named collection of users.
|
||||||
|
type Group struct {
|
||||||
|
ID string `yaml:"id" json:"id"`
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
Description string `yaml:"description" json:"description"`
|
||||||
|
Members []string `yaml:"members" json:"members"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role is a named permission set.
|
||||||
|
type Role struct {
|
||||||
|
ID string `yaml:"id" json:"id"`
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
Description string `yaml:"description" json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is a registered OIDC client (static in v0.1 — no dynamic registration).
|
||||||
|
type Client struct {
|
||||||
|
ClientID string `yaml:"clientId" json:"clientId"`
|
||||||
|
DisplayName string `yaml:"displayName" json:"displayName"`
|
||||||
|
RedirectURIs []string `yaml:"redirectUris" json:"redirectUris"`
|
||||||
|
AllowedScopes []string `yaml:"allowedScopes" json:"allowedScopes"`
|
||||||
|
GrantTypes []string `yaml:"grantTypes" json:"grantTypes"`
|
||||||
|
ClientType string `yaml:"clientType" json:"clientType"` // "confidential" | "public"
|
||||||
|
SecretRef string `yaml:"secretRef,omitempty" json:"secretRef,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Membership links a user to a group.
|
||||||
|
type Membership struct {
|
||||||
|
UserID string `yaml:"userId" json:"userId"`
|
||||||
|
GroupID string `yaml:"groupId" json:"groupId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MFAEnrollment records that a user has enrolled MFA via privacyIDEA.
|
||||||
|
type MFAEnrollment struct {
|
||||||
|
UserID string `yaml:"userId" json:"userId"`
|
||||||
|
Provider string `yaml:"provider" json:"provider"` // "privacyidea"
|
||||||
|
State string `yaml:"state" json:"state"` // "enabled" | "disabled" | "pending"
|
||||||
|
EnrolledAt time.Time `yaml:"enrolledAt,omitempty" json:"enrolledAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory is the full canonical identity directory snapshot.
|
||||||
|
// Used for provisioning, validation, and migration operations.
|
||||||
|
type Directory struct {
|
||||||
|
Users []User `yaml:"users" json:"users"`
|
||||||
|
Groups []Group `yaml:"groups" json:"groups"`
|
||||||
|
Roles []Role `yaml:"roles" json:"roles"`
|
||||||
|
Clients []Client `yaml:"clients" json:"clients"`
|
||||||
|
}
|
||||||
85
src/internal/errors/taxonomy.go
Normal file
85
src/internal/errors/taxonomy.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Package errors implements the KeyCape error taxonomy from spec §5.
|
||||||
|
// All profile errors are structured and machine-readable.
|
||||||
|
// Errors MUST NOT be silent — every unsupported or misused feature returns a typed error.
|
||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorType is a stable string identifier for profile error categories.
|
||||||
|
type ErrorType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ErrFeatureNotSupported is returned when a feature is outside the NetKingdom IAM Profile.
|
||||||
|
ErrFeatureNotSupported ErrorType = "feature_not_supported_by_profile"
|
||||||
|
|
||||||
|
// ErrKeycloakModeOnly is returned when a feature exists only in expanded (Keycloak) mode.
|
||||||
|
ErrKeycloakModeOnly ErrorType = "available_in_keycloak_mode_only"
|
||||||
|
|
||||||
|
// ErrRejectedForSafety is returned when a feature is intentionally blocked for security reasons.
|
||||||
|
ErrRejectedForSafety ErrorType = "rejected_for_profile_safety"
|
||||||
|
|
||||||
|
// ErrInvalidProfileUsage is returned when a supported endpoint/feature is used incorrectly.
|
||||||
|
ErrInvalidProfileUsage ErrorType = "invalid_profile_usage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProfileError is a structured error per spec §5.2.
|
||||||
|
// JSON format: {"error": "...", "description": "...", "feature": "..."}
|
||||||
|
type ProfileError struct {
|
||||||
|
Error ErrorType `json:"error"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Feature string `json:"feature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write writes the error as JSON with the given HTTP status code.
|
||||||
|
func (e *ProfileError) Write(w http.ResponseWriter, status int) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoError implements the standard error interface.
|
||||||
|
func (e *ProfileError) GoError() string {
|
||||||
|
if e.Feature != "" {
|
||||||
|
return string(e.Error) + ": " + e.Description + " [feature=" + e.Feature + "]"
|
||||||
|
}
|
||||||
|
return string(e.Error) + ": " + e.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeatureNotSupported constructs a feature_not_supported_by_profile error.
|
||||||
|
func FeatureNotSupported(description, feature string) *ProfileError {
|
||||||
|
return &ProfileError{
|
||||||
|
Error: ErrFeatureNotSupported,
|
||||||
|
Description: description,
|
||||||
|
Feature: feature,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeycloakModeOnly constructs an available_in_keycloak_mode_only error.
|
||||||
|
func KeycloakModeOnly(description, feature string) *ProfileError {
|
||||||
|
return &ProfileError{
|
||||||
|
Error: ErrKeycloakModeOnly,
|
||||||
|
Description: description,
|
||||||
|
Feature: feature,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectedForSafety constructs a rejected_for_profile_safety error.
|
||||||
|
func RejectedForSafety(description, feature string) *ProfileError {
|
||||||
|
return &ProfileError{
|
||||||
|
Error: ErrRejectedForSafety,
|
||||||
|
Description: description,
|
||||||
|
Feature: feature,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidProfileUsage constructs an invalid_profile_usage error.
|
||||||
|
func InvalidProfileUsage(description, feature string) *ProfileError {
|
||||||
|
return &ProfileError{
|
||||||
|
Error: ErrInvalidProfileUsage,
|
||||||
|
Description: description,
|
||||||
|
Feature: feature,
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/internal/errors/taxonomy_test.go
Normal file
141
src/internal/errors/taxonomy_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package errors_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
profileerrors "keycape/internal/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestErrorTypeConstants(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
errType profileerrors.ErrorType
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"FeatureNotSupported", profileerrors.ErrFeatureNotSupported, "feature_not_supported_by_profile"},
|
||||||
|
{"KeycloakModeOnly", profileerrors.ErrKeycloakModeOnly, "available_in_keycloak_mode_only"},
|
||||||
|
{"RejectedForSafety", profileerrors.ErrRejectedForSafety, "rejected_for_profile_safety"},
|
||||||
|
{"InvalidProfileUsage", profileerrors.ErrInvalidProfileUsage, "invalid_profile_usage"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if string(tt.errType) != tt.expected {
|
||||||
|
t.Errorf("got %q, want %q", tt.errType, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConstructorHelpers(t *testing.T) {
|
||||||
|
t.Run("FeatureNotSupported", func(t *testing.T) {
|
||||||
|
e := profileerrors.FeatureNotSupported("dynamic registration is not allowed", "dynamic_client_registration")
|
||||||
|
if e.Error != profileerrors.ErrFeatureNotSupported {
|
||||||
|
t.Errorf("wrong error type: %v", e.Error)
|
||||||
|
}
|
||||||
|
if e.Description != "dynamic registration is not allowed" {
|
||||||
|
t.Errorf("wrong description: %v", e.Description)
|
||||||
|
}
|
||||||
|
if e.Feature != "dynamic_client_registration" {
|
||||||
|
t.Errorf("wrong feature: %v", e.Feature)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("KeycloakModeOnly", func(t *testing.T) {
|
||||||
|
e := profileerrors.KeycloakModeOnly("identity broker requires expanded mode", "identity_broker")
|
||||||
|
if e.Error != profileerrors.ErrKeycloakModeOnly {
|
||||||
|
t.Errorf("wrong error type: %v", e.Error)
|
||||||
|
}
|
||||||
|
if e.Feature != "identity_broker" {
|
||||||
|
t.Errorf("wrong feature: %v", e.Feature)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RejectedForSafety", func(t *testing.T) {
|
||||||
|
e := profileerrors.RejectedForSafety("wildcard redirect URIs weaken security", "wildcard_redirect_uri")
|
||||||
|
if e.Error != profileerrors.ErrRejectedForSafety {
|
||||||
|
t.Errorf("wrong error type: %v", e.Error)
|
||||||
|
}
|
||||||
|
if e.Feature != "wildcard_redirect_uri" {
|
||||||
|
t.Errorf("wrong feature: %v", e.Feature)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidProfileUsage", func(t *testing.T) {
|
||||||
|
e := profileerrors.InvalidProfileUsage("PKCE code_challenge is required", "missing_pkce")
|
||||||
|
if e.Error != profileerrors.ErrInvalidProfileUsage {
|
||||||
|
t.Errorf("wrong error type: %v", e.Error)
|
||||||
|
}
|
||||||
|
if e.Feature != "missing_pkce" {
|
||||||
|
t.Errorf("wrong feature: %v", e.Feature)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileErrorJSON(t *testing.T) {
|
||||||
|
e := profileerrors.FeatureNotSupported("dynamic registration is not allowed", "dynamic_client_registration")
|
||||||
|
data, err := json.Marshal(e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal error: %v", err)
|
||||||
|
}
|
||||||
|
s := string(data)
|
||||||
|
if !strings.Contains(s, `"error":"feature_not_supported_by_profile"`) {
|
||||||
|
t.Errorf("missing error field: %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, `"description":"dynamic registration is not allowed"`) {
|
||||||
|
t.Errorf("missing description field: %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, `"feature":"dynamic_client_registration"`) {
|
||||||
|
t.Errorf("missing feature field: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileErrorOmitsFeatureWhenEmpty(t *testing.T) {
|
||||||
|
e := &profileerrors.ProfileError{
|
||||||
|
Error: profileerrors.ErrInvalidProfileUsage,
|
||||||
|
Description: "bad request",
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal error: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), `"feature"`) {
|
||||||
|
t.Errorf("feature field should be omitted when empty: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileErrorWrite(t *testing.T) {
|
||||||
|
e := profileerrors.FeatureNotSupported("not supported", "some_feature")
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
e.Write(rr, http.StatusBadRequest)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
ct := rr.Header().Get("Content-Type")
|
||||||
|
if ct != "application/json" {
|
||||||
|
t.Errorf("expected Content-Type application/json, got %q", ct)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "feature_not_supported_by_profile") {
|
||||||
|
t.Errorf("body missing error type: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileErrorGoError(t *testing.T) {
|
||||||
|
e := profileerrors.FeatureNotSupported("desc", "feat")
|
||||||
|
s := e.GoError()
|
||||||
|
if !strings.Contains(s, "feature_not_supported_by_profile") {
|
||||||
|
t.Errorf("GoError missing error type: %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "desc") {
|
||||||
|
t.Errorf("GoError missing description: %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "feat") {
|
||||||
|
t.Errorf("GoError missing feature: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/internal/validator/report.go
Normal file
28
src/internal/validator/report.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Package validator implements the canonical LDAP schema validator for KeyCape.
|
||||||
|
// The validator enforces the NetKingdom LDAP schema (spec §3, §4).
|
||||||
|
// It runs in CI, provisioning, and migration modes.
|
||||||
|
package validator
|
||||||
|
|
||||||
|
// RuleResult captures the outcome of a single validation rule.
|
||||||
|
type RuleResult struct {
|
||||||
|
Rule string `json:"rule"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report is the machine-readable output of a validation run.
|
||||||
|
type Report struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Structural []RuleResult `json:"structural"`
|
||||||
|
Semantic []RuleResult `json:"semantic"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode identifies the operational context of the validator.
|
||||||
|
type Mode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeCI Mode = "ci"
|
||||||
|
ModeProvisioning Mode = "provisioning"
|
||||||
|
ModeMigration Mode = "migration"
|
||||||
|
)
|
||||||
236
src/internal/validator/validator.go
Normal file
236
src/internal/validator/validator.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"keycape/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Snapshot is the input to the validator: a resolved canonical directory.
|
||||||
|
type Snapshot struct {
|
||||||
|
Users []domain.User
|
||||||
|
Groups []domain.Group
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate runs all structural and semantic rules against the snapshot.
|
||||||
|
// The mode string is recorded in the report but does not change rule behaviour in v0.1.
|
||||||
|
func Validate(snap Snapshot, mode Mode) Report {
|
||||||
|
report := Report{
|
||||||
|
Mode: string(mode),
|
||||||
|
}
|
||||||
|
|
||||||
|
report.Structural = runStructural(snap)
|
||||||
|
report.Semantic = runSemantic(snap)
|
||||||
|
|
||||||
|
report.Passed = allPassed(report.Structural) && allPassed(report.Semantic)
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- structural rules ---
|
||||||
|
|
||||||
|
func runStructural(snap Snapshot) []RuleResult {
|
||||||
|
return []RuleResult{
|
||||||
|
checkValidDNStructure(snap),
|
||||||
|
checkRequiredAttributesPresent(snap),
|
||||||
|
checkNoUnknownAttributes(snap),
|
||||||
|
checkValidGroupMemberships(snap),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkValidDNStructure verifies that all user and group IDs are non-empty
|
||||||
|
// and contain only characters valid in a LDAP uid/cn naming attribute.
|
||||||
|
func checkValidDNStructure(snap Snapshot) RuleResult {
|
||||||
|
r := RuleResult{Rule: "valid_dn_structure", Passed: true}
|
||||||
|
for _, u := range snap.Users {
|
||||||
|
if u.ID == "" {
|
||||||
|
r.Passed = false
|
||||||
|
r.Message = appendMsg(r.Message, "user has empty id")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isValidNamingValue(u.Username) {
|
||||||
|
r.Passed = false
|
||||||
|
r.Message = appendMsg(r.Message, fmt.Sprintf("user %q has invalid username for DN: %q", u.ID, u.Username))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range snap.Groups {
|
||||||
|
if g.ID == "" {
|
||||||
|
r.Passed = false
|
||||||
|
r.Message = appendMsg(r.Message, "group has empty id")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isValidNamingValue(g.Name) {
|
||||||
|
r.Passed = false
|
||||||
|
r.Message = appendMsg(r.Message, fmt.Sprintf("group %q has invalid name for DN: %q", g.ID, g.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRequiredAttributesPresent verifies users have uid, cn, sn equivalents
|
||||||
|
// (id, username, displayName) and groups have id and name.
|
||||||
|
func checkRequiredAttributesPresent(snap Snapshot) RuleResult {
|
||||||
|
r := RuleResult{Rule: "required_attributes_present", Passed: true}
|
||||||
|
for _, u := range snap.Users {
|
||||||
|
if u.Username == "" {
|
||||||
|
r.Passed = false
|
||||||
|
r.Message = appendMsg(r.Message, fmt.Sprintf("user %q missing required attribute: username (uid)", u.ID))
|
||||||
|
}
|
||||||
|
if u.DisplayName == "" {
|
||||||
|
r.Passed = false
|
||||||
|
r.Message = appendMsg(r.Message, fmt.Sprintf("user %q missing required attribute: displayName (cn)", u.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range snap.Groups {
|
||||||
|
if g.Name == "" {
|
||||||
|
r.Passed = false
|
||||||
|
r.Message = appendMsg(r.Message, fmt.Sprintf("group %q missing required attribute: name (cn)", g.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNoUnknownAttributes is a placeholder for attribute allow-list enforcement.
|
||||||
|
// In v0.1 with the canonical Go model all fields are known by type; this rule
|
||||||
|
// checks that no LDAPAttributes keys are empty strings.
|
||||||
|
func checkNoUnknownAttributes(snap Snapshot) RuleResult {
|
||||||
|
r := RuleResult{Rule: "no_unknown_attributes", Passed: true}
|
||||||
|
for _, u := range snap.Users {
|
||||||
|
for k := range u.LDAPAttributes {
|
||||||
|
if strings.TrimSpace(k) == "" {
|
||||||
|
r.Passed = false
|
||||||
|
r.Message = appendMsg(r.Message, fmt.Sprintf("user %q has blank LDAP attribute key", u.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkValidGroupMemberships verifies that every member ID listed in a group
|
||||||
|
// is non-empty.
|
||||||
|
func checkValidGroupMemberships(snap Snapshot) RuleResult {
|
||||||
|
r := RuleResult{Rule: "valid_group_memberships", Passed: true}
|
||||||
|
for _, g := range snap.Groups {
|
||||||
|
for i, m := range g.Members {
|
||||||
|
if strings.TrimSpace(m) == "" {
|
||||||
|
r.Passed = false
|
||||||
|
r.Message = appendMsg(r.Message, fmt.Sprintf("group %q has blank member at index %d", g.ID, i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- semantic rules ---
|
||||||
|
|
||||||
|
func runSemantic(snap Snapshot) []RuleResult {
|
||||||
|
return []RuleResult{
|
||||||
|
checkReferencedUsersExist(snap),
|
||||||
|
checkNoCyclicGroups(snap),
|
||||||
|
checkUsernamesUnique(snap),
|
||||||
|
checkEmailFormatValid(snap),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkReferencedUsersExist verifies that every member ID in every group
|
||||||
|
// refers to an existing user.
|
||||||
|
func checkReferencedUsersExist(snap Snapshot) RuleResult {
|
||||||
|
r := RuleResult{Rule: "referenced_users_exist", Passed: true}
|
||||||
|
userIDs := make(map[string]bool, len(snap.Users))
|
||||||
|
for _, u := range snap.Users {
|
||||||
|
userIDs[u.ID] = true
|
||||||
|
}
|
||||||
|
for _, g := range snap.Groups {
|
||||||
|
for _, m := range g.Members {
|
||||||
|
if !userIDs[m] {
|
||||||
|
r.Passed = false
|
||||||
|
r.Message = appendMsg(r.Message, fmt.Sprintf("group %q references unknown user %q", g.ID, m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNoCyclicGroups detects cycles in group.Members referencing other groups.
|
||||||
|
// In v0.1 Members are user IDs (not group IDs), so any group ID in Members is a cycle.
|
||||||
|
func checkNoCyclicGroups(snap Snapshot) RuleResult {
|
||||||
|
r := RuleResult{Rule: "no_cyclic_groups", Passed: true}
|
||||||
|
groupIDs := make(map[string]bool, len(snap.Groups))
|
||||||
|
for _, g := range snap.Groups {
|
||||||
|
groupIDs[g.ID] = true
|
||||||
|
}
|
||||||
|
for _, g := range snap.Groups {
|
||||||
|
for _, m := range g.Members {
|
||||||
|
if groupIDs[m] {
|
||||||
|
r.Passed = false
|
||||||
|
r.Message = appendMsg(r.Message, fmt.Sprintf("group %q contains group member %q (cycles not allowed)", g.ID, m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkUsernamesUnique verifies no two users share the same username.
|
||||||
|
func checkUsernamesUnique(snap Snapshot) RuleResult {
|
||||||
|
r := RuleResult{Rule: "usernames_unique", Passed: true}
|
||||||
|
seen := make(map[string]string) // username -> first user id
|
||||||
|
for _, u := range snap.Users {
|
||||||
|
if first, dup := seen[u.Username]; dup {
|
||||||
|
r.Passed = false
|
||||||
|
r.Message = appendMsg(r.Message, fmt.Sprintf("duplicate username %q: users %q and %q", u.Username, first, u.ID))
|
||||||
|
} else {
|
||||||
|
seen[u.Username] = u.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkEmailFormatValid verifies that all non-empty user email addresses parse correctly.
|
||||||
|
func checkEmailFormatValid(snap Snapshot) RuleResult {
|
||||||
|
r := RuleResult{Rule: "email_format_valid", Passed: true}
|
||||||
|
for _, u := range snap.Users {
|
||||||
|
if u.Email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := mail.ParseAddress(u.Email); err != nil {
|
||||||
|
r.Passed = false
|
||||||
|
r.Message = appendMsg(r.Message, fmt.Sprintf("user %q has invalid email %q: %v", u.ID, u.Email, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func allPassed(results []RuleResult) bool {
|
||||||
|
for _, r := range results {
|
||||||
|
if !r.Passed {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendMsg(existing, msg string) string {
|
||||||
|
if existing == "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return existing + "; " + msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidNamingValue checks that a DN naming attribute value is non-empty
|
||||||
|
// and does not contain characters that would break an LDAP DN.
|
||||||
|
// The restricted characters are: , = + < > # ; \ "
|
||||||
|
func isValidNamingValue(v string) bool {
|
||||||
|
if v == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range v {
|
||||||
|
switch c {
|
||||||
|
case ',', '=', '+', '<', '>', '#', ';', '\\', '"':
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
314
src/internal/validator/validator_test.go
Normal file
314
src/internal/validator/validator_test.go
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
package validator_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"keycape/internal/domain"
|
||||||
|
"keycape/internal/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func makeUser(id, username, displayName, email string) domain.User {
|
||||||
|
return domain.User{
|
||||||
|
ID: id,
|
||||||
|
Username: username,
|
||||||
|
DisplayName: displayName,
|
||||||
|
Email: email,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGroup(id, name string, members ...string) domain.Group {
|
||||||
|
return domain.Group{ID: id, Name: name, Members: members}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- structural: valid_dn_structure ---
|
||||||
|
|
||||||
|
func TestValidDNStructure_Pass(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Structural, "valid_dn_structure")
|
||||||
|
if !result.Passed {
|
||||||
|
t.Errorf("expected pass, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidDNStructure_EmptyID(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("", "alice", "Alice", "alice@example.com")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Structural, "valid_dn_structure")
|
||||||
|
if result.Passed {
|
||||||
|
t.Error("expected fail for empty user ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidDNStructure_InvalidUsername(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("u1", "alice,bad", "Alice", "alice@example.com")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Structural, "valid_dn_structure")
|
||||||
|
if result.Passed {
|
||||||
|
t.Error("expected fail for username with comma")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidDNStructure_InvalidGroupName(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Groups: []domain.Group{makeGroup("g1", "bad=group")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Structural, "valid_dn_structure")
|
||||||
|
if result.Passed {
|
||||||
|
t.Error("expected fail for group name with equals sign")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- structural: required_attributes_present ---
|
||||||
|
|
||||||
|
func TestRequiredAttributesPresent_Pass(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||||
|
Groups: []domain.Group{makeGroup("g1", "admins")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Structural, "required_attributes_present")
|
||||||
|
if !result.Passed {
|
||||||
|
t.Errorf("expected pass, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiredAttributesPresent_MissingUsername(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("u1", "", "Alice", "alice@example.com")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Structural, "required_attributes_present")
|
||||||
|
if result.Passed {
|
||||||
|
t.Error("expected fail for missing username")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiredAttributesPresent_MissingDisplayName(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("u1", "alice", "", "alice@example.com")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Structural, "required_attributes_present")
|
||||||
|
if result.Passed {
|
||||||
|
t.Error("expected fail for missing displayName")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- structural: no_unknown_attributes ---
|
||||||
|
|
||||||
|
func TestNoUnknownAttributes_Pass(t *testing.T) {
|
||||||
|
u := makeUser("u1", "alice", "Alice", "alice@example.com")
|
||||||
|
u.LDAPAttributes = map[string]string{"sn": "Example"}
|
||||||
|
snap := validator.Snapshot{Users: []domain.User{u}}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Structural, "no_unknown_attributes")
|
||||||
|
if !result.Passed {
|
||||||
|
t.Errorf("expected pass, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoUnknownAttributes_BlankKey(t *testing.T) {
|
||||||
|
u := makeUser("u1", "alice", "Alice", "alice@example.com")
|
||||||
|
u.LDAPAttributes = map[string]string{"": "value"}
|
||||||
|
snap := validator.Snapshot{Users: []domain.User{u}}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Structural, "no_unknown_attributes")
|
||||||
|
if result.Passed {
|
||||||
|
t.Error("expected fail for blank attribute key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- structural: valid_group_memberships ---
|
||||||
|
|
||||||
|
func TestValidGroupMemberships_Pass(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Groups: []domain.Group{makeGroup("g1", "admins", "u1", "u2")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Structural, "valid_group_memberships")
|
||||||
|
if !result.Passed {
|
||||||
|
t.Errorf("expected pass, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidGroupMemberships_BlankMember(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Groups: []domain.Group{makeGroup("g1", "admins", "u1", "")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Structural, "valid_group_memberships")
|
||||||
|
if result.Passed {
|
||||||
|
t.Error("expected fail for blank member ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- semantic: referenced_users_exist ---
|
||||||
|
|
||||||
|
func TestReferencedUsersExist_Pass(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||||
|
Groups: []domain.Group{makeGroup("g1", "admins", "u1")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Semantic, "referenced_users_exist")
|
||||||
|
if !result.Passed {
|
||||||
|
t.Errorf("expected pass, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReferencedUsersExist_UnknownUser(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||||
|
Groups: []domain.Group{makeGroup("g1", "admins", "u99")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Semantic, "referenced_users_exist")
|
||||||
|
if result.Passed {
|
||||||
|
t.Error("expected fail for unknown user reference")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- semantic: no_cyclic_groups ---
|
||||||
|
|
||||||
|
func TestNoCyclicGroups_Pass(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||||
|
Groups: []domain.Group{makeGroup("g1", "admins", "u1")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Semantic, "no_cyclic_groups")
|
||||||
|
if !result.Passed {
|
||||||
|
t.Errorf("expected pass, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoCyclicGroups_GroupInMembers(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Groups: []domain.Group{
|
||||||
|
makeGroup("g1", "admins", "g2"),
|
||||||
|
makeGroup("g2", "users", "g1"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Semantic, "no_cyclic_groups")
|
||||||
|
if result.Passed {
|
||||||
|
t.Error("expected fail for group referencing another group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- semantic: usernames_unique ---
|
||||||
|
|
||||||
|
func TestUsernamesUnique_Pass(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{
|
||||||
|
makeUser("u1", "alice", "Alice", "alice@example.com"),
|
||||||
|
makeUser("u2", "bob", "Bob", "bob@example.com"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Semantic, "usernames_unique")
|
||||||
|
if !result.Passed {
|
||||||
|
t.Errorf("expected pass, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsernamesUnique_Duplicate(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{
|
||||||
|
makeUser("u1", "alice", "Alice", "alice@example.com"),
|
||||||
|
makeUser("u2", "alice", "Alice Two", "alice2@example.com"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Semantic, "usernames_unique")
|
||||||
|
if result.Passed {
|
||||||
|
t.Error("expected fail for duplicate username")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- semantic: email_format_valid ---
|
||||||
|
|
||||||
|
func TestEmailFormatValid_Pass(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Semantic, "email_format_valid")
|
||||||
|
if !result.Passed {
|
||||||
|
t.Errorf("expected pass, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailFormatValid_InvalidEmail(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("u1", "alice", "Alice", "not-an-email")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Semantic, "email_format_valid")
|
||||||
|
if result.Passed {
|
||||||
|
t.Error("expected fail for invalid email format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailFormatValid_EmptyEmailSkipped(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("u1", "alice", "Alice", "")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
result := findRule(r.Semantic, "email_format_valid")
|
||||||
|
if !result.Passed {
|
||||||
|
t.Errorf("empty email should pass (optional): %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- report overall pass/fail ---
|
||||||
|
|
||||||
|
func TestReportPassed_AllGood(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
if !r.Passed {
|
||||||
|
t.Errorf("expected overall pass, but report failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportFailed_OneRuleFails(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{
|
||||||
|
Users: []domain.User{makeUser("u1", "alice", "Alice", "not-an-email")},
|
||||||
|
}
|
||||||
|
r := validator.Validate(snap, validator.ModeCI)
|
||||||
|
if r.Passed {
|
||||||
|
t.Error("expected overall fail when email is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeRecordedInReport(t *testing.T) {
|
||||||
|
snap := validator.Snapshot{}
|
||||||
|
r := validator.Validate(snap, validator.ModeMigration)
|
||||||
|
if r.Mode != "migration" {
|
||||||
|
t.Errorf("expected mode migration, got %q", r.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helper ---
|
||||||
|
|
||||||
|
func findRule(results []validator.RuleResult, name string) validator.RuleResult {
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Rule == name {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validator.RuleResult{Rule: name, Passed: false, Message: "rule not found in report"}
|
||||||
|
}
|
||||||
475
workplans/KEY-WP-0001-keycape-implementation.md
Normal file
475
workplans/KEY-WP-0001-keycape-implementation.md
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
---
|
||||||
|
id: KEY-WP-0001
|
||||||
|
type: workplan
|
||||||
|
title: "KeyCape Implementation — Lightweight IAM Profile"
|
||||||
|
domain: netkingdom
|
||||||
|
repo: key-cape
|
||||||
|
status: active
|
||||||
|
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
|
||||||
|
spec_refs:
|
||||||
|
- wiki/KeyCapeSpecification_v0.1.md
|
||||||
|
- wiki/KeyCapeSpecificationPack_v0.1.md
|
||||||
|
decisions:
|
||||||
|
- id: ADR-0001
|
||||||
|
title: "Implementation language: Go"
|
||||||
|
hub_decision_id: 620beb04-fa3f-4a9d-9806-02890a7a2b0d
|
||||||
|
status: accepted
|
||||||
|
ref: docs/adr/ADR-0001-choose-go-for-keycape.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# KEY-WP-0001 — KeyCape Implementation
|
||||||
|
|
||||||
|
Implements the **NetKingdom IAM Profile** via KeyCape: a stateless, profile-constrained OIDC
|
||||||
|
server orchestrating Authelia, LLDAP, and privacyIDEA. Replaceable by Keycloak without
|
||||||
|
application changes.
|
||||||
|
|
||||||
|
## Language Decision
|
||||||
|
|
||||||
|
**Go** — decided 2026-03-13 by Bernd. See `docs/adr/ADR-0001-choose-go-for-keycape.md`.
|
||||||
|
|
||||||
|
KeyCape is an orchestrating boundary service (HTTP, adapters, JWT via library, CLI tooling) —
|
||||||
|
Go's strongest domain. Rust revisitable if a subcomponent needs stronger guarantees later.
|
||||||
|
Mandatory guardrails: typed domain models, narrow adapter interfaces, layered architecture,
|
||||||
|
fuzz tests on validator/redirect/claim-mapping, no cleverness.
|
||||||
|
|
||||||
|
## Repo Structure (from spec §12)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
server/
|
||||||
|
oidc/ # Profile endpoints
|
||||||
|
telemetry/ # Structured event emission
|
||||||
|
errors/ # Error taxonomy + enforcement middleware
|
||||||
|
adapters/
|
||||||
|
authelia/ # Auth flow delegation
|
||||||
|
lldap/ # Identity directory reads
|
||||||
|
privacyidea/ # MFA enforcement
|
||||||
|
validator/ # Canonical LDAP schema validator binary
|
||||||
|
migration/
|
||||||
|
lldap-export/ # LLDAP → canonical
|
||||||
|
keycape-to-keycloak/ # Canonical → Keycloak realm import
|
||||||
|
lldap-to-ldap/ # LLDAP → OpenLDAP/389DS/AD LDIF
|
||||||
|
spec/
|
||||||
|
canonical-model.yaml # Source of truth for all identity data
|
||||||
|
ldap-schema.yaml # Canonical LDAP schema rules
|
||||||
|
tests/
|
||||||
|
profile/ # Scenario A — lightweight baseline
|
||||||
|
negative/ # Scenario D — unsupported feature rejection
|
||||||
|
migration/ # Scenarios B & C — replacement
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency Order
|
||||||
|
|
||||||
|
```
|
||||||
|
T01 (project setup)
|
||||||
|
└─ T02 (canonical model) T04 (error taxonomy)
|
||||||
|
└─ T03 (LDAP validator) └─ T13 (telemetry)
|
||||||
|
└─ T10 (LLDAP adapter) └─ T14 (enforcement layer)
|
||||||
|
└─ T11 (Authelia) │
|
||||||
|
└─ T12 (privacyIDEA) │
|
||||||
|
│ │
|
||||||
|
T05 ─ T06 ─ T07 ─ T08 ─┴─ T09 (OIDC server)
|
||||||
|
│
|
||||||
|
T18 (profile tests / Scenario A)
|
||||||
|
T21 (negative tests / Scenario D)
|
||||||
|
│
|
||||||
|
T15 → T16 → T19 (Scenario B)
|
||||||
|
T15 → T17 → T20 (Scenario C)
|
||||||
|
│
|
||||||
|
T22 (dev stack)
|
||||||
|
T23 (production packaging)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Foundations
|
||||||
|
|
||||||
|
## T01 — Project setup: Go module, repo layout, CI skeleton
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T01
|
||||||
|
hub_task_id: 25613e3f-2a65-409e-afaa-d23ded0bc256
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialise language module in `src/`. Create directory skeleton per spec §12. Add Makefile
|
||||||
|
targets: `build`, `test`, `lint`. Set up CI (build + test on every push). Scaffolding only —
|
||||||
|
no application code. **Agent must call `record_decision()` with chosen language (Go or Rust).**
|
||||||
|
|
||||||
|
## T02 — Canonical identity model: machine-readable schema
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T02
|
||||||
|
hub_task_id: deee2929-9386-41db-bf91-fbd9ad646c28
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T01]
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `spec/canonical-model.yaml`. Six entities: User, Group, Role, Client, Membership,
|
||||||
|
MFAEnrollment (fields per spec §2). Include JSON Schema or CUE schema for programmatic
|
||||||
|
validation. This file is the **source of truth** — all other code derives from it.
|
||||||
|
|
||||||
|
## T03 — Canonical LDAP schema + validator
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T03
|
||||||
|
hub_task_id: 02592c65-db23-474b-b06b-019e95df8146
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T01, T02]
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `spec/ldap-schema.yaml`: tree layout (`ou=users`, `ou=groups`, `ou=clients` under
|
||||||
|
`dc=netkingdom,dc=local`), object classes (`inetOrgPerson`, `groupOfNames`), required/optional
|
||||||
|
attributes. Implement `validator/` binary. Structural rules: valid DN, required attrs, no unknown
|
||||||
|
attrs, valid group memberships. Semantic rules: referenced users exist, no cycles, usernames
|
||||||
|
unique, email format valid. Validator runs in `--mode=ci|provisioning|migration`. Emits
|
||||||
|
machine-readable report.
|
||||||
|
|
||||||
|
## T04 — Error taxonomy: types, JSON format, middleware
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T04
|
||||||
|
hub_task_id: 46870fd6-0672-432b-8824-6bc2e24811b3
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T01]
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement four error types (spec §5):
|
||||||
|
- `feature_not_supported_by_profile`
|
||||||
|
- `available_in_keycloak_mode_only`
|
||||||
|
- `rejected_for_profile_safety`
|
||||||
|
- `invalid_profile_usage`
|
||||||
|
|
||||||
|
JSON format: `{"error": "...", "description": "...", "feature": "..."}`. HTTP middleware wraps
|
||||||
|
all handler errors. Error type strings are stable and test-assertable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — OIDC Server
|
||||||
|
|
||||||
|
## T05 — OIDC discovery endpoint (/.well-known/openid-configuration)
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T05
|
||||||
|
hub_task_id: 92eb8916-cb22-4786-9f16-a8a07272f818
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T04]
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /.well-known/openid-configuration`. Advertise **only** profile-supported features:
|
||||||
|
`authorization_code`, S256 PKCE, RS256, static scopes. Must NOT advertise: dynamic registration,
|
||||||
|
implicit flow. Issuer configurable. Cacheable response.
|
||||||
|
|
||||||
|
## T06 — Authorization endpoint with PKCE and redirect URI validation
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T06
|
||||||
|
hub_task_id: c3df620b-9864-4ff1-ba6d-26057c6f4d59
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T04, T11, T12, T13, T14]
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET/POST /authorize`. Validate: `client_id` (static config), `redirect_uri` (exact match —
|
||||||
|
wildcard → `rejected_for_profile_safety`), `response_type=code`, `scope` contains `openid`,
|
||||||
|
`code_challenge` present (missing → `invalid_profile_usage`), `code_challenge_method=S256`.
|
||||||
|
Delegate to Authelia adapter. Store PKCE state server-side. No implicit or hybrid flow.
|
||||||
|
|
||||||
|
## T07 — Token endpoint: JWT/RS256, canonical claim mapping
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T07
|
||||||
|
hub_task_id: d3248f4d-e0e9-4144-9844-c9768dc896d6
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T06, T08, T10]
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /token`. Validate PKCE `code_verifier`. Issue RS256 JWT via standard library (no custom
|
||||||
|
crypto). Mandatory claims: `iss`, `sub` (canonical user ID), `aud`, `exp`, `iat`. Optional
|
||||||
|
claims: `preferred_username` (LDAP `uid`), `email` (LDAP `mail`), `groups` (groupOfNames),
|
||||||
|
`roles`. Short, explicitly configured token lifetime. Any other grant type →
|
||||||
|
`feature_not_supported_by_profile`.
|
||||||
|
|
||||||
|
## T08 — JWKS endpoint (/jwks)
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T08
|
||||||
|
hub_task_id: 58a1d705-b788-4d66-8c0a-33edff63a885
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T01]
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /jwks`. RS256 public key in JWK Set format. Key loaded from config. Key rotation: serve
|
||||||
|
multiple keys during rotation window, keyed by `kid`. Standard library key generation only.
|
||||||
|
|
||||||
|
## T09 — Userinfo endpoint (/userinfo)
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T09
|
||||||
|
hub_task_id: 742d3924-21e9-4304-86e9-0400af0e81ee
|
||||||
|
priority: medium
|
||||||
|
status: todo
|
||||||
|
depends_on: [T07, T10]
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /userinfo`. Optional per spec — implement if any registered client requires it. Validate
|
||||||
|
Bearer token (RS256 + expiry). Return claim subset scoped to granted scopes. Claims must be
|
||||||
|
identical to ID token for same scopes. If no client needs it: stub returning
|
||||||
|
`available_in_keycloak_mode_only`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Backend Adapters
|
||||||
|
|
||||||
|
## T10 — LLDAP adapter: user and group reads
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T10
|
||||||
|
hub_task_id: 2043b10a-6822-45f8-abcc-4e233d918fb0
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T02, T03]
|
||||||
|
```
|
||||||
|
|
||||||
|
`adapters/lldap`. LDAP protocol connection to LLDAP. Interface: `LookupUser(username) → canonical
|
||||||
|
User`, `LookupGroups(userDN) → []Group`, `ValidatePassword(username, password) → bool`. Attribute
|
||||||
|
map: `uid→username`, `cn→displayName`, `mail→email`, `memberOf→groups`. Run canonical LDAP schema
|
||||||
|
validator on every read. No LDAP internals exposed to `server/`.
|
||||||
|
|
||||||
|
## T11 — Authelia adapter: session and auth flow delegation
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T11
|
||||||
|
hub_task_id: ad129a14-1552-4717-b1dd-b529d18ce681
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T04, T13]
|
||||||
|
```
|
||||||
|
|
||||||
|
`adapters/authelia`. Initiate auth redirect to Authelia, receive callback, extract authenticated
|
||||||
|
user identity, hand off to MFA check (T12). Must not leak Authelia session tokens/cookies into
|
||||||
|
profile layer. Unavailable Authelia → fail closed (`auth_failure` event).
|
||||||
|
|
||||||
|
## T12 — privacyIDEA adapter: MFA enforcement
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T12
|
||||||
|
hub_task_id: 1ef196e6-2304-4cf6-b205-47ac1da879ec
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T02, T13]
|
||||||
|
```
|
||||||
|
|
||||||
|
`adapters/privacyidea`. **KeyCape must NOT implement MFA logic.** Interface:
|
||||||
|
`CheckMFARequired(user) → bool`, `ValidateMFAToken(user, token) → bool`. MFA failure → no token
|
||||||
|
issued + `auth_failure` telemetry. MFA enrollment state from canonical `MFAEnrollment` entity.
|
||||||
|
privacyIDEA remains stable across lightweight → expanded migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Telemetry & Enforcement
|
||||||
|
|
||||||
|
## T13 — Telemetry pipeline: structured event emission
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T13
|
||||||
|
hub_task_id: 704146bf-cd60-4922-b18b-3d209cff3ac3
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T01]
|
||||||
|
```
|
||||||
|
|
||||||
|
`server/telemetry`. Event types (spec §6.1): `auth_start`, `auth_success`, `auth_failure`,
|
||||||
|
`token_issued`, `unsupported_feature`, `invalid_request`, `migration_event`. Required fields
|
||||||
|
(spec §6.2): `timestamp`, `client_id`, `endpoint`, `feature`, `result`, `error_type`, `scopes`,
|
||||||
|
`grant_type`, `environment`, `trace_id`. Pluggable outputs: structured log (default), Prometheus
|
||||||
|
metrics endpoint. Every auth and error path emits an event — **no silent paths**.
|
||||||
|
|
||||||
|
## T14 — Unsupported feature enforcement layer
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T14
|
||||||
|
hub_task_id: 71f44886-ab61-4160-a435-72b35af472a0
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T04, T13]
|
||||||
|
```
|
||||||
|
|
||||||
|
`server/errors` enforcement middleware. Intercept any parameter, grant type, scope, or client
|
||||||
|
config exceeding the profile. Return correct error type + emit `unsupported_feature` telemetry.
|
||||||
|
Maintain a **registry** of unsupported features (adding new ones requires no handler changes):
|
||||||
|
`dynamic_client_registration`, `identity_broker`, `wildcard_redirect_uri`, `implicit_flow`, etc.
|
||||||
|
Every registry entry must have a corresponding test in T21.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Migration Tooling
|
||||||
|
|
||||||
|
## T15 — Migration: LLDAP → canonical export
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T15
|
||||||
|
hub_task_id: 1bd13f76-2d62-429d-b230-d785ef6a3f2f
|
||||||
|
priority: medium
|
||||||
|
status: todo
|
||||||
|
depends_on: [T02, T03, T10]
|
||||||
|
```
|
||||||
|
|
||||||
|
`migration/lldap-export` tool. Read all users, groups, memberships, attributes from LLDAP. Map
|
||||||
|
to canonical model. Validate against LDAP schema validator before writing. Output:
|
||||||
|
`canonical-export.yaml`. Emit `migration_event` telemetry. Idempotent. Include incompatibility
|
||||||
|
report for unmappable LLDAP data.
|
||||||
|
|
||||||
|
## T16 — Migration: canonical → Keycloak import
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T16
|
||||||
|
hub_task_id: f3d50e80-f6b5-4c0c-a5f3-08308ea1a95e
|
||||||
|
priority: medium
|
||||||
|
status: todo
|
||||||
|
depends_on: [T15]
|
||||||
|
```
|
||||||
|
|
||||||
|
`migration/keycape-to-keycloak` tool. Read canonical export (T15). Transform to Keycloak realm
|
||||||
|
import format: users, groups, clients, roles, scope mappings. Preserve: same issuer, same claims,
|
||||||
|
same scopes, same client behavior. Output: `keycloak-realm-import.json`. Emit `migration_event`.
|
||||||
|
Include round-trip validation report.
|
||||||
|
|
||||||
|
## T17 — Migration: LLDAP → full LDAP (OpenLDAP / 389DS / AD)
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T17
|
||||||
|
hub_task_id: 044d99c8-39cb-4f35-9308-912ae829bd22
|
||||||
|
priority: medium
|
||||||
|
status: todo
|
||||||
|
depends_on: [T15]
|
||||||
|
```
|
||||||
|
|
||||||
|
`migration/lldap-to-ldap` tool. Export via T15 canonical export. Generate LDIF for target
|
||||||
|
(`--target=openldap|389ds|ad`). Migrate: users, groups, memberships, attributes. Run validator
|
||||||
|
on output LDIF before import. Produce validation report. **Orthogonal to T16** — the two
|
||||||
|
migration dimensions are independent (spec §14.1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Replacement Tests
|
||||||
|
|
||||||
|
## T18 — Profile test suite: Scenario A (lightweight baseline)
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T18
|
||||||
|
hub_task_id: 76abc3f6-9c4e-4aca-9995-72b728925812
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T05, T06, T07, T08, T09, T22]
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/profile`. Provision canonical fixtures into LLDAP + Authelia + KeyCape. Test categories
|
||||||
|
(spec §15.3): discovery, login flow (PKCE), token claim assertions (all mandatory + optional),
|
||||||
|
redirect validation, client config, MFA policy, logout (if implemented). Tests are
|
||||||
|
**backend-agnostic** — same suite runs in T19 and T20. Must pass for Scenario A conformance.
|
||||||
|
|
||||||
|
## T19 — Replacement test suite: Scenario B (IAM swap, same directory)
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T19
|
||||||
|
hub_task_id: 56d03e89-934b-4992-bfe4-b32f275882e3
|
||||||
|
priority: medium
|
||||||
|
status: todo
|
||||||
|
depends_on: [T18, T16]
|
||||||
|
```
|
||||||
|
|
||||||
|
Run T18 suite against Keycloak + LLDAP (configured from T16 canonical export). **No test code
|
||||||
|
changes allowed.** Migration successful only if all T18 tests pass. Proves IAM replaceability
|
||||||
|
without directory migration.
|
||||||
|
|
||||||
|
## T20 — Replacement test suite: Scenario C (full expansion)
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T20
|
||||||
|
hub_task_id: ec3cae5c-9942-4be7-acc0-1eb9f02aba45
|
||||||
|
priority: medium
|
||||||
|
status: todo
|
||||||
|
depends_on: [T19, T17]
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply T17 LLDAP→OpenLDAP migration, then T16 Keycloak import. Run T18 suite. Migration successful
|
||||||
|
only if all tests pass. privacyIDEA must remain stable (no MFA re-enrollment required).
|
||||||
|
|
||||||
|
## T21 — Negative profile tests: Scenario D (unsupported feature rejection)
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T21
|
||||||
|
hub_task_id: a5112b63-121f-4d17-ac1a-fb46d160413e
|
||||||
|
priority: high
|
||||||
|
status: todo
|
||||||
|
depends_on: [T14]
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/negative`. For every entry in T14 unsupported-feature registry: attempt usage, assert
|
||||||
|
correct `error.error` string, assert `unsupported_feature` telemetry event emitted. Covered
|
||||||
|
cases: `dynamic_client_registration`, `implicit_flow`, wildcard redirect URIs, identity brokering,
|
||||||
|
missing PKCE, unknown scopes, unknown grant types. **Must run in CI.** Proves enforcement layer
|
||||||
|
is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Operations
|
||||||
|
|
||||||
|
## T22 — Dev mode stack: docker-compose with LLDAP + Authelia
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T22
|
||||||
|
hub_task_id: e840963f-cd19-4b38-857a-7c40df165d3d
|
||||||
|
priority: medium
|
||||||
|
status: todo
|
||||||
|
depends_on: [T01]
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.dev.yml`: KeyCape, LLDAP, Authelia, privacyIDEA (or stub). Pre-seeded with
|
||||||
|
canonical fixtures from T02. Makefile targets: `make dev` (start + verify basic auth flow),
|
||||||
|
`make seed` (re-apply fixtures without full restart). Deterministic: same seed → same state.
|
||||||
|
Test environment for T18 and T21.
|
||||||
|
|
||||||
|
## T23 — Slim production packaging: binary + config + health
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0001-T23
|
||||||
|
hub_task_id: d5723683-f739-4362-b62e-71213dc5a89e
|
||||||
|
priority: low
|
||||||
|
status: todo
|
||||||
|
depends_on: [T18, T21]
|
||||||
|
```
|
||||||
|
|
||||||
|
Single stateless binary. Declarative YAML config: profile version, client definitions, backend
|
||||||
|
connections, LDAP schema rules, privacyIDEA settings, telemetry destination, token lifetime,
|
||||||
|
issuer URL. Static config validation on startup. `/healthz` endpoint. Minimal container image
|
||||||
|
(distroless or Alpine). Config environment-promotable via env var overrides only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria (from spec §15.4 and §20)
|
||||||
|
|
||||||
|
A release is conformant when:
|
||||||
|
|
||||||
|
- [ ] Scenario A tests pass (T18)
|
||||||
|
- [ ] Scenario D tests pass (T21)
|
||||||
|
- [ ] Scenario B tests pass (T19) — IAM migration verified
|
||||||
|
- [ ] Scenario C tests pass (T20) — full expansion verified
|
||||||
|
- [ ] All error responses use taxonomy types from spec §5
|
||||||
|
- [ ] All auth/error paths emit structured telemetry (T13)
|
||||||
|
- [ ] Canonical LDAP schema validator passes on all fixtures (T03)
|
||||||
|
- [ ] No handwritten cryptography anywhere in the codebase
|
||||||
|
- [ ] Config is statically validated at startup (T23)
|
||||||
Reference in New Issue
Block a user