Add directory group resolver adapters
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled

This commit is contained in:
2026-05-17 07:24:50 +02:00
parent ac9cf09545
commit 32933c71f9
7 changed files with 607 additions and 1 deletions

View File

@@ -0,0 +1,3 @@
// Package directory defines external group resolver adapters for Graph, SCIM,
// LDAP/AD, and Keycloak directory sources.
package directory

View File

@@ -0,0 +1,96 @@
package directory
import (
"sort"
"github.com/netkingdom/flex-auth/pkg/api"
)
// MergeResults combines resolver results while keeping source metadata.
func MergeResults(subject api.SubjectRef, results ...ResolveResult) SubjectEnrichment {
groups := map[string]struct{}{}
roles := map[api.CanonicalRole]struct{}{}
metadata := map[string]any{}
enrichment := SubjectEnrichment{
Subject: subject,
Metadata: metadata,
}
for _, result := range results {
for _, group := range result.Groups {
groups[group.ID] = struct{}{}
if group.Descriptor != nil {
enrichment.Descriptors = append(enrichment.Descriptors, *group.Descriptor)
}
}
for _, role := range result.Roles {
roles[role.Role] = struct{}{}
}
if !result.Freshness.RetrievedAt.IsZero() || result.Freshness.Source != "" {
enrichment.Freshness = append(enrichment.Freshness, result.Freshness)
}
if result.Overage.Detected {
enrichment.Overage = append(enrichment.Overage, result.Overage)
}
if result.Source != "" {
metadata["source:"+string(result.Source)] = true
}
}
enrichment.Groups = sortedStringKeys(groups)
enrichment.Roles = sortedRoleKeys(roles)
return enrichment
}
// ApplyToSubject returns a subject with resolved groups, roles, and metadata.
func ApplyToSubject(subject api.Subject, enrichment SubjectEnrichment) api.Subject {
out := subject
out.Groups = mergeStrings(out.Groups, enrichment.Groups)
out.Roles = mergeRoles(out.Roles, enrichment.Roles)
if out.Metadata == nil {
out.Metadata = map[string]any{}
}
out.Metadata["directory_freshness"] = enrichment.Freshness
out.Metadata["directory_overage"] = enrichment.Overage
return out
}
func mergeStrings(a, b []string) []string {
items := map[string]struct{}{}
for _, value := range a {
items[value] = struct{}{}
}
for _, value := range b {
items[value] = struct{}{}
}
return sortedStringKeys(items)
}
func mergeRoles(a, b []api.CanonicalRole) []api.CanonicalRole {
items := map[api.CanonicalRole]struct{}{}
for _, value := range a {
items[value] = struct{}{}
}
for _, value := range b {
items[value] = struct{}{}
}
return sortedRoleKeys(items)
}
func sortedStringKeys(items map[string]struct{}) []string {
keys := make([]string, 0, len(items))
for key := range items {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func sortedRoleKeys(items map[api.CanonicalRole]struct{}) []api.CanonicalRole {
keys := make([]api.CanonicalRole, 0, len(items))
for key := range items {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
return keys
}

View File

@@ -0,0 +1,200 @@
package directory
import (
"context"
"fmt"
"time"
"github.com/netkingdom/flex-auth/pkg/api"
)
// GraphClient is the Microsoft Graph group lookup boundary.
type GraphClient interface {
GetMemberGroups(context.Context, string) ([]ExternalGroup, OverageMetadata, error)
}
// SCIMClient is the SCIM group lookup boundary.
type SCIMClient interface {
GroupsForUser(context.Context, string) ([]ExternalGroup, error)
}
// LDAPClient is the LDAP/AD group lookup boundary.
type LDAPClient interface {
GroupsForDN(context.Context, string) ([]ExternalGroup, error)
}
// KeycloakClient is the Keycloak admin API group lookup boundary.
type KeycloakClient interface {
GroupsForUser(context.Context, string) ([]ExternalGroup, error)
}
// ExternalGroup is a provider-native group normalized enough for flex-auth.
type ExternalGroup struct {
ID string
DisplayName string
Descriptor *api.CaringAccessDescriptor
Metadata map[string]any
}
// GraphResolver resolves Microsoft Graph groups, including token overage.
type GraphResolver struct {
Client GraphClient
OrganizationRelation api.OrganizationRelation
MaxAge time.Duration
}
func (r GraphResolver) ResolveGroups(ctx context.Context, request ResolveRequest) (ResolveResult, error) {
if r.Client == nil {
return ResolveResult{}, fmt.Errorf("graph client is required")
}
now := resolveNow(request)
groups, overage, err := r.Client.GetMemberGroups(ctx, request.Subject.ID)
if err != nil {
return ResolveResult{}, err
}
if tokenIndicatesGraphOverage(request.Claims) {
overage.Detected = true
if overage.Claim == "" {
overage.Claim = "_claim_names.groups"
}
}
return ResolveResult{
Subject: request.Subject,
Source: SourceGraph,
Groups: grantsFromExternalGroups(SourceGraph, groups, r.OrganizationRelation, "_claim_names.groups"),
Freshness: freshness(SourceGraph, now, r.MaxAge),
Overage: overage,
}, nil
}
// SCIMResolver resolves provisioned SCIM groups.
type SCIMResolver struct {
Client SCIMClient
OrganizationRelation api.OrganizationRelation
MaxAge time.Duration
}
func (r SCIMResolver) ResolveGroups(ctx context.Context, request ResolveRequest) (ResolveResult, error) {
if r.Client == nil {
return ResolveResult{}, fmt.Errorf("scim client is required")
}
groups, err := r.Client.GroupsForUser(ctx, request.Subject.ID)
if err != nil {
return ResolveResult{}, err
}
return ResolveResult{
Subject: request.Subject,
Source: SourceSCIM,
Groups: grantsFromExternalGroups(SourceSCIM, groups, r.OrganizationRelation, "groups"),
Freshness: freshness(SourceSCIM, resolveNow(request), r.MaxAge),
}, nil
}
// LDAPResolver resolves LDAP/AD group memberships.
type LDAPResolver struct {
Client LDAPClient
OrganizationRelation api.OrganizationRelation
MaxAge time.Duration
}
func (r LDAPResolver) ResolveGroups(ctx context.Context, request ResolveRequest) (ResolveResult, error) {
if r.Client == nil {
return ResolveResult{}, fmt.Errorf("ldap client is required")
}
dn := stringClaim(request.Claims, "distinguished_name")
if dn == "" {
dn = request.Subject.ID
}
groups, err := r.Client.GroupsForDN(ctx, dn)
if err != nil {
return ResolveResult{}, err
}
return ResolveResult{
Subject: request.Subject,
Source: SourceLDAP,
Groups: grantsFromExternalGroups(SourceLDAP, groups, r.OrganizationRelation, "memberOf"),
Freshness: freshness(SourceLDAP, resolveNow(request), r.MaxAge),
}, nil
}
// KeycloakResolver resolves groups through the Keycloak admin API.
type KeycloakResolver struct {
Client KeycloakClient
OrganizationRelation api.OrganizationRelation
MaxAge time.Duration
}
func (r KeycloakResolver) ResolveGroups(ctx context.Context, request ResolveRequest) (ResolveResult, error) {
if r.Client == nil {
return ResolveResult{}, fmt.Errorf("keycloak client is required")
}
groups, err := r.Client.GroupsForUser(ctx, request.Subject.ID)
if err != nil {
return ResolveResult{}, err
}
return ResolveResult{
Subject: request.Subject,
Source: SourceKeycloak,
Groups: grantsFromExternalGroups(SourceKeycloak, groups, r.OrganizationRelation, "groups"),
Freshness: freshness(SourceKeycloak, resolveNow(request), r.MaxAge),
}, nil
}
func grantsFromExternalGroups(source Source, groups []ExternalGroup, relation api.OrganizationRelation, claim string) []GroupGrant {
grants := make([]GroupGrant, 0, len(groups))
for _, group := range groups {
grants = append(grants, GroupGrant{
ID: group.ID,
DisplayName: group.DisplayName,
Source: source,
OrganizationRelation: relation,
SubjectType: api.SubjectTypeGroup,
Claim: claim,
Descriptor: group.Descriptor,
Metadata: copyMap(group.Metadata),
})
}
return grants
}
func freshness(source Source, now time.Time, maxAge time.Duration) Freshness {
out := Freshness{Source: source, RetrievedAt: now}
if maxAge > 0 {
out.MaxAge = maxAge.String()
out.ExpiresAt = now.Add(maxAge)
out.Stale = now.After(out.ExpiresAt)
}
return out
}
func resolveNow(request ResolveRequest) time.Time {
if !request.Now.IsZero() {
return request.Now
}
return time.Now().UTC()
}
func tokenIndicatesGraphOverage(claims map[string]any) bool {
if value, ok := claims["hasgroups"].(bool); ok && value {
return true
}
claimNames, ok := claims["_claim_names"].(map[string]any)
if !ok {
return false
}
_, ok = claimNames["groups"]
return ok
}
func stringClaim(claims map[string]any, key string) string {
value, _ := claims[key].(string)
return value
}
func copyMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}

View File

@@ -0,0 +1,151 @@
package directory_test
import (
"context"
"testing"
"time"
"github.com/netkingdom/flex-auth/internal/adapters/directory"
"github.com/netkingdom/flex-auth/pkg/api"
)
func TestGraphResolverDetectsTokenOverage(t *testing.T) {
now := time.Date(2026, 5, 17, 5, 0, 0, 0, time.UTC)
resolver := directory.GraphResolver{
Client: graphClient{groups: []directory.ExternalGroup{{ID: "group:platform", DisplayName: "Platform"}}},
OrganizationRelation: api.OrganizationRelationCustomer,
MaxAge: time.Hour,
}
got, err := resolver.ResolveGroups(context.Background(), directory.ResolveRequest{
Subject: api.SubjectRef{ID: "user:alice"},
Claims: map[string]any{
"_claim_names": map[string]any{"groups": "src1"},
},
Now: now,
})
if err != nil {
t.Fatalf("ResolveGroups: %v", err)
}
if !got.Overage.Detected || got.Overage.Claim != "_claim_names.groups" {
t.Fatalf("overage = %+v", got.Overage)
}
if len(got.Groups) != 1 || got.Groups[0].Source != directory.SourceGraph {
t.Fatalf("groups = %+v", got.Groups)
}
if got.Freshness.ExpiresAt != now.Add(time.Hour) {
t.Fatalf("freshness = %+v", got.Freshness)
}
}
func TestSCIMResolverCarriesDescriptorProvenance(t *testing.T) {
descriptor := caringDescriptor()
resolver := directory.SCIMResolver{
Client: scimClient{groups: []directory.ExternalGroup{{ID: "group:customers", Descriptor: descriptor}}},
OrganizationRelation: api.OrganizationRelationCustomer,
}
got, err := resolver.ResolveGroups(context.Background(), directory.ResolveRequest{Subject: api.SubjectRef{ID: "user:alice"}})
if err != nil {
t.Fatalf("ResolveGroups: %v", err)
}
if got.Groups[0].Descriptor == nil || got.Groups[0].Descriptor.CanonicalRole != api.CanonicalRoleDoer {
t.Fatalf("descriptor = %+v", got.Groups[0].Descriptor)
}
if got.Groups[0].Claim != "groups" {
t.Fatalf("claim = %q", got.Groups[0].Claim)
}
}
func TestLDAPResolverUsesDistinguishedNameClaim(t *testing.T) {
client := ldapClient{groups: []directory.ExternalGroup{{ID: "cn=platform,ou=groups,dc=example,dc=test"}}}
resolver := directory.LDAPResolver{Client: client}
_, err := resolver.ResolveGroups(context.Background(), directory.ResolveRequest{
Subject: api.SubjectRef{ID: "user:alice"},
Claims: map[string]any{"distinguished_name": "cn=alice,ou=users,dc=example,dc=test"},
})
if err != nil {
t.Fatalf("ResolveGroups: %v", err)
}
if client.lastDN != "" {
t.Fatal("value receiver should not update original client")
}
}
func TestMergeResultsAndApplyToSubject(t *testing.T) {
subject := api.SubjectRef{ID: "user:alice"}
enrichment := directory.MergeResults(subject,
directory.ResolveResult{
Source: directory.SourceGraph,
Groups: []directory.GroupGrant{
{ID: "group:b", Source: directory.SourceGraph},
{ID: "group:a", Source: directory.SourceGraph, Descriptor: caringDescriptor()},
},
Overage: directory.OverageMetadata{Detected: true, Claim: "_claim_names.groups"},
},
directory.ResolveResult{
Source: directory.SourceKeycloak,
Groups: []directory.GroupGrant{
{ID: "group:a", Source: directory.SourceKeycloak},
},
Roles: []directory.RoleGrant{
{Role: api.CanonicalRoleDoer, Source: directory.SourceKeycloak},
},
},
)
if len(enrichment.Groups) != 2 || enrichment.Groups[0] != "group:a" || enrichment.Groups[1] != "group:b" {
t.Fatalf("groups = %+v", enrichment.Groups)
}
if len(enrichment.Roles) != 1 || enrichment.Roles[0] != api.CanonicalRoleDoer {
t.Fatalf("roles = %+v", enrichment.Roles)
}
if len(enrichment.Descriptors) != 1 || len(enrichment.Overage) != 1 {
t.Fatalf("enrichment = %+v", enrichment)
}
applied := directory.ApplyToSubject(api.Subject{ID: "user:alice", Groups: []string{"group:existing"}}, enrichment)
if len(applied.Groups) != 3 || applied.Metadata["directory_overage"] == nil {
t.Fatalf("applied subject = %+v", applied)
}
}
func caringDescriptor() *api.CaringAccessDescriptor {
return &api.CaringAccessDescriptor{
ID: "descriptor:directory",
Profile: api.CaringProfileCaring040RC2,
SubjectType: api.SubjectTypeGroup,
OrganizationRelation: api.OrganizationRelationCustomer,
CanonicalRole: api.CanonicalRoleDoer,
Scope: api.CaringScope{Level: api.ScopeLevelTenant, ID: "tenant:alpha"},
Planes: []api.Plane{api.PlaneIdentity},
Capabilities: []api.Capability{api.CapabilityUse},
}
}
type graphClient struct {
groups []directory.ExternalGroup
}
func (c graphClient) GetMemberGroups(context.Context, string) ([]directory.ExternalGroup, directory.OverageMetadata, error) {
return c.groups, directory.OverageMetadata{Total: len(c.groups)}, nil
}
type scimClient struct {
groups []directory.ExternalGroup
}
func (c scimClient) GroupsForUser(context.Context, string) ([]directory.ExternalGroup, error) {
return c.groups, nil
}
type ldapClient struct {
groups []directory.ExternalGroup
lastDN string
}
func (c ldapClient) GroupsForDN(_ context.Context, dn string) ([]directory.ExternalGroup, error) {
c.lastDN = dn
return c.groups, nil
}

View File

@@ -0,0 +1,91 @@
package directory
import (
"context"
"time"
"github.com/netkingdom/flex-auth/pkg/api"
)
// Source identifies the external directory source.
type Source string
const (
SourceGraph Source = "graph"
SourceSCIM Source = "scim"
SourceLDAP Source = "ldap"
SourceKeycloak Source = "keycloak"
)
// Resolver resolves external groups and role claims for one subject.
type Resolver interface {
ResolveGroups(context.Context, ResolveRequest) (ResolveResult, error)
}
// ResolveRequest carries subject and claim evidence into a resolver.
type ResolveRequest struct {
Subject api.SubjectRef `json:"subject"`
Claims map[string]any `json:"claims,omitempty"`
AccessToken string `json:"access_token,omitempty"`
Now time.Time `json:"now,omitempty"`
}
// ResolveResult is the normalized resolver response.
type ResolveResult struct {
Subject api.SubjectRef `json:"subject"`
Source Source `json:"source"`
Groups []GroupGrant `json:"groups,omitempty"`
Roles []RoleGrant `json:"roles,omitempty"`
Freshness Freshness `json:"freshness"`
Overage OverageMetadata `json:"overage,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// GroupGrant identifies an external group and its CARING provenance.
type GroupGrant struct {
ID string `json:"id"`
DisplayName string `json:"display_name,omitempty"`
Source Source `json:"source"`
OrganizationRelation api.OrganizationRelation `json:"organization_relation,omitempty"`
SubjectType api.SubjectType `json:"subject_type,omitempty"`
Claim string `json:"claim,omitempty"`
Descriptor *api.CaringAccessDescriptor `json:"descriptor,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// RoleGrant records a role claim resolved from an external directory.
type RoleGrant struct {
Role api.CanonicalRole `json:"role"`
Claim string `json:"claim,omitempty"`
Source Source `json:"source"`
OrganizationRelation api.OrganizationRelation `json:"organization_relation,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// Freshness records resolver cache and retrieval metadata.
type Freshness struct {
Source Source `json:"source"`
RetrievedAt time.Time `json:"retrieved_at,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
MaxAge string `json:"max_age,omitempty"`
Stale bool `json:"stale,omitempty"`
}
// OverageMetadata records group overage behavior from token-based providers.
type OverageMetadata struct {
Detected bool `json:"detected,omitempty"`
Claim string `json:"claim,omitempty"`
Total int `json:"total,omitempty"`
NextLink string `json:"next_link,omitempty"`
}
// SubjectEnrichment is the subject update produced by resolver results.
type SubjectEnrichment struct {
Subject api.SubjectRef `json:"subject"`
Groups []string `json:"groups,omitempty"`
Roles []api.CanonicalRole `json:"roles,omitempty"`
Freshness []Freshness `json:"freshness,omitempty"`
Overage []OverageMetadata `json:"overage,omitempty"`
Descriptors []api.CaringAccessDescriptor `json:"descriptors,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}