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 }