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 }