Skip to content

Commit 4c155dd

Browse files
Copilotintel352
andauthored
step.authz_check: write HTTP 403 response on authorization denial (#259)
* Initial plan * Add step.authz_check that writes 403 response on authorization denial Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * authz_check: apply PR review feedback - subject_field mapping, response_headers in output Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
1 parent a692591 commit 4c155dd

4 files changed

Lines changed: 478 additions & 1 deletion

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package module
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
9+
"github.com/CrisisTextLine/modular"
10+
)
11+
12+
// AuthzCheckStep evaluates a policy engine decision for the current pipeline
13+
// subject. On denial it writes a 403 Forbidden JSON response to the HTTP
14+
// response writer (when present) and stops the pipeline, matching the
15+
// pattern used by step.auth_validate for 401 responses.
16+
type AuthzCheckStep struct {
17+
name string
18+
engineName string // service name of the PolicyEngineModule
19+
subjectField string // field in pc.Current that holds the subject
20+
inputFrom string // optional: field in pc.Current to use as policy input
21+
app modular.Application
22+
}
23+
24+
// NewAuthzCheckStepFactory returns a StepFactory that creates AuthzCheckStep instances.
25+
func NewAuthzCheckStepFactory() StepFactory {
26+
return func(name string, config map[string]any, app modular.Application) (PipelineStep, error) {
27+
engineName, _ := config["policy_engine"].(string)
28+
if engineName == "" {
29+
return nil, fmt.Errorf("authz_check step %q: 'policy_engine' is required", name)
30+
}
31+
32+
subjectField, _ := config["subject_field"].(string)
33+
if subjectField == "" {
34+
subjectField = "subject"
35+
}
36+
37+
inputFrom, _ := config["input_from"].(string)
38+
39+
return &AuthzCheckStep{
40+
name: name,
41+
engineName: engineName,
42+
subjectField: subjectField,
43+
inputFrom: inputFrom,
44+
app: app,
45+
}, nil
46+
}
47+
}
48+
49+
// Name returns the step name.
50+
func (s *AuthzCheckStep) Name() string { return s.name }
51+
52+
// Execute evaluates the policy engine and writes a 403 response on denial.
53+
func (s *AuthzCheckStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) {
54+
if s.app == nil {
55+
return nil, fmt.Errorf("authz_check step %q: no application context", s.name)
56+
}
57+
58+
// Resolve the PolicyEngineModule from the service registry.
59+
eng, err := resolvePolicyEngine(s.app, s.engineName, s.name)
60+
if err != nil {
61+
return nil, err
62+
}
63+
64+
// Build the policy input: use a named field if configured, otherwise use
65+
// the full pipeline context (same strategy as step.policy_evaluate).
66+
// Track whether the input shares the same backing data as pc.Current so we
67+
// can clone before adding the subject key.
68+
var input map[string]any
69+
inputIsShared := false
70+
if s.inputFrom != "" {
71+
if raw, ok := pc.Current[s.inputFrom]; ok {
72+
if m, ok := raw.(map[string]any); ok {
73+
input = m
74+
}
75+
}
76+
}
77+
if input == nil {
78+
input = pc.Current
79+
inputIsShared = true
80+
}
81+
82+
// Map the configured subject field into the policy input so that
83+
// authorization decisions can depend on it. We read the subject from
84+
// pc.Current[s.subjectField] and expose it under the canonical "subject"
85+
// key in the input. Clone the input first when it shares data with
86+
// pc.Current to avoid side effects on the pipeline context.
87+
if s.subjectField != "" && s.subjectField != "subject" {
88+
if subj, ok := pc.Current[s.subjectField]; ok {
89+
if inputIsShared {
90+
cloned := make(map[string]any, len(input)+1)
91+
for k, v := range input {
92+
cloned[k] = v
93+
}
94+
input = cloned
95+
}
96+
input["subject"] = subj
97+
}
98+
}
99+
100+
// Evaluate the policy.
101+
decision, err := eng.Engine().Evaluate(ctx, input)
102+
if err != nil {
103+
return nil, fmt.Errorf("authz_check step %q: evaluate: %w", s.name, err)
104+
}
105+
106+
if !decision.Allowed {
107+
reason := "authorization denied"
108+
if len(decision.Reasons) > 0 {
109+
reason = decision.Reasons[0]
110+
}
111+
return s.forbiddenResponse(pc, reason)
112+
}
113+
114+
return &StepResult{Output: map[string]any{
115+
"allowed": true,
116+
"reasons": decision.Reasons,
117+
"metadata": decision.Metadata,
118+
}}, nil
119+
}
120+
121+
// forbiddenResponse writes a 403 JSON error response to the HTTP response
122+
// writer (when present) and stops the pipeline. The response body format
123+
// matches the expected {"error":"forbidden: ..."} shape described in the issue.
124+
func (s *AuthzCheckStep) forbiddenResponse(pc *PipelineContext, message string) (*StepResult, error) {
125+
errorMsg := fmt.Sprintf("forbidden: %s", message)
126+
errorBody := map[string]any{
127+
"error": errorMsg,
128+
}
129+
130+
if w, ok := pc.Metadata["_http_response_writer"].(http.ResponseWriter); ok {
131+
w.Header().Set("Content-Type", "application/json")
132+
w.WriteHeader(http.StatusForbidden)
133+
_ = json.NewEncoder(w).Encode(errorBody)
134+
pc.Metadata["_response_handled"] = true
135+
}
136+
137+
return &StepResult{
138+
Output: map[string]any{
139+
"response_status": http.StatusForbidden,
140+
"response_body": fmt.Sprintf(`{"error":%q}`, errorMsg),
141+
"response_headers": map[string]string{
142+
"Content-Type": "application/json",
143+
},
144+
"error": errorMsg,
145+
},
146+
Stop: true,
147+
}, nil
148+
}

0 commit comments

Comments
 (0)