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), checkRawAttributesWellFormed(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 } // canonicalLDAPMappings are the directory attributes the canonical model already // owns: User.username maps to uid, User.displayName to cn, User.email to mail, // and Group.name to cn. A raw attribute restating one of these creates a second // source for a value the model defines, which is how an export and the directory // it came from drift apart. var canonicalLDAPMappings = map[string]string{ "uid": "username", "cn": "displayName", "mail": "email", } // checkRawAttributesWellFormed validates the raw LDAP attributes a snapshot // carries alongside the canonical fields (KEY-WP-0021). // // `ldapAttributes` is defined as the attributes the canonical model does *not* // cover, so there is no schema-derived allow-list of permitted names to check // against — the field exists to carry exactly what the schema does not name. // What can be enforced is that each name is writable to a directory, does not // shadow a mapping the model owns, and is not the same attribute twice. func checkRawAttributesWellFormed(snap Snapshot) RuleResult { r := RuleResult{Rule: "raw_attributes_well_formed", Passed: true} for _, u := range snap.Users { // LDAP attribute descriptors are case-insensitive, so keys differing // only by case are one attribute supplied twice, not two attributes. seen := make(map[string]string, len(u.LDAPAttributes)) for key := range u.LDAPAttributes { switch { case strings.TrimSpace(key) == "": r.Passed = false r.Message = appendMsg(r.Message, fmt.Sprintf("user %q has blank LDAP attribute key", u.ID)) continue case !validAttributeDescriptor(key): r.Passed = false r.Message = appendMsg(r.Message, fmt.Sprintf( "user %q has LDAP attribute %q that is not a valid attribute descriptor", u.ID, key)) continue } folded := strings.ToLower(key) if field, canonical := canonicalLDAPMappings[folded]; canonical { r.Passed = false r.Message = appendMsg(r.Message, fmt.Sprintf( "user %q carries LDAP attribute %q as a raw attribute; it is owned by the canonical %s field", u.ID, key, field)) continue } if first, duplicate := seen[folded]; duplicate { r.Passed = false r.Message = appendMsg(r.Message, fmt.Sprintf( "user %q has LDAP attributes %q and %q that differ only by case", u.ID, first, key)) continue } seen[folded] = key } } return r } // validAttributeDescriptor reports whether name is an LDAP attribute descriptor // per RFC 4512: either a numeric OID, or a letter followed by letters, digits // and hyphens. Options after a semicolon (e.g. "description;lang-de") are not // accepted, since a canonical snapshot carries values, not transfer encodings. func validAttributeDescriptor(name string) bool { if name == "" { return false } if name[0] >= '0' && name[0] <= '9' { return validNumericOID(name) } for i := 0; i < len(name); i++ { c := name[i] switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z': case c >= '0' && c <= '9', c == '-': if i == 0 { return false } default: return false } } return true } // validNumericOID reports whether name is a dotted-decimal OID with at least two // arcs and no empty or leading-zero arc. func validNumericOID(name string) bool { arcs := strings.Split(name, ".") if len(arcs) < 2 { return false } for _, arc := range arcs { if arc == "" || (len(arc) > 1 && arc[0] == '0') { return false } for i := 0; i < len(arc); i++ { if arc[i] < '0' || arc[i] > '9' { return false } } } return true } // 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 }