diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 7b997e8a..bd9d00d1 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -60,6 +60,7 @@ All modules are instantiated from YAML config via the plugin factory registry. O | `auth.m2m` | Machine-to-machine OAuth2: client_credentials grant, JWT-bearer, ES256/HS256, JWKS endpoint | auth | | `auth.token-blacklist` | Token revocation blacklist backed by SQLite or in-memory store | auth | | `security.field-protection` | Field-level encryption/decryption for sensitive data fields | auth | +| `authz.local` | In-process exact-match RBAC enforcer for scenario testing (scenario_stub build tag only) | localauthz | > `auth.modular` was removed in favor of `auth.jwt`. diff --git a/plugins/all/extras_base_test.go b/plugins/all/extras_base_test.go index 0f346670..24f0c1c7 100644 --- a/plugins/all/extras_base_test.go +++ b/plugins/all/extras_base_test.go @@ -16,3 +16,16 @@ func TestDefaultPlugins_BaseExcludesStub(t *testing.T) { } } } + +// TestDefaultPlugins_BaseExcludesLocalAuthz asserts that without the +// "scenario_stub" build tag, DefaultPlugins() does NOT include the +// in-process authz enforcer. This guards against shipping the test-only +// exact-match RBAC module in production server builds. +func TestDefaultPlugins_BaseExcludesLocalAuthz(t *testing.T) { + for _, p := range DefaultPlugins() { + if p.Name() == "localauthz" { + t.Error("DefaultPlugins() contains 'localauthz' in a non-scenario_stub build — must not appear in production") + return + } + } +} diff --git a/plugins/all/extras_stub.go b/plugins/all/extras_stub.go index ea4f2ce5..10949ebf 100644 --- a/plugins/all/extras_stub.go +++ b/plugins/all/extras_stub.go @@ -2,8 +2,14 @@ package all -import pluginstub "github.com/GoCodeAlone/workflow/plugins/stubprovider" +import ( + pluginlocalauthz "github.com/GoCodeAlone/workflow/plugins/localauthz" + pluginstub "github.com/GoCodeAlone/workflow/plugins/stubprovider" +) func init() { - scenarioExtras = append(scenarioExtras, pluginstub.New()) + scenarioExtras = append(scenarioExtras, + pluginstub.New(), + pluginlocalauthz.New(), + ) } diff --git a/plugins/all/extras_stub_test.go b/plugins/all/extras_stub_test.go index af76a499..6c5f2981 100644 --- a/plugins/all/extras_stub_test.go +++ b/plugins/all/extras_stub_test.go @@ -24,3 +24,20 @@ func TestDefaultPlugins_ContainsStub(t *testing.T) { } t.Errorf("DefaultPlugins() does not contain 'stubprovider'; plugins: %v", names) } + +// TestDefaultPlugins_ContainsLocalAuthz asserts that when compiled with the +// "scenario_stub" build tag, DefaultPlugins() includes the in-process +// authz enforcer plugin named "localauthz". +func TestDefaultPlugins_ContainsLocalAuthz(t *testing.T) { + plugins := all.DefaultPlugins() + for _, p := range plugins { + if p.Name() == "localauthz" { + return // found + } + } + names := make([]string, 0, len(plugins)) + for _, p := range plugins { + names = append(names, p.Name()) + } + t.Errorf("DefaultPlugins() does not contain 'localauthz'; plugins: %v", names) +} diff --git a/plugins/localauthz/plugin.go b/plugins/localauthz/plugin.go new file mode 100644 index 00000000..4e88cd79 --- /dev/null +++ b/plugins/localauthz/plugin.go @@ -0,0 +1,145 @@ +// Package localauthz provides an in-process authz.local EnginePlugin for use +// with the scenario_stub build tag. It registers a module that implements the +// same Enforcer interface as authz.casbin — exact-match, allow-effect, +// default-deny — without the Casbin dependency. +// +// Intended use: scenario 92 and integration tests that need a lightweight +// in-process RBAC enforcer. NOT a replacement for authz.casbin in production. +// +// Config shape (YAML): +// +// type: authz.local +// config: +// policies: +// - ["operator", "infra:read", "allow"] +// - ["operator", "infra:apply", "allow"] +// - ["operator", "infra:destroy", "allow"] +// - ["viewer", "infra:read", "allow"] +// +// Each policy is a [subject, object, action] triple. A request is allowed +// when it exactly matches at least one triple; all other requests are denied. +package localauthz + +import ( + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/plugin" +) + +// Plugin is the engine plugin that registers the authz.local factory. +type Plugin struct { + plugin.BaseEnginePlugin +} + +// Compile-time assertion. +var _ plugin.EnginePlugin = (*Plugin)(nil) + +// New creates a new localauthz plugin. +func New() *Plugin { + return &Plugin{ + BaseEnginePlugin: plugin.BaseEnginePlugin{ + BaseNativePlugin: plugin.BaseNativePlugin{ + PluginName: "localauthz", + PluginVersion: "0.1.0", + PluginDescription: "In-process authz.local RBAC enforcer for scenario testing", + }, + Manifest: plugin.PluginManifest{ + Name: "localauthz", + Version: "0.1.0", + Author: "GoCodeAlone", + Description: "In-process authz.local RBAC enforcer for scenario testing", + ModuleTypes: []string{"authz.local"}, + }, + }, + } +} + +// ModuleFactories returns a factory for "authz.local". +func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { + return map[string]plugin.ModuleFactory{ + "authz.local": func(name string, cfg map[string]any) modular.Module { + return &localAuthzModule{name: name, cfg: cfg} + }, + } +} + +// ── in-process module ────────────────────────────────────────────────────── + +// policy is a parsed [subject, object, action] triple. +type policy struct{ sub, obj, act string } + +// localAuthzModule implements modular.Module + modular.ServiceAware and +// satisfies the module.Enforcer interface (variadic Enforce method). +type localAuthzModule struct { + name string + cfg map[string]any + policies []policy +} + +// Name returns the module instance name. +func (m *localAuthzModule) Name() string { return m.name } + +// Init parses the policies from config and logs a startup message. +func (m *localAuthzModule) Init(app modular.Application) error { + m.policies = parsePolicies(m.cfg) + app.Logger().Info("authz.local: loaded policies", + "module", m.name, + "count", len(m.policies), + ) + return nil +} + +// ProvidesServices registers this module under its own name so +// infra.admin can resolve it via app.GetService(authzModule, &Enforcer). +func (m *localAuthzModule) ProvidesServices() []modular.ServiceProvider { + return []modular.ServiceProvider{ + { + Name: m.name, + Description: "in-process RBAC enforcer (authz.local)", + Instance: m, + }, + } +} + +// RequiresServices returns nil — no dependencies. +func (m *localAuthzModule) RequiresServices() []modular.ServiceDependency { return nil } + +// Enforce checks whether (sub, obj, act) matches any configured policy. +// The variadic extra ...string is accepted but ignored — it exists to +// match the concrete Casbin wrapper's method signature (module.Enforcer +// plan-review C-NEW-1 constraint). Default-deny: returns false when no +// policy matches. +func (m *localAuthzModule) Enforce(sub, obj, act string, _ ...string) (bool, error) { + for _, p := range m.policies { + if p.sub == sub && p.obj == obj && p.act == act { + return true, nil + } + } + return false, nil +} + +// parsePolicies decodes config.policies from the raw map. +// Accepts []any{[]any{string, string, string}, ...} (YAML-decoded shape). +func parsePolicies(cfg map[string]any) []policy { + raw, ok := cfg["policies"] + if !ok { + return nil + } + items, ok := raw.([]any) + if !ok { + return nil + } + out := make([]policy, 0, len(items)) + for _, item := range items { + row, ok := item.([]any) + if !ok || len(row) < 3 { + continue + } + sub, _ := row[0].(string) + obj, _ := row[1].(string) + act, _ := row[2].(string) + if sub != "" && obj != "" && act != "" { + out = append(out, policy{sub, obj, act}) + } + } + return out +} diff --git a/plugins/localauthz/plugin_test.go b/plugins/localauthz/plugin_test.go new file mode 100644 index 00000000..505cea51 --- /dev/null +++ b/plugins/localauthz/plugin_test.go @@ -0,0 +1,160 @@ +package localauthz_test + +import ( + "testing" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/plugins/localauthz" +) + +// nopLogger satisfies modular.Logger for tests. +type nopLogger struct{} + +func (nopLogger) Debug(string, ...any) {} +func (nopLogger) Info(string, ...any) {} +func (nopLogger) Warn(string, ...any) {} +func (nopLogger) Error(string, ...any) {} + +// TestPlugin_ModuleFactories asserts the plugin registers "authz.local". +func TestPlugin_ModuleFactories(t *testing.T) { + p := localauthz.New() + factories := p.ModuleFactories() + if factories == nil { + t.Fatal("ModuleFactories returned nil") + } + if _, ok := factories["authz.local"]; !ok { + t.Fatalf("expected 'authz.local' in ModuleFactories, got %v", keys(factories)) + } +} + +// TestEnforce_Table covers the exact-match, allow-effect, default-deny contract. +func TestEnforce_Table(t *testing.T) { + p := localauthz.New() + factory := p.ModuleFactories()["authz.local"] + + policies := []any{ + []any{"operator", "infra:read", "allow"}, + []any{"operator", "infra:apply", "allow"}, + []any{"operator", "infra:destroy", "allow"}, + []any{"viewer", "infra:read", "allow"}, + } + mod := factory("my-authz", map[string]any{"policies": policies}) + + app := modular.NewStdApplication(nil, nopLogger{}) + if err := mod.Init(app); err != nil { + t.Fatalf("Init: %v", err) + } + + // Resolve the Enforcer from the service it registered. + type enforcer interface { + Enforce(sub, obj, act string, extra ...string) (bool, error) + } + sa, ok := mod.(modular.ServiceAware) + if !ok { + t.Fatalf("module does not implement modular.ServiceAware; got %T", mod) + } + var enf enforcer + for _, svc := range sa.ProvidesServices() { + if e, ok := svc.Instance.(enforcer); ok { + enf = e + break + } + } + if enf == nil { + t.Fatal("no enforcer service found after Init") + } + + cases := []struct { + sub, obj, act string + want bool + }{ + {"operator", "infra:read", "allow", true}, + {"operator", "infra:apply", "allow", true}, + {"operator", "infra:destroy", "allow", true}, + {"viewer", "infra:read", "allow", true}, + {"viewer", "infra:apply", "allow", false}, // not in policies → deny + {"viewer", "infra:destroy", "allow", false}, // not in policies → deny + {"unknown", "infra:read", "allow", false}, // unknown subject → deny + {"operator", "infra:apply", "deny", false}, // wrong act → deny + {"operator", "infra:noop", "allow", false}, // unknown obj → deny + } + for _, tc := range cases { + got, err := enf.Enforce(tc.sub, tc.obj, tc.act) + if err != nil { + t.Errorf("Enforce(%q,%q,%q): unexpected error: %v", tc.sub, tc.obj, tc.act, err) + continue + } + if got != tc.want { + t.Errorf("Enforce(%q,%q,%q) = %v, want %v", tc.sub, tc.obj, tc.act, got, tc.want) + } + } +} + +// TestEnforce_VariadicCompatible asserts extra args are silently accepted +// (matches the concrete Casbin wrapper's variadic signature). +func TestEnforce_VariadicCompatible(t *testing.T) { + p := localauthz.New() + factory := p.ModuleFactories()["authz.local"] + mod := factory("authz", map[string]any{ + "policies": []any{[]any{"u", "o", "a"}}, + }) + app := modular.NewStdApplication(nil, nopLogger{}) + if err := mod.Init(app); err != nil { + t.Fatalf("Init: %v", err) + } + sa := mod.(modular.ServiceAware) + type enforcer interface { + Enforce(sub, obj, act string, extra ...string) (bool, error) + } + var enf enforcer + for _, svc := range sa.ProvidesServices() { + if e, ok := svc.Instance.(enforcer); ok { + enf = e + } + } + // Variadic: extra args must not panic or cause error. + got, err := enf.Enforce("u", "o", "a", "extra1", "extra2") + if err != nil { + t.Fatalf("variadic Enforce: %v", err) + } + if !got { + t.Error("variadic Enforce should return true for matching policy") + } +} + +// TestEnforce_EmptyPolicies asserts default-deny with no policies configured. +func TestEnforce_EmptyPolicies(t *testing.T) { + p := localauthz.New() + factory := p.ModuleFactories()["authz.local"] + mod := factory("authz", map[string]any{}) + + app := modular.NewStdApplication(nil, nopLogger{}) + if err := mod.Init(app); err != nil { + t.Fatalf("Init: %v", err) + } + sa := mod.(modular.ServiceAware) + type enforcer interface { + Enforce(sub, obj, act string, extra ...string) (bool, error) + } + var enf enforcer + for _, svc := range sa.ProvidesServices() { + if e, ok := svc.Instance.(enforcer); ok { + enf = e + } + } + got, err := enf.Enforce("anyone", "infra:apply", "allow") + if err != nil { + t.Fatalf("Enforce: %v", err) + } + if got { + t.Error("empty policies: all requests should be denied") + } +} + +func keys[V any](m map[string]V) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + return ks +}