Skip to content

step.authz_check: write HTTP 403 response on authorization denial#259

Merged
intel352 merged 3 commits intomainfrom
copilot/fix-http-403-response
Mar 4, 2026
Merged

step.authz_check: write HTTP 403 response on authorization denial#259
intel352 merged 3 commits intomainfrom
copilot/fix-http-403-response

Conversation

Copy link
Contributor

Copilot AI commented Mar 4, 2026

When authorization is denied, the pipeline had no built-in step that wrote a 403 response to _http_response_writer, causing the openapi module handler to fall through and return 200 OK with the full result.Current context — leaking tokens, claims, and internal state.

Changes

  • module/pipeline_step_authz_check.go — New AuthzCheckStep using the existing PolicyEngineModule interface. On denial, writes {"error":"forbidden: ..."} with Content-Type: application/json and HTTP 403 directly to _http_response_writer, sets _response_handled = true, and stops the pipeline. Mirrors the pattern in step.auth_validate. When subject_field is configured with a non-default value, the subject is mapped to the canonical "subject" key in the policy input (cloning the input map to avoid mutating pc.Current). The step output also includes response_headers: {"Content-Type": "application/json"} so the HTTP trigger / openapi handler fallback path sends the correct content-type when no response writer is present.
  • module/pipeline_step_authz_check_test.go — Tests covering: allow, deny, HTTP response writing (with and without a response writer), response_headers content-type assertion for fallback path, input_from override, subject_field mapping into policy input, pipeline context mutation guard, nil app, factory validation.
  • plugins/pipelinesteps/plugin.go — Registers step.authz_check in StepTypes and StepFactories.
  • plugins/pipelinesteps/plugin_test.go — Adds step.authz_check to expectedSteps.

Config

Key Required Default Description
policy_engine Service name of the PolicyEngineModule
subject_field "subject" Field in pc.Current holding the subject; mapped to canonical "subject" key in policy input when set to a different name
input_from Use a named sub-map as policy input instead of full context

Example

steps:
  - name: check-authz
    type: step.authz_check
    config:
      policy_engine: my-policy-engine
      subject_field: auth_user_id

On denial: HTTP 403 with {"error":"forbidden: authorization denied"}. On allow: pipeline continues with allowed: true in step output.

Original prompt

This section details on the original issue you should resolve

<issue_title>step.authz_check: write HTTP 403 response on authorization denial</issue_title>
<issue_description>When step.authz_check denies a request, it returns StopPipeline: true with response_status: 403, response_body, and response_headers in the step output. However, it does NOT write the 403 response to the _http_response_writer.

Both the HTTP trigger handler and the openapi module handler check rw.written after pipeline execution:

  • If no step wrote to the ResponseWriter, the HTTP trigger returns 202 {"status": "workflow triggered"}
  • If no step wrote to the ResponseWriter, the openapi handler writes 200 OK with result.Currentleaking all pipeline context data (tokens, claims, internal state)

This differs from step.auth_validate, which correctly writes a 401 response to _http_response_writer before stopping the pipeline (see pipeline_step_auth_validate.go:110).

Expected behavior: step.authz_check should write the 403 JSON response to _http_response_writer on denial, matching the pattern used by step.auth_validate.

Reproduce:

  1. Configure a pipeline with step.authz_check where the subject is NOT authorized
  2. Invoke the pipeline via the openapi module's x-pipeline handler
  3. Observe: HTTP response is 200 OK with full pipeline context (including response_status: 403 in the JSON body)
  4. Expected: HTTP response is 403 Forbidden with {"error":"forbidden: ..."} body

Workaround: Ensure Casbin policies cover all routes so authz never fails for legitimate requests. There is no way to add a response-writing step after step.authz_check because StopPipeline: true prevents subsequent steps from executing.

Note: step.authz_check is provided by the workflow-plugin-authz external plugin. The fix likely needs to be in that plugin's forbiddenResult() function.</issue_description>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix HTTP 403 response writing on authorization denial step.authz_check: write HTTP 403 response on authorization denial Mar 4, 2026
@intel352 intel352 marked this pull request as ready for review March 4, 2026 01:27
Copilot AI review requested due to automatic review settings March 4, 2026 01:27
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a built-in step.authz_check pipeline step to prevent the OpenAPI handler (and HTTP trigger fallback) from returning 200 OK with the full pipeline context when authorization is denied, by writing a proper 403 Forbidden response and stopping the pipeline.

