Make snapshot attribute validation enforce a real rule
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 40s

Closes gap G06. checkNoUnknownAttributes was named for a rule it did not
implement: it rejected blank keys, which is not an allow-list, so a snapshot
carrying malformed or shadowing attribute names passed a check whose name said
otherwise. A rule that passes for the wrong reason is worse than an absent one,
because the report is read as evidence.

raw_attributes_well_formed requires each key to be a valid LDAP attribute
descriptor, forbids shadowing an attribute the canonical model owns (uid, cn,
mail) in any casing, and rejects keys differing only by case, which LDAP treats
as one attribute. spec/ldap-schema.yaml carries the same wording.

Two corrections to the gap's description rather than implementations of it. An
allow-list is not derivable: ldapAttributes is defined as what the canonical
model does not cover, so the schema cannot enumerate what may appear there. And
the reference constraint already existed -- checkValidGroupMemberships only
checks emptiness, but the semantic rule checkReferencedUsersExist resolves every
member against the user set.

Neutering the rule fails four of the five new tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
This commit is contained in:
tegwick 2026-09-07 23:22:55 +02:00
parent f366df814a
commit 21acb5cdd6
6 changed files with 298 additions and 26 deletions

View file

@ -34,7 +34,7 @@ func runStructural(snap Snapshot) []RuleResult {
return []RuleResult{
checkValidDNStructure(snap),
checkRequiredAttributesPresent(snap),
checkNoUnknownAttributes(snap),
checkRawAttributesWellFormed(snap),
checkValidGroupMemberships(snap),
}
}
@ -91,22 +91,109 @@ func checkRequiredAttributesPresent(snap Snapshot) RuleResult {
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}
// 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 {
for k := range u.LDAPAttributes {
if strings.TrimSpace(k) == "" {
// 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 {

View file

@ -1,6 +1,7 @@
package validator_test
import (
"strings"
"testing"
"keycape/internal/domain"
@ -105,27 +106,85 @@ func TestRequiredAttributesPresent_MissingDisplayName(t *testing.T) {
}
}
// --- structural: no_unknown_attributes ---
// --- structural: raw_attributes_well_formed ---
func TestNoUnknownAttributes_Pass(t *testing.T) {
// attributeRule runs the snapshot and returns the raw-attribute rule result.
func attributeRule(t *testing.T, attributes map[string]string) validator.RuleResult {
t.Helper()
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)
u.LDAPAttributes = attributes
r := validator.Validate(validator.Snapshot{Users: []domain.User{u}}, validator.ModeCI)
return findRule(r.Structural, "raw_attributes_well_formed")
}
// The rule must be selective, not merely strict: legitimate raw attributes are
// exactly what this field exists to carry.
func TestRawAttributes_AcceptsLegitimateAttributes(t *testing.T) {
for name, attributes := range map[string]map[string]string{
"simple descriptor": {"sn": "Example"},
"hyphenated and digits": {"employee-id2": "42"},
"numeric oid": {"1.3.6.1.4.1.1466.115.121.1.15": "value"},
"distinct attributes": {"sn": "Example", "givenName": "Alice"},
"no attributes": nil,
} {
t.Run(name, func(t *testing.T) {
if result := attributeRule(t, attributes); !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")
func TestRawAttributes_RejectsMalformedDescriptors(t *testing.T) {
for name, key := range map[string]string{
"blank": "",
"whitespace only": " ",
"leading digit": "2fa",
"leading hyphen": "-sn",
"underscore": "employee_id",
"space inside": "given Name",
"transfer option": "description;lang-de",
"single arc oid": "1",
"empty oid arc": "1..3",
"leading zero oid": "1.03",
"non numeric oid arc": "1.3.x",
} {
t.Run(name, func(t *testing.T) {
if result := attributeRule(t, map[string]string{key: "value"}); result.Passed {
t.Errorf("expected fail for attribute key %q", key)
}
})
}
}
// A raw attribute must not restate a value the canonical model owns, in any
// casing, since LDAP attribute descriptors are case-insensitive.
func TestRawAttributes_RejectsCanonicalMappings(t *testing.T) {
for _, key := range []string{"uid", "cn", "mail", "UID", "Mail", "cN"} {
t.Run(key, func(t *testing.T) {
if result := attributeRule(t, map[string]string{key: "value"}); result.Passed {
t.Errorf("expected fail for canonical mapping %q", key)
}
})
}
}
func TestRawAttributes_RejectsCaseOnlyDuplicates(t *testing.T) {
result := attributeRule(t, map[string]string{"givenName": "Alice", "givenname": "Alice"})
if result.Passed {
t.Error("expected fail for blank attribute key")
t.Error("expected fail for attributes differing only by case")
}
}
// The rule reports the offending user and attribute: a report saying only that
// something failed cannot be acted on.
func TestRawAttributes_MessageNamesUserAndAttribute(t *testing.T) {
result := attributeRule(t, map[string]string{"employee_id": "42"})
if result.Passed {
t.Fatal("expected fail")
}
if !strings.Contains(result.Message, "u1") || !strings.Contains(result.Message, "employee_id") {
t.Errorf("message does not identify the problem: %s", result.Message)
}
}