step.authz_check: write HTTP 403 response on authorization denial#259
step.authz_check: write HTTP 403 response on authorization denial#259
Conversation
Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
There was a problem hiding this comment.
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
AuthzCheckStepimplementation that evaluates aPolicyEngineModuledecision and writes a 403 JSON response to_http_response_writeron 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 | ||
| } | ||
|
|
There was a problem hiding this comment.
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).
| // 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 | |
| } | |
| } |
module/pipeline_step_authz_check.go
Outdated
| Output: map[string]any{ | ||
| "response_status": http.StatusForbidden, | ||
| "response_body": fmt.Sprintf(`{"error":%q}`, errorMsg), | ||
| "error": errorMsg, |
There was a problem hiding this comment.
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.
| "error": errorMsg, | |
| "response_headers": map[string]string{ | |
| "Content-Type": "application/json", | |
| }, | |
| "error": errorMsg, |
| 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"]) | ||
| } | ||
| } |
There was a problem hiding this comment.
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 apply changes based on the comments in this thread |
…se_headers in output Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Applied all three review feedback items in commit 94a95d8:
|
When authorization is denied, the pipeline had no built-in step that wrote a 403 response to
_http_response_writer, causing theopenapimodule handler to fall through and return200 OKwith the fullresult.Currentcontext — leaking tokens, claims, and internal state.Changes
module/pipeline_step_authz_check.go— NewAuthzCheckStepusing the existingPolicyEngineModuleinterface. On denial, writes{"error":"forbidden: ..."}withContent-Type: application/jsonandHTTP 403directly to_http_response_writer, sets_response_handled = true, and stops the pipeline. Mirrors the pattern instep.auth_validate. Whensubject_fieldis 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 mutatingpc.Current). The step output also includesresponse_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_headerscontent-type assertion for fallback path,input_fromoverride,subject_fieldmapping into policy input, pipeline context mutation guard, nil app, factory validation.plugins/pipelinesteps/plugin.go— Registersstep.authz_checkinStepTypesandStepFactories.plugins/pipelinesteps/plugin_test.go— Addsstep.authz_checktoexpectedSteps.Config
policy_enginePolicyEngineModulesubject_field"subject"pc.Currentholding the subject; mapped to canonical"subject"key in policy input when set to a different nameinput_fromExample
On denial:
HTTP 403with{"error":"forbidden: authorization denied"}. On allow: pipeline continues withallowed: truein step output.Original prompt
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.