From ec423cfe46555e7faf3af6320ad89cce06c731ae Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 25 Mar 2026 16:17:16 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Section=2021=20=E2=80=94=20?= =?UTF-8?q?Entitlement=20permission=20primitive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c.Entitled("action", quantity) checks permission before execution. Default: everything permitted (trusted conclave). Consumer packages replace checker via c.SetEntitlementChecker(). - Entitlement struct: Allowed, Unlimited, Limit, Used, Remaining, Reason - NearLimit(threshold), UsagePercent() convenience methods - EntitlementChecker function type — registered by go-entitlements/commerce-matrix - UsageRecorder for consumption tracking after gated actions succeed - Enforcement wired into Action.Run() — one gate for all capabilities - Security audit logging on denials (P11-6) - 16 AX-7 tests including full SaaS gating pattern simulation Maps 1:1 to RFC-004 EntitlementResult and RFC-005 PermissionResult. Co-Authored-By: Virgil --- action.go | 9 +- contract.go | 5 +- core.go | 3 + entitlement.go | 130 ++++++++++++++++++++++++ entitlement_test.go | 235 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 entitlement.go create mode 100644 entitlement_test.go diff --git a/action.go b/action.go index 357c87c..22f55c8 100644 --- a/action.go +++ b/action.go @@ -44,6 +44,7 @@ type Action struct { Description string Schema Options // declares expected input keys (optional) enabled bool + core *Core // for entitlement checks during Run() } // Run executes the action with panic recovery. @@ -57,6 +58,12 @@ func (a *Action) Run(ctx context.Context, opts Options) (result Result) { if !a.enabled { return Result{E("action.Run", Concat("action disabled: ", a.Name), nil), false} } + // Entitlement check — permission boundary + if a.core != nil { + if e := a.core.Entitled(a.Name); !e.Allowed { + return Result{E("action.Run", Concat("not entitled: ", a.Name, " — ", e.Reason), nil), false} + } + } defer func() { if r := recover(); r != nil { result = Result{E("action.Run", Sprint("panic in action ", a.Name, ": ", r), nil), false} @@ -90,7 +97,7 @@ func (a *Action) safeName() string { // c.Action("process.run").Exists() // check func (c *Core) Action(name string, handler ...ActionHandler) *Action { if len(handler) > 0 { - def := &Action{Name: name, Handler: handler[0], enabled: true} + def := &Action{Name: name, Handler: handler[0], enabled: true, core: c} c.ipc.actions.Set(name, def) return def } diff --git a/contract.go b/contract.go index 793ecd6..6a98c3a 100644 --- a/contract.go +++ b/contract.go @@ -96,8 +96,9 @@ func New(opts ...CoreOption) *Core { ipc: &Ipc{actions: NewRegistry[*Action](), tasks: NewRegistry[*Task]()}, info: systemInfo, i18n: &I18n{}, - services: &ServiceRegistry{Registry: NewRegistry[*Service]()}, - commands: &CommandRegistry{Registry: NewRegistry[*Command]()}, + services: &ServiceRegistry{Registry: NewRegistry[*Service]()}, + commands: &CommandRegistry{Registry: NewRegistry[*Command]()}, + entitlementChecker: defaultChecker, } c.context, c.cancel = context.WithCancel(context.Background()) diff --git a/core.go b/core.go index 94f0ee0..80562a3 100644 --- a/core.go +++ b/core.go @@ -32,6 +32,9 @@ type Core struct { info *SysInfo // c.Env("key") — Read-only system/environment information i18n *I18n // c.I18n() — Internationalisation and locale collection + entitlementChecker EntitlementChecker // default: everything permitted + usageRecorder UsageRecorder // default: nil (no-op) + context context.Context cancel context.CancelFunc taskIDCounter atomic.Uint64 diff --git a/entitlement.go b/entitlement.go new file mode 100644 index 0000000..8607f14 --- /dev/null +++ b/entitlement.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Permission primitive for the Core framework. +// Entitlement answers "can [subject] do [action] with [quantity]?" +// Default: everything permitted (trusted conclave). +// With go-entitlements: checks workspace packages, features, usage, boosts. +// With commerce-matrix: checks entity hierarchy, lock cascade. +// +// Usage: +// +// e := c.Entitled("process.run") // boolean gate +// e := c.Entitled("social.accounts", 3) // quantity check +// if e.Allowed { proceed() } +// if e.NearLimit(0.8) { showUpgradePrompt() } +// +// Registration: +// +// c.SetEntitlementChecker(myChecker) +// c.SetUsageRecorder(myRecorder) +package core + +import "context" + +// Entitlement is the result of a permission check. +// Carries context for both boolean gates (Allowed) and usage limits (Limit/Used/Remaining). +// +// e := c.Entitled("social.accounts", 3) +// e.Allowed // true +// e.Limit // 5 +// e.Used // 2 +// e.Remaining // 3 +// e.NearLimit(0.8) // false +type Entitlement struct { + Allowed bool // permission granted + Unlimited bool // no cap (agency tier, admin, trusted conclave) + Limit int // total allowed (0 = boolean gate) + Used int // current consumption + Remaining int // Limit - Used + Reason string // denial reason — for UI and audit logging +} + +// NearLimit returns true if usage exceeds the threshold percentage. +// +// if e.NearLimit(0.8) { showUpgradePrompt() } +func (e Entitlement) NearLimit(threshold float64) bool { + if e.Unlimited || e.Limit == 0 { + return false + } + return float64(e.Used)/float64(e.Limit) >= threshold +} + +// UsagePercent returns current usage as a percentage of the limit. +// +// pct := e.UsagePercent() // 75.0 +func (e Entitlement) UsagePercent() float64 { + if e.Limit == 0 { + return 0 + } + return float64(e.Used) / float64(e.Limit) * 100 +} + +// EntitlementChecker answers "can [subject] do [action] with [quantity]?" +// Subject comes from context (workspace, entity, user — consumer's concern). +type EntitlementChecker func(action string, quantity int, ctx context.Context) Entitlement + +// UsageRecorder records consumption after a gated action succeeds. +// Consumer packages provide the implementation (database, cache, etc). +type UsageRecorder func(action string, quantity int, ctx context.Context) + +// defaultChecker — trusted conclave, everything permitted. +func defaultChecker(_ string, _ int, _ context.Context) Entitlement { + return Entitlement{Allowed: true, Unlimited: true} +} + +// Entitled checks if an action is permitted in the current context. +// Default: always returns Allowed=true, Unlimited=true. +// Denials are logged via core.Security(). +// +// e := c.Entitled("process.run") +// e := c.Entitled("social.accounts", 3) +func (c *Core) Entitled(action string, quantity ...int) Entitlement { + qty := 1 + if len(quantity) > 0 { + qty = quantity[0] + } + + e := c.entitlementChecker(action, qty, c.Context()) + + if !e.Allowed { + Security("entitlement.denied", "action", action, "quantity", qty, "reason", e.Reason) + } + + return e +} + +// SetEntitlementChecker replaces the default (permissive) checker. +// Called by go-entitlements or commerce-matrix during OnStartup. +// +// func (s *EntitlementService) OnStartup(ctx context.Context) core.Result { +// s.Core().SetEntitlementChecker(s.check) +// return core.Result{OK: true} +// } +func (c *Core) SetEntitlementChecker(checker EntitlementChecker) { + c.entitlementChecker = checker +} + +// RecordUsage records consumption after a gated action succeeds. +// Delegates to the registered UsageRecorder. No-op if none registered. +// +// e := c.Entitled("ai.credits", 10) +// if e.Allowed { +// doWork() +// c.RecordUsage("ai.credits", 10) +// } +func (c *Core) RecordUsage(action string, quantity ...int) { + if c.usageRecorder == nil { + return + } + qty := 1 + if len(quantity) > 0 { + qty = quantity[0] + } + c.usageRecorder(action, qty, c.Context()) +} + +// SetUsageRecorder registers a usage tracking function. +// Called by go-entitlements during OnStartup. +func (c *Core) SetUsageRecorder(recorder UsageRecorder) { + c.usageRecorder = recorder +} diff --git a/entitlement_test.go b/entitlement_test.go new file mode 100644 index 0000000..b7acf69 --- /dev/null +++ b/entitlement_test.go @@ -0,0 +1,235 @@ +package core_test + +import ( + "context" + "testing" + + . "dappco.re/go/core" + "github.com/stretchr/testify/assert" +) + +// --- Entitled --- + +func TestEntitlement_Entitled_Good_DefaultPermissive(t *testing.T) { + c := New() + e := c.Entitled("anything") + assert.True(t, e.Allowed, "default checker permits everything") + assert.True(t, e.Unlimited) +} + +func TestEntitlement_Entitled_Good_BooleanGate(t *testing.T) { + c := New() + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + if action == "premium.feature" { + return Entitlement{Allowed: true} + } + return Entitlement{Allowed: false, Reason: "not in package"} + }) + + assert.True(t, c.Entitled("premium.feature").Allowed) + assert.False(t, c.Entitled("other.feature").Allowed) + assert.Equal(t, "not in package", c.Entitled("other.feature").Reason) +} + +func TestEntitlement_Entitled_Good_QuantityCheck(t *testing.T) { + c := New() + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + if action == "social.accounts" { + limit := 5 + used := 3 + remaining := limit - used + if qty > remaining { + return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"} + } + return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining} + } + return Entitlement{Allowed: true, Unlimited: true} + }) + + // Can create 2 more (3 used of 5) + e := c.Entitled("social.accounts", 2) + assert.True(t, e.Allowed) + assert.Equal(t, 5, e.Limit) + assert.Equal(t, 3, e.Used) + assert.Equal(t, 2, e.Remaining) + + // Can't create 3 more + e = c.Entitled("social.accounts", 3) + assert.False(t, e.Allowed) + assert.Equal(t, "limit exceeded", e.Reason) +} + +func TestEntitlement_Entitled_Bad_Denied(t *testing.T) { + c := New() + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + return Entitlement{Allowed: false, Reason: "locked by M1"} + }) + + e := c.Entitled("product.create") + assert.False(t, e.Allowed) + assert.Equal(t, "locked by M1", e.Reason) +} + +func TestEntitlement_Entitled_Ugly_DefaultQuantityIsOne(t *testing.T) { + c := New() + var receivedQty int + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + receivedQty = qty + return Entitlement{Allowed: true} + }) + + c.Entitled("test") + assert.Equal(t, 1, receivedQty, "default quantity should be 1") +} + +// --- Action.Run Entitlement Enforcement --- + +func TestEntitlement_ActionRun_Good_Permitted(t *testing.T) { + c := New() + c.Action("work", func(_ context.Context, _ Options) Result { + return Result{Value: "done", OK: true} + }) + + r := c.Action("work").Run(context.Background(), NewOptions()) + assert.True(t, r.OK) + assert.Equal(t, "done", r.Value) +} + +func TestEntitlement_ActionRun_Bad_Denied(t *testing.T) { + c := New() + c.Action("restricted", func(_ context.Context, _ Options) Result { + return Result{Value: "should not reach", OK: true} + }) + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + if action == "restricted" { + return Entitlement{Allowed: false, Reason: "tier too low"} + } + return Entitlement{Allowed: true, Unlimited: true} + }) + + r := c.Action("restricted").Run(context.Background(), NewOptions()) + assert.False(t, r.OK, "denied action must not execute") + err, ok := r.Value.(error) + assert.True(t, ok) + assert.Contains(t, err.Error(), "not entitled") + assert.Contains(t, err.Error(), "tier too low") +} + +func TestEntitlement_ActionRun_Good_OtherActionsStillWork(t *testing.T) { + c := New() + c.Action("allowed", func(_ context.Context, _ Options) Result { + return Result{Value: "ok", OK: true} + }) + c.Action("blocked", func(_ context.Context, _ Options) Result { + return Result{Value: "nope", OK: true} + }) + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + if action == "blocked" { + return Entitlement{Allowed: false, Reason: "nope"} + } + return Entitlement{Allowed: true, Unlimited: true} + }) + + assert.True(t, c.Action("allowed").Run(context.Background(), NewOptions()).OK) + assert.False(t, c.Action("blocked").Run(context.Background(), NewOptions()).OK) +} + +// --- NearLimit --- + +func TestEntitlement_NearLimit_Good(t *testing.T) { + e := Entitlement{Allowed: true, Limit: 100, Used: 85, Remaining: 15} + assert.True(t, e.NearLimit(0.8)) + assert.False(t, e.NearLimit(0.9)) +} + +func TestEntitlement_NearLimit_Bad_Unlimited(t *testing.T) { + e := Entitlement{Allowed: true, Unlimited: true} + assert.False(t, e.NearLimit(0.8), "unlimited should never be near limit") +} + +func TestEntitlement_NearLimit_Ugly_ZeroLimit(t *testing.T) { + e := Entitlement{Allowed: true, Limit: 0} + assert.False(t, e.NearLimit(0.8), "boolean gate (limit=0) should not report near limit") +} + +// --- UsagePercent --- + +func TestEntitlement_UsagePercent_Good(t *testing.T) { + e := Entitlement{Limit: 100, Used: 75} + assert.Equal(t, 75.0, e.UsagePercent()) +} + +func TestEntitlement_UsagePercent_Ugly_ZeroLimit(t *testing.T) { + e := Entitlement{Limit: 0, Used: 5} + assert.Equal(t, 0.0, e.UsagePercent(), "zero limit = boolean gate, no percentage") +} + +// --- RecordUsage --- + +func TestEntitlement_RecordUsage_Good(t *testing.T) { + c := New() + var recorded string + var recordedQty int + + c.SetUsageRecorder(func(action string, qty int, ctx context.Context) { + recorded = action + recordedQty = qty + }) + + c.RecordUsage("ai.credits", 10) + assert.Equal(t, "ai.credits", recorded) + assert.Equal(t, 10, recordedQty) +} + +func TestEntitlement_RecordUsage_Good_NoRecorder(t *testing.T) { + c := New() + // No recorder set — should not panic + assert.NotPanics(t, func() { + c.RecordUsage("anything", 5) + }) +} + +// --- Permission Model Integration --- + +func TestEntitlement_Ugly_SaaSGatingPattern(t *testing.T) { + c := New() + + // Simulate RFC-004 entitlement service + packages := map[string]int{ + "social.accounts": 5, + "social.posts.scheduled": 100, + "ai.credits": 50, + } + usage := map[string]int{ + "social.accounts": 3, + "social.posts.scheduled": 45, + "ai.credits": 48, + } + + c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement { + limit, hasFeature := packages[action] + if !hasFeature { + return Entitlement{Allowed: false, Reason: "feature not in package"} + } + used := usage[action] + remaining := limit - used + if qty > remaining { + return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"} + } + return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining} + }) + + // Can create 2 social accounts + e := c.Entitled("social.accounts", 2) + assert.True(t, e.Allowed) + + // AI credits near limit + e = c.Entitled("ai.credits", 1) + assert.True(t, e.Allowed) + assert.True(t, e.NearLimit(0.8)) + assert.Equal(t, 96.0, e.UsagePercent()) + + // Feature not in package + e = c.Entitled("premium.feature") + assert.False(t, e.Allowed) +}