diff --git a/authz/authz.go b/authz/authz.go index 9495c0a..32633cc 100644 --- a/authz/authz.go +++ b/authz/authz.go @@ -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. diff --git a/internal/module_casbin.go b/internal/module_casbin.go index 5ede417..667c6fe 100644 --- a/internal/module_casbin.go +++ b/internal/module_casbin.go @@ -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. diff --git a/internal/step_authz_check.go b/internal/step_authz_check.go index 2b57c4d..b01d2c5 100644 --- a/internal/step_authz_check.go +++ b/internal/step_authz_check.go @@ -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 @@ -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. @@ -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 == "" { + 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) + } + } + s.extraFields = append(s.extraFields, ef) + } + } + return s, nil } @@ -164,13 +211,23 @@ 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) } @@ -178,7 +235,7 @@ func (s *authzCheckStep) Execute( 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, @@ -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 } return result, nil } @@ -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, @@ -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 } @@ -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 diff --git a/internal/step_authz_check_test.go b/internal/step_authz_check_test.go index d031d93..4c76f02 100644 --- a/internal/step_authz_check_test.go +++ b/internal/step_authz_check_test.go @@ -295,6 +295,31 @@ func TestAuthzCheckStep_MissingAction(t *testing.T) { } } +func TestAuthzCheckStep_ExtraFieldsNotAList(t *testing.T) { + _, err := newAuthzCheckStep("s", map[string]any{ + "object": "/api/foo", + "action": "GET", + "extra_fields": "not-a-list", + }) + if err == nil { + t.Error("expected error when extra_fields is not a list") + } +} + +func TestAuthzCheckStep_ExtraFieldsDuplicateKey(t *testing.T) { + _, err := newAuthzCheckStep("s", map[string]any{ + "object": "/api/foo", + "action": "GET", + "extra_fields": []any{ + map[string]any{"key": "tenant", "value": "x"}, + map[string]any{"key": "tenant", "value": "y"}, + }, + }) + if err == nil { + t.Error("expected error for duplicate extra_fields key") + } +} + func TestAuthzCheckStep_ModuleNotFound(t *testing.T) { reg := &testRegistry{} // no module registered @@ -432,3 +457,201 @@ func TestAuthzCheckAuditOutput(t *testing.T) { t.Error("expected no audit_event in output when audit=false (default)") } } + +// tenantTestModule builds a 4-tuple Casbin module (sub, tenant, obj, act) +// suitable for testing extra_fields. +func tenantTestModule(t *testing.T) *CasbinModule { + t.Helper() + cfg := map[string]any{ + "model": ` +[request_definition] +r = sub, tenant, obj, act + +[policy_definition] +p = sub, tenant, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && r.tenant == p.tenant && r.obj == p.obj && (r.act == p.act || p.act == "*") +`, + "policies": []any{ + []any{"admin", "tenant-a", "/api/posts", "*"}, + []any{"editor", "tenant-b", "/api/posts", "GET"}, + }, + "roleAssignments": []any{ + []any{"alice", "admin"}, + []any{"bob", "editor"}, + }, + } + m, err := newCasbinModule("authz-tenant", cfg) + if err != nil { + t.Fatalf("newCasbinModule: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("CasbinModule.Init: %v", err) + } + return m +} + +// TestAuthzCheckStep_ExtraFieldsStaticAllow verifies that a static extra_field +// (tenant) is passed to Casbin and grants access when it matches the policy. +func TestAuthzCheckStep_ExtraFieldsStaticAllow(t *testing.T) { + mod := tenantTestModule(t) + reg := &testRegistry{mod: mod} + + s := newTestStep(t, map[string]any{ + "module": "authz-tenant", + "object": "/api/posts", + "action": "DELETE", + "extra_fields": []any{ + map[string]any{"key": "tenant", "value": "tenant-a"}, + }, + }, reg) + + // alice is admin for tenant-a → allowed + allowed, stopped := execute(t, s, + nil, + map[string]any{"auth_user_id": "alice"}, + nil, + ) + if !allowed || stopped { + t.Errorf("expected alice/tenant-a to be allowed; got allowed=%v stopped=%v", allowed, stopped) + } +} + +// TestAuthzCheckStep_ExtraFieldsStaticDeny verifies that a static extra_field +// (tenant) blocks access when the tenant does not match the policy. +func TestAuthzCheckStep_ExtraFieldsStaticDeny(t *testing.T) { + mod := tenantTestModule(t) + reg := &testRegistry{mod: mod} + + s := newTestStep(t, map[string]any{ + "module": "authz-tenant", + "object": "/api/posts", + "action": "DELETE", + "extra_fields": []any{ + map[string]any{"key": "tenant", "value": "tenant-b"}, + }, + }, reg) + + // alice is admin for tenant-a only; tenant-b should be denied + allowed, stopped := execute(t, s, + nil, + map[string]any{"auth_user_id": "alice"}, + nil, + ) + if allowed || !stopped { + t.Errorf("expected alice/tenant-b to be denied; got allowed=%v stopped=%v", allowed, stopped) + } +} + +// TestAuthzCheckStep_ExtraFieldsTemplate verifies that a Go template +// expression in an extra_field value is resolved from template data. +func TestAuthzCheckStep_ExtraFieldsTemplate(t *testing.T) { + mod := tenantTestModule(t) + reg := &testRegistry{mod: mod} + + s := newTestStep(t, map[string]any{ + "module": "authz-tenant", + "object": "/api/posts", + "action": "GET", + "extra_fields": []any{ + map[string]any{"key": "tenant", "value": "{{.affiliate_id}}"}, + }, + }, reg) + + // bob is editor for tenant-b; pass tenant via triggerData + allowed, stopped := execute(t, s, + map[string]any{"affiliate_id": "tenant-b"}, + map[string]any{"auth_user_id": "bob"}, + nil, + ) + if !allowed || stopped { + t.Errorf("expected bob/tenant-b GET to be allowed via template; got allowed=%v stopped=%v", allowed, stopped) + } +} + +// TestAuthzCheckStep_ExtraFieldsNestedStepsTemplate verifies that an +// extra_field template using the {{.steps.stepName.key}} syntax resolves +// correctly from prior step outputs. +func TestAuthzCheckStep_ExtraFieldsNestedStepsTemplate(t *testing.T) { + mod := tenantTestModule(t) + reg := &testRegistry{mod: mod} + + s := newTestStep(t, map[string]any{ + "module": "authz-tenant", + "object": "/api/posts", + "action": "GET", + "extra_fields": []any{ + map[string]any{"key": "tenant", "value": "{{.steps.auth.affiliate_id}}"}, + }, + }, reg) + + // bob is editor for tenant-b; pass tenant via nested step output + allowed, stopped := execute(t, s, + nil, + map[string]any{"auth_user_id": "bob"}, + map[string]map[string]any{ + "auth": {"affiliate_id": "tenant-b"}, + }, + ) + if !allowed || stopped { + t.Errorf("expected bob/tenant-b GET (from steps.auth) to be allowed; got allowed=%v stopped=%v", allowed, stopped) + } +} + +// TestAuthzCheckStep_ExtraFieldsAudit verifies that extra_field values appear +// in the audit_event output when audit=true. +func TestAuthzCheckStep_ExtraFieldsAudit(t *testing.T) { + mod := tenantTestModule(t) + reg := &testRegistry{mod: mod} + + s := newTestStep(t, map[string]any{ + "module": "authz-tenant", + "object": "/api/posts", + "action": "DELETE", + "audit": true, + "extra_fields": []any{ + map[string]any{"key": "tenant", "value": "tenant-a"}, + }, + }, reg) + + result, err := s.Execute(context.Background(), + nil, + nil, + map[string]any{"auth_user_id": "alice"}, + nil, + nil, + ) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.StopPipeline { + t.Error("expected alice/tenant-a to be allowed") + } + + auditRaw, ok := result.Output["audit_event"] + if !ok { + t.Fatal("expected audit_event in output when audit=true") + } + audit, ok := auditRaw.(map[string]any) + if !ok { + t.Fatalf("expected audit_event to be map[string]any, got %T", auditRaw) + } + extrasRaw, ok := audit["extra_fields"] + if !ok { + t.Fatal("expected audit_event.extra_fields when extra_fields configured") + } + extras, ok := extrasRaw.(map[string]any) + if !ok { + t.Fatalf("expected audit_event.extra_fields to be map[string]any, got %T", extrasRaw) + } + if v, _ := extras["tenant"].(string); v != "tenant-a" { + t.Errorf("audit_event.extra_fields.tenant: want %q, got %q", "tenant-a", v) + } +}