|
| 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