Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions pkg/armrpc/api/v1/errorcodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,35 @@ const (
// Used for failed invalid spec api validation.
CodeHTTPRequestPayloadAPISpecValidationFailed = "HttpRequestPayloadAPISpecValidationFailed"
)

// Authorization error codes. See pkg/authz for the HTTP status of each code.
const (
// Used when the user does not have permission for the requested action on the target (403).
CodeAuthorizationFailed = "AuthorizationFailed"

// Used when the execution record is missing, closed, revoked, or expired (403).
CodeExecutionRecordNotActive = "ExecutionRecordNotActive"

// Used when the request is outside the execution record's approved actions or targets (403).
CodeGrantScopeExceeded = "GrantScopeExceeded"

// Used when the caller is not the operation's assigned component, or the operation is already final (403).
CodeOperationNotAssigned = "OperationNotAssigned"

// Used when the work's inputs do not match the operation's approved input hash (409).
CodeOperationInputMismatch = "OperationInputMismatch"

// Used when queued work waited longer than the execution record's queue wait limit (403).
CodeQueueWaitLimitExceeded = "QueueWaitLimitExceeded"

// Used when the credential broker refuses a token because the requested scope is outside
// the environment or the execution record (403).
CodeCredentialIssuanceDenied = "CredentialIssuanceDenied"

// Used when a Kubernetes admission control rejects an application workload (403).
CodeAdmissionPolicyDenied = "AdmissionPolicyDenied"

// Used when Radius could not read the user's permissions or the execution record, so it
// refused rather than guessed (503).
CodeAuthorizationUnavailable = "AuthorizationUnavailable"
)
25 changes: 21 additions & 4 deletions pkg/authz/decision.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"fmt"

v1 "github.com/radius-project/radius/pkg/armrpc/api/v1"
"github.com/radius-project/radius/pkg/ucp/ucplog"
)

Expand All @@ -45,17 +46,33 @@ type Decision struct {
// Code is the error code for a denial, such as "AuthorizationFailed".
Code string

// Reason describes why the check denied the request.
// Reason is an internal diagnostic describing why the check denied the request.
// It may be logged, but is omitted from ARM responses.
Reason string

// Action is the caller's requested action, such as "Applications.Core/containers/write".
Action string

// Target is the caller's requested resource ID or scope, not a resource discovered
// while evaluating permissions.
Target string
}

// DeniedError is returned by Apply when a denial is enforced.
type DeniedError struct {
// Code is the error code for the denial.
Code string

// Reason describes why the request was denied.
// Reason is an internal diagnostic. It is included in Error() but never in
// ErrorResponse() or Response().
Reason string

// Action is the caller's requested action. It is required for ARM responses.
Action string

// Target is the caller's requested resource ID or scope, not a resource discovered
// while evaluating permissions. It is required for ARM responses.
Target string
}

// Error implements the error interface.
Expand All @@ -64,7 +81,7 @@ func (e *DeniedError) Error() string {
}

// CodeAuthorizationFailed is used when a denial does not carry a specific code.
const CodeAuthorizationFailed = "AuthorizationFailed"
const CodeAuthorizationFailed = v1.CodeAuthorizationFailed

// Apply acts on an authorization decision according to mode. It returns nil when the
// decision is allowed or mode is off, logs and returns nil for a denial in dry-run mode,
Expand Down Expand Up @@ -94,5 +111,5 @@ func Apply(ctx context.Context, mode Mode, decision Decision) error {
return nil
}

return &DeniedError{Code: code, Reason: decision.Reason}
return &DeniedError{Code: code, Reason: decision.Reason, Action: decision.Action, Target: decision.Target}
}
4 changes: 3 additions & 1 deletion pkg/authz/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,7 @@ limitations under the License.
// - ModeEnforce: denials are rejected with a specific error code.
//
// Checks build a Decision and pass it to Apply, which acts on it according to the
// configured mode.
// configured mode. An enforced denial is a *DeniedError, which converts to an ARM error
// response whose HTTP status comes from StatusForCode. Conversion requires the
// caller's requested action and target and omits the internal diagnostic reason.
package authz
130 changes: 130 additions & 0 deletions pkg/authz/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
Copyright 2026 The Radius Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package authz

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"

