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 }