feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- 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>
2026-03-13 01:27:54 +01:00
|
|
|
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),
|
Make snapshot attribute validation enforce a real rule
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
2026-09-07 23:22:55 +02:00
|
|
|
checkRawAttributesWellFormed(snap),
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- 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>
2026-03-13 01:27:54 +01:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
Make snapshot attribute validation enforce a real rule
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
2026-09-07 23:22:55 +02:00
|
|
|
// 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}
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- 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>
2026-03-13 01:27:54 +01:00
|
|
|
for _, u := range snap.Users {
|
Make snapshot attribute validation enforce a real rule
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
2026-09-07 23:22:55 +02:00
|
|
|
// 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 {
|
Prove the migration against live directories and fix what that surfaced
Finishes the unproven half of gap G04. Running the harness against real LLDAP,
OpenLDAP and Keycloak found four defects that the full unit suite passed over.
Every LLDAP user search pointed at a branch that does not exist. Config.userOU()
defaulted to ou=users while LLDAP stores users under ou=people, and nothing set
UserOU. LookupUser, ListUsers and ValidatePassword all derive from it, so all
three silently found nothing against a stock LLDAP -- human login included, not
only the export. Exposed by an export returning zero users while still emitting a
membership referencing uid=admin, a snapshot the repo's own validator rejects.
raw_attributes_well_formed, added one workplan earlier, rejected the LLDAP
adapter's own _validation_warning annotation, so the exporter's output failed its
own validation. Tooling annotations are exempt now, and a test proves the
exemption does not weaken the rule.
Migrated group memberships were dangling: resolveMemberDN passed a source DN
through unchanged while entries were written to the target branch, so groups
named entries the migrated directory does not contain. And empty groups could
not load at all, since groupOfNames makes member a MUST -- they reference a
placeholder entry the LDIF creates, an organizationalRole rather than a person,
emitted only when some group needs it.
The scenario-c compose file could not start: bitnami/openldap:2.6 does not
exist, though it passed docker compose config. Pinned to the image the scenario
was proved against.
Proof: LLDAP -> export -> validate -> LDIF -> ldapadd into OpenLDAP 1.5.0, every
entry added and every member resolving; a realm from the same export imports into
Keycloak and serves discovery. KeyCape passes 5/5 conformance checks, a migrated
Keycloak 4/5.
Relying-party behaviour and MFA against a migrated realm remain unexercised, and
credential/MFA migration is not supplied at all, so no harness can establish it.
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
2026-09-08 00:29:34 +02:00
|
|
|
// Keys beginning with "_" are annotations this tooling writes into
|
|
|
|
|
// the snapshot itself -- the LLDAP adapter records
|
|
|
|
|
// "_validation_warning" here, for one. They are not directory
|
|
|
|
|
// attributes and must not be judged as though they were, or the
|
|
|
|
|
// exporter's own output fails validation (KEY-WP-0023).
|
|
|
|
|
if strings.HasPrefix(key, "_") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
Make snapshot attribute validation enforce a real rule
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
2026-09-07 23:22:55 +02:00
|
|
|
switch {
|
|
|
|
|
case strings.TrimSpace(key) == "":
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- 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>
2026-03-13 01:27:54 +01:00
|
|
|
r.Passed = false
|
|
|
|
|
r.Message = appendMsg(r.Message, fmt.Sprintf("user %q has blank LDAP attribute key", u.ID))
|
Make snapshot attribute validation enforce a real rule
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
2026-09-07 23:22:55 +02:00
|
|
|
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
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- 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>
2026-03-13 01:27:54 +01:00
|
|
|
}
|
Make snapshot attribute validation enforce a real rule
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
2026-09-07 23:22:55 +02:00
|
|
|
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
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- 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>
2026-03-13 01:27:54 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return r
|
|
|
|
|
}
|
|
|
|
|
|
Make snapshot attribute validation enforce a real rule
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
2026-09-07 23:22:55 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- 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>
2026-03-13 01:27:54 +01:00
|
|
|
// 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
|
|
|
|
|
}
|