v1 "github.com/radius-project/radius/pkg/armrpc/api/v1"
"github.com/radius-project/radius/pkg/armrpc/rest"
"github.com/radius-project/radius/pkg/logging"
"github.com/radius-project/radius/pkg/ucp/ucplog"
)

// Log-only codes for mTLS handshake failures. A failed handshake has no HTTP response,
// so these codes are used only in logs and metrics and have no HTTP status.
const (
// CodePeerCertificateInvalid is logged when a peer's certificate fails verification.
CodePeerCertificateInvalid = "PeerCertificateInvalid"

// CodeCertificateExpired is logged when a component's own certificate has expired.
CodeCertificateExpired = "CertificateExpired"
)

var statusForCode = map[string]int{
v1.CodeInvalidAuthenticationInfo: http.StatusUnauthorized,
v1.CodeAuthorizationFailed: http.StatusForbidden,
v1.CodeExecutionRecordNotActive: http.StatusForbidden,
v1.CodeGrantScopeExceeded: http.StatusForbidden,
v1.CodeOperationNotAssigned: http.StatusForbidden,
v1.CodeOperationInputMismatch: http.StatusConflict,
v1.CodeQueueWaitLimitExceeded: http.StatusForbidden,
v1.CodeCredentialIssuanceDenied: http.StatusForbidden,
v1.CodeAdmissionPolicyDenied: http.StatusForbidden,
v1.CodeAuthorizationUnavailable: http.StatusServiceUnavailable,
}

// StatusForCode returns the HTTP status for an authorization error code. It returns
// http.StatusInternalServerError for log-only codes and unknown codes, since neither
// is expected in an HTTP response.
func StatusForCode(code string) int {
if status, ok := statusForCode[code]; ok {
return status
}
return http.StatusInternalServerError
}

// code returns the denial's error code, defaulting to AuthorizationFailed when unset.
func (e *DeniedError) code() string {
if e.Code == "" {
return v1.CodeAuthorizationFailed
}
return e.Code
}

// ErrorResponse converts the denial to an ARM error response body. Action and Target
// must identify the caller's requested action and target; conversion fails if either
// is blank. Reason is an internal diagnostic and is never included in the response.
func (e *DeniedError) ErrorResponse() (v1.ErrorResponse, error) {
if strings.TrimSpace(e.Action) == "" || strings.TrimSpace(e.Target) == "" {
return v1.ErrorResponse{}, fmt.Errorf("authorization response requires an action and target")
}

return v1.ErrorResponse{
Error: &v1.ErrorDetails{
Code: e.code(),
Message: fmt.Sprintf("Authorization denied for action '%s' on target '%s'", e.Action, e.Target),
Target: e.Target,
},
}, nil
}

// Response converts the denial to a rest.Response with the HTTP status for its code.
// It returns an error if the requested action or target is blank.
func (e *DeniedError) Response() (rest.Response, error) {
body, err := e.ErrorResponse()
if err != nil {
return nil, err
}

return &DeniedResponse{
StatusCode: StatusForCode(e.code()),
Body: body,
}, nil
}

// DeniedResponse is a rest.Response that writes an ARM error payload for an authorization failure.
type DeniedResponse struct {
// StatusCode is the HTTP status to write.
StatusCode int

// Body is the ARM error payload.
Body v1.ErrorResponse
}

// Apply writes the response status and JSON body to w.
func (r *DeniedResponse) Apply(ctx context.Context, w http.ResponseWriter, req *http.Request) error {
logger := ucplog.FromContextOrDiscard(ctx)
logger.Info(fmt.Sprintf("responding with status code: %d", r.StatusCode), logging.LogHTTPStatusCode, r.StatusCode)

bytes, err := json.MarshalIndent(r.Body, "", " ")
if err != nil {
return fmt.Errorf("error marshaling %T: %w", r.Body, err)
}

w.Header().Add("Content-Type", "application/json")
w.WriteHeader(r.StatusCode)
if _, err := w.Write(bytes); err != nil {
return fmt.Errorf("error writing marshaled %T bytes to output: %w", r.Body, err)
}

return nil
}
Loading
Loading