generated from coulomb/repo-seed
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>
This commit is contained in:
29
src/Makefile
Normal file
29
src/Makefile
Normal file
@@ -0,0 +1,29 @@
|
||||
GOBIN ?= $(shell go env GOPATH)/bin
|
||||
BINDIR = ../bin
|
||||
|
||||
.PHONY: all build test lint vet clean
|
||||
|
||||
all: vet lint test build
|
||||
|
||||
build:
|
||||
go build -o $(BINDIR)/keycape ./cmd/keycape/
|
||||
go build -o $(BINDIR)/validator ./cmd/validator/
|
||||
go build -o $(BINDIR)/lldap-export ./cmd/lldap-export/
|
||||
go build -o $(BINDIR)/keycape-to-keycloak ./cmd/keycape-to-keycloak/
|
||||
go build -o $(BINDIR)/lldap-to-ldap ./cmd/lldap-to-ldap/
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
lint:
|
||||
@if command -v golangci-lint >/dev/null 2>&1; then \
|
||||
golangci-lint run ./...; \
|
||||
else \
|
||||
echo "golangci-lint not installed, skipping (run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)"; \
|
||||
fi
|
||||
|
||||
clean:
|
||||
rm -rf $(BINDIR)
|
||||
13
src/cmd/keycape-to-keycloak/main.go
Normal file
13
src/cmd/keycape-to-keycloak/main.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// keycape-to-keycloak migrates a KeyCape canonical snapshot to a Keycloak
|
||||
// realm export format. Part of the NetKingdom IAM migration contract.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "keycape-to-keycloak: not yet implemented (T06+)")
|
||||
os.Exit(1)
|
||||
}
|
||||
14
src/cmd/keycape/main.go
Normal file
14
src/cmd/keycape/main.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// keycape is the main server binary for the KeyCape IAM profile service.
|
||||
// It orchestrates Authelia, LLDAP, and privacyIDEA to implement the
|
||||
// NetKingdom IAM Profile (OIDC/PKCE Authorization Code Flow).
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "keycape server: not yet implemented (T05+)")
|
||||
os.Exit(1)
|
||||
}
|
||||
13
src/cmd/lldap-export/main.go
Normal file
13
src/cmd/lldap-export/main.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// lldap-export exports the LLDAP directory as a canonical YAML snapshot
|
||||
// for use with the validator and migration tools.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "lldap-export: not yet implemented (T06+)")
|
||||
os.Exit(1)
|
||||
}
|
||||
13
src/cmd/lldap-to-ldap/main.go
Normal file
13
src/cmd/lldap-to-ldap/main.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// lldap-to-ldap migrates LLDAP directory data to standard LDAP (LDIF format).
|
||||
// Part of the NetKingdom IAM migration contract.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "lldap-to-ldap: not yet implemented (T06+)")
|
||||
os.Exit(1)
|
||||
}
|
||||
69
src/cmd/validator/main.go
Normal file
69
src/cmd/validator/main.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// validator is the CLI binary for the KeyCape canonical LDAP schema validator.
|
||||
// It reads a YAML directory snapshot and emits a machine-readable JSON report.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// validator --mode=ci|provisioning|migration --input=<snapshot.yaml>
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"keycape/internal/domain"
|
||||
"keycape/internal/validator"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mode := flag.String("mode", "ci", "validation mode: ci, provisioning, or migration")
|
||||
input := flag.String("input", "", "path to YAML directory snapshot (required)")
|
||||
flag.Parse()
|
||||
|
||||
if *input == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: --input is required")
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
m := validator.Mode(*mode)
|
||||
switch m {
|
||||
case validator.ModeCI, validator.ModeProvisioning, validator.ModeMigration:
|
||||
// valid
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "error: unknown mode %q (must be ci, provisioning, or migration)\n", *mode)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(*input)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error reading input: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var dir domain.Directory
|
||||
if err := yaml.Unmarshal(data, &dir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error parsing YAML: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
snap := validator.Snapshot{
|
||||
Users: dir.Users,
|
||||
Groups: dir.Groups,
|
||||
}
|
||||
report := validator.Validate(snap, m)
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(report); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error encoding report: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !report.Passed {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
5
src/go.mod
Normal file
5
src/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module keycape
|
||||
|
||||
go 1.22
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
4
src/go.sum
Normal file
4
src/go.sum
Normal file
@@ -0,0 +1,4 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
68
src/internal/domain/model.go
Normal file
68
src/internal/domain/model.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Package domain contains the canonical identity model for KeyCape.
|
||||
// This is the source of truth for all user, group, client, and MFA data.
|
||||
// All provisioning, tests, and migrations derive from these types.
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// User is the canonical identity entity — source of truth for all user data.
|
||||
type User struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Username string `yaml:"username" json:"username"`
|
||||
DisplayName string `yaml:"displayName" json:"displayName"`
|
||||
Email string `yaml:"email" json:"email"`
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Groups []string `yaml:"groups" json:"groups"`
|
||||
Roles []string `yaml:"roles" json:"roles"`
|
||||
MFAEnrollment *MFAEnrollment `yaml:"mfaEnrollment,omitempty" json:"mfaEnrollment,omitempty"`
|
||||
LDAPAttributes map[string]string `yaml:"ldapAttributes,omitempty" json:"ldapAttributes,omitempty"`
|
||||
}
|
||||
|
||||
// Group is a named collection of users.
|
||||
type Group struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Members []string `yaml:"members" json:"members"`
|
||||
}
|
||||
|
||||
// Role is a named permission set.
|
||||
type Role struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
}
|
||||
|
||||
// Client is a registered OIDC client (static in v0.1 — no dynamic registration).
|
||||
type Client struct {
|
||||
ClientID string `yaml:"clientId" json:"clientId"`
|
||||
DisplayName string `yaml:"displayName" json:"displayName"`
|
||||
RedirectURIs []string `yaml:"redirectUris" json:"redirectUris"`
|
||||
AllowedScopes []string `yaml:"allowedScopes" json:"allowedScopes"`
|
||||
GrantTypes []string `yaml:"grantTypes" json:"grantTypes"`
|
||||
ClientType string `yaml:"clientType" json:"clientType"` // "confidential" | "public"
|
||||
SecretRef string `yaml:"secretRef,omitempty" json:"secretRef,omitempty"`
|
||||
}
|
||||
|
||||
// Membership links a user to a group.
|
||||
type Membership struct {
|
||||
UserID string `yaml:"userId" json:"userId"`
|
||||
GroupID string `yaml:"groupId" json:"groupId"`
|
||||
}
|
||||
|
||||
// MFAEnrollment records that a user has enrolled MFA via privacyIDEA.
|
||||
type MFAEnrollment struct {
|
||||
UserID string `yaml:"userId" json:"userId"`
|
||||
Provider string `yaml:"provider" json:"provider"` // "privacyidea"
|
||||
State string `yaml:"state" json:"state"` // "enabled" | "disabled" | "pending"
|
||||
EnrolledAt time.Time `yaml:"enrolledAt,omitempty" json:"enrolledAt,omitempty"`
|
||||
}
|
||||
|
||||
// Directory is the full canonical identity directory snapshot.
|
||||
// Used for provisioning, validation, and migration operations.
|
||||
type Directory struct {
|
||||
Users []User `yaml:"users" json:"users"`
|
||||
Groups []Group `yaml:"groups" json:"groups"`
|
||||
Roles []Role `yaml:"roles" json:"roles"`
|
||||
Clients []Client `yaml:"clients" json:"clients"`
|
||||
}
|
||||
85
src/internal/errors/taxonomy.go
Normal file
85
src/internal/errors/taxonomy.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Package errors implements the KeyCape error taxonomy from spec §5.
|
||||
// All profile errors are structured and machine-readable.
|
||||
// Errors MUST NOT be silent — every unsupported or misused feature returns a typed error.
|
||||
package errors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ErrorType is a stable string identifier for profile error categories.
|
||||
type ErrorType string
|
||||
|
||||
const (
|
||||
// ErrFeatureNotSupported is returned when a feature is outside the NetKingdom IAM Profile.
|
||||
ErrFeatureNotSupported ErrorType = "feature_not_supported_by_profile"
|
||||
|
||||
// ErrKeycloakModeOnly is returned when a feature exists only in expanded (Keycloak) mode.
|
||||
ErrKeycloakModeOnly ErrorType = "available_in_keycloak_mode_only"
|
||||
|
||||
// ErrRejectedForSafety is returned when a feature is intentionally blocked for security reasons.
|
||||
ErrRejectedForSafety ErrorType = "rejected_for_profile_safety"
|
||||
|
||||
// ErrInvalidProfileUsage is returned when a supported endpoint/feature is used incorrectly.
|
||||
ErrInvalidProfileUsage ErrorType = "invalid_profile_usage"
|
||||
)
|
||||
|
||||
// ProfileError is a structured error per spec §5.2.
|
||||
// JSON format: {"error": "...", "description": "...", "feature": "..."}
|
||||
type ProfileError struct {
|
||||
Error ErrorType `json:"error"`
|
||||
Description string `json:"description"`
|
||||
Feature string `json:"feature,omitempty"`
|
||||
}
|
||||
|
||||
// Write writes the error as JSON with the given HTTP status code.
|
||||
func (e *ProfileError) Write(w http.ResponseWriter, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(e)
|
||||
}
|
||||
|
||||
// GoError implements the standard error interface.
|
||||
func (e *ProfileError) GoError() string {
|
||||
if e.Feature != "" {
|
||||
return string(e.Error) + ": " + e.Description + " [feature=" + e.Feature + "]"
|
||||
}
|
||||
return string(e.Error) + ": " + e.Description
|
||||
}
|
||||
|
||||
// FeatureNotSupported constructs a feature_not_supported_by_profile error.
|
||||
func FeatureNotSupported(description, feature string) *ProfileError {
|
||||
return &ProfileError{
|
||||
Error: ErrFeatureNotSupported,
|
||||
Description: description,
|
||||
Feature: feature,
|
||||
}
|
||||
}
|
||||
|
||||
// KeycloakModeOnly constructs an available_in_keycloak_mode_only error.
|
||||
func KeycloakModeOnly(description, feature string) *ProfileError {
|
||||
return &ProfileError{
|
||||
Error: ErrKeycloakModeOnly,
|
||||
Description: description,
|
||||
Feature: feature,
|
||||
}
|
||||
}
|
||||
|
||||
// RejectedForSafety constructs a rejected_for_profile_safety error.
|
||||
func RejectedForSafety(description, feature string) *ProfileError {
|
||||
return &ProfileError{
|
||||
Error: ErrRejectedForSafety,
|
||||
Description: description,
|
||||
Feature: feature,
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidProfileUsage constructs an invalid_profile_usage error.
|
||||
func InvalidProfileUsage(description, feature string) *ProfileError {
|
||||
return &ProfileError{
|
||||
Error: ErrInvalidProfileUsage,
|
||||
Description: description,
|
||||
Feature: feature,
|
||||
}
|
||||
}
|
||||
141
src/internal/errors/taxonomy_test.go
Normal file
141
src/internal/errors/taxonomy_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package errors_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
profileerrors "keycape/internal/errors"
|
||||
)
|
||||
|
||||
func TestErrorTypeConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errType profileerrors.ErrorType
|
||||
expected string
|
||||
}{
|
||||
{"FeatureNotSupported", profileerrors.ErrFeatureNotSupported, "feature_not_supported_by_profile"},
|
||||
{"KeycloakModeOnly", profileerrors.ErrKeycloakModeOnly, "available_in_keycloak_mode_only"},
|
||||
{"RejectedForSafety", profileerrors.ErrRejectedForSafety, "rejected_for_profile_safety"},
|
||||
{"InvalidProfileUsage", profileerrors.ErrInvalidProfileUsage, "invalid_profile_usage"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.errType) != tt.expected {
|
||||
t.Errorf("got %q, want %q", tt.errType, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructorHelpers(t *testing.T) {
|
||||
t.Run("FeatureNotSupported", func(t *testing.T) {
|
||||
e := profileerrors.FeatureNotSupported("dynamic registration is not allowed", "dynamic_client_registration")
|
||||
if e.Error != profileerrors.ErrFeatureNotSupported {
|
||||
t.Errorf("wrong error type: %v", e.Error)
|
||||
}
|
||||
if e.Description != "dynamic registration is not allowed" {
|
||||
t.Errorf("wrong description: %v", e.Description)
|
||||
}
|
||||
if e.Feature != "dynamic_client_registration" {
|
||||
t.Errorf("wrong feature: %v", e.Feature)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("KeycloakModeOnly", func(t *testing.T) {
|
||||
e := profileerrors.KeycloakModeOnly("identity broker requires expanded mode", "identity_broker")
|
||||
if e.Error != profileerrors.ErrKeycloakModeOnly {
|
||||
t.Errorf("wrong error type: %v", e.Error)
|
||||
}
|
||||
if e.Feature != "identity_broker" {
|
||||
t.Errorf("wrong feature: %v", e.Feature)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RejectedForSafety", func(t *testing.T) {
|
||||
e := profileerrors.RejectedForSafety("wildcard redirect URIs weaken security", "wildcard_redirect_uri")
|
||||
if e.Error != profileerrors.ErrRejectedForSafety {
|
||||
t.Errorf("wrong error type: %v", e.Error)
|
||||
}
|
||||
if e.Feature != "wildcard_redirect_uri" {
|
||||
t.Errorf("wrong feature: %v", e.Feature)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidProfileUsage", func(t *testing.T) {
|
||||
e := profileerrors.InvalidProfileUsage("PKCE code_challenge is required", "missing_pkce")
|
||||
if e.Error != profileerrors.ErrInvalidProfileUsage {
|
||||
t.Errorf("wrong error type: %v", e.Error)
|
||||
}
|
||||
if e.Feature != "missing_pkce" {
|
||||
t.Errorf("wrong feature: %v", e.Feature)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProfileErrorJSON(t *testing.T) {
|
||||
e := profileerrors.FeatureNotSupported("dynamic registration is not allowed", "dynamic_client_registration")
|
||||
data, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal error: %v", err)
|
||||
}
|
||||
s := string(data)
|
||||
if !strings.Contains(s, `"error":"feature_not_supported_by_profile"`) {
|
||||
t.Errorf("missing error field: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"description":"dynamic registration is not allowed"`) {
|
||||
t.Errorf("missing description field: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"feature":"dynamic_client_registration"`) {
|
||||
t.Errorf("missing feature field: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileErrorOmitsFeatureWhenEmpty(t *testing.T) {
|
||||
e := &profileerrors.ProfileError{
|
||||
Error: profileerrors.ErrInvalidProfileUsage,
|
||||
Description: "bad request",
|
||||
}
|
||||
data, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal error: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), `"feature"`) {
|
||||
t.Errorf("feature field should be omitted when empty: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileErrorWrite(t *testing.T) {
|
||||
e := profileerrors.FeatureNotSupported("not supported", "some_feature")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
e.Write(rr, http.StatusBadRequest)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", rr.Code)
|
||||
}
|
||||
ct := rr.Header().Get("Content-Type")
|
||||
if ct != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %q", ct)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "feature_not_supported_by_profile") {
|
||||
t.Errorf("body missing error type: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileErrorGoError(t *testing.T) {
|
||||
e := profileerrors.FeatureNotSupported("desc", "feat")
|
||||
s := e.GoError()
|
||||
if !strings.Contains(s, "feature_not_supported_by_profile") {
|
||||
t.Errorf("GoError missing error type: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, "desc") {
|
||||
t.Errorf("GoError missing description: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, "feat") {
|
||||
t.Errorf("GoError missing feature: %s", s)
|
||||
}
|
||||
}
|
||||
28
src/internal/validator/report.go
Normal file
28
src/internal/validator/report.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Package validator implements the canonical LDAP schema validator for KeyCape.
|
||||
// The validator enforces the NetKingdom LDAP schema (spec §3, §4).
|
||||
// It runs in CI, provisioning, and migration modes.
|
||||
package validator
|
||||
|
||||
// RuleResult captures the outcome of a single validation rule.
|
||||
type RuleResult struct {
|
||||
Rule string `json:"rule"`
|
||||
Passed bool `json:"passed"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Report is the machine-readable output of a validation run.
|
||||
type Report struct {
|
||||
Mode string `json:"mode"`
|
||||
Passed bool `json:"passed"`
|
||||
Structural []RuleResult `json:"structural"`
|
||||
Semantic []RuleResult `json:"semantic"`
|
||||
}
|
||||
|
||||
// Mode identifies the operational context of the validator.
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeCI Mode = "ci"
|
||||
ModeProvisioning Mode = "provisioning"
|
||||
ModeMigration Mode = "migration"
|
||||
)
|
||||
236
src/internal/validator/validator.go
Normal file
236
src/internal/validator/validator.go
Normal file
@@ -0,0 +1,236 @@
|
||||
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
|
||||
}
|
||||
314
src/internal/validator/validator_test.go
Normal file
314
src/internal/validator/validator_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package validator_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"keycape/internal/domain"
|
||||
"keycape/internal/validator"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func makeUser(id, username, displayName, email string) domain.User {
|
||||
return domain.User{
|
||||
ID: id,
|
||||
Username: username,
|
||||
DisplayName: displayName,
|
||||
Email: email,
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func makeGroup(id, name string, members ...string) domain.Group {
|
||||
return domain.Group{ID: id, Name: name, Members: members}
|
||||
}
|
||||
|
||||
// --- structural: valid_dn_structure ---
|
||||
|
||||
func TestValidDNStructure_Pass(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Structural, "valid_dn_structure")
|
||||
if !result.Passed {
|
||||
t.Errorf("expected pass, got: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidDNStructure_EmptyID(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("", "alice", "Alice", "alice@example.com")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Structural, "valid_dn_structure")
|
||||
if result.Passed {
|
||||
t.Error("expected fail for empty user ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidDNStructure_InvalidUsername(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("u1", "alice,bad", "Alice", "alice@example.com")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Structural, "valid_dn_structure")
|
||||
if result.Passed {
|
||||
t.Error("expected fail for username with comma")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidDNStructure_InvalidGroupName(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Groups: []domain.Group{makeGroup("g1", "bad=group")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Structural, "valid_dn_structure")
|
||||
if result.Passed {
|
||||
t.Error("expected fail for group name with equals sign")
|
||||
}
|
||||
}
|
||||
|
||||
// --- structural: required_attributes_present ---
|
||||
|
||||
func TestRequiredAttributesPresent_Pass(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||
Groups: []domain.Group{makeGroup("g1", "admins")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Structural, "required_attributes_present")
|
||||
if !result.Passed {
|
||||
t.Errorf("expected pass, got: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredAttributesPresent_MissingUsername(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("u1", "", "Alice", "alice@example.com")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Structural, "required_attributes_present")
|
||||
if result.Passed {
|
||||
t.Error("expected fail for missing username")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredAttributesPresent_MissingDisplayName(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("u1", "alice", "", "alice@example.com")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Structural, "required_attributes_present")
|
||||
if result.Passed {
|
||||
t.Error("expected fail for missing displayName")
|
||||
}
|
||||
}
|
||||
|
||||
// --- structural: no_unknown_attributes ---
|
||||
|
||||
func TestNoUnknownAttributes_Pass(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
if result.Passed {
|
||||
t.Error("expected fail for blank attribute key")
|
||||
}
|
||||
}
|
||||
|
||||
// --- structural: valid_group_memberships ---
|
||||
|
||||
func TestValidGroupMemberships_Pass(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Groups: []domain.Group{makeGroup("g1", "admins", "u1", "u2")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Structural, "valid_group_memberships")
|
||||
if !result.Passed {
|
||||
t.Errorf("expected pass, got: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidGroupMemberships_BlankMember(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Groups: []domain.Group{makeGroup("g1", "admins", "u1", "")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Structural, "valid_group_memberships")
|
||||
if result.Passed {
|
||||
t.Error("expected fail for blank member ID")
|
||||
}
|
||||
}
|
||||
|
||||
// --- semantic: referenced_users_exist ---
|
||||
|
||||
func TestReferencedUsersExist_Pass(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||
Groups: []domain.Group{makeGroup("g1", "admins", "u1")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Semantic, "referenced_users_exist")
|
||||
if !result.Passed {
|
||||
t.Errorf("expected pass, got: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencedUsersExist_UnknownUser(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||
Groups: []domain.Group{makeGroup("g1", "admins", "u99")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Semantic, "referenced_users_exist")
|
||||
if result.Passed {
|
||||
t.Error("expected fail for unknown user reference")
|
||||
}
|
||||
}
|
||||
|
||||
// --- semantic: no_cyclic_groups ---
|
||||
|
||||
func TestNoCyclicGroups_Pass(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||
Groups: []domain.Group{makeGroup("g1", "admins", "u1")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Semantic, "no_cyclic_groups")
|
||||
if !result.Passed {
|
||||
t.Errorf("expected pass, got: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCyclicGroups_GroupInMembers(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Groups: []domain.Group{
|
||||
makeGroup("g1", "admins", "g2"),
|
||||
makeGroup("g2", "users", "g1"),
|
||||
},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Semantic, "no_cyclic_groups")
|
||||
if result.Passed {
|
||||
t.Error("expected fail for group referencing another group")
|
||||
}
|
||||
}
|
||||
|
||||
// --- semantic: usernames_unique ---
|
||||
|
||||
func TestUsernamesUnique_Pass(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{
|
||||
makeUser("u1", "alice", "Alice", "alice@example.com"),
|
||||
makeUser("u2", "bob", "Bob", "bob@example.com"),
|
||||
},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Semantic, "usernames_unique")
|
||||
if !result.Passed {
|
||||
t.Errorf("expected pass, got: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernamesUnique_Duplicate(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{
|
||||
makeUser("u1", "alice", "Alice", "alice@example.com"),
|
||||
makeUser("u2", "alice", "Alice Two", "alice2@example.com"),
|
||||
},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Semantic, "usernames_unique")
|
||||
if result.Passed {
|
||||
t.Error("expected fail for duplicate username")
|
||||
}
|
||||
}
|
||||
|
||||
// --- semantic: email_format_valid ---
|
||||
|
||||
func TestEmailFormatValid_Pass(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Semantic, "email_format_valid")
|
||||
if !result.Passed {
|
||||
t.Errorf("expected pass, got: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailFormatValid_InvalidEmail(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("u1", "alice", "Alice", "not-an-email")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Semantic, "email_format_valid")
|
||||
if result.Passed {
|
||||
t.Error("expected fail for invalid email format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailFormatValid_EmptyEmailSkipped(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("u1", "alice", "Alice", "")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
result := findRule(r.Semantic, "email_format_valid")
|
||||
if !result.Passed {
|
||||
t.Errorf("empty email should pass (optional): %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// --- report overall pass/fail ---
|
||||
|
||||
func TestReportPassed_AllGood(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
if !r.Passed {
|
||||
t.Errorf("expected overall pass, but report failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportFailed_OneRuleFails(t *testing.T) {
|
||||
snap := validator.Snapshot{
|
||||
Users: []domain.User{makeUser("u1", "alice", "Alice", "not-an-email")},
|
||||
}
|
||||
r := validator.Validate(snap, validator.ModeCI)
|
||||
if r.Passed {
|
||||
t.Error("expected overall fail when email is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeRecordedInReport(t *testing.T) {
|
||||
snap := validator.Snapshot{}
|
||||
r := validator.Validate(snap, validator.ModeMigration)
|
||||
if r.Mode != "migration" {
|
||||
t.Errorf("expected mode migration, got %q", r.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helper ---
|
||||
|
||||
func findRule(results []validator.RuleResult, name string) validator.RuleResult {
|
||||
for _, r := range results {
|
||||
if r.Rule == name {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return validator.RuleResult{Rule: name, Passed: false, Message: "rule not found in report"}
|
||||
}
|
||||
Reference in New Issue
Block a user