feat: implement Section 21 — Entitlement permission primitive
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 <virgil@lethean.io>
This commit is contained in:
parent
14cd9c6adb
commit
ec423cfe46
5 changed files with 379 additions and 3 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
3
core.go
3
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
|
||||
|
|
|
|||
130
entitlement.go
Normal file
130
entitlement.go
Normal file
|
|
@ -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
|
||||
}
|
||||
235
entitlement_test.go
Normal file
235
entitlement_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue