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,65 @@
# Directory Group Resolver Adapters
Status: implemented for FLEX-WP-0004 P4.5.
## Role
Directory resolvers enrich flex-auth subjects with group and role
evidence from external identity systems. They do not decide access by
themselves. They feed the standalone, relationship, rule, Topaz, or
Keycloak decision paths with fresh subject metadata and provenance.
## Resolver Sources
Implemented resolver boundaries:
- Microsoft Graph group overage (`GraphResolver`)
- SCIM provisioning (`SCIMResolver`)
- LDAP/Active Directory (`LDAPResolver`)
- Keycloak admin API (`KeycloakResolver`)
Each resolver returns `ResolveResult` with normalized groups, roles,
freshness, overage, and metadata.
## Freshness
Every result carries:
- source
- retrieval time
- max age
- expiry time
- stale flag
Decision adapters can use this metadata to deny stale directory evidence
or to include freshness diagnostics in the decision envelope.
## Overage
Graph tokens can omit groups and instead emit overage indicators such
as `_claim_names.groups` or `hasgroups=true`. The Graph resolver records
that condition in `OverageMetadata` so downstream policy can distinguish
"no groups" from "groups omitted, lookup required".
## CARING Provenance
Each `GroupGrant` and `RoleGrant` identifies:
- source provider
- originating claim name
- organization relation
- subject type
- optional CARING descriptor
This makes it possible to explain that a canonical role or group came
from Graph, SCIM, LDAP/AD, or Keycloak rather than from an opaque token
claim. The source remains inspectable in CARING conformance reviews.
## Subject Enrichment
`MergeResults` deduplicates groups and roles across providers while
preserving freshness, overage, and descriptors. `ApplyToSubject` returns
a subject with resolved groups/roles and directory metadata attached.
The enriched subject can then flow through any flex-auth decision path
without changing protected-system request or decision contracts.

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"`
}

View File

@@ -129,7 +129,7 @@ source of truth.
```task
id: FLEX-WP-0004-T005
status: todo
status: done
priority: medium
state_hub_task_id: "4fc3fb91-8763-453e-8e54-36178cb11efd"
```