generated from coulomb/repo-seed
Add relationship PDP adapter boundary
This commit is contained in:
105
docs/relationship-pdp-adapter-boundary.md
Normal file
105
docs/relationship-pdp-adapter-boundary.md
Normal 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.
|
||||||
442
internal/adapters/relationship/adapter.go
Normal file
442
internal/adapters/relationship/adapter.go
Normal 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
|
||||||
|
}
|
||||||
304
internal/adapters/relationship/adapter_test.go
Normal file
304
internal/adapters/relationship/adapter_test.go
Normal 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
|
||||||
|
}
|
||||||
3
internal/adapters/relationship/doc.go
Normal file
3
internal/adapters/relationship/doc.go
Normal 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
|
||||||
197
internal/adapters/relationship/translate.go
Normal file
197
internal/adapters/relationship/translate.go
Normal 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)
|
||||||
|
}
|
||||||
164
internal/adapters/relationship/types.go
Normal file
164
internal/adapters/relationship/types.go
Normal 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
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ fail-closed defaults.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: FLEX-WP-0004-T002
|
id: FLEX-WP-0004-T002
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "b77a0b70-b492-46ba-badf-8c2eebe006aa"
|
state_hub_task_id: "b77a0b70-b492-46ba-badf-8c2eebe006aa"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user