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