diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md new file mode 100644 index 0000000..45d9a51 --- /dev/null +++ b/.claude/ralph-loop.local.md @@ -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 HEUREKA when done! diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..32a386e --- /dev/null +++ b/.github/workflows/ci.yml @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index 34adc93..aaf9278 100644 --- a/CLAUDE.md +++ b/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 ## What This Repo Is @@ -94,6 +98,27 @@ Application ──→ NetKingdom IAM Profile 5. **Telemetry** — demand visibility for unsupported features and auth events 6. **Migration tooling** — export/validate for LLDAP → Keycloak path +## Normative Constraints (from spec — binding on implementation) + +**Never silently emulate unsupported features.** Any request outside the profile MUST fail with a structured error from this taxonomy: +- `feature_not_supported_by_profile` — outside the NetKingdom IAM Profile entirely +- `available_in_keycloak_mode_only` — exists in expanded mode, absent here by design +- `rejected_for_profile_safety` — would weaken profile guarantees or security discipline +- `invalid_profile_usage` — supported endpoint/feature used incorrectly + +**Security hard rules:** No handwritten cryptography. No handwritten password hashing. Use established protocol and crypto libraries. Strict redirect URI validation. Strict issuer consistency. + +**Canonical identity model** is the source of truth for test fixtures, provisioning, migration, and validation — not any backend's native schema. + +**Spec Pack structure** (`wiki/KeyCapeSpecificationPack_v0.1.md`) contains 7 normative components agents must read before implementing: +1. Normative Specification — OIDC/PKCE contract, endpoints, scopes, claims, client model, MFA +2. Canonical Identity Schema — User, Group, Membership, Client, Role, MFAEnrollmentReference, etc. +3. Canonical LDAP Schema + Validator Rules — restricted LDAP expression of identity model +4. Error Taxonomy — machine-readable/human-readable/loggable structured errors +5. Telemetry Schema — event types, required fields (timestamp, env, client_id, endpoint, feature_category, correlation_id, …) +6. Migration Contract — LLDAP → full LDAP, KeyCape → Keycloak migration paths +7. Acceptance Test Matrix — lightweight baseline, IAM replacement, full expansion, negative profile tests + ## Workplan Convention (ADR-001) Workplans live in `workplans/-.md` with YAML frontmatter: diff --git a/docs/adr/ADR-0001-choose-go-for-keycape.md b/docs/adr/ADR-0001-choose-go-for-keycape.md new file mode 100644 index 0000000..52ab3e9 --- /dev/null +++ b/docs/adr/ADR-0001-choose-go-for-keycape.md @@ -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. diff --git a/spec/canonical-model.yaml b/spec/canonical-model.yaml new file mode 100644 index 0000000..99f88be --- /dev/null +++ b/spec/canonical-model.yaml @@ -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." diff --git a/spec/ldap-schema.yaml b/spec/ldap-schema.yaml new file mode 100644 index 0000000..56e1543 --- /dev/null +++ b/spec/ldap-schema.yaml @@ -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." diff --git a/src/Makefile b/src/Makefile new file mode 100644 index 0000000..083df0b --- /dev/null +++ b/src/Makefile @@ -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) diff --git a/src/cmd/keycape-to-keycloak/main.go b/src/cmd/keycape-to-keycloak/main.go new file mode 100644 index 0000000..4d0fbf7 --- /dev/null +++ b/src/cmd/keycape-to-keycloak/main.go @@ -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) +} diff --git a/src/cmd/keycape/main.go b/src/cmd/keycape/main.go new file mode 100644 index 0000000..5b2a2f0 --- /dev/null +++ b/src/cmd/keycape/main.go @@ -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) +} diff --git a/src/cmd/lldap-export/main.go b/src/cmd/lldap-export/main.go new file mode 100644 index 0000000..4ae8df8 --- /dev/null +++ b/src/cmd/lldap-export/main.go @@ -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) +} diff --git a/src/cmd/lldap-to-ldap/main.go b/src/cmd/lldap-to-ldap/main.go new file mode 100644 index 0000000..5b1b3d4 --- /dev/null +++ b/src/cmd/lldap-to-ldap/main.go @@ -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) +} diff --git a/src/cmd/validator/main.go b/src/cmd/validator/main.go new file mode 100644 index 0000000..4112720 --- /dev/null +++ b/src/cmd/validator/main.go @@ -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= +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) + } +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..01b6073 --- /dev/null +++ b/src/go.mod @@ -0,0 +1,5 @@ +module keycape + +go 1.22 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/src/go.sum @@ -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= diff --git a/src/internal/domain/model.go b/src/internal/domain/model.go new file mode 100644 index 0000000..6db7e2a --- /dev/null +++ b/src/internal/domain/model.go @@ -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"` +} diff --git a/src/internal/errors/taxonomy.go b/src/internal/errors/taxonomy.go new file mode 100644 index 0000000..80ebf07 --- /dev/null +++ b/src/internal/errors/taxonomy.go @@ -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, + } +} diff --git a/src/internal/errors/taxonomy_test.go b/src/internal/errors/taxonomy_test.go new file mode 100644 index 0000000..2f5722e --- /dev/null +++ b/src/internal/errors/taxonomy_test.go @@ -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) + } +} diff --git a/src/internal/validator/report.go b/src/internal/validator/report.go new file mode 100644 index 0000000..87baa85 --- /dev/null +++ b/src/internal/validator/report.go @@ -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" +) diff --git a/src/internal/validator/validator.go b/src/internal/validator/validator.go new file mode 100644 index 0000000..268248e --- /dev/null +++ b/src/internal/validator/validator.go @@ -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 +} diff --git a/src/internal/validator/validator_test.go b/src/internal/validator/validator_test.go new file mode 100644 index 0000000..34426c1 --- /dev/null +++ b/src/internal/validator/validator_test.go @@ -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"} +} diff --git a/workplans/KEY-WP-0001-keycape-implementation.md b/workplans/KEY-WP-0001-keycape-implementation.md new file mode 100644 index 0000000..33659dd --- /dev/null +++ b/workplans/KEY-WP-0001-keycape-implementation.md @@ -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)