diff --git a/docs/directory-group-resolver-adapters.md b/docs/directory-group-resolver-adapters.md new file mode 100644 index 0000000..b528206 --- /dev/null +++ b/docs/directory-group-resolver-adapters.md @@ -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. diff --git a/internal/adapters/directory/doc.go b/internal/adapters/directory/doc.go new file mode 100644 index 0000000..1f1c9aa --- /dev/null +++ b/internal/adapters/directory/doc.go @@ -0,0 +1,3 @@ +// Package directory defines external group resolver adapters for Graph, SCIM, +// LDAP/AD, and Keycloak directory sources. +package directory diff --git a/internal/adapters/directory/enrichment.go b/internal/adapters/directory/enrichment.go new file mode 100644 index 0000000..93f6e15 --- /dev/null +++ b/internal/adapters/directory/enrichment.go @@ -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 +} diff --git a/internal/adapters/directory/resolvers.go b/internal/adapters/directory/resolvers.go new file mode 100644 index 0000000..b041a07 --- /dev/null +++ b/internal/adapters/directory/resolvers.go @@ -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 +} diff --git a/internal/adapters/directory/resolvers_test.go b/internal/adapters/directory/resolvers_test.go new file mode 100644 index 0000000..2860128 --- /dev/null +++ b/internal/adapters/directory/resolvers_test.go @@ -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 +} diff --git a/internal/adapters/directory/types.go b/internal/adapters/directory/types.go new file mode 100644 index 0000000..e7fdf0a --- /dev/null +++ b/internal/adapters/directory/types.go @@ -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"` +} diff --git a/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md b/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md index b4df1d6..e6d6593 100644 --- a/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md +++ b/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md @@ -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" ```