generated from coulomb/repo-seed
feat: implement T14, T10 — enforcement middleware, LLDAP adapter
- T14: Unsupported feature registry with 7 pre-registered profile boundaries - T10: LLDAP adapter implementing UserRepository; validator-gated reads 24 tests pass, go vet clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,19 @@
|
|||||||
module keycape
|
module keycape
|
||||||
|
|
||||||
go 1.22
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.12
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
golang.org/x/sys v0.12.0 // indirect
|
golang.org/x/crypto v0.36.0 // indirect
|
||||||
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
37
src/go.sum
37
src/go.sum
@@ -1,18 +1,53 @@
|
|||||||
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||||
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||||
|
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||||
|
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
|
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||||
|
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
294
src/internal/adapters/lldap/adapter.go
Normal file
294
src/internal/adapters/lldap/adapter.go
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
package lldap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
|
||||||
|
"keycape/internal/domain"
|
||||||
|
"keycape/internal/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LDAPConn is a minimal interface over an LDAP connection, enabling test injection.
|
||||||
|
// Only the operations used by the adapter are included; no concrete LDAP types are
|
||||||
|
// exposed through return values or parameters visible outside this package.
|
||||||
|
type LDAPConn interface {
|
||||||
|
Bind(username, password string) error
|
||||||
|
Search(request *ldap.SearchRequest) (*ldap.SearchResult, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAPAdapter implements domain.UserRepository using an LLDAP backend.
|
||||||
|
// All LDAP types are confined to this package — the domain and server layers
|
||||||
|
// are not aware of any LDAP-specific constructs.
|
||||||
|
type LDAPAdapter struct {
|
||||||
|
cfg Config
|
||||||
|
dialFn func(addr string) (LDAPConn, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a production-ready LDAPAdapter that dials real LDAP connections.
|
||||||
|
func New(cfg Config) *LDAPAdapter {
|
||||||
|
return &LDAPAdapter{
|
||||||
|
cfg: cfg,
|
||||||
|
dialFn: defaultDialFn(cfg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewForTest returns an LDAPAdapter with a custom dial function for test injection.
|
||||||
|
// Production code should use New instead.
|
||||||
|
func NewForTest(cfg Config, dialFn func(addr string) (LDAPConn, error)) *LDAPAdapter {
|
||||||
|
return &LDAPAdapter{cfg: cfg, dialFn: dialFn}
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultDialFn returns a dial function that establishes a real LDAP connection.
|
||||||
|
func defaultDialFn(cfg Config) func(addr string) (LDAPConn, error) {
|
||||||
|
return func(addr string) (LDAPConn, error) {
|
||||||
|
u, err := url.Parse(cfg.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lldap: invalid URL %q: %w", cfg.URL, err)
|
||||||
|
}
|
||||||
|
if u.Scheme == "ldaps" {
|
||||||
|
conn, err := ldap.DialTLS("tcp", addr, &tls.Config{
|
||||||
|
InsecureSkipVerify: cfg.TLSSkipVerify, //nolint:gosec // dev flag, documented
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lldap: TLS dial %q: %w", addr, err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
conn, err := ldap.Dial("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lldap: dial %q: %w", addr, err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dial opens a new LDAP connection and performs the service-account bind.
|
||||||
|
func (a *LDAPAdapter) dial() (LDAPConn, error) {
|
||||||
|
u, err := url.Parse(a.cfg.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lldap: invalid URL %q: %w", a.cfg.URL, err)
|
||||||
|
}
|
||||||
|
host := u.Host
|
||||||
|
if host == "" {
|
||||||
|
host = a.cfg.URL // fallback for bare addr passed in tests
|
||||||
|
}
|
||||||
|
conn, err := a.dialFn(host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := conn.Bind(a.cfg.BindDN, a.cfg.BindPW); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("lldap: service bind failed: %w", err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// domain.UserRepository implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// LookupUser retrieves the canonical User for the given username.
|
||||||
|
// Returns domain.ErrUserNotFound when no matching entry exists.
|
||||||
|
// After mapping LDAP attributes the result is run through the canonical
|
||||||
|
// LDAP schema validator; a validation failure is returned as an error.
|
||||||
|
func (a *LDAPAdapter) LookupUser(ctx context.Context, username string) (*domain.User, error) {
|
||||||
|
conn, err := a.dial()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
filter := fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username))
|
||||||
|
req := ldap.NewSearchRequest(
|
||||||
|
a.cfg.userBaseDN(),
|
||||||
|
ldap.ScopeWholeSubtree,
|
||||||
|
ldap.NeverDerefAliases,
|
||||||
|
0, 0, false,
|
||||||
|
filter,
|
||||||
|
[]string{"dn", "uid", "cn", "sn", "mail", "memberOf"},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
result, err := conn.Search(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lldap: search for user %q: %w", username, err)
|
||||||
|
}
|
||||||
|
if len(result.Entries) == 0 {
|
||||||
|
return nil, domain.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := result.Entries[0]
|
||||||
|
user := mapEntryToUser(entry)
|
||||||
|
|
||||||
|
// Run the canonical LDAP schema validator.
|
||||||
|
snap := validator.Snapshot{Users: []domain.User{user}}
|
||||||
|
report := validator.Validate(snap, validator.ModeProvisioning)
|
||||||
|
if !report.Passed {
|
||||||
|
return nil, fmt.Errorf("lldap: validation failed for user %q: %s", username, validationSummary(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupGroups retrieves all groups the user (identified by their LDAP DN) belongs to.
|
||||||
|
func (a *LDAPAdapter) LookupGroups(ctx context.Context, userDN string) ([]domain.Group, error) {
|
||||||
|
conn, err := a.dial()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Search for groups that list the user as a member.
|
||||||
|
filter := fmt.Sprintf("(member=%s)", ldap.EscapeFilter(userDN))
|
||||||
|
req := ldap.NewSearchRequest(
|
||||||
|
a.cfg.groupBaseDN(),
|
||||||
|
ldap.ScopeWholeSubtree,
|
||||||
|
ldap.NeverDerefAliases,
|
||||||
|
0, 0, false,
|
||||||
|
filter,
|
||||||
|
[]string{"dn", "cn", "description"},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
result, err := conn.Search(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lldap: group search for DN %q: %w", userDN, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make([]domain.Group, 0, len(result.Entries))
|
||||||
|
for _, entry := range result.Entries {
|
||||||
|
groups = append(groups, domain.Group{
|
||||||
|
ID: entry.DN,
|
||||||
|
Name: entry.GetAttributeValue("cn"),
|
||||||
|
Description: entry.GetAttributeValue("description"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePassword returns true when the username and password are valid.
|
||||||
|
// It opens a second connection and attempts a user bind. Bind failure (wrong
|
||||||
|
// credentials) returns false, nil. Infrastructure errors return false, err.
|
||||||
|
func (a *LDAPAdapter) ValidatePassword(ctx context.Context, username, password string) (bool, error) {
|
||||||
|
// First resolve the user DN.
|
||||||
|
conn, err := a.dial()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username))
|
||||||
|
req := ldap.NewSearchRequest(
|
||||||
|
a.cfg.userBaseDN(),
|
||||||
|
ldap.ScopeWholeSubtree,
|
||||||
|
ldap.NeverDerefAliases,
|
||||||
|
0, 0, false,
|
||||||
|
filter,
|
||||||
|
[]string{"dn"},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
result, err := conn.Search(req)
|
||||||
|
conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("lldap: DN lookup for user %q: %w", username, err)
|
||||||
|
}
|
||||||
|
if len(result.Entries) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userDN := result.Entries[0].DN
|
||||||
|
|
||||||
|
// Attempt a user bind with the provided password using a fresh connection.
|
||||||
|
host := ldapHost(a.cfg.URL)
|
||||||
|
userConn, err := a.dialFn(host)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer userConn.Close()
|
||||||
|
|
||||||
|
if err := userConn.Bind(userDN, password); err != nil {
|
||||||
|
// Distinguish authentication failure from infrastructure error.
|
||||||
|
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("lldap: user bind for %q: %w", username, err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Attribute mapping helpers (LDAP → canonical domain model).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// mapEntryToUser converts an LDAP entry to a canonical domain.User.
|
||||||
|
// Attribute mapping per spec:
|
||||||
|
// - uid → Username
|
||||||
|
// - cn → DisplayName (sn as fallback)
|
||||||
|
// - sn → DisplayName fallback if cn is empty
|
||||||
|
// - mail → Email
|
||||||
|
// - memberOf → Groups (DNs parsed to group names)
|
||||||
|
// - dn → ID (stable identifier)
|
||||||
|
func mapEntryToUser(entry *ldap.Entry) domain.User {
|
||||||
|
displayName := entry.GetAttributeValue("cn")
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = entry.GetAttributeValue("sn")
|
||||||
|
}
|
||||||
|
|
||||||
|
memberOfs := entry.GetAttributeValues("memberOf")
|
||||||
|
groups := make([]string, 0, len(memberOfs))
|
||||||
|
for _, dn := range memberOfs {
|
||||||
|
groups = append(groups, groupNameFromDN(dn))
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.User{
|
||||||
|
ID: entry.DN,
|
||||||
|
Username: entry.GetAttributeValue("uid"),
|
||||||
|
DisplayName: displayName,
|
||||||
|
Email: entry.GetAttributeValue("mail"),
|
||||||
|
Groups: groups,
|
||||||
|
Enabled: true, // LLDAP does not expose a disabled flag in base schema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupNameFromDN extracts the cn value from an LDAP DN such as
|
||||||
|
// "cn=admins,ou=groups,dc=netkingdom,dc=local" → "admins".
|
||||||
|
// If parsing fails the full DN is returned unchanged.
|
||||||
|
func groupNameFromDN(dn string) string {
|
||||||
|
parts := strings.SplitN(dn, ",", 2)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return dn
|
||||||
|
}
|
||||||
|
kv := strings.SplitN(parts[0], "=", 2)
|
||||||
|
if len(kv) == 2 {
|
||||||
|
return kv[1]
|
||||||
|
}
|
||||||
|
return dn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ldapHost extracts host:port from a URL string; falls back to the raw value.
|
||||||
|
func ldapHost(rawURL string) string {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
return u.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
// validationSummary produces a short string summarising all failed rules.
|
||||||
|
func validationSummary(r validator.Report) string {
|
||||||
|
var msgs []string
|
||||||
|
for _, rule := range r.Structural {
|
||||||
|
if !rule.Passed {
|
||||||
|
msgs = append(msgs, rule.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rule := range r.Semantic {
|
||||||
|
if !rule.Passed {
|
||||||
|
msgs = append(msgs, rule.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(msgs, "; ")
|
||||||
|
}
|
||||||
341
src/internal/adapters/lldap/adapter_test.go
Normal file
341
src/internal/adapters/lldap/adapter_test.go
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
package lldap_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
|
||||||
|
"keycape/internal/adapters/lldap"
|
||||||
|
"keycape/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock LDAP connection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// mockConn implements lldap.LDAPConn for test injection.
|
||||||
|
type mockConn struct {
|
||||||
|
bindFn func(username, password string) error
|
||||||
|
searchFn func(req *ldap.SearchRequest) (*ldap.SearchResult, error)
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConn) Bind(username, password string) error {
|
||||||
|
if m.bindFn != nil {
|
||||||
|
return m.bindFn(username, password)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConn) Search(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
if m.searchFn != nil {
|
||||||
|
return m.searchFn(req)
|
||||||
|
}
|
||||||
|
return &ldap.SearchResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConn) Close() error {
|
||||||
|
m.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// testConfig returns a minimal Config suitable for tests.
|
||||||
|
func testConfig() lldap.Config {
|
||||||
|
return lldap.Config{
|
||||||
|
URL: "ldap://lldap:389",
|
||||||
|
BindDN: "cn=admin,dc=netkingdom,dc=local",
|
||||||
|
BindPW: "secret",
|
||||||
|
BaseDN: "dc=netkingdom,dc=local",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// singleEntryResult builds a SearchResult with one entry for LookupUser tests.
|
||||||
|
func singleEntryResult(dn, uid, cn, sn, mail string, memberOfs []string) *ldap.SearchResult {
|
||||||
|
attrs := []*ldap.EntryAttribute{
|
||||||
|
{Name: "uid", Values: []string{uid}},
|
||||||
|
{Name: "cn", Values: []string{cn}},
|
||||||
|
{Name: "sn", Values: []string{sn}},
|
||||||
|
{Name: "mail", Values: []string{mail}},
|
||||||
|
}
|
||||||
|
if len(memberOfs) > 0 {
|
||||||
|
attrs = append(attrs, &ldap.EntryAttribute{Name: "memberOf", Values: memberOfs})
|
||||||
|
}
|
||||||
|
return &ldap.SearchResult{
|
||||||
|
Entries: []*ldap.Entry{
|
||||||
|
{DN: dn, Attributes: attrs},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeAdapter returns an LDAPAdapter using the exported NewForTest constructor.
|
||||||
|
// We use the package-level helper exported for testing.
|
||||||
|
func makeAdapter(cfg lldap.Config, conn lldap.LDAPConn) *lldap.LDAPAdapter {
|
||||||
|
return lldap.NewForTest(cfg, func(_ string) (lldap.LDAPConn, error) {
|
||||||
|
return conn, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LookupUser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLookupUser_Success(t *testing.T) {
|
||||||
|
dn := "uid=alice,ou=users,dc=netkingdom,dc=local"
|
||||||
|
conn := &mockConn{
|
||||||
|
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
return singleEntryResult(
|
||||||
|
dn, "alice", "Alice Liddell", "Liddell", "alice@example.com",
|
||||||
|
[]string{"cn=admins,ou=groups,dc=netkingdom,dc=local"},
|
||||||
|
), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter := makeAdapter(testConfig(), conn)
|
||||||
|
user, err := adapter.LookupUser(context.Background(), "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Username != "alice" {
|
||||||
|
t.Errorf("Username: want %q, got %q", "alice", user.Username)
|
||||||
|
}
|
||||||
|
if user.DisplayName != "Alice Liddell" {
|
||||||
|
t.Errorf("DisplayName: want %q, got %q", "Alice Liddell", user.DisplayName)
|
||||||
|
}
|
||||||
|
if user.Email != "alice@example.com" {
|
||||||
|
t.Errorf("Email: want %q, got %q", "alice@example.com", user.Email)
|
||||||
|
}
|
||||||
|
if user.ID != dn {
|
||||||
|
t.Errorf("ID: want %q, got %q", dn, user.ID)
|
||||||
|
}
|
||||||
|
if len(user.Groups) != 1 || user.Groups[0] != "admins" {
|
||||||
|
t.Errorf("Groups: want [admins], got %v", user.Groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupUser_DisplayName_FallsBackToSN(t *testing.T) {
|
||||||
|
dn := "uid=bob,ou=users,dc=netkingdom,dc=local"
|
||||||
|
conn := &mockConn{
|
||||||
|
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
return singleEntryResult(dn, "bob", "", "Builder", "bob@example.com", nil), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter := makeAdapter(testConfig(), conn)
|
||||||
|
user, err := adapter.LookupUser(context.Background(), "bob")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if user.DisplayName != "Builder" {
|
||||||
|
t.Errorf("DisplayName fallback: want %q, got %q", "Builder", user.DisplayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupUser_NotFound(t *testing.T) {
|
||||||
|
conn := &mockConn{
|
||||||
|
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
return &ldap.SearchResult{}, nil // zero entries
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter := makeAdapter(testConfig(), conn)
|
||||||
|
_, err := adapter.LookupUser(context.Background(), "ghost")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, domain.ErrUserNotFound) {
|
||||||
|
t.Errorf("expected domain.ErrUserNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupUser_ValidationFailure(t *testing.T) {
|
||||||
|
// Return an entry with an empty DisplayName and empty sn — will fail validator.
|
||||||
|
dn := "uid=broken,ou=users,dc=netkingdom,dc=local"
|
||||||
|
conn := &mockConn{
|
||||||
|
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
attrs := []*ldap.EntryAttribute{
|
||||||
|
{Name: "uid", Values: []string{"broken"}},
|
||||||
|
{Name: "cn", Values: []string{""}},
|
||||||
|
{Name: "sn", Values: []string{""}},
|
||||||
|
{Name: "mail", Values: []string{"broken@example.com"}},
|
||||||
|
}
|
||||||
|
return &ldap.SearchResult{
|
||||||
|
Entries: []*ldap.Entry{{DN: dn, Attributes: attrs}},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter := makeAdapter(testConfig(), conn)
|
||||||
|
_, err := adapter.LookupUser(context.Background(), "broken")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LookupGroups
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLookupGroups_Success(t *testing.T) {
|
||||||
|
userDN := "uid=alice,ou=users,dc=netkingdom,dc=local"
|
||||||
|
conn := &mockConn{
|
||||||
|
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
return &ldap.SearchResult{
|
||||||
|
Entries: []*ldap.Entry{
|
||||||
|
{
|
||||||
|
DN: "cn=admins,ou=groups,dc=netkingdom,dc=local",
|
||||||
|
Attributes: []*ldap.EntryAttribute{
|
||||||
|
{Name: "cn", Values: []string{"admins"}},
|
||||||
|
{Name: "description", Values: []string{"Admins group"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter := makeAdapter(testConfig(), conn)
|
||||||
|
groups, err := adapter.LookupGroups(context.Background(), userDN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(groups) != 1 {
|
||||||
|
t.Fatalf("want 1 group, got %d", len(groups))
|
||||||
|
}
|
||||||
|
if groups[0].Name != "admins" {
|
||||||
|
t.Errorf("Group name: want %q, got %q", "admins", groups[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupGroups_Empty(t *testing.T) {
|
||||||
|
conn := &mockConn{
|
||||||
|
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
return &ldap.SearchResult{}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
adapter := makeAdapter(testConfig(), conn)
|
||||||
|
groups, err := adapter.LookupGroups(context.Background(), "uid=nobody,ou=users,dc=test,dc=local")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(groups) != 0 {
|
||||||
|
t.Errorf("expected 0 groups, got %d", len(groups))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ValidatePassword
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestValidatePassword_Success(t *testing.T) {
|
||||||
|
userDN := "uid=alice,ou=users,dc=netkingdom,dc=local"
|
||||||
|
|
||||||
|
callCount := 0
|
||||||
|
conn := &mockConn{
|
||||||
|
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
attrs := []*ldap.EntryAttribute{{Name: "dn", Values: []string{userDN}}}
|
||||||
|
return &ldap.SearchResult{
|
||||||
|
Entries: []*ldap.Entry{{DN: userDN, Attributes: attrs}},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
bindFn: func(username, password string) error {
|
||||||
|
callCount++
|
||||||
|
// First call: service bind (BindDN); second call: user bind.
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide two connections: one for the DN lookup and one for the user bind.
|
||||||
|
connIdx := 0
|
||||||
|
conns := []*mockConn{conn, {bindFn: func(u, p string) error { return nil }}}
|
||||||
|
adapter := lldap.NewForTest(testConfig(), func(_ string) (lldap.LDAPConn, error) {
|
||||||
|
c := conns[connIdx]
|
||||||
|
connIdx++
|
||||||
|
return c, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
ok, err := adapter.ValidatePassword(context.Background(), "alice", "correct")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Error("expected ValidatePassword to return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePassword_WrongPassword(t *testing.T) {
|
||||||
|
userDN := "uid=alice,ou=users,dc=netkingdom,dc=local"
|
||||||
|
|
||||||
|
searchConn := &mockConn{
|
||||||
|
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
attrs := []*ldap.EntryAttribute{{Name: "dn", Values: []string{userDN}}}
|
||||||
|
return &ldap.SearchResult{
|
||||||
|
Entries: []*ldap.Entry{{DN: userDN, Attributes: attrs}},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
userConn := &mockConn{
|
||||||
|
bindFn: func(username, password string) error {
|
||||||
|
return ldap.NewError(ldap.LDAPResultInvalidCredentials, errors.New("invalid credentials"))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
connIdx := 0
|
||||||
|
conns := []lldap.LDAPConn{searchConn, userConn}
|
||||||
|
adapter := lldap.NewForTest(testConfig(), func(_ string) (lldap.LDAPConn, error) {
|
||||||
|
c := conns[connIdx]
|
||||||
|
connIdx++
|
||||||
|
return c, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
ok, err := adapter.ValidatePassword(context.Background(), "alice", "wrong")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Error("expected ValidatePassword to return false for wrong password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePassword_BindFailure(t *testing.T) {
|
||||||
|
// Service bind fails — infrastructure error.
|
||||||
|
conn := &mockConn{
|
||||||
|
bindFn: func(username, password string) error {
|
||||||
|
return errors.New("connection refused")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
adapter := lldap.NewForTest(testConfig(), func(_ string) (lldap.LDAPConn, error) {
|
||||||
|
return conn, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
ok, err := adapter.ValidatePassword(context.Background(), "alice", "pass")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected infrastructure error, got nil")
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Error("expected false on bind failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePassword_UserNotFound(t *testing.T) {
|
||||||
|
conn := &mockConn{
|
||||||
|
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||||
|
return &ldap.SearchResult{}, nil // no entries
|
||||||
|
},
|
||||||
|
}
|
||||||
|
adapter := makeAdapter(testConfig(), conn)
|
||||||
|
|
||||||
|
ok, err := adapter.ValidatePassword(context.Background(), "ghost", "pass")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Error("expected false for non-existent user")
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/internal/adapters/lldap/config.go
Normal file
55
src/internal/adapters/lldap/config.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// Package lldap implements the UserRepository adapter for LLDAP (Lightweight LDAP).
|
||||||
|
// No LDAP types are exposed beyond this package — the domain and server layers
|
||||||
|
// interact exclusively through the domain.UserRepository interface.
|
||||||
|
package lldap
|
||||||
|
|
||||||
|
// Config holds all connection parameters for the LLDAP adapter.
|
||||||
|
type Config struct {
|
||||||
|
// URL is the LDAP server address, e.g. "ldap://lldap:389" or "ldaps://lldap:636".
|
||||||
|
URL string
|
||||||
|
|
||||||
|
// BindDN is the distinguished name used for the service account bind,
|
||||||
|
// e.g. "cn=admin,dc=netkingdom,dc=local".
|
||||||
|
BindDN string
|
||||||
|
|
||||||
|
// BindPW is the service account password.
|
||||||
|
BindPW string
|
||||||
|
|
||||||
|
// BaseDN is the search base, e.g. "dc=netkingdom,dc=local".
|
||||||
|
BaseDN string
|
||||||
|
|
||||||
|
// UserOU is the organisational unit for users. Defaults to "ou=users" when empty.
|
||||||
|
UserOU string
|
||||||
|
|
||||||
|
// GroupOU is the organisational unit for groups. Defaults to "ou=groups" when empty.
|
||||||
|
GroupOU string
|
||||||
|
|
||||||
|
// TLSSkipVerify disables TLS certificate verification. For development only.
|
||||||
|
TLSSkipVerify bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// userOU returns the effective UserOU, falling back to the default.
|
||||||
|
func (c Config) userOU() string {
|
||||||
|
if c.UserOU != "" {
|
||||||
|
return c.UserOU
|
||||||
|
}
|
||||||
|
return "ou=users"
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupOU returns the effective GroupOU, falling back to the default.
|
||||||
|
func (c Config) groupOU() string {
|
||||||
|
if c.GroupOU != "" {
|
||||||
|
return c.GroupOU
|
||||||
|
}
|
||||||
|
return "ou=groups"
|
||||||
|
}
|
||||||
|
|
||||||
|
// userBaseDN returns the full DN for the user search base.
|
||||||
|
func (c Config) userBaseDN() string {
|
||||||
|
return c.userOU() + "," + c.BaseDN
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupBaseDN returns the full DN for the group search base.
|
||||||
|
func (c Config) groupBaseDN() string {
|
||||||
|
return c.groupOU() + "," + c.BaseDN
|
||||||
|
}
|
||||||
27
src/internal/domain/repository.go
Normal file
27
src/internal/domain/repository.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// UserRepository is the adapter interface between the OIDC layer and the identity directory.
|
||||||
|
// The server/ layer sees ONLY this interface — no LDAP types leak through.
|
||||||
|
type UserRepository interface {
|
||||||
|
// LookupUser retrieves the canonical User record for the given username.
|
||||||
|
// Returns an error wrapping ErrUserNotFound when the user does not exist.
|
||||||
|
LookupUser(ctx context.Context, username string) (*User, error)
|
||||||
|
|
||||||
|
// LookupGroups retrieves all groups the user (identified by their LDAP DN) belongs to.
|
||||||
|
LookupGroups(ctx context.Context, userDN string) ([]Group, error)
|
||||||
|
|
||||||
|
// ValidatePassword returns true when the username and password are correct.
|
||||||
|
// Returns false (not an error) for wrong credentials; errors indicate
|
||||||
|
// infrastructure failures (network, config, etc.).
|
||||||
|
ValidatePassword(ctx context.Context, username, password string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrUserNotFound is returned by UserRepository.LookupUser when the
|
||||||
|
// requested user does not exist in the directory.
|
||||||
|
const ErrUserNotFound = userNotFound("user not found")
|
||||||
|
|
||||||
|
type userNotFound string
|
||||||
|
|
||||||
|
func (e userNotFound) Error() string { return string(e) }
|
||||||
203
src/internal/server/errors/enforcement.go
Normal file
203
src/internal/server/errors/enforcement.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
// Package errors implements the unsupported feature enforcement layer for KeyCape.
|
||||||
|
// Every request passes through the Registry middleware before reaching any handler.
|
||||||
|
// If a registered feature is detected the middleware writes a ProfileError JSON
|
||||||
|
// response, emits an EventUnsupportedFeature telemetry event, and short-circuits
|
||||||
|
// the handler chain. Adding a new unsupported feature requires only a call to
|
||||||
|
// Register — no handler changes are needed.
|
||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
profileerrors "keycape/internal/errors"
|
||||||
|
"keycape/internal/server/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnsupportedFeature describes a profile boundary that KeyCape enforces.
|
||||||
|
type UnsupportedFeature struct {
|
||||||
|
// Name is a stable string identifier used in telemetry and error payloads.
|
||||||
|
Name string
|
||||||
|
// ErrorType is the profile error category emitted when this feature is triggered.
|
||||||
|
ErrorType profileerrors.ErrorType
|
||||||
|
// Description is a human-readable explanation of why the feature is blocked.
|
||||||
|
Description string
|
||||||
|
// Detector reports whether the given request triggers this feature.
|
||||||
|
Detector func(r *http.Request) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry holds all known unsupported features and exposes middleware that
|
||||||
|
// enforces them on every incoming request.
|
||||||
|
type Registry struct {
|
||||||
|
features []UnsupportedFeature
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry returns an empty Registry. Use Register to add features and
|
||||||
|
// DefaultRegistry to obtain one pre-populated with the spec-mandated set.
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register appends a feature to the registry. Registered features are checked
|
||||||
|
// in insertion order; the first match wins.
|
||||||
|
func (reg *Registry) Register(f UnsupportedFeature) {
|
||||||
|
reg.features = append(reg.features, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware returns an http.Handler that evaluates all registered features
|
||||||
|
// for every request before delegating to next.
|
||||||
|
//
|
||||||
|
// If a feature is triggered:
|
||||||
|
// - A ProfileError JSON response is written with an appropriate HTTP status.
|
||||||
|
// - An EventUnsupportedFeature telemetry event is emitted via the Emitter
|
||||||
|
// stored in the request context (a NoopEmitter is used when none is set).
|
||||||
|
// - next is NOT called.
|
||||||
|
//
|
||||||
|
// If no feature matches, next is called normally.
|
||||||
|
func (reg *Registry) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
for _, f := range reg.features {
|
||||||
|
if f.Detector(r) {
|
||||||
|
pe := &profileerrors.ProfileError{
|
||||||
|
Error: f.ErrorType,
|
||||||
|
Description: f.Description,
|
||||||
|
Feature: f.Name,
|
||||||
|
}
|
||||||
|
pe.Write(w, httpStatusFor(f.ErrorType))
|
||||||
|
|
||||||
|
em := telemetry.EmitterFromContext(r.Context())
|
||||||
|
em.Emit(r.Context(), telemetry.Event{
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
EventType: telemetry.EventUnsupportedFeature,
|
||||||
|
Feature: f.Name,
|
||||||
|
ErrorType: string(f.ErrorType),
|
||||||
|
Endpoint: r.URL.Path,
|
||||||
|
Result: "failure",
|
||||||
|
Environment: "",
|
||||||
|
TraceID: "",
|
||||||
|
ClientID: r.URL.Query().Get("client_id"),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpStatusFor maps an ErrorType to its canonical HTTP status code.
|
||||||
|
func httpStatusFor(et profileerrors.ErrorType) int {
|
||||||
|
switch et {
|
||||||
|
case profileerrors.ErrInvalidProfileUsage:
|
||||||
|
return http.StatusBadRequest
|
||||||
|
case profileerrors.ErrRejectedForSafety:
|
||||||
|
return http.StatusForbidden
|
||||||
|
case profileerrors.ErrKeycloakModeOnly:
|
||||||
|
return http.StatusNotImplemented
|
||||||
|
default: // ErrFeatureNotSupported
|
||||||
|
return http.StatusNotImplemented
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default feature set (spec §4 — normative).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// DefaultRegistry returns a Registry pre-populated with all spec-mandated
|
||||||
|
// unsupported features. No handler changes are required to enforce new entries.
|
||||||
|
func DefaultRegistry() *Registry {
|
||||||
|
reg := NewRegistry()
|
||||||
|
|
||||||
|
// 1. Dynamic client registration (RFC 7591) — not in the profile.
|
||||||
|
reg.Register(UnsupportedFeature{
|
||||||
|
Name: "dynamic_client_registration",
|
||||||
|
ErrorType: profileerrors.ErrFeatureNotSupported,
|
||||||
|
Description: "Dynamic client registration is not part of the NetKingdom IAM Profile. Register clients statically in KeyCape configuration.",
|
||||||
|
Detector: func(r *http.Request) bool {
|
||||||
|
return (r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/connect/register")) ||
|
||||||
|
strings.Contains(r.URL.Path, "registration")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Implicit flow — blocked for security.
|
||||||
|
reg.Register(UnsupportedFeature{
|
||||||
|
Name: "implicit_flow",
|
||||||
|
ErrorType: profileerrors.ErrRejectedForSafety,
|
||||||
|
Description: "The implicit flow (response_type=token or id_token) is rejected. Use the authorization code flow with PKCE.",
|
||||||
|
Detector: func(r *http.Request) bool {
|
||||||
|
rt := r.URL.Query().Get("response_type")
|
||||||
|
if rt == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Blocked when response_type contains "token" or "id_token" but NOT when it is exactly "code".
|
||||||
|
// "code token" (hybrid) is also blocked.
|
||||||
|
return rt == "token" || rt == "id_token" || strings.Contains(rt, "token") && rt != "code"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Wildcard redirect_uri — blocked for security.
|
||||||
|
reg.Register(UnsupportedFeature{
|
||||||
|
Name: "wildcard_redirect_uri",
|
||||||
|
ErrorType: profileerrors.ErrRejectedForSafety,
|
||||||
|
Description: "Wildcard redirect URIs are not permitted. Register exact redirect URIs in the client configuration.",
|
||||||
|
Detector: func(r *http.Request) bool {
|
||||||
|
return strings.Contains(r.URL.Query().Get("redirect_uri"), "*")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Identity brokering — available only in Keycloak mode.
|
||||||
|
reg.Register(UnsupportedFeature{
|
||||||
|
Name: "identity_broker",
|
||||||
|
ErrorType: profileerrors.ErrKeycloakModeOnly,
|
||||||
|
Description: "Identity brokering is available only in expanded (Keycloak) mode.",
|
||||||
|
Detector: func(r *http.Request) bool {
|
||||||
|
return strings.Contains(r.URL.Path, "/broker/")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. PKCE plain method — blocked for security (must use S256).
|
||||||
|
// Registered BEFORE missing_pkce so a plain-method request is reported
|
||||||
|
// as pkce_plain_method, not missing_pkce.
|
||||||
|
reg.Register(UnsupportedFeature{
|
||||||
|
Name: "pkce_plain_method",
|
||||||
|
ErrorType: profileerrors.ErrRejectedForSafety,
|
||||||
|
Description: "PKCE plain code challenge method is not allowed. Use S256.",
|
||||||
|
Detector: func(r *http.Request) bool {
|
||||||
|
return r.URL.Query().Get("code_challenge_method") == "plain"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 6. Missing PKCE on /authorize — invalid profile usage.
|
||||||
|
reg.Register(UnsupportedFeature{
|
||||||
|
Name: "missing_pkce",
|
||||||
|
ErrorType: profileerrors.ErrInvalidProfileUsage,
|
||||||
|
Description: "Requests to /authorize must include a code_challenge (PKCE S256 required).",
|
||||||
|
Detector: func(r *http.Request) bool {
|
||||||
|
return strings.HasSuffix(r.URL.Path, "/authorize") &&
|
||||||
|
r.URL.Query().Get("code_challenge") == ""
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 7. Unknown grant type on /token.
|
||||||
|
reg.Register(UnsupportedFeature{
|
||||||
|
Name: "unknown_grant_type",
|
||||||
|
ErrorType: profileerrors.ErrFeatureNotSupported,
|
||||||
|
Description: "Only authorization_code and refresh_token grant types are supported.",
|
||||||
|
Detector: func(r *http.Request) bool {
|
||||||
|
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/token") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
gt := r.URL.Query().Get("grant_type")
|
||||||
|
if gt == "" {
|
||||||
|
// Also check form body if already parsed — callers may pre-parse.
|
||||||
|
gt = r.FormValue("grant_type")
|
||||||
|
}
|
||||||
|
if gt == "" {
|
||||||
|
return false // no grant_type present; let the handler decide
|
||||||
|
}
|
||||||
|
return gt != "authorization_code" && gt != "refresh_token"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return reg
|
||||||
|
}
|
||||||
299
src/internal/server/errors/enforcement_test.go
Normal file
299
src/internal/server/errors/enforcement_test.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package errors_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
profileerrors "keycape/internal/errors"
|
||||||
|
serverrors "keycape/internal/server/errors"
|
||||||
|
"keycape/internal/server/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// recEmitter records emitted events for assertions.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type recEmitter struct {
|
||||||
|
events []telemetry.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recEmitter) Emit(_ context.Context, ev telemetry.Event) {
|
||||||
|
r.events = append(r.events, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRecEmitter() *recEmitter { return &recEmitter{} }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: build request with emitter in context.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func reqWithEmitter(method, target string, em telemetry.Emitter) *http.Request {
|
||||||
|
req := httptest.NewRequest(method, target, nil)
|
||||||
|
ctx := telemetry.WithEmitter(req.Context(), em)
|
||||||
|
return req.WithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests — default registry features triggered.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestDefaultRegistry_DynamicClientRegistration_PostConnect(t *testing.T) {
|
||||||
|
reg := serverrors.DefaultRegistry()
|
||||||
|
em := newRecEmitter()
|
||||||
|
handler := reg.Middleware(alwaysOK())
|
||||||
|
|
||||||
|
w := serve(handler, reqWithEmitter(http.MethodPost, "/connect/register", em))
|
||||||
|
|
||||||
|
assertProfileError(t, w, profileerrors.ErrFeatureNotSupported, "dynamic_client_registration")
|
||||||
|
assertTelemetryEmitted(t, em, "dynamic_client_registration")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRegistry_DynamicClientRegistration_PathContainsRegistration(t *testing.T) {
|
||||||
|
reg := serverrors.DefaultRegistry()
|
||||||
|
em := newRecEmitter()
|
||||||
|
handler := reg.Middleware(alwaysOK())
|
||||||
|
|
||||||
|
w := serve(handler, reqWithEmitter(http.MethodGet, "/oauth/registration/info", em))
|
||||||
|
|
||||||
|
assertProfileError(t, w, profileerrors.ErrFeatureNotSupported, "dynamic_client_registration")
|
||||||
|
assertTelemetryEmitted(t, em, "dynamic_client_registration")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRegistry_ImplicitFlow_Token(t *testing.T) {
|
||||||
|
reg := serverrors.DefaultRegistry()
|
||||||
|
em := newRecEmitter()
|
||||||
|
handler := reg.Middleware(alwaysOK())
|
||||||
|
|
||||||
|
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?response_type=token", em))
|
||||||
|
|
||||||
|
assertProfileError(t, w, profileerrors.ErrRejectedForSafety, "implicit_flow")
|
||||||
|
assertTelemetryEmitted(t, em, "implicit_flow")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRegistry_ImplicitFlow_IDToken(t *testing.T) {
|
||||||
|
reg := serverrors.DefaultRegistry()
|
||||||
|
em := newRecEmitter()
|
||||||
|
handler := reg.Middleware(alwaysOK())
|
||||||
|
|
||||||
|
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?response_type=id_token", em))
|
||||||
|
|
||||||
|
assertProfileError(t, w, profileerrors.ErrRejectedForSafety, "implicit_flow")
|
||||||
|
assertTelemetryEmitted(t, em, "implicit_flow")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRegistry_WildcardRedirectURI(t *testing.T) {
|
||||||
|
reg := serverrors.DefaultRegistry()
|
||||||
|
em := newRecEmitter()
|
||||||
|
handler := reg.Middleware(alwaysOK())
|
||||||
|
|
||||||
|
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?redirect_uri=https%3A%2F%2Fexample.com%2F*%2Fcb", em))
|
||||||
|
|
||||||
|
assertProfileError(t, w, profileerrors.ErrRejectedForSafety, "wildcard_redirect_uri")
|
||||||
|
assertTelemetryEmitted(t, em, "wildcard_redirect_uri")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRegistry_IdentityBroker(t *testing.T) {
|
||||||
|
reg := serverrors.DefaultRegistry()
|
||||||
|
em := newRecEmitter()
|
||||||
|
handler := reg.Middleware(alwaysOK())
|
||||||
|
|
||||||
|
w := serve(handler, reqWithEmitter(http.MethodGet, "/auth/realms/master/broker/github/endpoint", em))
|
||||||
|
|
||||||
|
assertProfileError(t, w, profileerrors.ErrKeycloakModeOnly, "identity_broker")
|
||||||
|
assertTelemetryEmitted(t, em, "identity_broker")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRegistry_MissingPKCE(t *testing.T) {
|
||||||
|
reg := serverrors.DefaultRegistry()
|
||||||
|
em := newRecEmitter()
|
||||||
|
handler := reg.Middleware(alwaysOK())
|
||||||
|
|
||||||
|
// /authorize without code_challenge
|
||||||
|
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?response_type=code&client_id=myapp", em))
|
||||||
|
|
||||||
|
assertProfileError(t, w, profileerrors.ErrInvalidProfileUsage, "missing_pkce")
|
||||||
|
assertTelemetryEmitted(t, em, "missing_pkce")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRegistry_MissingPKCE_WithCodeChallenge_PassesThrough(t *testing.T) {
|
||||||
|
reg := serverrors.DefaultRegistry()
|
||||||
|
em := newRecEmitter()
|
||||||
|
called := false
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
handler := reg.Middleware(next)
|
||||||
|
|
||||||
|
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?response_type=code&code_challenge=abc&code_challenge_method=S256", em))
|
||||||
|
|
||||||
|
if !called {
|
||||||
|
t.Fatal("expected next handler to be called when code_challenge is present")
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRegistry_PKCEPlainMethod(t *testing.T) {
|
||||||
|
reg := serverrors.DefaultRegistry()
|
||||||
|
em := newRecEmitter()
|
||||||
|
handler := reg.Middleware(alwaysOK())
|
||||||
|
|
||||||
|
w := serve(handler, reqWithEmitter(http.MethodGet, "/authorize?code_challenge=abc&code_challenge_method=plain", em))
|
||||||
|
|
||||||
|
assertProfileError(t, w, profileerrors.ErrRejectedForSafety, "pkce_plain_method")
|
||||||
|
assertTelemetryEmitted(t, em, "pkce_plain_method")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRegistry_UnknownGrantType(t *testing.T) {
|
||||||
|
reg := serverrors.DefaultRegistry()
|
||||||
|
em := newRecEmitter()
|
||||||
|
handler := reg.Middleware(alwaysOK())
|
||||||
|
|
||||||
|
w := serve(handler, reqWithEmitter(http.MethodPost, "/token?grant_type=client_credentials", em))
|
||||||
|
|
||||||
|
assertProfileError(t, w, profileerrors.ErrFeatureNotSupported, "unknown_grant_type")
|
||||||
|
assertTelemetryEmitted(t, em, "unknown_grant_type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRegistry_UnknownGrantType_AllowedTypes(t *testing.T) {
|
||||||
|
reg := serverrors.DefaultRegistry()
|
||||||
|
handler := reg.Middleware(alwaysOK())
|
||||||
|
|
||||||
|
for _, gt := range []string{"authorization_code", "refresh_token"} {
|
||||||
|
req := reqWithEmitter(http.MethodPost, "/token?grant_type="+gt, newRecEmitter())
|
||||||
|
w := serve(handler, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("grant_type=%q: expected 200 (pass-through), got %d: %s", gt, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests — no feature triggered: passes through.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestDefaultRegistry_NoMatchPassesThrough(t *testing.T) {
|
||||||
|
reg := serverrors.DefaultRegistry()
|
||||||
|
em := newRecEmitter()
|
||||||
|
called := false
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
handler := reg.Middleware(next)
|
||||||
|
|
||||||
|
w := serve(handler, reqWithEmitter(http.MethodGet, "/userinfo", em))
|
||||||
|
|
||||||
|
if !called {
|
||||||
|
t.Fatal("expected next handler to be called for unmatched request")
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if len(em.events) != 0 {
|
||||||
|
t.Fatalf("expected no telemetry events, got %d", len(em.events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests — custom feature registration.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestRegistry_CustomFeature(t *testing.T) {
|
||||||
|
reg := serverrors.NewRegistry()
|
||||||
|
reg.Register(serverrors.UnsupportedFeature{
|
||||||
|
Name: "test_feature",
|
||||||
|
ErrorType: profileerrors.ErrFeatureNotSupported,
|
||||||
|
Description: "test feature blocked",
|
||||||
|
Detector: func(r *http.Request) bool { return strings.Contains(r.URL.Path, "/test-blocked") },
|
||||||
|
})
|
||||||
|
em := newRecEmitter()
|
||||||
|
handler := reg.Middleware(alwaysOK())
|
||||||
|
|
||||||
|
w := serve(handler, reqWithEmitter(http.MethodGet, "/test-blocked/foo", em))
|
||||||
|
|
||||||
|
assertProfileError(t, w, profileerrors.ErrFeatureNotSupported, "test_feature")
|
||||||
|
assertTelemetryEmitted(t, em, "test_feature")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_CustomFeature_NoMatch_PassesThrough(t *testing.T) {
|
||||||
|
reg := serverrors.NewRegistry()
|
||||||
|
reg.Register(serverrors.UnsupportedFeature{
|
||||||
|
Name: "test_feature",
|
||||||
|
ErrorType: profileerrors.ErrFeatureNotSupported,
|
||||||
|
Description: "test feature blocked",
|
||||||
|
Detector: func(r *http.Request) bool { return strings.Contains(r.URL.Path, "/test-blocked") },
|
||||||
|
})
|
||||||
|
called := false
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
handler := reg.Middleware(next)
|
||||||
|
|
||||||
|
w := serve(handler, reqWithEmitter(http.MethodGet, "/safe-path", newRecEmitter()))
|
||||||
|
|
||||||
|
if !called {
|
||||||
|
t.Fatal("expected next to be called when no feature matches")
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func alwaysOK() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serve(h http.Handler, r *http.Request) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertProfileError(t *testing.T, w *httptest.ResponseRecorder, errType profileerrors.ErrorType, feature string) {
|
||||||
|
t.Helper()
|
||||||
|
if w.Code == http.StatusOK {
|
||||||
|
t.Fatalf("expected non-200 status, got 200")
|
||||||
|
}
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "application/json") {
|
||||||
|
t.Fatalf("expected application/json content type, got %q", ct)
|
||||||
|
}
|
||||||
|
var pe profileerrors.ProfileError
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&pe); err != nil {
|
||||||
|
t.Fatalf("failed to decode ProfileError: %v", err)
|
||||||
|
}
|
||||||
|
if pe.Error != errType {
|
||||||
|
t.Errorf("expected error type %q, got %q", errType, pe.Error)
|
||||||
|
}
|
||||||
|
if pe.Feature != feature {
|
||||||
|
t.Errorf("expected feature %q, got %q", feature, pe.Feature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertTelemetryEmitted(t *testing.T, em *recEmitter, feature string) {
|
||||||
|
t.Helper()
|
||||||
|
if len(em.events) == 0 {
|
||||||
|
t.Fatalf("expected telemetry event for feature %q, got none", feature)
|
||||||
|
}
|
||||||
|
last := em.events[len(em.events)-1]
|
||||||
|
if last.EventType != telemetry.EventUnsupportedFeature {
|
||||||
|
t.Errorf("expected event type %q, got %q", telemetry.EventUnsupportedFeature, last.EventType)
|
||||||
|
}
|
||||||
|
if last.Feature != feature {
|
||||||
|
t.Errorf("expected feature %q in event, got %q", feature, last.Feature)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user