Skip to content
Closed
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
1 change: 1 addition & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
13 changes: 13 additions & 0 deletions plugins/all/extras_base_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
10 changes: 8 additions & 2 deletions plugins/all/extras_stub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
}
17 changes: 17 additions & 0 deletions plugins/all/extras_stub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
145 changes: 145 additions & 0 deletions plugins/localauthz/plugin.go
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +19 to +20
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 }
Comment on lines +67 to +68

// 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
Comment on lines +81 to +88
}

// 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
}
160 changes: 160 additions & 0 deletions plugins/localauthz/plugin_test.go
Original file line number Diff line number Diff line change
@@ -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")
Comment on lines +109 to +116
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")
Comment on lines +139 to +145
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
}
Loading