generated from coulomb/repo-seed
Add directory group resolver adapters
This commit is contained in:
65
docs/directory-group-resolver-adapters.md
Normal file
65
docs/directory-group-resolver-adapters.md
Normal 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.
|
||||
3
internal/adapters/directory/doc.go
Normal file
3
internal/adapters/directory/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package directory defines external group resolver adapters for Graph, SCIM,
|
||||
// LDAP/AD, and Keycloak directory sources.
|
||||
package directory
|
||||
96
internal/adapters/directory/enrichment.go
Normal file
96
internal/adapters/directory/enrichment.go
Normal 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
|
||||
}
|
||||
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
|
||||
}
|
||||
151
internal/adapters/directory/resolvers_test.go
Normal file
151
internal/adapters/directory/resolvers_test.go
Normal 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
|
||||
}
|
||||
91
internal/adapters/directory/types.go
Normal file
91
internal/adapters/directory/types.go
Normal 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"`
|
||||
}
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user