diff --git a/docs/relationship-pdp-adapter-boundary.md b/docs/relationship-pdp-adapter-boundary.md new file mode 100644 index 0000000..db10c6a --- /dev/null +++ b/docs/relationship-pdp-adapter-boundary.md @@ -0,0 +1,105 @@ +# Relationship PDP Adapter Boundary + +Status: implemented for FLEX-WP-0004 P4.2. + +## Role + +The relationship PDP adapter is the common boundary for OpenFGA, +SpiceDB, and other tuple-oriented authorization systems. These backends +answer questions like: + +```text +is subject S related to object O through relation R? +``` + +flex-auth keeps the protected-system API stable. The adapter translates +`CheckRequest`, `BatchCheckRequest`, registry relationships, and +`list_allowed` queries into tuple checks, then wraps backend responses +back into the canonical `DecisionEnvelope`. + +## Tuple Mapping + +Canonical flex-auth relationship facts map to: + +| flex-auth | tuple field | +| --- | --- | +| resource type | `object_type` | +| resource id | `object_id` | +| action or relation | `relation` | +| subject type | `subject_type` | +| subject id | `subject_id` | +| group membership indirection | `subject_relation=member` | + +Registry group and team membership become `group#member` tuples. +Resource parent edges become `parent` tuples. Resource owners become +`owner_team` tuples. Relationship facts keep conditions, provenance, +metadata, and CARING descriptors on the imported tuple so backend +results can preserve explanatory context. + +## Inheritance + +Relationship backends represent inheritance differently: + +- OpenFGA usually models it in the authorization model through rewrites. +- SpiceDB usually models it through relation definitions and caveats. +- flex-auth records inherited evidence in + `TupleCheckResult.InheritedFrom`. + +The envelope does not expose backend-native rewrite syntax. It records +the fact that inheritance participated through diagnostics and preserves +the matched CARING descriptor from direct or inherited tuples. + +## Batch And List + +`BatchCheck` preserves request order. If the backend returns a partial +batch, flex-auth emits fail-closed deny envelopes for the affected +resources. + +`ListAllowed` returns a `ListAllowedResult` containing allow envelopes, +the backend consistency token, and diagnostics. It intentionally returns +envelopes instead of raw resource ids so downstream consumers keep the +same audit and CARING metadata they receive from single checks. + +## Consistency Metadata + +Tuple backends expose different consistency tokens: + +- OpenFGA: model id plus optional tuple-store continuation/freshness + metadata. +- SpiceDB: zedtoken. + +The adapter stores the backend token in +`DecisionEnvelope.provenance.directory_etag`. The field name is kept for +compatibility with the existing flex-auth envelope; for relationship PDPs +it means "relationship backend consistency token". + +## Failure Behavior + +The adapter fails closed for: + +- backend unavailable: `relationship_backend_unavailable` +- stale consistency token: `relationship_data_stale` +- partial backend result: `relationship_partial_result` +- untranslatable request: `relationship_request_incomplete` + +Each failure is a deny envelope with `diagnostics.relationship_failure` +and a CARING conformance finding. This keeps delegated tuple behavior +aligned with standalone fail-closed behavior. + +## CARING Preservation + +Tuple systems do not understand CARING directly. flex-auth therefore +keeps CARING metadata at the adapter boundary: + +- request descriptor wins when supplied; +- backend result descriptor is next; +- matched tuple descriptor is next; +- inherited tuple descriptor is next; +- otherwise the envelope includes + `RELATIONSHIP-CARING-DESCRIPTOR-MISSING`. + +The adapter copies scope, planes, capabilities, exposure modes, +restrictions, derived capabilities, conformance findings, and +exposure-event hooks into the decision envelope. Backend-native role or +relation names should not leak into protected systems as a replacement +for CARING canonical roles. diff --git a/internal/adapters/relationship/adapter.go b/internal/adapters/relationship/adapter.go new file mode 100644 index 0000000..d24433f --- /dev/null +++ b/internal/adapters/relationship/adapter.go @@ -0,0 +1,442 @@ +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 +} diff --git a/internal/adapters/relationship/adapter_test.go b/internal/adapters/relationship/adapter_test.go new file mode 100644 index 0000000..781252b --- /dev/null +++ b/internal/adapters/relationship/adapter_test.go @@ -0,0 +1,304 @@ +package relationship_test + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/netkingdom/flex-auth/internal/adapters/relationship" + "github.com/netkingdom/flex-auth/internal/registry" + "github.com/netkingdom/flex-auth/pkg/api" +) + +func TestSnapshotToTuplesMapsCanonicalRegistry(t *testing.T) { + tuples := relationship.SnapshotToTuples(loadRegistry(t).Snapshot()) + + want := relationship.Tuple{ + ObjectType: "document", + ObjectID: "document:internal-note", + Relation: "reader", + SubjectType: "group", + SubjectID: "group:platform-architecture", + SubjectRelation: "member", + } + got := findTuple(tuples, want) + if got.Caring == nil || got.Caring.CanonicalRole != api.CanonicalRoleDoer { + t.Fatalf("reader tuple CARING = %+v; want Doer descriptor", got.Caring) + } + if len(got.Conditions) != 1 || got.Conditions[0] != api.ConditionLogged { + t.Fatalf("conditions = %+v; want Logged", got.Conditions) + } +} + +func TestAdapterCheckPreservesCARINGFromMatchedTuple(t *testing.T) { + descriptor := caringDescriptor() + backend := &fakeBackend{ + checkResult: relationship.TupleCheckResult{ + Allowed: true, + Reason: "direct_tuple", + ConsistencyToken: "zed:42", + MatchedTuples: []relationship.Tuple{ + { + ObjectType: "document", + ObjectID: "document:internal-note", + Relation: "reader", + SubjectType: "group", + SubjectID: "group:platform-architecture", + Caring: descriptor, + }, + }, + InheritedFrom: []relationship.Tuple{ + {ObjectType: "knowledge_base", ObjectID: "knowledge-base:markitect-example", Relation: "reader"}, + }, + Diagnostics: map[string]any{"backend_trace": "trace-1"}, + }, + } + adapter := newAdapter(t, backend) + + got, err := adapter.Check(context.Background(), api.CheckRequest{ + ID: "check:relationship-allow", + Subject: api.SubjectRef{ID: "user:alice", Type: api.SubjectTypeHuman}, + Action: "read", + Resource: api.ResourceRef{ + ID: "document:internal-note", + Type: "document", + System: "markitect-tool", + }, + }) + if err != nil { + t.Fatalf("Check: %v", err) + } + + if got.Effect != api.DecisionEffectAllow || got.Reason != "direct_tuple" { + t.Fatalf("decision = %s/%s; want allow/direct_tuple", got.Effect, got.Reason) + } + if got.Provenance.Evaluator != "relationship-pdp/openfga" || got.Provenance.DirectoryETag != "zed:42" { + t.Fatalf("provenance = %+v", got.Provenance) + } + if got.Diagnostics["matched_tuples"] != 1 || got.Diagnostics["inherited_tuples"] != 1 { + t.Fatalf("diagnostics = %+v; want matched and inherited counts", got.Diagnostics) + } + if got.Caring == nil || got.Caring.Descriptor == nil { + t.Fatal("missing CARING metadata") + } + if len(got.Caring.DerivedCapabilities) != 1 || got.Caring.DerivedCapabilities[0].Capability != api.CapabilityViewCollection { + t.Fatalf("derived capabilities = %+v", got.Caring.DerivedCapabilities) + } + if got.Caring.ExposureEvent == nil || got.Caring.ExposureEvent.Type != api.ExposureEventSupport { + t.Fatalf("exposure event = %+v", got.Caring.ExposureEvent) + } +} + +func TestAdapterCheckFailsClosedOnStaleBackend(t *testing.T) { + backend := &fakeBackend{ + checkErr: relationship.NewBackendError(relationship.FailureStaleData, "check", errors.New("zed token too old")), + } + adapter := newAdapter(t, backend) + + got, err := adapter.Check(context.Background(), api.CheckRequest{ + ID: "check:stale", + Subject: api.SubjectRef{ID: "user:alice"}, + Action: "read", + Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"}, + CaringContext: caringDescriptor(), + }) + if err != nil { + t.Fatalf("Check: %v", err) + } + if got.Effect != api.DecisionEffectDeny || got.Reason != "relationship_data_stale" { + t.Fatalf("decision = %s/%s; want stale deny", got.Effect, got.Reason) + } + if got.Diagnostics["relationship_failure"] != "stale_data" { + t.Fatalf("diagnostics = %+v", got.Diagnostics) + } + if got.Caring.ConformanceFindings[0].Code != "RELATIONSHIP-DATA-STALE" { + t.Fatalf("finding = %+v", got.Caring.ConformanceFindings[0]) + } +} + +func TestBatchCheckPreservesBackendOrder(t *testing.T) { + backend := &fakeBackend{ + batchResults: []relationship.TupleCheckResult{ + {Allowed: true, ConsistencyToken: "zed:1"}, + {Allowed: false, Reason: "no_tuple", ConsistencyToken: "zed:2"}, + }, + } + adapter := newAdapter(t, backend) + + got, err := adapter.BatchCheck(context.Background(), api.BatchCheckRequest{ + ID: "batch:documents", + Subject: api.SubjectRef{ID: "user:alice"}, + Action: "read", + Resources: []api.ResourceRef{ + {ID: "document:internal-note", Type: "document", System: "markitect-tool"}, + {ID: "document:missing", Type: "document", System: "markitect-tool"}, + }, + }) + if err != nil { + t.Fatalf("BatchCheck: %v", err) + } + if len(got) != 2 { + t.Fatalf("len(got) = %d; want 2", len(got)) + } + if got[0].Effect != api.DecisionEffectAllow || got[1].Effect != api.DecisionEffectDeny { + t.Fatalf("effects = %s/%s; want allow/deny", got[0].Effect, got[1].Effect) + } + if got[0].Resource.ID != "document:internal-note" || got[1].Resource.ID != "document:missing" { + t.Fatalf("resource order = %s/%s", got[0].Resource.ID, got[1].Resource.ID) + } +} + +func TestListAllowedReturnsEnvelopeResults(t *testing.T) { + backend := &fakeBackend{ + listResult: relationship.TupleListResult{ + ConsistencyToken: "zed:list", + Objects: []relationship.ListedObject{ + { + Resource: api.ResourceRef{ + ID: "document:internal-note", + Type: "document", + System: "markitect-tool", + }, + CaringDescriptor: caringDescriptor(), + }, + }, + }, + } + adapter := newAdapter(t, backend) + + got, err := adapter.ListAllowed(context.Background(), relationship.TupleListRequest{ + Subject: api.SubjectRef{ID: "user:alice"}, + Relation: "read", + ResourceType: "document", + System: "markitect-tool", + }) + if err != nil { + t.Fatalf("ListAllowed: %v", err) + } + if got.ConsistencyToken != "zed:list" || len(got.Decisions) != 1 { + t.Fatalf("list result = %+v", got) + } + if got.Decisions[0].Effect != api.DecisionEffectAllow || got.Decisions[0].Reason != "relationship_list_allowed" { + t.Fatalf("decision = %+v", got.Decisions[0]) + } +} + +func TestImportTuplesDelegatesSnapshotTuples(t *testing.T) { + backend := &fakeBackend{} + adapter := newAdapter(t, backend) + + report, err := adapter.ImportTuples(context.Background(), loadRegistry(t).Snapshot()) + if err != nil { + t.Fatalf("ImportTuples: %v", err) + } + if report.TuplesWritten != len(backend.imported) { + t.Fatalf("report = %+v imported=%d", report, len(backend.imported)) + } + if findTuple(backend.imported, relationship.Tuple{ + ObjectType: "document", + ObjectID: "document:internal-note", + Relation: "reader", + SubjectType: "group", + SubjectID: "group:platform-architecture", + SubjectRelation: "member", + }).Caring == nil { + t.Fatal("imported reader tuple lost CARING descriptor") + } +} + +func newAdapter(t *testing.T, backend *fakeBackend) *relationship.Adapter { + t.Helper() + adapter, err := relationship.New(backend, relationship.Options{ + BackendName: "openfga", + PolicyPackage: "relationship.boundary", + PolicyVersion: "v1", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + return adapter +} + +func loadRegistry(t *testing.T) *registry.Store { + t.Helper() + store, err := registry.LoadFile(filepath.Join("..", "..", "..", "examples", "caring", "registry_snapshot.json")) + if err != nil { + t.Fatalf("LoadFile: %v", err) + } + return store +} + +func caringDescriptor() *api.CaringAccessDescriptor { + return &api.CaringAccessDescriptor{ + ID: "descriptor:tuple-reader", + Profile: api.CaringProfileCaring040RC2, + SubjectType: api.SubjectTypeGroup, + OrganizationRelation: api.OrganizationRelationCustomer, + CanonicalRole: api.CanonicalRoleDoer, + Scope: api.CaringScope{ + Level: api.ScopeLevelResource, + ID: "document:internal-note", + Tenant: "tenant:alpha", + Resource: "document:internal-note", + }, + Planes: []api.Plane{api.PlaneData}, + Capabilities: []api.Capability{api.CapabilityView}, + ExposureModes: []api.ExposureMode{api.ExposureModeMasked}, + Restrictions: []api.Restriction{api.RestrictionExportBlocked}, + ExposureEvent: api.ExposureEventSupport, + DerivedCapabilities: []api.CaringDerivedCapability{ + {Capability: api.CapabilityViewCollection, Reason: "parent reader inheritance", Source: "tuple:parent"}, + }, + } +} + +func findTuple(tuples []relationship.Tuple, want relationship.Tuple) relationship.Tuple { + for _, tuple := range tuples { + if tuple.ObjectType == want.ObjectType && + tuple.ObjectID == want.ObjectID && + tuple.Relation == want.Relation && + tuple.SubjectType == want.SubjectType && + tuple.SubjectID == want.SubjectID && + tuple.SubjectRelation == want.SubjectRelation { + return tuple + } + } + panic("tuple not found") +} + +type fakeBackend struct { + checkResult relationship.TupleCheckResult + checkErr error + batchResults []relationship.TupleCheckResult + listResult relationship.TupleListResult + imported []relationship.Tuple +} + +func (b *fakeBackend) Check(context.Context, relationship.TupleCheckRequest) (relationship.TupleCheckResult, error) { + return b.checkResult, b.checkErr +} + +func (b *fakeBackend) BatchCheck(_ context.Context, requests []relationship.TupleCheckRequest) ([]relationship.TupleCheckResult, error) { + if b.batchResults != nil { + return b.batchResults, nil + } + results := make([]relationship.TupleCheckResult, len(requests)) + for i := range results { + results[i] = b.checkResult + } + return results, b.checkErr +} + +func (b *fakeBackend) ListObjects(context.Context, relationship.TupleListRequest) (relationship.TupleListResult, error) { + return b.listResult, nil +} + +func (b *fakeBackend) ImportTuples(_ context.Context, tuples []relationship.Tuple) (relationship.ImportReport, error) { + b.imported = append([]relationship.Tuple(nil), tuples...) + return relationship.ImportReport{TuplesWritten: len(tuples), ConsistencyToken: "zed:import"}, nil +} + +func (b *fakeBackend) Health(context.Context) error { + return nil +} diff --git a/internal/adapters/relationship/doc.go b/internal/adapters/relationship/doc.go new file mode 100644 index 0000000..4444461 --- /dev/null +++ b/internal/adapters/relationship/doc.go @@ -0,0 +1,3 @@ +// Package relationship defines the backend-neutral relationship PDP boundary +// for OpenFGA, SpiceDB, and similar tuple-oriented authorization systems. +package relationship diff --git a/internal/adapters/relationship/translate.go b/internal/adapters/relationship/translate.go new file mode 100644 index 0000000..8637faa --- /dev/null +++ b/internal/adapters/relationship/translate.go @@ -0,0 +1,197 @@ +package relationship + +import ( + "fmt" + "sort" + "strings" + + "github.com/netkingdom/flex-auth/internal/registry" + "github.com/netkingdom/flex-auth/pkg/api" +) + +// SnapshotToTuples maps flex-auth registry data to relationship tuples. +func SnapshotToTuples(snapshot registry.Snapshot) []Tuple { + index := newSnapshotIndex(snapshot) + tuples := map[string]Tuple{} + addTuple := func(tuple Tuple) { + if tuple.ObjectType == "" || tuple.ObjectID == "" || tuple.Relation == "" || + tuple.SubjectType == "" || tuple.SubjectID == "" { + return + } + tuples[tupleKey(tuple)] = tuple + } + + for _, group := range snapshot.Groups { + for _, member := range group.Members { + addTuple(Tuple{ + ObjectType: "group", + ObjectID: group.ID, + Relation: "member", + SubjectType: index.subjectType(member), + SubjectID: index.subjectID(member), + }) + } + } + + for _, team := range snapshot.Teams { + for _, member := range team.Members { + addTuple(Tuple{ + ObjectType: "group", + ObjectID: teamObjectID(team.ID), + Relation: "member", + SubjectType: index.subjectType(member), + SubjectID: index.subjectID(member), + }) + } + } + + for _, manifest := range snapshot.ResourceManifests { + for _, resource := range manifest.Resources { + if resource.Parent != "" { + addTuple(Tuple{ + ObjectType: resource.Type, + ObjectID: resource.ID, + Relation: "parent", + SubjectType: index.resourceType(resource.Parent), + SubjectID: resource.Parent, + Metadata: map[string]any{ + "system": manifest.System, + }, + }) + } + if resource.Owner != "" { + addTuple(Tuple{ + ObjectType: resource.Type, + ObjectID: resource.ID, + Relation: "owner_team", + SubjectType: "group", + SubjectID: teamOrGroupObjectID(resource.Owner), + Metadata: map[string]any{ + "system": manifest.System, + }, + }) + } + } + } + + for _, relationship := range snapshot.Relationships { + subjectType := index.subjectType(relationship.Subject) + subjectRelation := "" + if subjectType == "group" && relationship.Relation != "member" && relationship.Relation != "owner_team" { + subjectRelation = "member" + } + addTuple(Tuple{ + ObjectType: index.resourceType(relationship.Object), + ObjectID: relationship.Object, + Relation: relationship.Relation, + SubjectType: subjectType, + SubjectID: index.subjectID(relationship.Subject), + SubjectRelation: subjectRelation, + Conditions: append([]api.Condition(nil), relationship.Conditions...), + Caring: relationship.Caring, + Provenance: copyMap(relationship.Provenance), + Metadata: copyMap(relationship.Metadata), + }) + } + + return sortedTuples(tuples) +} + +type snapshotIndex struct { + groups map[string]api.Group + teams map[string]api.Team + resourceTypes map[string]string +} + +func newSnapshotIndex(snapshot registry.Snapshot) snapshotIndex { + index := snapshotIndex{ + groups: make(map[string]api.Group), + teams: make(map[string]api.Team), + resourceTypes: make(map[string]string), + } + for _, group := range snapshot.Groups { + index.groups[group.ID] = group + } + for _, team := range snapshot.Teams { + index.teams[team.ID] = team + index.teams[teamObjectID(team.ID)] = team + } + for _, manifest := range snapshot.ResourceManifests { + for _, resource := range manifest.Resources { + index.resourceTypes[resource.ID] = resource.Type + } + } + return index +} + +func (i snapshotIndex) subjectType(id string) string { + if _, ok := i.groups[id]; ok { + return "group" + } + if _, ok := i.teams[id]; ok { + return "group" + } + if strings.HasPrefix(id, "group:") || strings.HasPrefix(id, "team:") || strings.HasPrefix(id, "reader:") { + return "group" + } + if resourceType := i.resourceType(id); resourceType != "" { + return resourceType + } + return "user" +} + +func (i snapshotIndex) subjectID(id string) string { + if _, ok := i.teams[id]; ok { + return teamObjectID(id) + } + return id +} + +func (i snapshotIndex) resourceType(id string) string { + if resourceType := i.resourceTypes[id]; resourceType != "" { + return resourceType + } + if inferred := inferTypeFromID(id); inferred != "" { + return inferred + } + return "resource" +} + +func tupleKey(tuple Tuple) string { + return fmt.Sprintf( + "%s\x00%s\x00%s\x00%s\x00%s\x00%s", + tuple.ObjectType, + tuple.ObjectID, + tuple.Relation, + tuple.SubjectType, + tuple.SubjectID, + tuple.SubjectRelation, + ) +} + +func sortedTuples(tuples map[string]Tuple) []Tuple { + keys := make([]string, 0, len(tuples)) + for key := range tuples { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]Tuple, 0, len(keys)) + for _, key := range keys { + out = append(out, tuples[key]) + } + return out +} + +func teamObjectID(id string) string { + if strings.HasPrefix(id, "team:") { + return id + } + return "team:" + id +} + +func teamOrGroupObjectID(id string) string { + if strings.HasPrefix(id, "team:") || strings.HasPrefix(id, "group:") || strings.HasPrefix(id, "reader:") { + return id + } + return teamObjectID(id) +} diff --git a/internal/adapters/relationship/types.go b/internal/adapters/relationship/types.go new file mode 100644 index 0000000..dc0b184 --- /dev/null +++ b/internal/adapters/relationship/types.go @@ -0,0 +1,164 @@ +package relationship + +import ( + "context" + "errors" + "fmt" + + "github.com/netkingdom/flex-auth/pkg/api" +) + +const ( + // EvaluatorName is recorded in delegated relationship-PDP provenance. + EvaluatorName = "relationship-pdp" + // DelegatedMode is the stable mode used for delegated tuple checks. + DelegatedMode = "delegated" +) + +// Backend is the protocol boundary for OpenFGA, SpiceDB, and similar systems. +type Backend interface { + Check(context.Context, TupleCheckRequest) (TupleCheckResult, error) + BatchCheck(context.Context, []TupleCheckRequest) ([]TupleCheckResult, error) + ListObjects(context.Context, TupleListRequest) (TupleListResult, error) + ImportTuples(context.Context, []Tuple) (ImportReport, error) + Health(context.Context) error +} + +// Options configures the relationship adapter without binding callers to a +// specific backend protocol. +type Options struct { + BackendName string + PolicyPackage string + PolicyVersion string +} + +// Tuple is the canonical relation fact sent to tuple-oriented PDPs. +type Tuple struct { + ObjectType string `json:"object_type"` + ObjectID string `json:"object_id"` + Relation string `json:"relation"` + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + SubjectRelation string `json:"subject_relation,omitempty"` + Conditions []api.Condition `json:"conditions,omitempty"` + Caring *api.CaringAccessDescriptor `json:"caring,omitempty"` + Provenance map[string]any `json:"provenance,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// TupleCheckRequest is the backend-neutral relationship check. +type TupleCheckRequest struct { + ObjectType string `json:"object_type"` + ObjectID string `json:"object_id"` + Relation string `json:"relation"` + SubjectType string `json:"subject_type"` + SubjectID string `json:"subject_id"` + Context map[string]any `json:"context,omitempty"` + ConsistencyToken string `json:"consistency_token,omitempty"` +} + +// TupleCheckResult is a tuple-PDP response before flex-auth envelope wrapping. +type TupleCheckResult struct { + Allowed bool + Effect api.DecisionEffect + Reason string + MatchedRule string + ConsistencyToken string + Stale bool + Partial bool + MatchedTuples []Tuple + InheritedFrom []Tuple + Diagnostics map[string]any + CaringDescriptor *api.CaringAccessDescriptor + ConformanceFindings []api.CaringConformanceFinding +} + +// TupleListRequest asks the backend for objects a subject may access. +type TupleListRequest struct { + Subject api.SubjectRef `json:"subject"` + Relation string `json:"relation"` + ResourceType string `json:"resource_type,omitempty"` + System string `json:"system,omitempty"` + Tenant string `json:"tenant,omitempty"` + Filters map[string]any `json:"filters,omitempty"` + Context map[string]any `json:"context,omitempty"` + ConsistencyToken string `json:"consistency_token,omitempty"` +} + +// ListedObject is one backend list result. +type ListedObject struct { + Resource api.ResourceRef `json:"resource"` + ConsistencyToken string `json:"consistency_token,omitempty"` + MatchedTuples []Tuple `json:"matched_tuples,omitempty"` + Diagnostics map[string]any `json:"diagnostics,omitempty"` + CaringDescriptor *api.CaringAccessDescriptor `json:"caring_descriptor,omitempty"` +} + +// TupleListResult is a backend list response. +type TupleListResult struct { + Objects []ListedObject `json:"objects"` + ConsistencyToken string `json:"consistency_token,omitempty"` + Stale bool `json:"stale,omitempty"` + Partial bool `json:"partial,omitempty"` + Diagnostics map[string]any `json:"diagnostics,omitempty"` +} + +// ListAllowedResult is the flex-auth list response with consistency metadata. +type ListAllowedResult struct { + Decisions []api.DecisionEnvelope `json:"decisions"` + ConsistencyToken string `json:"consistency_token,omitempty"` + Diagnostics map[string]any `json:"diagnostics,omitempty"` +} + +// ImportReport summarizes tuple import. +type ImportReport struct { + TuplesWritten int `json:"tuples_written"` + ConsistencyToken string `json:"consistency_token,omitempty"` +} + +// FailureKind classifies fail-closed delegated relationship decisions. +type FailureKind string + +const ( + FailureUnavailable FailureKind = "unavailable" + FailureStaleData FailureKind = "stale_data" + FailurePartialResult FailureKind = "partial_result" + FailureInvalidRequest FailureKind = "invalid_request" +) + +// BackendError wraps tuple-backend failures with adapter semantics. +type BackendError struct { + Kind FailureKind + Op string + Err error +} + +func (e *BackendError) Error() string { + if e == nil { + return "" + } + if e.Err == nil { + return fmt.Sprintf("relationship backend %s failed: %s", e.Op, e.Kind) + } + return fmt.Sprintf("relationship backend %s failed: %s: %v", e.Op, e.Kind, e.Err) +} + +func (e *BackendError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// NewBackendError classifies a relationship-backend failure. +func NewBackendError(kind FailureKind, op string, err error) error { + return &BackendError{Kind: kind, Op: op, Err: err} +} + +func failureKind(err error) FailureKind { + var backend *BackendError + if errors.As(err, &backend) && backend.Kind != "" { + return backend.Kind + } + return FailureUnavailable +} 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 7b176bd..c28ea45 100644 --- a/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md +++ b/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md @@ -79,7 +79,7 @@ fail-closed defaults. ```task id: FLEX-WP-0004-T002 -status: todo +status: done priority: high state_hub_task_id: "b77a0b70-b492-46ba-badf-8c2eebe006aa" ```