generated from coulomb/repo-seed
Add Keycloak authorization adapter path
This commit is contained in:
78
docs/keycloak-authz-adapter-path.md
Normal file
78
docs/keycloak-authz-adapter-path.md
Normal file
@@ -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.
|
||||||
306
internal/adapters/keycloak/adapter.go
Normal file
306
internal/adapters/keycloak/adapter.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
192
internal/adapters/keycloak/adapter_test.go
Normal file
192
internal/adapters/keycloak/adapter_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
2
internal/adapters/keycloak/doc.go
Normal file
2
internal/adapters/keycloak/doc.go
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Package keycloak defines the Keycloak Authorization Services adapter path.
|
||||||
|
package keycloak
|
||||||
121
internal/adapters/keycloak/types.go
Normal file
121
internal/adapters/keycloak/types.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -116,7 +116,7 @@ Define and implement adapter contracts for OPA/Rego and Cedar-style policies:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: FLEX-WP-0004-T004
|
id: FLEX-WP-0004-T004
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "8d3bbc28-985b-4dd7-9fb8-f9a858eb5a6b"
|
state_hub_task_id: "8d3bbc28-985b-4dd7-9fb8-f9a858eb5a6b"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user