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:
2026-03-13 01:27:54 +01:00
parent f3b1cdcba4
commit 329e996619
21 changed files with 1992 additions and 0 deletions

View 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"
)

View 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
}

View 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"}
}