Changes:

  • Added AuthzCheckStep implementation that evaluates a PolicyEngineModule decision and writes a 403 JSON response to _http_response_writer on denial.
  • Added unit tests for allow/deny paths, response writing behavior, and factory validation.
  • Registered the new step type in the pipeline-steps plugin and updated plugin tests.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
module/pipeline_step_authz_check.go New step implementation that evaluates policy decisions and writes 403 responses on denial.
module/pipeline_step_authz_check_test.go New test suite covering allowed/denied execution, HTTP response writing, and factory validation.
plugins/pipelinesteps/plugin.go Registers step.authz_check in StepTypes and StepFactories.
plugins/pipelinesteps/plugin_test.go Updates expected step list to include step.authz_check.

if input == nil {
input = pc.Current
}

Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

subject_field is configured and stored on the step, but it is never used when building the policy input. As written, changing subject_field has no effect on authorization decisions, which contradicts the step’s config/docs/tests. Consider mapping the configured subject field into the input passed to the policy engine (or remove subject_field entirely if it’s not intended to be supported).

Suggested change
// Map the configured subject field into the policy input so that
// authorization decisions can depend on it. We read the subject from
// pc.Current[s.subjectField] and expose it under the canonical "subject"
// key in the input. When input == pc.Current, clone before mutation to
// avoid side effects on the pipeline context.
if s.subjectField != "" && s.subjectField != "subject" {
if subj, ok := pc.Current[s.subjectField]; ok {
if input == pc.Current {
cloned := make(map[string]any, len(input)+1)
for k, v := range input {
cloned[k] = v
}
input = cloned
}
input["subject"] = subj
}
}

Copilot uses AI. Check for mistakes.
Output: map[string]any{
"response_status": http.StatusForbidden,
"response_body": fmt.Sprintf(`{"error":%q}`, errorMsg),
"error": errorMsg,
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When no _http_response_writer is present, this step relies on response_status/response_body for the HTTP trigger/openapi handler fallback response, but it does not set response_headers (e.g., Content-Type: application/json). This can lead to 403 responses being returned without a JSON content-type header in those code paths. Populate response_headers in the step output to match the behavior when writing directly to the ResponseWriter.

Suggested change
"error": errorMsg,
"response_headers": map[string]string{
"Content-Type": "application/json",
},
"error": errorMsg,

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +141
func TestAuthzCheckStep_WritesHTTPResponse_NoResponseWriter(t *testing.T) {
factory := NewAuthzCheckStepFactory()
eng := newMockPolicyEngine()
_ = eng.LoadPolicy("deny-all", "deny")
app := newTestPolicyApp("policy", eng)

step, err := factory("authz", map[string]any{
"policy_engine": "policy",
}, app)
if err != nil {
t.Fatalf("factory error: %v", err)
}

// No response writer in metadata — should still stop pipeline with output.
pc := NewPipelineContext(map[string]any{"subject": "user-1"}, nil)
result, err := step.Execute(context.Background(), pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Stop {
t.Error("expected Stop=true even without response writer")
}
if result.Output["response_status"] != http.StatusForbidden {
t.Errorf("expected response_status=403, got %v", result.Output["response_status"])
}
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests cover Content-Type when a ResponseWriter is present, but there’s no assertion for the fallback (no ResponseWriter) case where the openapi/HTTP trigger uses response_headers from step output. If you add response_headers to the step output, add an assertion here to ensure Content-Type: application/json is included for the fallback path too.

Copilot uses AI. Check for mistakes.
@intel352
Copy link
Contributor

intel352 commented Mar 4, 2026

@copilot apply changes based on the comments in this thread

…se_headers in output

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 4, 2026

@copilot apply changes based on the comments in this thread

Applied all three review feedback items in commit 94a95d8:

  1. subject_field now maps into policy input: When subject_field is configured (and differs from the default "subject"), the value from pc.Current[s.subjectField] is copied into input["subject"] before evaluation. When input shares the same backing map as pc.Current, it is cloned first to avoid side effects on the pipeline context.

  2. response_headers added to step output: The fallback step output now includes "response_headers": {"Content-Type": "application/json"} so the HTTP trigger / openapi handler fallback path sends the correct content-type header.

  3. New/updated tests: Added assertions for response_headers in the no-ResponseWriter test, plus TestAuthzCheckStep_SubjectFieldMappedToInput and TestAuthzCheckStep_SubjectFieldMappingDoesNotMutatePipelineContext.

@intel352 intel352 merged commit 4c155dd into main Mar 4, 2026
14 checks passed
@intel352 intel352 deleted the copilot/fix-http-403-response branch March 4, 2026 10:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

step.authz_check: write HTTP 403 response on authorization denial

3 participants