From 360025e38b827b37553413b0f7e0946431ad3204 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 17 May 2026 07:18:45 +0200 Subject: [PATCH] Add Keycloak authorization adapter path --- docs/keycloak-authz-adapter-path.md | 78 +++++ internal/adapters/keycloak/adapter.go | 306 ++++++++++++++++++ internal/adapters/keycloak/adapter_test.go | 192 +++++++++++ internal/adapters/keycloak/doc.go | 2 + internal/adapters/keycloak/types.go | 121 +++++++ ...04-delegated-pdp-and-directory-adapters.md | 2 +- 6 files changed, 700 insertions(+), 1 deletion(-) create mode 100644 docs/keycloak-authz-adapter-path.md create mode 100644 internal/adapters/keycloak/adapter.go create mode 100644 internal/adapters/keycloak/adapter_test.go create mode 100644 internal/adapters/keycloak/doc.go create mode 100644 internal/adapters/keycloak/types.go diff --git a/docs/keycloak-authz-adapter-path.md b/docs/keycloak-authz-adapter-path.md new file mode 100644 index 0000000..9556607 --- /dev/null +++ b/docs/keycloak-authz-adapter-path.md @@ -0,0 +1,78 @@ +# Keycloak Authorization Services Adapter Path + +Status: implemented for FLEX-WP-0004 P4.4. + +## Role + +The Keycloak path is for deployments that already use Keycloak as the +identity provider and want to evaluate some authorization decisions +through Keycloak Authorization Services. flex-auth still remains the +source of truth for protected-system resources, CARING descriptors, and +decision envelopes. + +## Mapping + +flex-auth maps a check to Keycloak's UMA permission shape: + +| flex-auth | Keycloak | +| --- | --- | +| protected system | resource server / audience | +| resource id | resource id | +| action | scope | +| subject | requesting party | +| context and CARING descriptor | claim token | + +The adapter builds a permission as `resource_id#scope`, for example +`document:internal-note#read`. + +## Resource Registration + +`ResourceRegistrationsFromManifest` converts a +`ResourceManifest` into Keycloak resource registrations: + +- resource id and type are preserved; +- manifest actions become scopes; +- path becomes the URI; +- labels, trust zone, owner, parent, system, and type are stored as + resource attributes. + +Keycloak can mirror these resources, but flex-auth keeps the original +manifest as the canonical record. + +## Decision Wrapping + +Keycloak allow/deny results are wrapped into the standard +`DecisionEnvelope`: + +- `provenance.evaluator=keycloak-authz` +- `provenance.mode=delegated` +- Keycloak RPT token id and permission appear in diagnostics +- CARING descriptor and conformance findings are preserved + +Backend-native Keycloak policy names do not replace CARING canonical +roles in protected-system responses. + +## Failure Behavior + +The adapter fails closed for: + +- Keycloak unavailable: `keycloak_unavailable` +- stale policy state: `keycloak_policy_stale` +- partial result: `keycloak_partial_result` +- untranslatable request: `keycloak_request_incomplete` + +Each failure returns a deny envelope with `diagnostics.keycloak_failure` +and a CARING conformance finding. + +## Boundaries + +This path intentionally does not make Keycloak the only policy source of +truth. flex-auth continues to own: + +- resource manifests from protected systems; +- CARING descriptors and conformance findings; +- audit and explanation envelope shape; +- adapter-neutral request/decision APIs. + +Keycloak is a delegated evaluator and resource mirror for Keycloak-heavy +installations, not the canonical model for the whole product. diff --git a/internal/adapters/keycloak/adapter.go b/internal/adapters/keycloak/adapter.go new file mode 100644 index 0000000..a7eba8a --- /dev/null +++ b/internal/adapters/keycloak/adapter.go @@ -0,0 +1,306 @@ +package keycloak + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + "github.com/netkingdom/flex-auth/pkg/api" +) + +// Adapter maps flex-auth checks and resources to Keycloak Authorization +// Services while preserving the flex-auth decision envelope. +type Adapter struct { + client Client + realm string + audience string + policyPackage string + policyVersion string +} + +func New(client Client, options Options) (*Adapter, error) { + if client == nil { + return nil, fmt.Errorf("keycloak client is required") + } + if options.Realm == "" { + return nil, fmt.Errorf("keycloak realm is required") + } + if options.Audience == "" { + return nil, fmt.Errorf("keycloak audience is required") + } + return &Adapter{ + client: client, + realm: options.Realm, + audience: options.Audience, + policyPackage: options.PolicyPackage, + policyVersion: options.PolicyVersion, + }, nil +} + +func (a *Adapter) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) { + authz, err := a.BuildAuthorizationRequest(request) + if err != nil { + return a.failureEnvelope(request, AuthorizationRequest{}, FailureInvalidRequest, err), nil + } + result, err := a.client.Authorize(ctx, authz) + if err != nil { + return a.failureEnvelope(request, authz, failureKind(err), err), nil + } + if result.StalePolicy { + return a.failureEnvelope(request, authz, FailureStalePolicy, nil), nil + } + if result.PartialResult { + return a.failureEnvelope(request, authz, FailurePartialResult, nil), nil + } + return a.envelope(request, authz, result), nil +} + +func (a *Adapter) RegisterResourceManifest(ctx context.Context, manifest api.ResourceManifest) (ResourceImportReport, error) { + return a.client.RegisterResources(ctx, ResourceRegistrationsFromManifest(manifest)) +} + +func (a *Adapter) BuildAuthorizationRequest(request api.CheckRequest) (AuthorizationRequest, error) { + if request.Subject.ID == "" || request.Action == "" || request.Resource.ID == "" { + return AuthorizationRequest{}, fmt.Errorf("subject id, action, and resource id are required") + } + claims := map[string]any{ + "subject": request.Subject, + "resource": request.Resource, + "action": request.Action, + "context": copyMap(request.Context), + } + if request.CaringContext != nil { + claims["caring_context"] = request.CaringContext + } + return AuthorizationRequest{ + Realm: a.realm, + Audience: a.audience, + Subject: request.Subject, + Permission: Permission{ + ResourceID: request.Resource.ID, + Scope: request.Action, + }, + ClaimToken: claims, + }, nil +} + +func ResourceRegistrationsFromManifest(manifest api.ResourceManifest) []ResourceRegistration { + registrations := make([]ResourceRegistration, 0, len(manifest.Resources)) + for _, resource := range manifest.Resources { + attrs := map[string][]string{ + "flex_auth_system": {manifest.System}, + "flex_auth_type": {resource.Type}, + } + if resource.TrustZone != "" { + attrs["trust_zone"] = []string{resource.TrustZone} + } + if resource.Parent != "" { + attrs["parent"] = []string{resource.Parent} + } + if resource.Owner != "" { + attrs["owner"] = []string{resource.Owner} + } + if len(resource.Labels) > 0 { + attrs["labels"] = append([]string(nil), resource.Labels...) + } + registrations = append(registrations, ResourceRegistration{ + ID: resource.ID, + Name: resource.ID, + Type: resource.Type, + URI: resource.Path, + Scopes: append([]string(nil), manifest.Actions...), + Attributes: attrs, + }) + } + return registrations +} + +func (a *Adapter) envelope(request api.CheckRequest, authz AuthorizationRequest, result AuthorizationResult) api.DecisionEnvelope { + effect := api.DecisionEffectDeny + if result.Allowed { + effect = api.DecisionEffectAllow + } + reason := result.Reason + if reason == "" { + if result.Allowed { + reason = "keycloak_permission_granted" + } else { + reason = "keycloak_permission_denied" + } + } + policyVersion := firstNonEmpty(result.PolicyVersion, request.PolicyVersion, a.policyVersion) + diagnostics := copyMap(result.Diagnostics) + addDiagnostics(diagnostics, authz, "") + if result.RPTTokenID != "" { + diagnostics["rpt_token_id"] = result.RPTTokenID + } + envelope := api.DecisionEnvelope{ + RequestID: request.ID, + Effect: effect, + Reason: reason, + MatchedPolicyVersion: policyVersion, + MatchedRule: reason, + Resource: request.Resource, + Subject: request.Subject, + Diagnostics: diagnostics, + Provenance: api.DecisionProvenance{ + Evaluator: EvaluatorName, + Mode: DelegatedMode, + PolicyPackage: a.policyPackage, + PolicyVersion: policyVersion, + }, + Caring: caringDecisionMetadata(firstDescriptor(request.CaringContext, result.CaringDescriptor), result.ConformanceFindings), + } + envelope.ID = decisionID(a.policyPackage, policyVersion, request, effect, reason) + return envelope +} + +func (a *Adapter) failureEnvelope(request api.CheckRequest, authz AuthorizationRequest, 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(authz, kind, err), + Provenance: api.DecisionProvenance{ + Evaluator: EvaluatorName, + Mode: DelegatedMode, + PolicyPackage: a.policyPackage, + PolicyVersion: policyVersion, + }, + Caring: caringDecisionMetadata(request.CaringContext, []api.CaringConformanceFinding{failureFinding(kind)}), + } + envelope.ID = decisionID(a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason) + return envelope +} + +func caringDecisionMetadata(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: "KEYCLOAK-CARING-DESCRIPTOR-MISSING", + Severity: "warning", + Message: "Keycloak authorization 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...) + return metadata +} + +func addDiagnostics(diagnostics map[string]any, request AuthorizationRequest, failure string) { + diagnostics["adapter"] = "keycloak" + diagnostics["mode"] = DelegatedMode + if request.Realm != "" { + diagnostics["realm"] = request.Realm + diagnostics["audience"] = request.Audience + diagnostics["permission"] = request.Permission.ResourceID + "#" + request.Permission.Scope + } + if failure != "" { + diagnostics["keycloak_failure"] = failure + } +} + +func failureDiagnostics(request AuthorizationRequest, kind FailureKind, err error) map[string]any { + diagnostics := map[string]any{} + addDiagnostics(diagnostics, request, string(kind)) + if err != nil { + diagnostics["error"] = err.Error() + } + return diagnostics +} + +func failureReason(kind FailureKind) string { + switch kind { + case FailureStalePolicy: + return "keycloak_policy_stale" + case FailurePartialResult: + return "keycloak_partial_result" + case FailureInvalidRequest: + return "keycloak_request_incomplete" + default: + return "keycloak_unavailable" + } +} + +func failureFinding(kind FailureKind) api.CaringConformanceFinding { + code := "KEYCLOAK-UNAVAILABLE" + message := "Keycloak Authorization Services was unavailable; flex-auth denied fail-closed." + switch kind { + case FailureStalePolicy: + code = "KEYCLOAK-POLICY-STALE" + message = "Keycloak policy state was stale; flex-auth denied fail-closed." + case FailurePartialResult: + code = "KEYCLOAK-PARTIAL-RESULT" + message = "Keycloak returned a partial result; flex-auth denied fail-closed." + case FailureInvalidRequest: + code = "KEYCLOAK-REQUEST-INCOMPLETE" + message = "Request could not be translated to a Keycloak UMA permission; flex-auth denied fail-closed." + } + return api.CaringConformanceFinding{Code: code, Severity: "error", Message: message} +} + +func decisionID(policyPackage, policyVersion string, request api.CheckRequest, effect api.DecisionEffect, reason string) string { + data, _ := json.Marshal(struct { + Adapter string `json:"adapter"` + 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"` + }{ + Adapter: EvaluatorName, + PolicyPackage: policyPackage, + PolicyVersion: policyVersion, + Request: request, + Effect: effect, + Reason: reason, + }) + sum := sha256.Sum256(data) + return "decision:keycloak:" + hex.EncodeToString(sum[:8]) +} + +func firstDescriptor(values ...*api.CaringAccessDescriptor) *api.CaringAccessDescriptor { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} + +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 +} diff --git a/internal/adapters/keycloak/adapter_test.go b/internal/adapters/keycloak/adapter_test.go new file mode 100644 index 0000000..8ea1e86 --- /dev/null +++ b/internal/adapters/keycloak/adapter_test.go @@ -0,0 +1,192 @@ +package keycloak_test + +import ( + "context" + "errors" + "testing" + + "github.com/netkingdom/flex-auth/internal/adapters/keycloak" + "github.com/netkingdom/flex-auth/pkg/api" +) + +func TestBuildAuthorizationRequestUsesUMAPermissionShape(t *testing.T) { + adapter := newAdapter(t, &fakeClient{}) + + got, err := adapter.BuildAuthorizationRequest(api.CheckRequest{ + ID: "check:keycloak", + Subject: api.SubjectRef{ID: "user:alice"}, + Action: "read", + Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"}, + Context: map[string]any{"purpose": "support"}, + CaringContext: caringDescriptor(), + }) + if err != nil { + t.Fatalf("BuildAuthorizationRequest: %v", err) + } + if got.Realm != "platform" || got.Audience != "markitect-tool" { + t.Fatalf("realm/audience = %s/%s", got.Realm, got.Audience) + } + if got.Permission.ResourceID != "document:internal-note" || got.Permission.Scope != "read" { + t.Fatalf("permission = %+v", got.Permission) + } + if got.ClaimToken["caring_context"] == nil { + t.Fatal("claim token missing CARING context") + } +} + +func TestResourceRegistrationsFromManifest(t *testing.T) { + got := keycloak.ResourceRegistrationsFromManifest(api.ResourceManifest{ + ID: "manifest:markitect", + System: "markitect-tool", + Actions: []string{"read", "export"}, + Resources: []api.Resource{ + { + ID: "document:internal-note", + Type: "document", + Path: "/docs/internal-note", + Labels: []string{"internal"}, + TrustZone: "internal", + Owner: "team:platform", + }, + }, + }) + if len(got) != 1 { + t.Fatalf("len = %d", len(got)) + } + if got[0].ID != "document:internal-note" || got[0].Type != "document" { + t.Fatalf("registration = %+v", got[0]) + } + if len(got[0].Scopes) != 2 || got[0].Attributes["trust_zone"][0] != "internal" { + t.Fatalf("registration = %+v", got[0]) + } +} + +func TestAdapterCheckWrapsKeycloakAllow(t *testing.T) { + client := &fakeClient{ + result: keycloak.AuthorizationResult{ + Allowed: true, + Reason: "uma_permission_granted", + RPTTokenID: "rpt:123", + PolicyVersion: "kc-v2", + Diagnostics: map[string]any{"policy": "document-reader"}, + }, + } + adapter := newAdapter(t, client) + + got, err := adapter.Check(context.Background(), api.CheckRequest{ + ID: "check:allow", + 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.DecisionEffectAllow || got.Reason != "uma_permission_granted" { + t.Fatalf("decision = %s/%s", got.Effect, got.Reason) + } + if got.Provenance.Evaluator != keycloak.EvaluatorName || got.MatchedPolicyVersion != "kc-v2" { + t.Fatalf("provenance = %+v matched=%s", got.Provenance, got.MatchedPolicyVersion) + } + if got.Diagnostics["permission"] != "document:internal-note#read" || got.Diagnostics["rpt_token_id"] != "rpt:123" { + t.Fatalf("diagnostics = %+v", got.Diagnostics) + } + if got.Caring == nil || got.Caring.Descriptor == nil { + t.Fatal("missing CARING descriptor") + } +} + +func TestAdapterFailsClosedOnUnavailableKeycloak(t *testing.T) { + client := &fakeClient{ + err: keycloak.NewBackendError(keycloak.FailureUnavailable, "authorize", errors.New("connect refused")), + } + adapter := newAdapter(t, client) + + got, err := adapter.Check(context.Background(), api.CheckRequest{ + ID: "check:down", + 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 != "keycloak_unavailable" { + t.Fatalf("decision = %s/%s; want fail closed", got.Effect, got.Reason) + } + if got.Diagnostics["keycloak_failure"] != "unavailable" { + t.Fatalf("diagnostics = %+v", got.Diagnostics) + } + if got.Caring.ConformanceFindings[0].Code != "KEYCLOAK-UNAVAILABLE" { + t.Fatalf("finding = %+v", got.Caring.ConformanceFindings[0]) + } +} + +func TestRegisterResourceManifestDelegatesToClient(t *testing.T) { + client := &fakeClient{} + adapter := newAdapter(t, client) + + report, err := adapter.RegisterResourceManifest(context.Background(), api.ResourceManifest{ + System: "markitect-tool", + Actions: []string{"read"}, + Resources: []api.Resource{ + {ID: "document:internal-note", Type: "document"}, + }, + }) + if err != nil { + t.Fatalf("RegisterResourceManifest: %v", err) + } + if report.ResourcesWritten != 1 || len(client.registered) != 1 { + t.Fatalf("report = %+v registered = %+v", report, client.registered) + } +} + +func newAdapter(t *testing.T, client *fakeClient) *keycloak.Adapter { + t.Helper() + adapter, err := keycloak.New(client, keycloak.Options{ + Realm: "platform", + Audience: "markitect-tool", + PolicyPackage: "keycloak.authz", + PolicyVersion: "v1", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + return adapter +} + +func caringDescriptor() *api.CaringAccessDescriptor { + return &api.CaringAccessDescriptor{ + ID: "descriptor:keycloak-reader", + Profile: api.CaringProfileCaring040RC2, + SubjectType: api.SubjectTypeHuman, + OrganizationRelation: api.OrganizationRelationCustomer, + CanonicalRole: api.CanonicalRoleDoer, + Scope: api.CaringScope{Level: api.ScopeLevelResource, ID: "document:internal-note"}, + Planes: []api.Plane{api.PlaneData}, + Capabilities: []api.Capability{api.CapabilityView}, + ExposureModes: []api.ExposureMode{api.ExposureModeMasked}, + Restrictions: []api.Restriction{api.RestrictionExportBlocked}, + } +} + +type fakeClient struct { + result keycloak.AuthorizationResult + err error + registered []keycloak.ResourceRegistration +} + +func (c *fakeClient) Authorize(context.Context, keycloak.AuthorizationRequest) (keycloak.AuthorizationResult, error) { + return c.result, c.err +} + +func (c *fakeClient) RegisterResources(_ context.Context, resources []keycloak.ResourceRegistration) (keycloak.ResourceImportReport, error) { + c.registered = append([]keycloak.ResourceRegistration(nil), resources...) + return keycloak.ResourceImportReport{ResourcesWritten: len(resources), ResourceServerID: "rs:markitect"}, nil +} + +func (c *fakeClient) Health(context.Context) error { + return nil +} diff --git a/internal/adapters/keycloak/doc.go b/internal/adapters/keycloak/doc.go new file mode 100644 index 0000000..9b827d5 --- /dev/null +++ b/internal/adapters/keycloak/doc.go @@ -0,0 +1,2 @@ +// Package keycloak defines the Keycloak Authorization Services adapter path. +package keycloak diff --git a/internal/adapters/keycloak/types.go b/internal/adapters/keycloak/types.go new file mode 100644 index 0000000..59e4ddf --- /dev/null +++ b/internal/adapters/keycloak/types.go @@ -0,0 +1,121 @@ +package keycloak + +import ( + "context" + "errors" + "fmt" + + "github.com/netkingdom/flex-auth/pkg/api" +) + +const ( + EvaluatorName = "keycloak-authz" + DelegatedMode = "delegated" +) + +// Client is the boundary to Keycloak Authorization Services. +type Client interface { + Authorize(context.Context, AuthorizationRequest) (AuthorizationResult, error) + RegisterResources(context.Context, []ResourceRegistration) (ResourceImportReport, error) + Health(context.Context) error +} + +// Options configures Keycloak mapping without making Keycloak the source of +// truth for flex-auth resources or policies. +type Options struct { + Realm string + Audience string + PolicyPackage string + PolicyVersion string +} + +// AuthorizationRequest is the UMA permission request flex-auth sends to +// Keycloak. +type AuthorizationRequest struct { + Realm string `json:"realm"` + Audience string `json:"audience"` + Subject api.SubjectRef `json:"subject"` + Permission Permission `json:"permission"` + ClaimToken map[string]any `json:"claim_token,omitempty"` +} + +// Permission is the Keycloak resource#scope tuple. +type Permission struct { + ResourceID string `json:"resource_id"` + Scope string `json:"scope"` +} + +// AuthorizationResult is a Keycloak decision before flex-auth wrapping. +type AuthorizationResult struct { + Allowed bool + Reason string + RPTTokenID string + PolicyVersion string + Diagnostics map[string]any + CaringDescriptor *api.CaringAccessDescriptor + ConformanceFindings []api.CaringConformanceFinding + StalePolicy bool + PartialResult bool +} + +// ResourceRegistration is the Keycloak resource server registration shape. +type ResourceRegistration struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + URI string `json:"uri,omitempty"` + Scopes []string `json:"scopes,omitempty"` + Attributes map[string][]string `json:"attributes,omitempty"` +} + +// ResourceImportReport summarizes resource registration. +type ResourceImportReport struct { + ResourcesWritten int + ResourceServerID string +} + +// FailureKind classifies fail-closed Keycloak decisions. +type FailureKind string + +const ( + FailureUnavailable FailureKind = "unavailable" + FailureStalePolicy FailureKind = "stale_policy" + FailurePartialResult FailureKind = "partial_result" + FailureInvalidRequest FailureKind = "invalid_request" +) + +// BackendError wraps Keycloak 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("keycloak %s failed: %s", e.Op, e.Kind) + } + return fmt.Sprintf("keycloak %s failed: %s: %v", e.Op, e.Kind, e.Err) +} + +func (e *BackendError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func NewBackendError(kind FailureKind, op string, err error) error { + return &BackendError{Kind: kind, Op: op, Err: err} +} + +func failureKind(err error) FailureKind { + var backend *BackendError + if errors.As(err, &backend) && backend.Kind != "" { + return backend.Kind + } + return FailureUnavailable +} diff --git a/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md b/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md index 3c2034d..b4df1d6 100644 --- a/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md +++ b/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md @@ -116,7 +116,7 @@ Define and implement adapter contracts for OPA/Rego and Cedar-style policies: ```task id: FLEX-WP-0004-T004 -status: todo +status: done priority: medium state_hub_task_id: "8d3bbc28-985b-4dd7-9fb8-f9a858eb5a6b" ```