From fa4e251e02d62ea308ce8f54cf5138b40f2c91ae Mon Sep 17 00:00:00 2001 From: Ronnie Smith Date: Wed, 21 Jan 2026 00:49:40 -0800 Subject: [PATCH 1/7] work in progress alpha-05 --- scopie.go | 210 +++++++++++++++-------- scopie_test.go | 92 ++++++---- testdata/scenarios.json | 369 ++++++++++++++++++++++------------------ 3 files changed, 397 insertions(+), 274 deletions(-) diff --git a/scopie.go b/scopie.go index 147cdd8..eca69ad 100644 --- a/scopie.go +++ b/scopie.go @@ -12,8 +12,9 @@ const ( VariablePrefix = byte('@') Wildcard = byte('*') - AllowPermission = "allow" - DenyPermission = "deny" + AllowGrant = "allow" + DenyGrant = "deny" + GrantSeparator = byte(':') ) const ( @@ -29,14 +30,17 @@ var ( errSuperNotLast = errors.New("scopie-105: super wildcard not in the last block") errSuperInArray = errors.New("scopie-103: super wildcard found in array block") errWildcardInArray = errors.New("scopie-102: wildcard found in array block") - errScopesEmpty = errors.New("scopie-106 in scope: scopes was empty") - errScopeEmpty = errors.New("scopie-106 in scope: scope was empty") - errRuleEmpty = errors.New("scopie-106 in rule: rule was empty") + errActionsEmpty = errors.New("scopie-106 in action: actions was empty") + errActionEmpty = errors.New("scopie-106 in action: action was empty") + errPermissionEmpty = errors.New("scopie-106 in permission: permission was empty") + + errPermissionDoesNotStartWithGrant = errors.New("scopie-107: permission does not start with a grant") // validation specific - errValidateScopeRulesEmpty = errors.New("scopie-106: scope or rule was empty") - errValidateNoScopeRules = errors.New("scopie-106: scope or rule array was empty") - errValidateInconsistent = errors.New("scopie-107: inconsistent array of scopes and rules") + errValidateActionEmpty = errors.New("scopie-106: action was empty") + errValidateActionsEmpty = errors.New("scopie-106: actions array was empty") + errValidatePermissionEmpty = errors.New("scopie-106: permission was empty") + errValidatePermissionsEmpty = errors.New("scopie-106: permission array was empty") ) // IsAllowedFunc is a type wrapper for [IsAllowed] that can be used as @@ -45,8 +49,9 @@ type IsAllowedFunc func(map[string]string, string, string) (bool, error) // ValidateScopeFunc is a type wrapper for [ValidateScopes] that can be // used as a dependency. -type ValidateScopeFunc func(string) error +// type ValidateScopeFunc func(string) error +// TODO: REWRITE // IsAllowed returns whether or not the scopes are allowed with the given rules. // [Is Allowed Spec] is the function specification. // @@ -72,37 +77,38 @@ type ValidateScopeFunc func(string) error // } // // [Is Allowed Spec]: https://scopie.dev/specification/functions/#is-allowed -func IsAllowed(scopes, rules []string, vars map[string]string) (bool, error) { - if len(scopes) == 0 { - return false, errScopesEmpty +func IsAllowed(actions, permissions []string, vars map[string]string) (bool, error) { + if len(actions) == 0 { + return false, errActionsEmpty } - if len(rules) == 0 { + if len(permissions) == 0 { return false, nil } hasBeenAllowed := false - for _, actorRule := range rules { + for _, actorRule := range permissions { if len(actorRule) == 0 { - return false, errRuleEmpty + return false, errPermissionEmpty } actorRule := actorRule - isAllowBlock := strings.HasPrefix(actorRule, AllowPermission) + // TODO: maybe don't just check allow as it could be invalid + isAllowBlock := strings.HasPrefix(actorRule, AllowGrant) if isAllowBlock && hasBeenAllowed { continue } - for _, actionScope := range scopes { + for _, actionScope := range actions { if len(actionScope) == 0 { - return false, errScopeEmpty + return false, errActionEmpty } actionScope := actionScope - match, err := compareRuleToScope(&actorRule, &actionScope, vars) + match, err := comparePermissionToAction(&actorRule, &actionScope, vars) if err != nil { return false, err } @@ -118,6 +124,7 @@ func IsAllowed(scopes, rules []string, vars map[string]string) (bool, error) { return hasBeenAllowed, nil } +// TODO: we now have two separate validation funcs // ValidateScopes checks whether or not the given scopes or rules are valid given the // requirements outlined in the specification. // [Validate Scopes Spec] is the function specification. @@ -128,59 +135,81 @@ func IsAllowed(scopes, rules []string, vars map[string]string) (bool, error) { // } // // [Validate Scopes Spec]: https://scopie.dev/specification/functions/#validate-scopes -func ValidateScopes(scopeOrRules []string) error { - if len(scopeOrRules) == 0 { - return errValidateNoScopeRules +func ValidateActions(actions []string) error { + if len(actions) == 0 { + return errValidateActionsEmpty } - isRules := strings.HasPrefix(scopeOrRules[0], AllowPermission) || - strings.HasPrefix(scopeOrRules[0], DenyPermission) + for _, action := range actions { + if action == "" { + return errValidateActionEmpty + } + + for i := range action { + if action[i] == BlockSeperator { + continue + } - for _, scope := range scopeOrRules { - if scope == "" { - return errValidateScopeRulesEmpty + if !isValidLiteral(action[i]) { + return fmt.Errorf(fmtValidateInvalidChar, string(action[i])) + } } + } - scopeIsRule := strings.HasPrefix(scope, AllowPermission) || - strings.HasPrefix(scope, DenyPermission) + return nil +} - if isRules != scopeIsRule { - return errValidateInconsistent +func ValidatePermissions(permissions []string) error { + if len(permissions) == 0 { + return errValidatePermissionsEmpty + } + + for _, permission := range permissions { + if permission == "" { + return errValidatePermissionEmpty } inArray := false - for i := range scope { - if scope[i] == BlockSeperator { + i, err := skipGrant(&permission, 0) + if err != nil { + return errPermissionDoesNotStartWithGrant + } + + // skip the separator + i++ + + for ; i < len(permission); i++ { + if permission[i] == BlockSeperator { inArray = false continue } - if scope[i] == ArraySeperator { + if permission[i] == ArraySeperator { inArray = true continue } if inArray { - if scope[i] == Wildcard && i < len(scope)-1 && scope[i+1] == Wildcard { + if permission[i] == Wildcard && i < len(permission)-1 && permission[i+1] == Wildcard { return errSuperInArray } - if scope[i] == Wildcard { + if permission[i] == Wildcard { return errWildcardInArray } - if scope[i] == VariablePrefix { - end := endOfArrayElement(&scope, i) - return fmt.Errorf(fmtValidateVarInArray, scope[i+1:end]) + if permission[i] == VariablePrefix { + end := endOfArrayElement(&permission, i) + return fmt.Errorf(fmtValidateVarInArray, permission[i+1:end]) } } - if !isValidCharacter(scope[i]) { - return fmt.Errorf(fmtValidateInvalidChar, string(scope[i])) + if !isValidCharacter(permission[i]) { + return fmt.Errorf(fmtValidateInvalidChar, string(permission[i])) } - if scope[i] == Wildcard && i < len(scope)-1 && scope[i+1] == Wildcard && i < len(scope)-2 { + if permission[i] == Wildcard && i < len(permission)-1 && permission[i+1] == Wildcard && i < len(permission)-2 { return errSuperNotLast } } @@ -189,42 +218,43 @@ func ValidateScopes(scopeOrRules []string) error { return nil } -func compareRuleToScope( - rule *string, - scope *string, +func comparePermissionToAction( + permission *string, + action *string, vars map[string]string, ) (bool, error) { - // Skip the allow and deny prefix for actors - ruleLeft, _, _ := endOfBlock(rule, 0, "rule") + // Skip the allow and deny prefix for permission + permissionLeft, _ := skipGrant(permission, 0) + // TODO: handle the error above - ruleLeft += 1 // don't forget to skip the slash - scopeLeft := 0 + permissionLeft += 1 // don't forget to skip the separator + actionLeft := 0 - for ruleLeft < len(*rule) || scopeLeft < len(*scope) { + for permissionLeft < len(*permission) || actionLeft < len(*action) { // In case one is longer then the other - if (ruleLeft < len(*rule)) != (scopeLeft < len(*scope)) { + if (permissionLeft < len(*permission)) != (actionLeft < len(*action)) { return false, nil } - scopeSlider, _, err := endOfBlock(scope, scopeLeft, "scope") + actionSlider, _, err := endOfBlock(action, actionLeft, "action") if err != nil { return false, err } - ruleSlider, ruleArray, err := endOfBlock(rule, ruleLeft, "rule") + permissionSlider, permissionArray, err := endOfBlock(permission, permissionLeft, "permission") if err != nil { return false, err } // Super wildcards are checked here as it skips the who rest of the checks. - if ruleSlider-ruleLeft == 2 && (*rule)[ruleLeft] == Wildcard && (*rule)[ruleLeft+1] == Wildcard { - if len(*rule) > ruleSlider { + if permissionSlider-permissionLeft == 2 && (*permission)[permissionLeft] == Wildcard && (*permission)[permissionLeft+1] == Wildcard { + if len(*permission) > permissionSlider { return false, errSuperNotLast } return true, nil } else { - match, err := compareBlock(rule, ruleLeft, ruleSlider, ruleArray, scope, scopeLeft, scopeSlider, vars) + match, err := compareBlock(permission, permissionLeft, permissionSlider, permissionArray, action, actionLeft, actionSlider, vars) if err != nil { return false, err } @@ -234,61 +264,77 @@ func compareRuleToScope( } } - scopeLeft = scopeSlider + 1 - ruleLeft = ruleSlider + 1 + actionLeft = actionSlider + 1 + permissionLeft = permissionSlider + 1 } return true, nil } func compareBlock( - rule *string, ruleLeft, ruleSlider int, ruleArray bool, - scope *string, scopeLeft, scopeSlider int, + permission *string, permissionLeft, permissionSlider int, permissionArray bool, + action *string, actionLeft, actionSlider int, vars map[string]string, ) (bool, error) { - if (*rule)[ruleLeft] == VariablePrefix { - key := (*rule)[ruleLeft+1 : ruleSlider] + if (*permission)[permissionLeft] == VariablePrefix { + key := (*permission)[permissionLeft+1 : permissionSlider] varValue, found := vars[key] if !found { return false, fmt.Errorf(fmtAllowedVarNotFound, key) } - return varValue == (*scope)[scopeLeft:scopeSlider], nil + return varValue == (*action)[actionLeft:actionSlider], nil } - if ruleSlider-ruleLeft == 1 && (*rule)[ruleLeft] == Wildcard { + if permissionSlider-permissionLeft == 1 && (*permission)[permissionLeft] == Wildcard { return true, nil } - if ruleArray { - for ruleLeft < ruleSlider { - arrayRight := endOfArrayElement(rule, ruleLeft) + if permissionArray { + for permissionLeft < permissionSlider { + arrayRight := endOfArrayElement(permission, permissionLeft) - if (*rule)[ruleLeft] == VariablePrefix { - key := (*rule)[ruleLeft+1 : arrayRight] + if (*permission)[permissionLeft] == VariablePrefix { + key := (*permission)[permissionLeft+1 : arrayRight] return false, fmt.Errorf(fmtAllowedVarInArray, key) } - if (*rule)[ruleLeft] == Wildcard { - if arrayRight-ruleLeft > 1 && (*rule)[ruleLeft+1] == Wildcard { + if (*permission)[permissionLeft] == Wildcard { + if arrayRight-permissionLeft > 1 && (*permission)[permissionLeft+1] == Wildcard { return false, errSuperInArray } return false, errWildcardInArray } - if (*rule)[ruleLeft:arrayRight] == (*scope)[scopeLeft:scopeSlider] { + if (*permission)[permissionLeft:arrayRight] == (*action)[actionLeft:actionSlider] { return true, nil } - ruleLeft = arrayRight + 1 + permissionLeft = arrayRight + 1 } return false, nil } - return (*rule)[ruleLeft:ruleSlider] == (*scope)[scopeLeft:scopeSlider], nil + return (*permission)[permissionLeft:permissionSlider] == (*action)[actionLeft:actionSlider], nil +} + +func skipGrant(value *string, start int) (int, error) { + // TODO: actually do this properly... + if strings.HasPrefix(*value, AllowGrant) + + for i := start; i < len(*value); i++ { + if (*value)[i] == GrantSeparator { + return i, nil + } else if !isValidCharacter((*value)[i]) { + invalidChar := string((*value)[i]) + return 0, fmt.Errorf(fmtAllowedInvalidChar, "permission", invalidChar) + } + } + + return len(*value), nil } func endOfBlock(value *string, start int, category string) (int, bool, error) { @@ -319,6 +365,22 @@ func endOfArrayElement(value *string, start int) int { return len(*value) } +func isValidLiteral(char byte) bool { + if char >= 'a' && char <= 'z' { + return true + } + + if char >= 'A' && char <= 'Z' { + return true + } + + if char >= '0' && char <= '9' { + return true + } + + return char == '_' || char == '-' +} + func isValidCharacter(char byte) bool { if char >= 'a' && char <= 'z' { return true diff --git a/scopie_test.go b/scopie_test.go index cec070b..21c191e 100644 --- a/scopie_test.go +++ b/scopie_test.go @@ -10,25 +10,32 @@ import ( ) type testAllowedScenario struct { - ID string `json:"id"` - Rules []string `json:"rules"` - Scopes []string `json:"scopes"` - Result bool `json:"result"` - Variables map[string]string `json:"variables"` - Error string `json:"error"` + ID string `json:"id"` + Actions []string `json:"actions"` + Permissions []string `json:"permissions"` + Result bool `json:"result"` + Variables map[string]string `json:"variables"` + Error string `json:"error"` } -type testValidScenario struct { - ID string `json:"id"` - Scopes []string `json:"scopes"` - Error string `json:"error"` +type testValidActionScenario struct { + ID string `json:"id"` + Actions []string `json:"actions"` + Error string `json:"error"` +} + +type testValidPermissionScenario struct { + ID string `json:"id"` + Permissions []string `json:"permissions"` + Error string `json:"error"` } type coreTestCase struct { - Version string `json:"version"` - IsAllowedTests []testAllowedScenario `json:"isAllowedTests"` - ScopeValidTests []testValidScenario `json:"validateScopesTests"` - Benchmarks []testAllowedScenario `json:"benchmarks"` + Version string `json:"version"` + IsAllowedTests []testAllowedScenario `json:"isAllowedTests"` + ActionValidTests []testValidActionScenario `json:"validateActionsTests"` + PermissionValidTests []testValidPermissionScenario `json:"validatePermissionsTests"` + Benchmarks []testAllowedScenario `json:"benchmarks"` } var testCases coreTestCase @@ -52,7 +59,7 @@ func TestMain(m *testing.M) { func Test_IsAllowed(t *testing.T) { for _, scenario := range testCases.IsAllowedTests { t.Run(scenario.ID, func(t *testing.T) { - res, err := IsAllowed(scenario.Scopes, scenario.Rules, scenario.Variables) + res, err := IsAllowed(scenario.Actions, scenario.Permissions, scenario.Variables) if scenario.Error != "" { then.NotNil(t, err) then.Equals(t, scenario.Error, err.Error()) @@ -68,17 +75,32 @@ func Test_IsAllowedBenchmarks(t *testing.T) { // Also run our benchmarks as test cases separate from running benchmarks for _, scenario := range testCases.Benchmarks { t.Run(scenario.ID, func(t *testing.T) { - res, err := IsAllowed(scenario.Scopes, scenario.Rules, scenario.Variables) + res, err := IsAllowed(scenario.Actions, scenario.Permissions, scenario.Variables) then.Equals(t, scenario.Result, res) then.Nil(t, err) }) } } -func Test_ScopeValid(t *testing.T) { - for _, scenario := range testCases.ScopeValidTests { +func Test_ActionValid(t *testing.T) { + for _, scenario := range testCases.ActionValidTests { + t.Run(scenario.ID, func(t *testing.T) { + err := ValidateActions(scenario.Actions) + if scenario.Error == "" { + then.Nil(t, err) + } else { + then.NotNil(t, err) + then.Equals(t, scenario.Error, err.Error()) + } + }) + } +} + +func Test_PermissionValid(t *testing.T) { + for _, scenario := range testCases.PermissionValidTests { t.Run(scenario.ID, func(t *testing.T) { - err := ValidateScopes(scenario.Scopes) + t.Log(scenario.Permissions) + err := ValidatePermissions(scenario.Permissions) if scenario.Error == "" { then.Nil(t, err) } else { @@ -91,48 +113,48 @@ func Test_ScopeValid(t *testing.T) { type compareTestCase struct { name string - actor string + user string action string vars map[string]string err error res bool } -func Test_CompareActorToRule(t *testing.T) { +func Test_CompareUserToAction(t *testing.T) { for _, tc := range []compareTestCase{ { name: "basic equality", - actor: "allow/alpha/beta", + user: "allow:alpha/beta", action: "alpha/beta", res: true, }, { name: "first inequality", - actor: "allow/alpha/beta", + user: "allow:alpha/beta", action: "delta/beta", res: false, }, { name: "last inequality", - actor: "allow/alpha/beta/ceti/delta", + user: "allow:alpha/beta/ceti/delta", action: "alpha/beta/ceti/epsilon", res: false, }, { name: "wildcard equality", - actor: "allow/alpha/beta/*/delta", + user: "allow:alpha/beta/*/delta", action: "alpha/beta/ceti/delta", res: true, }, { name: "super wildcard equality", - actor: "allow/alpha/beta/**", + user: "allow:alpha/beta/**", action: "alpha/beta/ceti/delta", res: true, }, { name: "variable usage", - actor: "allow/alpha/@user", + user: "allow:alpha/@user", action: "alpha/our_user", vars: map[string]string{ "user": "our_user", @@ -141,13 +163,13 @@ func Test_CompareActorToRule(t *testing.T) { }, { name: "first array value", - actor: "allow/alpha/beta|ceti|delta", + user: "allow:alpha/beta|ceti|delta", action: "alpha/beta", res: true, }, { name: "last array value", - actor: "allow/alpha/beta|ceti|delta", + user: "allow:alpha/beta|ceti|delta", action: "alpha/delta", // last array value of epsilon res: true, }, @@ -155,7 +177,7 @@ func Test_CompareActorToRule(t *testing.T) { t.Run(tc.name, func(t *testing.T) { tc := tc - doesMatch, err := compareRuleToScope(&tc.actor, &tc.action, tc.vars) + doesMatch, err := comparePermissionToAction(&tc.user, &tc.action, tc.vars) if tc.err == nil { then.Nil(t, err) then.Equals(t, tc.res, doesMatch) @@ -172,7 +194,7 @@ func Benchmark_Validations(b *testing.B) { b.ReportAllocs() for b.Loop() { - _, err := IsAllowed(scenario.Scopes, scenario.Rules, scenario.Variables) + _, err := IsAllowed(scenario.Actions, scenario.Permissions, scenario.Variables) then.Nil(b, err) } }) @@ -180,9 +202,9 @@ func Benchmark_Validations(b *testing.B) { } func ExampleIsAllowed() { - userRules := []string{"allow/blog/create|update"} + userPermissions := []string{"allow:blog/create|update"} - allowed, err := IsAllowed([]string{"blog/create"}, userRules, nil) + allowed, err := IsAllowed([]string{"blog/create"}, userPermissions, nil) if err != nil { panic("invalid scopes or rules") } @@ -194,8 +216,9 @@ func ExampleIsAllowed() { // create the blog here } +/* func ExampleValidateScopes() { - userRules := []string{"allow/blog/create|update"} + userRules := []string{"allow:blog/create|update"} err := ValidateScopes(userRules) if err != nil { @@ -204,3 +227,4 @@ func ExampleValidateScopes() { // save rules } +*/ diff --git a/testdata/scenarios.json b/testdata/scenarios.json index 557237d..4c9b84f 100644 --- a/testdata/scenarios.json +++ b/testdata/scenarios.json @@ -1,52 +1,52 @@ { - "version": "alpha-04", + "version": "alpha-05", "isAllowedTests": [ { "id": "basic allow match", - "rules": ["allow/blog/read"], - "scopes": ["blog/read"], + "permissions": ["allow:blog/read"], + "actions": ["blog/read"], "result": true }, { "id": "basic deny match", - "rules": ["deny/blog/read"], - "scopes": ["blog/read"], + "permissions": ["deny:blog/read"], + "actions": ["blog/read"], "result": false }, { "id": "no match is deny", - "rules": ["deny/blog/read"], - "scopes": ["accounts/read"], + "permissions": ["deny:blog/read"], + "actions": ["accounts/read"], "result": false }, { "id": "no match is deny 2", - "rules": ["deny/blog/read"], - "scopes": ["blog/reader"], + "permissions": ["deny:blog/read"], + "actions": ["blog/reader"], "result": false }, { - "id": "rules shorter then scopes", - "rules": ["allow/blog/public"], - "scopes": ["blog/public/read"], + "id": "permissions shorter then actions", + "permissions": ["allow:blog/public"], + "actions": ["blog/public/read"], "result": false }, { - "id": "scopes shorter then rules", - "rules": ["allow/blog/public/read"], - "scopes": ["blog/public"], + "id": "actions shorter then permissions", + "permissions": ["allow:blog/public/read"], + "actions": ["blog/public"], "result": false }, { "id": "match with array", - "rules": ["allow/pzazz/jazzy/fuzzy|folky|buzzy"], - "scopes": ["pzazz/jazzy/buzzy"], + "permissions": ["allow:pzazz/jazzy/fuzzy|folky|buzzy"], + "actions": ["pzazz/jazzy/buzzy"], "result": true }, { "id": "match with variable", - "rules": ["allow/quick/zappy/@name01"], - "scopes": ["quick/zappy/value01"], + "permissions": ["allow:quick/zappy/@name01"], + "actions": ["quick/zappy/value01"], "variables": { "name01": "value01" }, @@ -54,80 +54,80 @@ }, { "id": "match with wildcard", - "rules": ["allow/alamo/aline/*"], - "scopes": ["alamo/aline/anchor"], + "permissions": ["allow:alamo/aline/*"], + "actions": ["alamo/aline/anchor"], "variables": { "name01": "value01" }, "result": true }, { - "id": "rules rule has more wildcards then scopes rule", - "rules": ["allow/*/*/*"], - "scopes": ["alamo/anchor"], + "id": "permissions permission has more wildcards then actions permission", + "permissions": ["allow:*/*/*"], + "actions": ["alamo/anchor"], "result": false }, { "id": "match with super wildcard", - "rules": ["allow/antic/**"], - "scopes": ["antic/apeek/antre"], + "permissions": ["allow:antic/**"], + "actions": ["antic/apeek/antre"], "variables": { "name01": "value01" }, "result": true }, { - "id": "invalid special character in rules", - "rules": ["allow/blog/:155"], - "scopes": ["blog/read"], - "error": "scopie-100 in rule: invalid character ':'" + "id": "invalid special character in permissions", + "permissions": ["allow:blog/:155"], + "actions": ["blog/read"], + "error": "scopie-100 in permission: invalid character ':'" }, { - "id": "invalid special character in scopes", - "rules": ["allow/blog/read"], - "scopes": ["blog/:155"], - "error": "scopie-100 in scope: invalid character ':'" + "id": "invalid special character in actions", + "permissions": ["allow:blog/read"], + "actions": ["blog/:155"], + "error": "scopie-100 in action: invalid character ':'" }, { "id": "variable inside an array group", - "rules": ["allow/blog/read|@group|write"], - "scopes": ["blog/exec"], + "permissions": ["allow:blog/read|@group|write"], + "actions": ["blog/exec"], "error": "scopie-101: variable 'group' found in array block" }, { "id": "variable and the end of an array group", - "rules": ["allow/blog/read|write|@group"], - "scopes": ["blog/exec"], + "permissions": ["allow:blog/read|write|@group"], + "actions": ["blog/exec"], "error": "scopie-101: variable 'group' found in array block" }, { "id": "wildcard inside an array group", - "rules": ["allow/blog/read|*|write"], - "scopes": ["blog/exec"], + "permissions": ["allow:blog/read|*|write"], + "actions": ["blog/exec"], "error": "scopie-102: wildcard found in array block" }, { "id": "wildcard at the end of an array group", - "rules": ["allow/blog/read|write|*"], - "scopes": ["blog/exec"], + "permissions": ["allow:blog/read|write|*"], + "actions": ["blog/exec"], "error": "scopie-102: wildcard found in array block" }, { "id": "super wildcard inside an array group", - "rules": ["allow/blog/read|**|write"], - "scopes": ["blog/exec"], + "permissions": ["allow:blog/read|**|write"], + "actions": ["blog/exec"], "error": "scopie-103: super wildcard found in array block" }, { "id": "super wildcard at the end of an array group", - "rules": ["allow/blog/read|write|**"], - "scopes": ["blog/exec"], + "permissions": ["allow:blog/read|write|**"], + "actions": ["blog/exec"], "error": "scopie-103: super wildcard found in array block" }, { "id": "variable not found", - "rules": ["allow/blog/@group","allow/blog/read"], - "scopes": ["blog/exec"], + "permissions": ["allow:blog/@group","allow:blog/read"], + "actions": ["blog/exec"], "variables": { "name01": "value01" }, @@ -135,8 +135,8 @@ }, { "id": "variable not found 2", - "rules": ["allow/blog/@group"], - "scopes": ["blog/exec"], + "permissions": ["allow:blog/@group"], + "actions": ["blog/exec"], "variables": { "name01": "value01" }, @@ -144,186 +144,223 @@ }, { "id": "super wildcard not in the last block", - "rules": ["allow/blog/**/create"], - "scopes": ["blog/exec"], + "permissions": ["allow:blog/**/create"], + "actions": ["blog/exec"], "error": "scopie-105: super wildcard not in the last block" }, { - "id": "empty rules array", - "rules": [], - "scopes": ["blog/exec"], + "id": "empty permissions array", + "permissions": [], + "actions": ["blog/exec"], "result": false }, { - "id": "empty rules rule", - "rules": [""], - "scopes": ["blog/exec"], - "error": "scopie-106 in rule: rule was empty" + "id": "empty permissions permission", + "permissions": [""], + "actions": ["blog/exec"], + "error": "scopie-106 in permission: permission was empty" }, { - "id": "empty scopes array", - "rules": ["allow/accounts/read"], - "scopes": [], - "error": "scopie-106 in scope: scopes was empty" + "id": "empty actions array", + "permissions": ["allow:accounts/read"], + "actions": [], + "error": "scopie-106 in action: actions was empty" }, { - "id": "empty scopes rule", - "rules": ["allow/accounts/read"], - "scopes": [""], - "error": "scopie-106 in scope: scope was empty" + "id": "empty actions permission", + "permissions": ["allow:accounts/read"], + "actions": [""], + "error": "scopie-106 in action: action was empty" + }, + { + "id": "permission does not start with grant", + "permissions": ["maybe:accounts/read"], + "actions": ["accounts/read"], + "error": "scopie-107: permission does not start with a grant" + } + ], + "validateActionsTests": [ + { + "id": "simple action", + "actions": ["blog/read"] + }, + { + "id": "invalid special character", + "actions": ["blog/:15"], + "error": "scopie-100: invalid character ':'" + }, + { + "id": "invalid with wildcard", + "actions": ["blog/*"], + "error": "scopie-100: invalid character '*'" + }, + { + "id": "invalid with var prefix", + "actions": ["blog/@"], + "error": "scopie-100: invalid character '@'" + }, + { + "id": "invalid with array separator", + "actions": ["blog/a|b"], + "error": "scopie-100: invalid character '|'" + }, + { + "id": "empty action", + "actions": [""], + "error": "scopie-106: action was empty" + }, + { + "id": "empty action at second index", + "actions": ["blog/delete", ""], + "error": "scopie-106: action was empty" } ], - "validateScopesTests": [ + "validatePermissionsTests": [ { - "id": "simple scope", - "scopes": ["allow/blog/read"] + "id": "simple permission", + "permissions": ["allow:blog/read"] }, { "id": "with wildcard", - "scopes": ["allow/blog/*/read"] + "permissions": ["allow:blog/*/read"] }, { "id": "with super wildcard", - "scopes": ["allow/blog/**"] + "permissions": ["allow:blog/**"] }, { "id": "with array", - "scopes": ["allow/blog/primary|secondary|third/read"] + "permissions": ["allow:blog/primary|secondary|third/read"] }, { "id": "with variable", - "scopes": ["allow/blog/@owner/read"] + "permissions": ["allow:blog/@owner/read"] }, { "id": "combination", - "scopes": ["allow/blog/*/@region/primary|secondary/**"] + "permissions": ["allow:blog/*/@region/primary|secondary/**"] + }, + { + "id": "invalid start", + "permissions": ["maybe:blog/create"], + "error": "scopie-107: permission does not start with a grant" }, { "id": "invalid special character", - "scopes": ["allow/blog/:15"], - "error": "scopie-100: invalid character ':'" + "permissions": ["allow:blog/+15"], + "error": "scopie-100: invalid character '+'" }, { "id": "variable inside an array group", - "scopes": ["allow/blog/read|@group|write"], + "permissions": ["allow:blog/read|@group|write"], "error": "scopie-101: variable 'group' found in array block" }, { "id": "variable and the end of an array group", - "scopes": ["allow/blog/read|write|@group"], + "permissions": ["allow:blog/read|write|@group"], "error": "scopie-101: variable 'group' found in array block" }, { "id": "wildcard inside an array group", - "scopes": ["allow/blog/read|*|write"], + "permissions": ["allow:blog/read|*|write"], "error": "scopie-102: wildcard found in array block" }, { "id": "wildcard at the end of an array group", - "scopes": ["allow/blog/read|write|*"], + "permissions": ["allow:blog/read|write|*"], "error": "scopie-102: wildcard found in array block" }, { "id": "super wildcard inside an array group", - "scopes": ["allow/blog/read|**|write"], + "permissions": ["allow:blog/read|**|write"], "error": "scopie-103: super wildcard found in array block" }, { "id": "super wildcard at the end of an array group", - "scopes": ["allow/blog/read|write|**"], + "permissions": ["allow:blog/read|write|**"], "error": "scopie-103: super wildcard found in array block" }, { "id": "super wildcard not in the last block", - "scopes": ["allow/blog/**/create"], + "permissions": ["allow:blog/**/create"], "error": "scopie-105: super wildcard not in the last block" }, { - "id": "empty scope", - "scopes": [""], - "error": "scopie-106: scope or rule was empty" - }, - { - "id": "rule then scope", - "scopes": ["allow/blog/**", "blog/read"], - "error": "scopie-107: inconsistent array of scopes and rules" - }, - { - "id": "scope then rule", - "scopes": ["blog/**", "allow/blog/read"], - "error": "scopie-107: inconsistent array of scopes and rules" + "id": "empty permission", + "permissions": [""], + "error": "scopie-106: permission was empty" }, { - "id": "empty scope at second index", - "scopes": ["allow/blog/**", ""], - "error": "scopie-106: scope or rule was empty" + "id": "empty permission at second index", + "permissions": ["allow:blog/**", ""], + "error": "scopie-106: permission was empty" } ], "benchmarks": [ { - "id": "allow,1rules,1rule,1block1length", - "rules": ["allow/A"], - "scopes": ["A"], + "id": "allow,1permissions,1permission,1block1length", + "permissions": ["allow:A"], + "actions": ["A"], "result": true }, { - "id": "deny,1rules,1rule,1block1length", - "rules": ["deny/A"], - "scopes": ["A"], + "id": "deny,1permissions,1permission,1block1length", + "permissions": ["deny:A"], + "actions": ["A"], "result": false }, { - "id": "allow,5rules,3scopes,3blocks10length,best", - "rules": ["allow/razzmatazz/vajazzling/buckjumper", "allow/blackjacks/unpuzzling/unmuzzling", "allow/jumboizing/embezzling/buckjumped", "allow/whizzbangs/squeezebox/puzzlingly", "allow/buzzworthy/bemuzzling/jazzercise"], - "scopes": ["razzmatazz/vajazzling/buckjumper", "buzzphase/dizzingly/puzzlement", "schemozzle/scuzzballs/zigzagging"], + "id": "allow,5permissions,3actions,3blocks10length,best", + "permissions": ["allow:razzmatazz/vajazzling/buckjumper", "allow:blackjacks/unpuzzling/unmuzzling", "allow:jumboizing/embezzling/buckjumped", "allow:whizzbangs/squeezebox/puzzlingly", "allow:buzzworthy/bemuzzling/jazzercise"], + "actions": ["razzmatazz/vajazzling/buckjumper", "buzzphase/dizzingly/puzzlement", "schemozzle/scuzzballs/zigzagging"], "result": true }, { - "id": "allow,5rules,3scopes,3blocks10length,worst", - "rules": ["allow/razzmatazz/vajazzling/buckjumper", "allow/blackjacks/unpuzzling/unmuzzling", "allow/jumboizing/embezzling/buckjumped", "allow/whizzbangs/squeezebox/puzzlingly", "allow/buzzworthy/bemuzzling/jazzercise"], - "scopes": ["buzzphase/dizzingly/puzzlement", "schemozzle/scuzzballs/zigzagging", "buzzworthy/bemuzzling/jazzercise"], + "id": "allow,5permissions,3actions,3blocks10length,worst", + "permissions": ["allow:razzmatazz/vajazzling/buckjumper", "allow:blackjacks/unpuzzling/unmuzzling", "allow:jumboizing/embezzling/buckjumped", "allow:whizzbangs/squeezebox/puzzlingly", "allow:buzzworthy/bemuzzling/jazzercise"], + "actions": ["buzzphase/dizzingly/puzzlement", "schemozzle/scuzzballs/zigzagging", "buzzworthy/bemuzzling/jazzercise"], "result": true }, { - "id": "deny,5rules,3scopes,3blocks10length,best", - "rules": ["deny/razzmatazz/vajazzling/buckjumper", "allow/blackjacks/unpuzzling/unmuzzling", "allow/jumboizing/embezzling/buckjumped", "allow/whizzbangs/squeezebox/puzzlingly", "allow/buzzworthy/bemuzzling/jazzercise"], - "scopes": ["razzmatazz/vajazzling/buckjumper", "buzzphase/dizzingly/puzzlement", "schemozzle/scuzzballs/zigzagging"], + "id": "deny,5permissions,3actions,3blocks10length,best", + "permissions": ["deny:razzmatazz/vajazzling/buckjumper", "allow:blackjacks/unpuzzling/unmuzzling", "allow:jumboizing/embezzling/buckjumped", "allow:whizzbangs/squeezebox/puzzlingly", "allow:buzzworthy/bemuzzling/jazzercise"], + "actions": ["razzmatazz/vajazzling/buckjumper", "buzzphase/dizzingly/puzzlement", "schemozzle/scuzzballs/zigzagging"], "result": false }, { - "id": "deny,5rules,3scopes,3blocks10length,worst", - "rules": ["allow/razzmatazz/vajazzling/buckjumper", "allow/blackjacks/unpuzzling/unmuzzling", "allow/jumboizing/embezzling/buckjumped", "allow/whizzbangs/squeezebox/puzzlingly", "deny/buzzworthy/bemuzzling/jazzercise"], - "scopes": ["buzzphase/dizzingly/puzzlement", "schemozzle/scuzzballs/zigzagging", "buzzworthy/bemuzzling/jazzercise"], + "id": "deny,5permissions,3actions,3blocks10length,worst", + "permissions": ["allow:razzmatazz/vajazzling/buckjumper", "allow:blackjacks/unpuzzling/unmuzzling", "allow:jumboizing/embezzling/buckjumped", "allow:whizzbangs/squeezebox/puzzlingly", "deny:buzzworthy/bemuzzling/jazzercise"], + "actions": ["buzzphase/dizzingly/puzzlement", "schemozzle/scuzzballs/zigzagging", "buzzworthy/bemuzzling/jazzercise"], "result": false }, { - "id": "allow,2rules,1rule,3block5length,3array,best", - "rules": ["allow/pzazz|bezzy|bizzy/jazzy|fizzy|pozzy/buzzy|fuzzy|muzzy", "allow/whizz|zhuzh|abuzz/scuzz|dizzy|frizz/huzza|mezza|mezzo"], - "scopes": ["pzazz/jazzy/buzzy"], + "id": "allow,2permissions,1permission,3block5length,3array,best", + "permissions": ["allow:pzazz|bezzy|bizzy/jazzy|fizzy|pozzy/buzzy|fuzzy|muzzy", "allow:whizz|zhuzh|abuzz/scuzz|dizzy|frizz/huzza|mezza|mezzo"], + "actions": ["pzazz/jazzy/buzzy"], "result": true }, { - "id": "allow,2rules,1rule,3block5length,3array,worst", - "rules": ["allow/pzazz|bezzy|bizzy/jazzy|fizzy|pozzy/buzzy|fuzzy|muzzy", "allow/whizz|zhuzh|abuzz/scuzz|dizzy|frizz/huzza|mezza|mezzo"], - "scopes": ["abuzz/frizz/mezzo"], + "id": "allow,2permissions,1permission,3block5length,3array,worst", + "permissions": ["allow:pzazz|bezzy|bizzy/jazzy|fizzy|pozzy/buzzy|fuzzy|muzzy", "allow:whizz|zhuzh|abuzz/scuzz|dizzy|frizz/huzza|mezza|mezzo"], + "actions": ["abuzz/frizz/mezzo"], "result": true }, { - "id": "deny,2rules,1rule,3block5length,3array,best", - "rules": ["deny/pzazz|bezzy|bizzy/jazzy|fizzy|pozzy/buzzy|fuzzy|muzzy", "allow/whizz|zhuzh|abuzz/scuzz|dizzy|frizz/huzza|mezza|mezzo"], - "scopes": ["pzazz/jazzy/buzzy"], + "id": "deny,2permissions,1permission,3block5length,3array,best", + "permissions": ["deny:pzazz|bezzy|bizzy/jazzy|fizzy|pozzy/buzzy|fuzzy|muzzy", "allow:whizz|zhuzh|abuzz/scuzz|dizzy|frizz/huzza|mezza|mezzo"], + "actions": ["pzazz/jazzy/buzzy"], "result": false }, { - "id": "deny,2rules,1rule,3block5length,3array,worst", - "rules": ["allow/pzazz|bezzy|bizzy/jazzy|fizzy|pozzy/buzzy|fuzzy|muzzy", "deny/whizz|zhuzh|abuzz/scuzz|dizzy|frizz/huzza|mezza|mezzo"], - "scopes": ["abuzz/frizz/mezzo"], + "id": "deny,2permissions,1permission,3block5length,3array,worst", + "permissions": ["allow:pzazz|bezzy|bizzy/jazzy|fizzy|pozzy/buzzy|fuzzy|muzzy", "deny:whizz|zhuzh|abuzz/scuzz|dizzy|frizz/huzza|mezza|mezzo"], + "actions": ["abuzz/frizz/mezzo"], "result": false }, { - "id": "allow,3rules,1rule,3block5length,3variables,best", - "rules": ["allow/@name01/@name02/@name03", "allow/jimmy/jimpy/junky", "allow/mujik/muzak/quack"], - "scopes": ["value01/value02/value03"], + "id": "allow,3permissions,1permission,3block5length,3variables,best", + "permissions": ["allow:@name01/@name02/@name03", "allow:jimmy/jimpy/junky", "allow:mujik/muzak/quack"], + "actions": ["value01/value02/value03"], "variables": { "name01": "value01", "name02": "value02", @@ -332,9 +369,9 @@ "result": true }, { - "id": "allow,3rules,1rule,3block5length,3variables,worst", - "rules": ["allow/jimmy/jimpy/junky", "allow/mujik/muzak/quack", "allow/@name01/@name02/@name03"], - "scopes": ["value01/value02/value03"], + "id": "allow,3permissions,1permission,3block5length,3variables,worst", + "permissions": ["allow:jimmy/jimpy/junky", "allow:mujik/muzak/quack", "allow:@name01/@name02/@name03"], + "actions": ["value01/value02/value03"], "variables": { "name01": "value01", "name02": "value02", @@ -343,9 +380,9 @@ "result": true }, { - "id": "deny,2rules,1rule,3block5length,3variables,best", - "rules": ["deny/@name01/@name02/@name03", "allow/quick/zappy/zippy", "allow/jacks/jocko/jugum"], - "scopes": ["value01/value02/value03"], + "id": "deny,2permissions,1permission,3block5length,3variables,best", + "permissions": ["deny:@name01/@name02/@name03", "allow:quick/zappy/zippy", "allow:jacks/jocko/jugum"], + "actions": ["value01/value02/value03"], "variables": { "name01": "value01", "name02": "value02", @@ -354,9 +391,9 @@ "result": false }, { - "id": "deny,2rules,1rule,3block5length,3variables,worst", - "rules": ["allow/quick/zappy/zippy", "allow/jacks/jocko/jugum", "deny/@name01/@name02/@name03"], - "scopes": ["value01/value02/value03"], + "id": "deny,2permissions,1permission,3block5length,3variables,worst", + "permissions": ["allow:quick/zappy/zippy", "allow:jacks/jocko/jugum", "deny:@name01/@name02/@name03"], + "actions": ["value01/value02/value03"], "variables": { "name01": "value01", "name02": "value02", @@ -365,51 +402,51 @@ "result": false }, { - "id": "allow,3rules,1rule,3block5length,wildcard,best", - "rules": ["allow/jivey/*/juicy", "allow/jimmy/jimpy/junky", "allow/mujik/muzak/quack"], - "scopes": ["jivey/juked/juicy"], + "id": "allow,3permissions,1permission,3block5length,wildcard,best", + "permissions": ["allow:jivey/*/juicy", "allow:jimmy/jimpy/junky", "allow:mujik/muzak/quack"], + "actions": ["jivey/juked/juicy"], "result": true }, { - "id": "allow,3rules,1rule,3block5length,wildcard,worst", - "rules": ["allow/jimmy/jimpy/junky", "allow/mujik/muzak/quack", "allow/juffs/*/jaggy"], - "scopes": ["juffs/jokey/jaggy"], + "id": "allow,3permissions,1permission,3block5length,wildcard,worst", + "permissions": ["allow:jimmy/jimpy/junky", "allow:mujik/muzak/quack", "allow:juffs/*/jaggy"], + "actions": ["juffs/jokey/jaggy"], "result": true }, { - "id": "deny,2rules,1rule,3block5length,wildcard,best", - "rules": ["deny/khazi/*/pujah", "allow/quick/zappy/zippy", "allow/jacks/jocko/jugum"], - "scopes": ["khazi/zincy/pujah"], + "id": "deny,2permissions,1permission,3block5length,wildcard,best", + "permissions": ["deny:khazi/*/pujah", "allow:quick/zappy/zippy", "allow:jacks/jocko/jugum"], + "actions": ["khazi/zincy/pujah"], "result": false }, { - "id": "deny,2rules,1rule,3block5length,wildcard,worst", - "rules": ["allow/quick/zappy/zippy", "allow/jacks/jocko/jugum", "deny/zilch/*/kanzu"], - "scopes": ["zilch/karzy/kanzu"], + "id": "deny,2permissions,1permission,3block5length,wildcard,worst", + "permissions": ["allow:quick/zappy/zippy", "allow:jacks/jocko/jugum", "deny:zilch/*/kanzu"], + "actions": ["zilch/karzy/kanzu"], "result": false }, { - "id": "allow,3rules,1rule,3block5length,superwildcard,best", - "rules": ["allow/jivey/**", "allow/jimmy/jimpy/junky", "allow/mujik/muzak/quack"], - "scopes": ["jivey/juked/juicy"], + "id": "allow,3permissions,1permission,3block5length,superwildcard,best", + "permissions": ["allow:jivey/**", "allow:jimmy/jimpy/junky", "allow:mujik/muzak/quack"], + "actions": ["jivey/juked/juicy"], "result": true }, { - "id": "allow,3rules,1rule,3block5length,superwildcard,worst", - "rules": ["allow/jimmy/jimpy/junky", "allow/mujik/muzak/quack", "allow/juffs/**"], - "scopes": ["juffs/jokey/jaggy"], + "id": "allow,3permissions,1permission,3block5length,superwildcard,worst", + "permissions": ["allow:jimmy/jimpy/junky", "allow:mujik/muzak/quack", "allow:juffs/**"], + "actions": ["juffs/jokey/jaggy"], "result": true }, { - "id": "deny,2rules,1rule,3block5length,superwildcard,best", - "rules": ["deny/khazi/**", "allow/quick/zappy/zippy", "allow/jacks/jocko/jugum"], - "scopes": ["khazi/zincy/pujah"], + "id": "deny,2permissions,1permission,3block5length,superwildcard,best", + "permissions": ["deny:khazi/**", "allow:quick/zappy/zippy", "allow:jacks/jocko/jugum"], + "actions": ["khazi/zincy/pujah"], "result": false }, { - "id": "deny,2rules,1rule,3block5length,superwildcard,worst", - "rules": ["allow/quick/zappy/zippy", "allow/jacks/jocko/jugum", "deny/zilch/**"], - "scopes": ["zilch/karzy/kanzu"], + "id": "deny,2permissions,1permission,3block5length,superwildcard,worst", + "permissions": ["allow:quick/zappy/zippy", "allow:jacks/jocko/jugum", "deny:zilch/**"], + "actions": ["zilch/karzy/kanzu"], "result": false } ] From f42965bd29a0a76750cc7a3e51ceb08b069c74c6 Mon Sep 17 00:00:00 2001 From: Ronnie Smith Date: Wed, 21 Jan 2026 20:08:28 -0800 Subject: [PATCH 2/7] finished off scenarios and added docs --- scopie.go | 105 ++++++++++++++++++---------------------- scopie_test.go | 4 +- testdata/scenarios.json | 28 +++++++++++ 3 files changed, 78 insertions(+), 59 deletions(-) diff --git a/scopie.go b/scopie.go index eca69ad..cd92c14 100644 --- a/scopie.go +++ b/scopie.go @@ -38,39 +38,30 @@ var ( // validation specific errValidateActionEmpty = errors.New("scopie-106: action was empty") - errValidateActionsEmpty = errors.New("scopie-106: actions array was empty") + errValidateActionsEmpty = errors.New("scopie-106: action array was empty") errValidatePermissionEmpty = errors.New("scopie-106: permission was empty") errValidatePermissionsEmpty = errors.New("scopie-106: permission array was empty") ) -// IsAllowedFunc is a type wrapper for [IsAllowed] that can be used as -// a dependency. +// IsAllowedFunc is a type wrapper for [IsAllowed] to be used a dependency. type IsAllowedFunc func(map[string]string, string, string) (bool, error) -// ValidateScopeFunc is a type wrapper for [ValidateScopes] that can be -// used as a dependency. -// type ValidateScopeFunc func(string) error +// ValidateActionsFunc is a type wrapper for [ValidateActions] to be used as a dependency. +type ValidateActionsFunc func (actions []string) error -// TODO: REWRITE -// IsAllowed returns whether or not the scopes are allowed with the given rules. -// [Is Allowed Spec] is the function specification. -// -// Scopes specifies one or more scopes our actor must match. -// When using more then one scope, they are treated as a series of OR conditions, -// and an actor will be allowed if they match any of the scopes. -// -// Rules specifies one or more rules our requesting scopes has to have -// to be allowed access. -// An optional dictionary or map of variable to values. -// Variable keys should not start with `@` +// ValidateActionsFunc is a type wrapper for [ValidatePermissions] to be used as a dependency. +type ValidatePermissionsFunc func (permissions []string) error + +// IsAllowed returns whether or not the user with the given permissions are allowed to complete +// the action. See [Is Allowed Spec] for additional details. // // isAllowed, err := IsAllowed( -// []string{"accounts/thor/edit", -// "allow/accounts/@username/*", +// []string{"accounts/thor/edit"}, +// "allow:accounts/@username/*", // map[string]string{"username": "thor"}, // ) // if err != nil { -// return fmt.Errorf("invalid scope or rule: %w", err) +// return fmt.Errorf("invalid action or permission: %w", err) // } // if !isAllowed { // return fmt.Errorf("unauthorized") @@ -88,27 +79,26 @@ func IsAllowed(actions, permissions []string, vars map[string]string) (bool, err hasBeenAllowed := false - for _, actorRule := range permissions { - if len(actorRule) == 0 { + for _, permission := range permissions { + if len(permission) == 0 { return false, errPermissionEmpty } - actorRule := actorRule + isAllowBlock := strings.HasPrefix(permission, AllowGrant) + if !isAllowBlock && !strings.HasPrefix(permission, DenyGrant) { + return false, errPermissionDoesNotStartWithGrant + } - // TODO: maybe don't just check allow as it could be invalid - isAllowBlock := strings.HasPrefix(actorRule, AllowGrant) if isAllowBlock && hasBeenAllowed { continue } - for _, actionScope := range actions { - if len(actionScope) == 0 { + for _, action := range actions { + if len(action) == 0 { return false, errActionEmpty } - actionScope := actionScope - - match, err := comparePermissionToAction(&actorRule, &actionScope, vars) + match, err := comparePermissionToAction(&permission, &action, vars) if err != nil { return false, err } @@ -124,18 +114,17 @@ func IsAllowed(actions, permissions []string, vars map[string]string) (bool, err return hasBeenAllowed, nil } -// TODO: we now have two separate validation funcs -// ValidateScopes checks whether or not the given scopes or rules are valid given the +// ValidateActions checks whether or not the given actions are valid given the // requirements outlined in the specification. // [Validate Scopes Spec] is the function specification. // -// err := ValidateScopes("allow/accounts/@username/*") +// err := ValidateActions("accounts/create") // if err != nil { -// return fmt.Errorf("scope is invalid: %w", err) +// return fmt.Errorf("action is invalid: %w", err) // } // -// [Validate Scopes Spec]: https://scopie.dev/specification/functions/#validate-scopes -func ValidateActions(actions []string) error { +// [Validate Actions Spec]: https://scopie.dev/specification/functions/#validate-actions +func ValidateActions(actions ...string) error { if len(actions) == 0 { return errValidateActionsEmpty } @@ -159,7 +148,18 @@ func ValidateActions(actions []string) error { return nil } -func ValidatePermissions(permissions []string) error { + +// ValidatePermissions checks whether or not the given permissions are valid given the +// requirements outlined in the specification. +// [Validate Permissions Spec] is the function specification. +// +// err := ValidatePermissionsFunc("allow:accounts/read") +// if err != nil { +// return fmt.Errorf("action is invalid: %w", err) +// } +// +// [Validate Permissions Spec]: https://scopie.dev/specification/functions/#validate-permissions +func ValidatePermissions(permissions ...string) error { if len(permissions) == 0 { return errValidatePermissionsEmpty } @@ -176,9 +176,6 @@ func ValidatePermissions(permissions []string) error { return errPermissionDoesNotStartWithGrant } - // skip the separator - i++ - for ; i < len(permission); i++ { if permission[i] == BlockSeperator { inArray = false @@ -223,11 +220,8 @@ func comparePermissionToAction( action *string, vars map[string]string, ) (bool, error) { - // Skip the allow and deny prefix for permission + // skip grant error is pre-checked permissionLeft, _ := skipGrant(permission, 0) - // TODO: handle the error above - - permissionLeft += 1 // don't forget to skip the separator actionLeft := 0 for permissionLeft < len(*permission) || actionLeft < len(*action) { @@ -322,19 +316,17 @@ func compareBlock( } func skipGrant(value *string, start int) (int, error) { - // TODO: actually do this properly... - if strings.HasPrefix(*value, AllowGrant) + subStr := (*value)[start:] - for i := start; i < len(*value); i++ { - if (*value)[i] == GrantSeparator { - return i, nil - } else if !isValidCharacter((*value)[i]) { - invalidChar := string((*value)[i]) - return 0, fmt.Errorf(fmtAllowedInvalidChar, "permission", invalidChar) - } + if strings.HasPrefix(subStr, DenyGrant) { + return 5, nil + } + + if strings.HasPrefix(subStr, AllowGrant) { + return 6, nil } - return len(*value), nil + return 0, errPermissionDoesNotStartWithGrant } func endOfBlock(value *string, start int, category string) (int, bool, error) { @@ -356,8 +348,7 @@ func endOfBlock(value *string, start int, category string) (int, bool, error) { func endOfArrayElement(value *string, start int) int { for i := start + 1; i < len(*value); i++ { - if (*value)[i] == BlockSeperator || - (*value)[i] == ArraySeperator { + if (*value)[i] == BlockSeperator || (*value)[i] == ArraySeperator { return i } } diff --git a/scopie_test.go b/scopie_test.go index 21c191e..ab3c9a9 100644 --- a/scopie_test.go +++ b/scopie_test.go @@ -85,7 +85,7 @@ func Test_IsAllowedBenchmarks(t *testing.T) { func Test_ActionValid(t *testing.T) { for _, scenario := range testCases.ActionValidTests { t.Run(scenario.ID, func(t *testing.T) { - err := ValidateActions(scenario.Actions) + err := ValidateActions(scenario.Actions...) if scenario.Error == "" { then.Nil(t, err) } else { @@ -100,7 +100,7 @@ func Test_PermissionValid(t *testing.T) { for _, scenario := range testCases.PermissionValidTests { t.Run(scenario.ID, func(t *testing.T) { t.Log(scenario.Permissions) - err := ValidatePermissions(scenario.Permissions) + err := ValidatePermissions(scenario.Permissions...) if scenario.Error == "" { then.Nil(t, err) } else { diff --git a/testdata/scenarios.json b/testdata/scenarios.json index 4c9b84f..6b77aac 100644 --- a/testdata/scenarios.json +++ b/testdata/scenarios.json @@ -177,6 +177,12 @@ "permissions": ["maybe:accounts/read"], "actions": ["accounts/read"], "error": "scopie-107: permission does not start with a grant" + }, + { + "id": "permission does not start with grant when second permission", + "permissions": ["deny:admin/write", "maybe:accounts/read"], + "actions": ["accounts/read"], + "error": "scopie-107: permission does not start with a grant" } ], "validateActionsTests": [ @@ -184,6 +190,18 @@ "id": "simple action", "actions": ["blog/read"] }, + { + "id": "cased action", + "actions": ["Blog/Write"] + }, + { + "id": "numbered action", + "actions": ["Blog/Write100"] + }, + { + "id": "special character action", + "actions": ["Blog/Delete-500"] + }, { "id": "invalid special character", "actions": ["blog/:15"], @@ -204,6 +222,11 @@ "actions": ["blog/a|b"], "error": "scopie-100: invalid character '|'" }, + { + "id": "empty action array", + "actions": [], + "error": "scopie-106: action array was empty" + }, { "id": "empty action", "actions": [""], @@ -240,6 +263,11 @@ "id": "combination", "permissions": ["allow:blog/*/@region/primary|secondary/**"] }, + { + "id": "empty permissions", + "permissions": [], + "error": "scopie-106: permission array was empty" + }, { "id": "invalid start", "permissions": ["maybe:blog/create"], From 277e3da7b3befebac6b2587a69e84ac7371922b7 Mon Sep 17 00:00:00 2001 From: Ronnie Smith Date: Wed, 21 Jan 2026 20:10:10 -0800 Subject: [PATCH 3/7] update and fix linter errors --- .golangci.yml | 12 +++++++----- scopie.go | 35 ++++++++++++++++++++++++++--------- scopie_test.go | 1 + 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index d038253..3bb4797 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -8,7 +8,7 @@ linters: - goconst - goprintffuncname - gosec - - lll + - intrange - misspell - nakedret - noctx @@ -18,7 +18,7 @@ linters: - unparam - usetesting - whitespace - - wsl + - wsl_v5 settings: exhaustive: default-signifies-exhaustive: false @@ -38,6 +38,10 @@ linters: - common-false-positives - legacy - std-error-handling + rules: + - linters: + - gosec + path: (.+)_test\.go paths: - third_party$ - builtin$ @@ -46,6 +50,7 @@ formatters: enable: - gci - gofmt + - golines settings: gci: sections: @@ -56,9 +61,6 @@ formatters: - dot - alias - localmodule - goimports: - local-prefixes: - - github.com/miniscruff/scopie-go exclusions: generated: lax paths: diff --git a/scopie.go b/scopie.go index cd92c14..136a8d5 100644 --- a/scopie.go +++ b/scopie.go @@ -34,7 +34,9 @@ var ( errActionEmpty = errors.New("scopie-106 in action: action was empty") errPermissionEmpty = errors.New("scopie-106 in permission: permission was empty") - errPermissionDoesNotStartWithGrant = errors.New("scopie-107: permission does not start with a grant") + errPermissionDoesNotStartWithGrant = errors.New( + "scopie-107: permission does not start with a grant", + ) // validation specific errValidateActionEmpty = errors.New("scopie-106: action was empty") @@ -47,10 +49,10 @@ var ( type IsAllowedFunc func(map[string]string, string, string) (bool, error) // ValidateActionsFunc is a type wrapper for [ValidateActions] to be used as a dependency. -type ValidateActionsFunc func (actions []string) error +type ValidateActionsFunc func(actions []string) error // ValidateActionsFunc is a type wrapper for [ValidatePermissions] to be used as a dependency. -type ValidatePermissionsFunc func (permissions []string) error +type ValidatePermissionsFunc func(permissions []string) error // IsAllowed returns whether or not the user with the given permissions are allowed to complete // the action. See [Is Allowed Spec] for additional details. @@ -148,7 +150,6 @@ func ValidateActions(actions ...string) error { return nil } - // ValidatePermissions checks whether or not the given permissions are valid given the // requirements outlined in the specification. // [Validate Permissions Spec] is the function specification. @@ -188,7 +189,8 @@ func ValidatePermissions(permissions ...string) error { } if inArray { - if permission[i] == Wildcard && i < len(permission)-1 && permission[i+1] == Wildcard { + if permission[i] == Wildcard && i < len(permission)-1 && + permission[i+1] == Wildcard { return errSuperInArray } @@ -206,7 +208,8 @@ func ValidatePermissions(permissions ...string) error { return fmt.Errorf(fmtValidateInvalidChar, string(permission[i])) } - if permission[i] == Wildcard && i < len(permission)-1 && permission[i+1] == Wildcard && i < len(permission)-2 { + if permission[i] == Wildcard && i < len(permission)-1 && permission[i+1] == Wildcard && + i < len(permission)-2 { return errSuperNotLast } } @@ -235,20 +238,34 @@ func comparePermissionToAction( return false, err } - permissionSlider, permissionArray, err := endOfBlock(permission, permissionLeft, "permission") + permissionSlider, permissionArray, err := endOfBlock( + permission, + permissionLeft, + "permission", + ) if err != nil { return false, err } // Super wildcards are checked here as it skips the who rest of the checks. - if permissionSlider-permissionLeft == 2 && (*permission)[permissionLeft] == Wildcard && (*permission)[permissionLeft+1] == Wildcard { + if permissionSlider-permissionLeft == 2 && (*permission)[permissionLeft] == Wildcard && + (*permission)[permissionLeft+1] == Wildcard { if len(*permission) > permissionSlider { return false, errSuperNotLast } return true, nil } else { - match, err := compareBlock(permission, permissionLeft, permissionSlider, permissionArray, action, actionLeft, actionSlider, vars) + match, err := compareBlock( + permission, + permissionLeft, + permissionSlider, + permissionArray, + action, + actionLeft, + actionSlider, + vars, + ) if err != nil { return false, err } diff --git a/scopie_test.go b/scopie_test.go index ab3c9a9..e6457d2 100644 --- a/scopie_test.go +++ b/scopie_test.go @@ -100,6 +100,7 @@ func Test_PermissionValid(t *testing.T) { for _, scenario := range testCases.PermissionValidTests { t.Run(scenario.ID, func(t *testing.T) { t.Log(scenario.Permissions) + err := ValidatePermissions(scenario.Permissions...) if scenario.Error == "" { then.Nil(t, err) From f0600dc9558e223f1da59648b64bd8008ef5c3e1 Mon Sep 17 00:00:00 2001 From: Ronnie Smith Date: Wed, 21 Jan 2026 20:30:41 -0800 Subject: [PATCH 4/7] update linter in CI --- .github/workflows/test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0f3318a..2d56aea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,20 +25,20 @@ jobs: runs-on: ubuntu-latest steps: + - name: Check out code + uses: actions/checkout@v6 + - name: Setup Go uses: actions/setup-go@v6 with: - go-version: 1.24 - - - name: Check out code - uses: actions/checkout@v6 + go-version-file: 'go.mod' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: # Required: the version of golangci-lint is required and must be specified # without patch version: we always use the latest patch version. - version: 'v2.1' + version: 'v2.6' - name: Test run: go test -coverprofile=c.out ./... From dd4c89ba820e6e3cec99b92e5d66878d410aa88c Mon Sep 17 00:00:00 2001 From: Ronnie Smith Date: Wed, 21 Jan 2026 20:32:00 -0800 Subject: [PATCH 5/7] fragment --- .changes/unreleased/Changed-20260121-203158.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/unreleased/Changed-20260121-203158.yaml diff --git a/.changes/unreleased/Changed-20260121-203158.yaml b/.changes/unreleased/Changed-20260121-203158.yaml new file mode 100644 index 0000000..d2ad001 --- /dev/null +++ b/.changes/unreleased/Changed-20260121-203158.yaml @@ -0,0 +1,5 @@ +kind: Changed +body: Support alpha05 spec with name changes +time: 2026-01-21T20:31:58.383732725-08:00 +custom: + Issue: "30" From 744311b1cf461ebcb0ba6272a2475e8c80a26f4b Mon Sep 17 00:00:00 2001 From: Ronnie Smith Date: Wed, 21 Jan 2026 20:34:04 -0800 Subject: [PATCH 6/7] small update to gen release script --- .github/workflows/gen-release-pr.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gen-release-pr.yml b/.github/workflows/gen-release-pr.yml index 1bbb4dc..6f56d0f 100644 --- a/.github/workflows/gen-release-pr.yml +++ b/.github/workflows/gen-release-pr.yml @@ -29,6 +29,10 @@ jobs: version: latest args: latest + - name: Trim Notes + run: | + tail -n +3 .changes/$(go run main.go latest).md > ${{ github.workspace }}/rel_notes.md + - name: Create Pull Request uses: peter-evans/create-pull-request@v8 with: @@ -36,4 +40,4 @@ jobs: title: Release ${{ steps.latest.outputs.output }} branch: release/${{ steps.latest.outputs.output }} commit-message: Release ${{ steps.latest.outputs.output }} - body-path: ".changes/${{ steps.latest.outputs.output }}.md" + body-path: '${{ github.workspace }}/rel_notes.md' From b65ad5ce54d3057af534a34cc31e58ee6e407858 Mon Sep 17 00:00:00 2001 From: Ronnie Smith Date: Wed, 21 Jan 2026 20:35:58 -0800 Subject: [PATCH 7/7] update release notes path to be trimmed too --- .github/workflows/release.yml | 44 +++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cda7c9..8ce6021 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,24 +13,28 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version: 1.24 - - - name: Get the latest version - id: latest - uses: miniscruff/changie-action@v2 - with: - version: latest - args: latest - - - name: Release - uses: softprops/action-gh-release@v2 - with: - body_path: ".changes/${{ steps.latest.outputs.output }}.md" - tag_name: "${{ steps.latest.outputs.output }}" + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: 1.24 + + - name: Get the latest version + id: latest + uses: miniscruff/changie-action@v2 + with: + version: latest + args: latest + + - name: Trim Notes + run: | + tail -n +3 .changes/$(go run main.go latest).md > ${{ github.workspace }}/rel_notes.md + + - name: Release + uses: softprops/action-gh-release@v2 + with: + body_path: "${{ github.workspace }}/rel_notes.md" + tag_name: "${{ steps.latest.outputs.output }}"