key-cape/src/internal/validator/validator.go

324 lines
9.5 KiB
Go
Raw Normal View History

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
}