Add relationship PDP adapter boundary
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled

This commit is contained in:
2026-05-17 07:06:14 +02:00
parent 90021d16b6
commit 4bb329c921
7 changed files with 1216 additions and 1 deletions

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
// Package relationship defines the backend-neutral relationship PDP boundary
// for OpenFGA, SpiceDB, and similar tuple-oriented authorization systems.
package relationship

View File

@@ -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)
}

View File

@@ -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
}

View File

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