generated from coulomb/repo-seed
Add directory group resolver adapters
This commit is contained in:
200
internal/adapters/directory/resolvers.go
Normal file
200
internal/adapters/directory/resolvers.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user