Skip to content
Merged
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
4 changes: 2 additions & 2 deletions authz/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ func (m *casbinModuleWrapper) Stop(ctx context.Context) error {
}

// Enforce delegates to the inner CasbinModule.
func (m *casbinModuleWrapper) Enforce(sub, obj, act string) (bool, error) {
func (m *casbinModuleWrapper) Enforce(sub, obj, act string, extra ...string) (bool, error) {
if m.inner == nil {
return false, fmt.Errorf("authz.casbin %q: not initialized", m.name)
}
return m.inner.Enforce(sub, obj, act)
return m.inner.Enforce(sub, obj, act, extra...)
}

// AddPolicy delegates to the inner CasbinModule.
Expand Down
16 changes: 13 additions & 3 deletions internal/module_casbin.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,16 +306,26 @@ func (m *CasbinModule) Stop(_ context.Context) error {
return nil
}

// Enforce checks whether sub can perform act on obj.
// Enforce checks whether sub can perform act on obj with optional extra request
// dimensions. Extra fields are inserted between sub and (obj, act), so the
// Casbin request tuple becomes (sub, extra[0], extra[1], …, obj, act).
// This allows multi-tenant models such as r = sub, tenant, obj, act.
// It is safe for concurrent use.
func (m *CasbinModule) Enforce(sub, obj, act string) (bool, error) {
func (m *CasbinModule) Enforce(sub, obj, act string, extra ...string) (bool, error) {
m.mu.RLock()
e := m.enforcer
m.mu.RUnlock()
if e == nil {
return false, fmt.Errorf("authz.casbin %q: enforcer not initialized", m.name)
}
return e.Enforce(sub, obj, act)
args := make([]any, 1+len(extra)+2)
args[0] = sub
for i, v := range extra {
args[1+i] = v
}
args[1+len(extra)] = obj
args[1+len(extra)+1] = act
return e.Enforce(args...)
}

// AddPolicy adds a policy rule and saves it to the adapter.
Expand Down
111 changes: 101 additions & 10 deletions internal/step_authz_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,17 @@ import (
// object: "/api/v1/tenants" # static object, or Go template: "{{.request_path}}"
// action: "POST" # static action, or Go template: "{{.request_method}}"
// audit: false # when true, adds audit_event to output (default: false)
// extra_fields: # optional extra Casbin request dimensions (inserted between sub and obj/act)
// - key: tenant # field name (used as audit key)
// value: "{{.steps.auth.affiliate_id}}" # static value or Go template
type authzCheckStep struct {
name string
moduleName string
subjectKey string
object string
action string
audit bool
name string
moduleName string
subjectKey string
object string
action string
audit bool
extraFields []extraField

// parsed templates (nil when static string is used)
objectTmpl *template.Template
Expand All @@ -39,6 +43,14 @@ type authzCheckStep struct {
registry moduleRegistry
}

// extraField represents one additional request dimension for the Casbin Enforce
// call. The value can be a static string or a Go template expression.
type extraField struct {
key string
value string
tmpl *template.Template
}

// moduleRegistry abstracts module look-up so tests can inject a fake enforcer.
type moduleRegistry interface {
// GetEnforcer returns the CasbinModule for the given module name.
Expand Down Expand Up @@ -125,6 +137,41 @@ func newAuthzCheckStep(name string, config map[string]any) (*authzCheckStep, err
s.action = action
}

// Parse optional extra_fields for multi-dimensional enforcement (e.g. tenant).
if raw, exists := config["extra_fields"]; exists {
rawFields, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("step.authz_check_casbin %q: extra_fields must be a list", name)
}
seen := make(map[string]bool, len(rawFields))
for i, rawItem := range rawFields {
item, ok := rawItem.(map[string]any)
if !ok {
return nil, fmt.Errorf("step.authz_check_casbin %q: extra_fields[%d] must be a map with \"key\" and \"value\"", name, i)
}
key, _ := item["key"].(string)
val, _ := item["value"].(string)
if key == "" {
Comment on lines +150 to +154
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

PR description/examples refer to step.authz_check, but this repository’s step type appears to be step.authz_check_casbin (as reflected in the step’s error messages here). Consider aligning the PR description/README/examples so users don’t configure a non-existent step type.

Copilot uses AI. Check for mistakes.
return nil, fmt.Errorf("step.authz_check_casbin %q: extra_fields[%d] missing required \"key\"", name, i)
}
if val == "" {
return nil, fmt.Errorf("step.authz_check_casbin %q: extra_fields[%d] missing required \"value\"", name, i)
}
if seen[key] {
return nil, fmt.Errorf("step.authz_check_casbin %q: extra_fields has duplicate key %q", name, key)
}
seen[key] = true
ef := extraField{key: key, value: val}
if isTemplate(val) {
ef.tmpl, err = template.New(fmt.Sprintf("extra_field_%d", i)).Parse(val)
if err != nil {
return nil, fmt.Errorf("step.authz_check_casbin %q: parse extra_fields[%d] template: %w", name, i, err)
Comment on lines +164 to +168
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

There’s no validation for duplicate extra_fields keys. If the config repeats a key, the later value will overwrite the earlier one in audit_event, producing confusing/incomplete audit output. Consider validating keys are unique (or structuring audit output to preserve duplicates).

Copilot uses AI. Check for mistakes.
}
}
s.extraFields = append(s.extraFields, ef)
}
}

return s, nil
}

Expand Down Expand Up @@ -164,21 +211,31 @@ func (s *authzCheckStep) Execute(
return nil, fmt.Errorf("step.authz_check_casbin %q: resolve action: %w", s.name, err)
}

// Resolve optional extra request dimensions (e.g. tenant).
extraVals := make([]string, len(s.extraFields))
for i, ef := range s.extraFields {
v, err := resolve(ef.value, ef.tmpl, tmplData)
if err != nil {
return nil, fmt.Errorf("step.authz_check_casbin %q: resolve extra_fields[%d] (%s): %w", s.name, i, ef.key, err)
}
extraVals[i] = v
}

// Look up the Casbin enforcer.
mod, ok := s.registry.GetEnforcer(s.moduleName)
if !ok {
return nil, fmt.Errorf("step.authz_check_casbin %q: authz module %q not found; check module name in config", s.name, s.moduleName)
}

allowed, err := mod.Enforce(subject, object, action)
allowed, err := mod.Enforce(subject, object, action, extraVals...)
if err != nil {
return nil, fmt.Errorf("step.authz_check_casbin %q: enforce: %w", s.name, err)
}

if !allowed {
result := forbiddenResult(fmt.Sprintf("forbidden: %s is not permitted to %s %s", subject, action, object))
if s.audit {
result.Output["audit_event"] = map[string]any{
evt := map[string]any{
"type": "authz_decision",
"subject": subject,
"object": object,
Expand All @@ -187,6 +244,14 @@ func (s *authzCheckStep) Execute(
"timestamp": time.Now().UTC().Format(time.RFC3339),
"module": s.moduleName,
}
if len(s.extraFields) > 0 {
extras := make(map[string]any, len(s.extraFields))
for i, ef := range s.extraFields {
extras[ef.key] = extraVals[i]
}
evt["extra_fields"] = extras
}
result.Output["audit_event"] = evt
Comment on lines 246 to +254
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

extra_fields keys are written directly into audit_event, so a user-supplied key like type, subject, allowed, etc. would overwrite core audit fields (and could change types). Consider rejecting reserved keys at config-parse time or nesting extras under a dedicated key (e.g., audit_event.extra_fields).

Copilot uses AI. Check for mistakes.
}
return result, nil
}
Expand All @@ -198,7 +263,7 @@ func (s *authzCheckStep) Execute(
"authz_allowed": true,
}
if s.audit {
output["audit_event"] = map[string]any{
evt := map[string]any{
"type": "authz_decision",
"subject": subject,
"object": object,
Expand All @@ -207,6 +272,14 @@ func (s *authzCheckStep) Execute(
"timestamp": time.Now().UTC().Format(time.RFC3339),
"module": s.moduleName,
}
if len(s.extraFields) > 0 {
extras := make(map[string]any, len(s.extraFields))
for i, ef := range s.extraFields {
extras[ef.key] = extraVals[i]
}
evt["extra_fields"] = extras
}
output["audit_event"] = evt
}
return &sdk.StepResult{Output: output}, nil
}
Expand Down Expand Up @@ -245,15 +318,33 @@ func resolveSubject(key string, stepOutputs map[string]map[string]any, current,

// buildTemplateData merges all context maps into a single flat map for template
// execution. Later sources overwrite earlier ones: triggerData < stepOutputs < current.
// Step outputs are also available under a nested "steps" key (set only when
// triggerData does not already define one) so templates can reference individual
// step outputs with {{.steps.stepName.fieldKey}}.
//
// Note: dot-notation ({{.steps.stepName.key}}) only works when the step name is
// a valid Go identifier. For step names containing dashes or other special
// characters use index notation instead:
//
// {{ index .steps "step-name" "fieldKey" }}
func buildTemplateData(triggerData map[string]any, stepOutputs map[string]map[string]any, current map[string]any) map[string]any {
data := make(map[string]any)
for k, v := range triggerData {
data[k] = v
}
for _, out := range stepOutputs {
steps := make(map[string]any, len(stepOutputs))
for name, out := range stepOutputs {
for k, v := range out {
data[k] = v
}
steps[name] = out
}
// Only inject the "steps" key when the caller has not already supplied one
// in triggerData, to avoid silently overwriting an existing value.
if len(steps) > 0 {
if _, exists := data["steps"]; !exists {
data["steps"] = steps
}
}
for k, v := range current {
data[k] = v
Expand Down
Loading
Loading