Files
flex-auth/internal/adapters/directory/resolvers.go
tegwick 32933c71f9
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Add directory group resolver adapters
2026-05-17 07:24:50 +02:00

201 lines
5.7 KiB
Go

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
}