generated from coulomb/repo-seed
- 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>
237 lines
6.6 KiB
Go
237 lines
6.6 KiB
Go
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
|
|
}
|