generated from coulomb/repo-seed
201 lines
5.7 KiB
Go
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
|
|
}
|