generated from coulomb/repo-seed
443 lines
15 KiB
Go
443 lines
15 KiB
Go
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
|
|
}
|