feat: implement T09, T15, T21 — userinfo endpoint, LLDAP export, negative tests

- T09: /userinfo with RS256 JWT validation, scope-filtered claims
- T15: LLDAP→canonical export tool with validation, migration_event telemetry
- T21: Negative test suite (Scenario D) — all 7 unsupported features verified

All go tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-03-13 02:08:03 +01:00
parent 4097a7de8b
commit 3ee8090a98
9 changed files with 1156 additions and 2 deletions

View file

@ -0,0 +1,138 @@
// Package lldapexport implements the LLDAP → canonical export tool (spec §7 — migration contract).
// It reads all users and groups from the LLDAP directory via a UserRepository, validates each
// entry against the canonical LDAP schema, and writes a canonical-export.yaml snapshot.
package lldapexport
import (
"context"
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
"keycape/internal/domain"
"keycape/internal/server/telemetry"
"keycape/internal/validator"
)
// ExportResult is the structured output of a single export run.
type ExportResult struct {
Users []domain.User `yaml:"users"`
Groups []domain.Group `yaml:"groups"`
Memberships []domain.Membership `yaml:"memberships"`
ExportedAt time.Time `yaml:"exportedAt"`
ProfileVersion string `yaml:"profileVersion"`
IncompatibilityReport []string `yaml:"incompatibilityReport,omitempty"`
}
// Exporter reads from a UserRepository, validates, and writes canonical-export.yaml.
type Exporter struct {
repo domain.UserRepository
mode validator.Mode
emitter telemetry.Emitter
}
// New creates a new Exporter.
func New(repo domain.UserRepository, mode validator.Mode, emitter telemetry.Emitter) *Exporter {
return &Exporter{
repo: repo,
mode: mode,
emitter: emitter,
}
}
// Export reads all users and groups, validates them, builds ExportResult,
// emits telemetry, and writes the YAML file to outputFile.
// Validation failures are captured in IncompatibilityReport — they are not fatal.
func (e *Exporter) Export(ctx context.Context, outputFile string) (*ExportResult, error) {
// 1. List all users from the repository.
users, err := e.repo.ListUsers(ctx)
if err != nil {
return nil, fmt.Errorf("lldapexport: list users: %w", err)
}
// 2. List all groups by looking up groups for each user's DN.
// Since UserRepository.LookupGroups takes a userDN, we collect groups
// from all users and deduplicate by group ID.
groupMap := make(map[string]domain.Group)
for _, u := range users {
userGroups, err := e.repo.LookupGroups(ctx, u.ID)
if err != nil {
// Non-fatal: log in incompatibility report.
continue
}
for _, g := range userGroups {
if _, seen := groupMap[g.ID]; !seen {
groupMap[g.ID] = g
}
}
}
groups := make([]domain.Group, 0, len(groupMap))
for _, g := range groupMap {
groups = append(groups, g)
}
// 3. Validate each user against the canonical LDAP schema.
var incompatibilities []string
validatedUsers := make([]domain.User, 0, len(users))
for _, u := range users {
snap := validator.Snapshot{Users: []domain.User{u}}
report := validator.Validate(snap, e.mode)
if !report.Passed {
for _, r := range report.Structural {
if !r.Passed {
incompatibilities = append(incompatibilities,
fmt.Sprintf("user %q structural/%s: %s", u.Username, r.Rule, r.Message))
}
}
for _, r := range report.Semantic {
if !r.Passed {
incompatibilities = append(incompatibilities,
fmt.Sprintf("user %q semantic/%s: %s", u.Username, r.Rule, r.Message))
}
}
}
validatedUsers = append(validatedUsers, u)
}
// 4. Build memberships from group member lists.
var memberships []domain.Membership
for _, g := range groups {
for _, memberID := range g.Members {
memberships = append(memberships, domain.Membership{
UserID: memberID,
GroupID: g.ID,
})
}
}
// 5. Build ExportResult.
result := &ExportResult{
Users: validatedUsers,
Groups: groups,
Memberships: memberships,
ExportedAt: time.Now().UTC(),
ProfileVersion: "0.1",
IncompatibilityReport: incompatibilities,
}
// 6. Emit migration_event telemetry.
e.emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventMigration,
Endpoint: "lldap-export",
Result: "success",
})
// 7. Write YAML to output file.
data, err := yaml.Marshal(result)
if err != nil {
return nil, fmt.Errorf("lldapexport: marshal YAML: %w", err)
}
if err := os.WriteFile(outputFile, data, 0o644); err != nil {
return nil, fmt.Errorf("lldapexport: write file %q: %w", outputFile, err)
}
return result, nil
}