package relationship import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "strings" "github.com/netkingdom/flex-auth/internal/registry" "github.com/netkingdom/flex-auth/pkg/api" ) // Adapter wraps tuple-oriented PDP results into flex-auth decision envelopes. type Adapter struct { backend Backend backendName string policyPackage string policyVersion string } // New creates a relationship PDP adapter. func New(backend Backend, options Options) (*Adapter, error) { if backend == nil { return nil, fmt.Errorf("relationship backend is required") } backendName := options.BackendName if backendName == "" { backendName = "relationship" } return &Adapter{ backend: backend, backendName: backendName, policyPackage: options.PolicyPackage, policyVersion: options.PolicyVersion, }, nil } // Check evaluates one subject/action/resource tuple. func (a *Adapter) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) { tupleRequest, err := BuildTupleCheckRequest(request) if err != nil { return a.failureEnvelope(request, TupleCheckRequest{}, FailureInvalidRequest, err), nil } result, err := a.backend.Check(ctx, tupleRequest) if err != nil { return a.failureEnvelope(request, tupleRequest, failureKind(err), err), nil } if result.Stale { return a.failureEnvelope(request, tupleRequest, FailureStaleData, nil), nil } if result.Partial { return a.failureEnvelope(request, tupleRequest, FailurePartialResult, nil), nil } return a.envelope(request, tupleRequest, result), nil } // BatchCheck evaluates resources in order. func (a *Adapter) BatchCheck(ctx context.Context, request api.BatchCheckRequest) ([]api.DecisionEnvelope, error) { tupleRequests := make([]TupleCheckRequest, 0, len(request.Resources)) checkRequests := make([]api.CheckRequest, 0, len(request.Resources)) for _, resource := range request.Resources { checkRequest := api.CheckRequest{ ID: request.ID, Subject: request.Subject, Action: request.Action, Resource: resource, Context: request.Context, PolicyVersion: request.PolicyVersion, } tupleRequest, err := BuildTupleCheckRequest(checkRequest) if err != nil { decision := a.failureEnvelope(checkRequest, TupleCheckRequest{}, FailureInvalidRequest, err) tupleRequests = append(tupleRequests, TupleCheckRequest{}) checkRequests = append(checkRequests, checkRequest) return []api.DecisionEnvelope{decision}, nil } tupleRequests = append(tupleRequests, tupleRequest) checkRequests = append(checkRequests, checkRequest) } results, err := a.backend.BatchCheck(ctx, tupleRequests) if err != nil { decisions := make([]api.DecisionEnvelope, 0, len(checkRequests)) for i, checkRequest := range checkRequests { decisions = append(decisions, a.failureEnvelope(checkRequest, tupleRequests[i], failureKind(err), err)) } return decisions, nil } if len(results) != len(checkRequests) { decisions := make([]api.DecisionEnvelope, 0, len(checkRequests)) for i, checkRequest := range checkRequests { decisions = append(decisions, a.failureEnvelope(checkRequest, tupleRequests[i], FailurePartialResult, nil)) } return decisions, nil } decisions := make([]api.DecisionEnvelope, 0, len(results)) for i, result := range results { if result.Stale { decisions = append(decisions, a.failureEnvelope(checkRequests[i], tupleRequests[i], FailureStaleData, nil)) continue } if result.Partial { decisions = append(decisions, a.failureEnvelope(checkRequests[i], tupleRequests[i], FailurePartialResult, nil)) continue } decisions = append(decisions, a.envelope(checkRequests[i], tupleRequests[i], result)) } return decisions, nil } // ListAllowed asks the relationship backend for allowed objects and returns // allow envelopes with consistency metadata. func (a *Adapter) ListAllowed(ctx context.Context, request TupleListRequest) (ListAllowedResult, error) { result, err := a.backend.ListObjects(ctx, request) if err != nil { return ListAllowedResult{ Diagnostics: failureDiagnostics(a.backendName, TupleCheckRequest{}, failureKind(err), err), }, nil } if result.Stale || result.Partial { kind := FailureStaleData if result.Partial { kind = FailurePartialResult } return ListAllowedResult{ ConsistencyToken: result.ConsistencyToken, Diagnostics: failureDiagnostics(a.backendName, TupleCheckRequest{}, kind, nil), }, nil } out := ListAllowedResult{ ConsistencyToken: result.ConsistencyToken, Diagnostics: copyMap(result.Diagnostics), Decisions: make([]api.DecisionEnvelope, 0, len(result.Objects)), } for _, listed := range result.Objects { checkRequest := api.CheckRequest{ Subject: request.Subject, Action: request.Relation, Resource: listed.Resource, Context: request.Context, } tupleRequest, _ := BuildTupleCheckRequest(checkRequest) checkResult := TupleCheckResult{ Allowed: true, Reason: "relationship_list_allowed", ConsistencyToken: firstNonEmpty(listed.ConsistencyToken, result.ConsistencyToken), MatchedTuples: listed.MatchedTuples, Diagnostics: listed.Diagnostics, CaringDescriptor: listed.CaringDescriptor, } out.Decisions = append(out.Decisions, a.envelope(checkRequest, tupleRequest, checkResult)) } return out, nil } // ImportTuples writes canonical registry relationships to the backend. func (a *Adapter) ImportTuples(ctx context.Context, snapshot registry.Snapshot) (ImportReport, error) { return a.backend.ImportTuples(ctx, SnapshotToTuples(snapshot)) } // BuildTupleCheckRequest maps flex-auth's stable request to a tuple check. func BuildTupleCheckRequest(request api.CheckRequest) (TupleCheckRequest, error) { objectType := request.Resource.Type if objectType == "" { objectType = inferTypeFromID(request.Resource.ID) } if objectType == "" || request.Resource.ID == "" || request.Action == "" || request.Subject.ID == "" { return TupleCheckRequest{}, fmt.Errorf("resource type/id, action, and subject id are required") } return TupleCheckRequest{ ObjectType: objectType, ObjectID: request.Resource.ID, Relation: request.Action, SubjectType: tupleSubjectType(request.Subject.Type, request.Subject.ID), SubjectID: request.Subject.ID, Context: copyMap(request.Context), ConsistencyToken: contextString(request.Context, "consistency_token"), }, nil } func (a *Adapter) envelope(request api.CheckRequest, tupleRequest TupleCheckRequest, result TupleCheckResult) api.DecisionEnvelope { effect := result.Effect if effect == "" { if result.Allowed { effect = api.DecisionEffectAllow } else { effect = api.DecisionEffectDeny } } reason := result.Reason if reason == "" { if effect == api.DecisionEffectAllow { reason = "relationship_tuple_allow" } else { reason = "relationship_tuple_deny" } } policyVersion := firstNonEmpty(request.PolicyVersion, a.policyVersion) diagnostics := copyMap(result.Diagnostics) addTupleDiagnostics(diagnostics, a.backendName, tupleRequest, "") if len(result.MatchedTuples) > 0 { diagnostics["matched_tuples"] = len(result.MatchedTuples) } if len(result.InheritedFrom) > 0 { diagnostics["inherited_tuples"] = len(result.InheritedFrom) } envelope := api.DecisionEnvelope{ RequestID: request.ID, Effect: effect, Reason: reason, MatchedPolicyVersion: policyVersion, MatchedRule: firstNonEmpty(result.MatchedRule, reason), Resource: request.Resource, Subject: request.Subject, Diagnostics: diagnostics, Provenance: api.DecisionProvenance{ Evaluator: EvaluatorName + "/" + a.backendName, Mode: DelegatedMode, PolicyPackage: a.policyPackage, PolicyVersion: policyVersion, DirectoryETag: result.ConsistencyToken, }, Caring: caringDecisionMetadata(request, descriptorForResult(request, result), result.ConformanceFindings), } envelope.ID = decisionID(a.backendName, a.policyPackage, policyVersion, request, effect, reason, result.ConsistencyToken) return envelope } func (a *Adapter) failureEnvelope(request api.CheckRequest, tupleRequest TupleCheckRequest, kind FailureKind, err error) api.DecisionEnvelope { reason := failureReason(kind) policyVersion := firstNonEmpty(request.PolicyVersion, a.policyVersion) envelope := api.DecisionEnvelope{ RequestID: request.ID, Effect: api.DecisionEffectDeny, Reason: reason, MatchedPolicyVersion: policyVersion, MatchedRule: reason, Resource: request.Resource, Subject: request.Subject, Diagnostics: failureDiagnostics(a.backendName, tupleRequest, kind, err), Provenance: api.DecisionProvenance{ Evaluator: EvaluatorName + "/" + a.backendName, Mode: DelegatedMode, PolicyPackage: a.policyPackage, PolicyVersion: policyVersion, }, Caring: caringDecisionMetadata(request, request.CaringContext, []api.CaringConformanceFinding{failureFinding(kind)}), } envelope.ID = decisionID(a.backendName, a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason, "") return envelope } func descriptorForResult(request api.CheckRequest, result TupleCheckResult) *api.CaringAccessDescriptor { if request.CaringContext != nil { return request.CaringContext } if result.CaringDescriptor != nil { return result.CaringDescriptor } for _, tuple := range result.MatchedTuples { if tuple.Caring != nil { return tuple.Caring } } for _, tuple := range result.InheritedFrom { if tuple.Caring != nil { return tuple.Caring } } return nil } func caringDecisionMetadata(request api.CheckRequest, descriptor *api.CaringAccessDescriptor, findings []api.CaringConformanceFinding) *api.CaringDecisionMetadata { profile := api.CaringProfileCaring040RC2 if descriptor != nil && descriptor.Profile != "" { profile = descriptor.Profile } metadata := &api.CaringDecisionMetadata{ Profile: profile, ConformanceFindings: append([]api.CaringConformanceFinding(nil), findings...), } if descriptor == nil { metadata.ConformanceFindings = append(metadata.ConformanceFindings, api.CaringConformanceFinding{ Code: "RELATIONSHIP-CARING-DESCRIPTOR-MISSING", Severity: "warning", Message: "relationship backend result did not include a CARING descriptor", Fields: []string{"caring_context"}, }) return metadata } descriptorCopy := *descriptor metadata.Descriptor = &descriptorCopy metadata.RestrictionsEvaluated = append([]api.Restriction(nil), descriptor.Restrictions...) metadata.ExposureModes = append([]api.ExposureMode(nil), descriptor.ExposureModes...) metadata.DerivedCapabilities = append([]api.CaringDerivedCapability(nil), descriptor.DerivedCapabilities...) if descriptor.ExposureEvent != "" { scope := descriptor.Scope metadata.ExposureEvent = &api.CaringExposureEvent{ ID: decisionID("relationship", "", "", request, api.DecisionEffectAllow, string(descriptor.ExposureEvent), ""), Type: descriptor.ExposureEvent, Actor: request.Subject.ID, Subject: request.Subject.ID, Descriptor: &descriptorCopy, Scope: &scope, Planes: append([]api.Plane(nil), descriptor.Planes...), CapabilitiesUsed: append([]api.Capability(nil), descriptor.Capabilities...), ExposureModes: append([]api.ExposureMode(nil), descriptor.ExposureModes...), Reason: "relationship backend result carries CARING exposure event hook", AuthoritySource: EvaluatorName, } } return metadata } func failureDiagnostics(backendName string, request TupleCheckRequest, kind FailureKind, err error) map[string]any { diagnostics := map[string]any{} addTupleDiagnostics(diagnostics, backendName, request, string(kind)) if err != nil { diagnostics["error"] = err.Error() } return diagnostics } func addTupleDiagnostics(diagnostics map[string]any, backendName string, request TupleCheckRequest, failure string) { diagnostics["adapter"] = "relationship" diagnostics["backend"] = backendName diagnostics["mode"] = DelegatedMode if failure != "" { diagnostics["relationship_failure"] = failure } if request.ObjectType != "" { diagnostics["tuple_object_type"] = request.ObjectType diagnostics["tuple_object_id"] = request.ObjectID diagnostics["tuple_relation"] = request.Relation diagnostics["tuple_subject_type"] = request.SubjectType diagnostics["tuple_subject_id"] = request.SubjectID } } func failureReason(kind FailureKind) string { switch kind { case FailureStaleData: return "relationship_data_stale" case FailurePartialResult: return "relationship_partial_result" case FailureInvalidRequest: return "relationship_request_incomplete" default: return "relationship_backend_unavailable" } } func failureFinding(kind FailureKind) api.CaringConformanceFinding { code := "RELATIONSHIP-BACKEND-UNAVAILABLE" message := "Relationship backend was unavailable; flex-auth denied the delegated request fail-closed." switch kind { case FailureStaleData: code = "RELATIONSHIP-DATA-STALE" message = "Relationship backend consistency token was stale; flex-auth denied fail-closed." case FailurePartialResult: code = "RELATIONSHIP-PARTIAL-RESULT" message = "Relationship backend returned a partial result; flex-auth denied fail-closed." case FailureInvalidRequest: code = "RELATIONSHIP-REQUEST-INCOMPLETE" message = "Request could not be translated to a tuple check; flex-auth denied fail-closed." } return api.CaringConformanceFinding{Code: code, Severity: "error", Message: message} } func decisionID(backendName, policyPackage, policyVersion string, request api.CheckRequest, effect api.DecisionEffect, reason, consistencyToken string) string { data, _ := json.Marshal(struct { Adapter string `json:"adapter"` Backend string `json:"backend"` PolicyPackage string `json:"policy_package,omitempty"` PolicyVersion string `json:"policy_version,omitempty"` Request api.CheckRequest `json:"request"` Effect api.DecisionEffect `json:"effect"` Reason string `json:"reason"` ConsistencyToken string `json:"consistency_token,omitempty"` }{ Adapter: EvaluatorName, Backend: backendName, PolicyPackage: policyPackage, PolicyVersion: policyVersion, Request: request, Effect: effect, Reason: reason, ConsistencyToken: consistencyToken, }) sum := sha256.Sum256(data) return "decision:relationship:" + hex.EncodeToString(sum[:8]) } func tupleSubjectType(subjectType api.SubjectType, id string) string { switch subjectType { case api.SubjectTypeGroup, api.SubjectTypeOrganization: return "group" default: if strings.HasPrefix(id, "group:") || strings.HasPrefix(id, "team:") || strings.HasPrefix(id, "reader:") { return "group" } return "user" } } func inferTypeFromID(id string) string { before, _, ok := strings.Cut(id, ":") if !ok { return "" } return before } func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { return value } } return "" } 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 } func contextString(context map[string]any, key string) string { value, _ := context[key].(string) return value }