├── .editorconfig ├── .github └── workflows │ ├── golangci-lint.yaml │ ├── semgrep.yml │ ├── test-go.yaml │ ├── test-js.yaml │ └── test-php.yaml ├── .gitignore ├── .phan └── config.php ├── LICENSE ├── OWNERS ├── README.md ├── VERSION ├── authr.go ├── authr_test.go ├── authrutil ├── struct_resource.go └── struct_resource_test.go ├── composer.json ├── composer.lock ├── contrib └── semver ├── doc.go ├── go.mod ├── go.sum ├── json.go ├── json_test.go ├── makefile ├── php ├── Makefile ├── phpunit.xml ├── src │ ├── Authr.php │ ├── Authr │ │ ├── Condition.php │ │ ├── Condition │ │ │ ├── Operator │ │ │ │ ├── ArrayDifference.php │ │ │ │ ├── ArrayIntersect.php │ │ │ │ ├── Equals.php │ │ │ │ ├── In.php │ │ │ │ ├── Like.php │ │ │ │ ├── NotEquals.php │ │ │ │ ├── NotIn.php │ │ │ │ └── RegExp │ │ │ │ │ ├── CaseInsensitive.php │ │ │ │ │ ├── CaseSensitive.php │ │ │ │ │ ├── InverseCaseInsensitive.php │ │ │ │ │ └── InverseCaseSensitive.php │ │ │ └── OperatorInterface.php │ │ ├── ConditionSet.php │ │ ├── EvaluatorInterface.php │ │ ├── Exception.php │ │ ├── Exception │ │ │ ├── InvalidAdHocResourceException.php │ │ │ ├── InvalidConditionOperator.php │ │ │ ├── InvalidConditionSetException.php │ │ │ ├── InvalidRuleException.php │ │ │ ├── InvalidSlugSetException.php │ │ │ ├── RuntimeException.php │ │ │ └── ValidationException.php │ │ ├── Resource.php │ │ ├── ResourceInterface.php │ │ ├── Rule.php │ │ ├── RuleList.php │ │ ├── SlugSet.php │ │ └── SubjectInterface.php │ └── AuthrInterface.php └── test │ ├── Authr │ ├── Condition │ │ └── Operator │ │ │ ├── ArrayDifferenceTest.php │ │ │ ├── ArrayIntersectTest.php │ │ │ ├── EqualsTest.php │ │ │ ├── InTest.php │ │ │ ├── LikeTest.php │ │ │ ├── NotEqualsTest.php │ │ │ ├── NotInTest.php │ │ │ └── RegExp │ │ │ ├── CaseInsensitiveTest.php │ │ │ ├── CaseSensitiveTest.php │ │ │ ├── InverseCaseInsensitiveTest.php │ │ │ └── InverseCaseSensitiveTest.php │ ├── ConditionSetTest.php │ ├── ConditionTest.php │ ├── ResourceTest.php │ ├── RuleTest.php │ ├── SlugSetTest.php │ └── TestSubject.php │ ├── AuthrTest.php │ └── TestCase.php ├── regexp_cache.go ├── regexp_cache_test.go ├── rule-schema.json └── ts ├── .babelrc ├── Makefile ├── README.md ├── package-lock.json ├── package.json ├── src ├── authrError.ts ├── condition.ts ├── conditionSet.ts ├── index.ts ├── resource.ts ├── rule.ts ├── slugSet.ts ├── subject.ts └── util.ts ├── test ├── condition.test.js ├── conditionSet.test.js ├── index.test.js └── slugSet.test.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,php}] 8 | indent_style = space 9 | 10 | [*.js] 11 | indent_size = 2 12 | 13 | [*.php] 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yaml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | pull_request: 4 | jobs: 5 | golangci: 6 | name: Lint Go 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: golangci-lint 11 | uses: golangci/golangci-lint-action@v2 12 | with: 13 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. 14 | version: v1.29 15 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | 2 | on: 3 | pull_request: {} 4 | workflow_dispatch: {} 5 | push: 6 | branches: 7 | - main 8 | - master 9 | schedule: 10 | - cron: '0 0 * * *' 11 | name: Semgrep config 12 | jobs: 13 | semgrep: 14 | name: semgrep/ci 15 | runs-on: ubuntu-20.04 16 | env: 17 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 18 | SEMGREP_URL: https://cloudflare.semgrep.dev 19 | SEMGREP_APP_URL: https://cloudflare.semgrep.dev 20 | SEMGREP_VERSION_CHECK_URL: https://cloudflare.semgrep.dev/api/check-version 21 | container: 22 | image: returntocorp/semgrep 23 | steps: 24 | - uses: actions/checkout@v3 25 | - run: semgrep ci 26 | -------------------------------------------------------------------------------- /.github/workflows/test-go.yaml: -------------------------------------------------------------------------------- 1 | name: Golang Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * 0" # weekly 8 | 9 | jobs: 10 | test-go: 11 | runs-on: ubuntu-latest 12 | name: Test Go (${{ matrix.go }}) 13 | strategy: 14 | matrix: 15 | go: ["1.12", "1.13", "1.14", "1.15"] 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set Up golang-${{ matrix.go }} 19 | uses: actions/setup-go@v2 20 | with: 21 | go-version: ${{ matrix.go }} 22 | - name: Get dependencies 23 | run: | 24 | go mod download 25 | go mod vendor 26 | go mod verify 27 | - name: Test 28 | run: | 29 | go test -race -v ./... 30 | -------------------------------------------------------------------------------- /.github/workflows/test-js.yaml: -------------------------------------------------------------------------------- 1 | name: JavaScript Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * 0" # weekly 8 | 9 | jobs: 10 | test-js: 11 | runs-on: ubuntu-latest 12 | name: Test JS (Node.JS v${{ matrix.node_js }}) 13 | strategy: 14 | matrix: 15 | node_js: ["12", "13", "14", "15"] 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-node@v2-beta 19 | with: 20 | node-version: ${{ matrix.node_js }} 21 | - name: Test 22 | run: | 23 | make -C ts/ test 24 | -------------------------------------------------------------------------------- /.github/workflows/test-php.yaml: -------------------------------------------------------------------------------- 1 | name: PHP Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * 0" # weekly 8 | 9 | jobs: 10 | test-php: 11 | runs-on: ubuntu-latest 12 | name: Test PHP (${{ matrix.php }}) 13 | strategy: 14 | matrix: 15 | php: ["7.3", "7.4", "8.0"] 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: shivammathur/setup-php@2.9.0 19 | with: 20 | php-version: ${{ matrix.php }} 21 | - name: Test 22 | run: | 23 | make -C php/ setup test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | vendor/ 3 | 4 | npm-debug.log 5 | /ts/node_modules 6 | /ts/build 7 | 8 | /php/vendor 9 | c.out 10 | *.pprof 11 | -------------------------------------------------------------------------------- /.phan/config.php: -------------------------------------------------------------------------------- 1 | [ 5 | './php/src' 6 | ], 7 | 'exclude_analysis_directory_list' => [ 8 | './php/vendor/psr/log' 9 | ] 10 | ]; 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Cloudflare. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 2. Redistributions in binary form must reproduce the above copyright notice, 9 | this list of conditions and the following disclaimer in the documentation 10 | and/or other materials provided with the distribution. 11 | 3. Neither the name of the copyright holder nor the names of its contributors 12 | may be used to endorse or promote products derived from this software without 13 | specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 19 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 22 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /OWNERS: -------------------------------------------------------------------------------- 1 | # a list of people who are actively maintaining this code 2 | nicholas@cloudflare.com 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # authr 2 | 3 | [![GO Build Status](https://github.com/cloudflare/authr/workflows/Golang%20Tests/badge.svg)](https://github.com/cloudflare/authr/actions?query=workflow%3A%22Golang+Tests%22) 4 | [![JS Build Status](https://github.com/cloudflare/authr/workflows/JavaScript%20Tests/badge.svg)](https://github.com/cloudflare/authr/actions?query=workflow%3A%22JavaScript+Tests%22) 5 | [![PHP Build Status](https://github.com/cloudflare/authr/workflows/PHP%20Tests/badge.svg)](https://github.com/cloudflare/authr/actions?query=workflow%3A%22PHP+Tests%22) 6 | 7 | a flexible, expressive, language-agnostic access-control framework. 8 | 9 | ## how it works 10 | 11 | _authr_ is an access-control framework. describing it as a "framework" is intentional because out of the box it is not going to automatically start securing your application. it is _extremely_ agnostic about quite a few things. it represents building blocks that can be orchestrated and put together in order to underpin an access-control system. by being so fundamental, it can fit almost any need when it comes to controlling access to specific resources in a particular system. 12 | 13 | ### vocabulary 14 | 15 | the framework itself has similar vocabulary to an [ABAC](https://en.wikipedia.org/wiki/Attribute-based_access_control) access-control system. the key terms are explained below. 16 | 17 | #### subject 18 | 19 | a _subject_ in this framework represents an entity that is capable of performing actions; an _actor_ if you will. in most cases this will represent a "user" or an "admin". 20 | 21 | #### resource 22 | 23 | a _resource_ represents an entity which can be acted upon. in a blogging application this might be a "post" or a "comment". those are things which can be acted upon by subjects wanting to "edit" them or "delete" them. it _is_ worth noting that subjects can also be resources — a "user" is something that can act and be acted upon. 24 | 25 | a _resource_ has **attributes** which can be analyzed by authr. for example, a `post` might have an attribute `id` which is `333`. or, a user might have an attribute `email` which would be `person@awesome.blog`. 26 | 27 | #### action 28 | 29 | an _action_ is a simple, terse description of what action is being attempted. if say a "user" was attempting to fix a typo in their "post", the _action_ might just be `edit`. 30 | 31 | #### rule 32 | 33 | a rule is a statement that composes conditions on resource and actions and specifies whether to allow or deny the attempt if the rule is matched. so, for example if you wanted to "allow" a subject to edit a private post, the JSON representation of the rule might look like this: 34 | 35 | ```json 36 | { 37 | "access": "allow", 38 | "where": { 39 | "action": "edit", 40 | "rsrc_type": "post", 41 | "rsrc_match": [["@type", "=", "private"]] 42 | } 43 | } 44 | ``` 45 | 46 | notice the lack of anything that specifies conditions on _who_ is actually performing the action. this is important; more on that in a second. 47 | 48 | ### agnosticism through interfaces 49 | 50 | across implementations, _authr_ requires that objects implement certain functionality so that its engine can properly analyze resources against a list of rules that _belong_ to a subject. 51 | 52 | once the essential objects in an application have implemented these interfaces, the essential question can finally be asked: **can this subject perform this action on this resource?** 53 | 54 | ```php 55 | getActor(); 69 | 70 | // get the resource 71 | $resource = $this->getUser($args['id']); 72 | 73 | // check permissions! 74 | if (!$this->authr->can($subject, 'update', $resource)) { 75 | throw new HTTPException\Forbidden('Permission denied!'); 76 | } 77 | 78 | ... 79 | } 80 | } 81 | ``` 82 | 83 | ### forming the subject 84 | 85 | _authr_ is most of the time identifiable as an ABAC framework. it relies on the ability to place certain conditions on the attributes of resources. there is however one _key_ difference: **there is no way to specify conditions on the subject in rule statements.** 86 | 87 | instead, the only way to specify that a specific actor is able to perform an action on a resource is to emit a rule from the returned list of rules that will match the action and allow it to happen. therefore, **a subject is only ever known as a list of rules.** 88 | 89 | ```go 90 | type Subject interface { 91 | GetRules() ([]*Rule, error) 92 | } 93 | ``` 94 | 95 | and instead of the rules being statically defined somewhere and needing to make the framework worry about where to retrieve the rules from, **rules belong to subjects** and are only ever retrieved from the subject. 96 | 97 | when permissions are checked, the framework will simply call a method available via an interface on the subject to retrieve a list of rules for that specific subject. then, it will iterate through that list until it matches a rule and return a boolean based on whether the rule wanted to allow or deny. 98 | 99 | #### why disallow inspection of attributes on the actor? 100 | 101 | by reducing actors to just a list of rules, it condenses all of the logic about what a subject is capable of to a single area and keeps it from being scattered all over an application's codebase. 102 | 103 | also, in traditional RBAC access-control systems, the notion of checking if a particular actor is in a certain "role" or checking the actors ID to determine access is incredibly brittle and "ages" a codebase. 104 | 105 | by having a single component which is responsible for answering the question of access-control, combined with being forced to clearly express what an actor can do with the authr rules, it leads to an incredible separation of concerns and a much more sustainable codebase. 106 | 107 | even if authr is not the access-control you choose, there is a distinct advantage to organizing access-control in your services this way, and authr makes sure that things stay that way. 108 | 109 | ### expressing permissions across service boundaries 110 | 111 | because the basic unit of permission in authr is a rule defined in JSON, it is possible to let other services do the access-control checks for their own purposes. 112 | 113 | an example of this internally at Cloudflare is in a administrative service. by having this permissions defined in JSON, we can simply transfer all the rules down to the front-end (in JavaScript) and allow the front-end to hide/show certain functionality _based_ on the permission of whoever is logged in. 114 | 115 | when you can have the front-end and the back-end of a service seamlessly agreeing with each other on access-control by only updating a single rule, once, it can lead to much easier maintainability. 116 | 117 | ## todo 118 | 119 | - [ ] create integration tests that ensure implementations agree with each other 120 | - [ ] finish go implementation 121 | - [ ] add examples of full apps using authr for access-contro 122 | - [ ] add documentation about the rules format 123 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 3.0.1 2 | -------------------------------------------------------------------------------- /authr.go: -------------------------------------------------------------------------------- 1 | package authr 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | var rcache regexpCache = &noopRegexpCache{} 11 | 12 | var ( 13 | operators = map[string]operator{ 14 | "=": operatorFunc(looseEquality), 15 | "!=": negate(operatorFunc(looseEquality)), 16 | "$in": in("$in", false), 17 | "$nin": in("$nin", true), 18 | "~=": operatorFunc(like), 19 | "&": intersect("&", false), 20 | "-": intersect("-", true), 21 | "~": ®expOperator{ci: false, inv: false}, 22 | "~*": ®expOperator{ci: true, inv: false}, 23 | "!~": ®expOperator{ci: false, inv: true}, 24 | "!~*": ®expOperator{ci: true, inv: true}, 25 | } 26 | ) 27 | 28 | const Version = "3.0.1" 29 | 30 | func init() { 31 | rcache = newRegexpListCache(5) 32 | } 33 | 34 | // Error is used for any error that occurs during authr's evaluation. They are 35 | // normally returned as a result of improperly constructed rules. 36 | type Error string 37 | 38 | func (e Error) Error() string { 39 | return string(e) 40 | } 41 | 42 | // Access represents a value which will distinguish a rule as either being 43 | // a restricting rule or a permitting one. 44 | type Access string 45 | 46 | const ( 47 | // Allow when set as the "access" on a rule will return true when the rule 48 | // is matched 49 | Allow Access = "allow" 50 | 51 | // Deny when set as the "access" on a rule will return false when the rule 52 | // is matched 53 | Deny Access = "deny" 54 | ) 55 | 56 | // logicalConjunction is the representation of the logic that joins condition 57 | // sets 58 | type logicalConjunction string 59 | 60 | func (l logicalConjunction) String() string { 61 | return string(l) 62 | } 63 | 64 | const ( 65 | // logicalAnd is used as a single key in a map to denote a set of conditions 66 | // that should be evaluated and all values should be true, to return true 67 | logicalAnd logicalConjunction = "$and" 68 | 69 | // logicalOr is used as a single key in a map to denote a set of conditions 70 | // that should be evaluated and any values should be true to return true 71 | logicalOr logicalConjunction = "$or" 72 | 73 | // ImpliedConjunction is the default conjunction on condition sets that do 74 | // not have an explicit conjunction 75 | ImpliedConjunction = logicalAnd 76 | ) 77 | 78 | // Subject is an abstract representation of an entity capable of performing 79 | // actions on resources. It is distinguished by have a method which is supposed 80 | // to return a list of rules that apply to the subject. 81 | type Subject interface { 82 | // GetRules simply retrieves a list of rules. The ordering of these rules 83 | // does matter. The rules themselves can be retrieve by any means necessary — 84 | // whether it be from a database or a config file; whatever works. 85 | GetRules() ([]*Rule, error) 86 | } 87 | 88 | // Resource is an abstract representation of an entity that is the target of 89 | // actions performed by subjects. Resources have a type and attributes. 90 | // 91 | // A "type" is what you might expect. If a blog were in need of an access 92 | // control system, the resource type for a post would simply be "post" and the 93 | // writers "author" perhaps. 94 | // 95 | // Attributes are any properties of a resource that can be evaluated. A post, 96 | // for example, can have "tags", which when being retrieve with 97 | // GetResourceAttribute() would return a slice of strings. 98 | // 99 | // Unknown or missing properties should simply return "nil" and not an error. 100 | type Resource interface { 101 | GetResourceType() (string, error) 102 | GetResourceAttribute(string) (interface{}, error) 103 | } 104 | 105 | // Rule represents the basic building block of an access control system. They 106 | // can be likened to a single statement in an access-control list (ACL). Rules 107 | // are entities which are said to "belong" to subjects in that they have been 108 | // granted or applied to subjects based on the state of a datastore or the state 109 | // of the subject themselves. 110 | // 111 | // Building rules in Go (instead of say Unmarshaling from JSON) looks like this: 112 | // r := new(Rule). 113 | // Access(Allow). 114 | // Where( 115 | // Action("delete"), 116 | // Not(ResourceType("user")), 117 | // ResourceMatch( 118 | // Cond("@id", "!=", "1"), 119 | // Or( 120 | // Cond("@status", "=", "active"), 121 | // Cond("@deleted_date", "=", nil), 122 | // ), 123 | // ), 124 | // ) 125 | // This can be quite verbose, externally. A suggestion to reduce the verbosity 126 | // might be to have a dedicate .go file that specifies rules where you can dot 127 | // import authr. (https://golang.org/ref/spec#Import_declarations) 128 | type Rule struct { 129 | access Access 130 | where struct { 131 | resourceType SlugSet 132 | resourceMatch ConditionSet 133 | action SlugSet 134 | } 135 | meta interface{} 136 | } 137 | 138 | func (r Rule) Access(at Access) *Rule { 139 | r.access = at 140 | return &r 141 | } 142 | 143 | func (r Rule) Meta(meta interface{}) *Rule { 144 | r.meta = meta 145 | return &r 146 | } 147 | 148 | func (r Rule) Where(action, resourceType SlugSet, conditions ConditionSet) *Rule { 149 | r.where.action = action 150 | r.where.resourceType = resourceType 151 | r.where.resourceMatch = conditions 152 | return &r 153 | } 154 | 155 | type slugSetMode int 156 | 157 | const ( 158 | allowlist slugSetMode = iota 159 | blocklist 160 | wildcard 161 | ) 162 | 163 | // SlugSet is an internal means of representing an arbitrary set of strings. The 164 | // "rsrc_type" and "action" sections of a rule have this type. 165 | type SlugSet struct { 166 | mode slugSetMode 167 | elements []string 168 | } 169 | 170 | func newSlugSet(slugs []string) SlugSet { 171 | ss := SlugSet{} 172 | if len(slugs) == 1 && slugs[0] == "*" { 173 | ss.mode = wildcard 174 | slugs = []string{} 175 | } 176 | ss.elements = slugs 177 | return ss 178 | } 179 | 180 | // ResourceType allows for the specification of resource types in a rule. The 181 | // default mode is an "allowlist". Use Not(Action(...)) to specify a "blocklist" 182 | func ResourceType(sset ...string) SlugSet { 183 | return newSlugSet(sset) 184 | } 185 | 186 | // Action allows for the specification of actions in a rule. The default mode is 187 | // an "allowlist". Use Not(Action(...)) to specify a "blocklist" 188 | func Action(sset ...string) SlugSet { 189 | return newSlugSet(sset) 190 | } 191 | 192 | // Not will return a copy of the provided SlugSet that will operate in a blocklist 193 | // mode. Meaning the elements if matched in a calculation will return "false" 194 | func Not(s SlugSet) SlugSet { 195 | s.mode = blocklist 196 | return s 197 | } 198 | 199 | func (s SlugSet) contains(b string) (bool, error) { 200 | if s.mode == wildcard { 201 | return true, nil 202 | } 203 | contained := false 204 | for _, a := range s.elements { 205 | if a == b { 206 | contained = true 207 | break 208 | } 209 | } 210 | if s.mode == blocklist { 211 | return !contained, nil 212 | } else if s.mode == allowlist { 213 | return contained, nil 214 | } 215 | panic(fmt.Sprintf("unknown slugset mode: '%v'", s.mode)) 216 | } 217 | 218 | type ConditionSet struct { 219 | conj logicalConjunction 220 | evaluators []Evaluator 221 | } 222 | 223 | // ResourceMatch is just a more readable way to start the rsrc_match section of 224 | // a rule. It uses the implied logical conjunction AND. 225 | func ResourceMatch(es ...Evaluator) ConditionSet { 226 | return And(es...).(ConditionSet) 227 | } 228 | 229 | // And returns an Evaluator that combines multiple Evaluators and will evaluate 230 | // the set of evaluators with the logical conjunction AND. The behavior of the 231 | // AND evaluator is to evaluate each sub-evaluator in order until one returns 232 | // false or all return true. Once it finds a negative evaluator, it will halt 233 | // and return — also known as short-circuiting. 234 | func And(subEvaluators ...Evaluator) Evaluator { 235 | return ConditionSet{ 236 | conj: logicalAnd, 237 | evaluators: subEvaluators, 238 | } 239 | } 240 | 241 | // Or returns an Evaluator that is just like And, except it evaluate with the OR 242 | // logical conjunction. Meaning it will evaluate until a sub-evaluator returns 243 | // true, and also short-circuit. 244 | func Or(subEvaluators ...Evaluator) Evaluator { 245 | return ConditionSet{ 246 | conj: logicalOr, 247 | evaluators: subEvaluators, 248 | } 249 | } 250 | 251 | func (c ConditionSet) evaluate(r Resource) (bool, error) { 252 | result := true // Vacuous truth: https://en.wikipedia.org/wiki/Vacuous_truth 253 | for _, eval := range c.evaluators { 254 | subresult, err := eval.evaluate(r) 255 | if err != nil { 256 | return false, err 257 | } 258 | if c.conj == logicalOr { 259 | if subresult { 260 | return true, nil // short-circuit 261 | } 262 | result = false 263 | } else if c.conj == logicalAnd { 264 | if !subresult { 265 | return false, nil // short-circuit 266 | } 267 | result = true 268 | } 269 | } 270 | return result, nil 271 | } 272 | 273 | // Can is the core access control computation function. It takes in a subject, 274 | // action, and resource. It will answer the question "Can this subject perform 275 | // this action on this resource?". 276 | func Can(s Subject, action string, r Resource) (bool, error) { 277 | var ( 278 | err error 279 | rules []*Rule 280 | resourceType string 281 | ) 282 | if rules, err = s.GetRules(); err != nil { 283 | return false, err 284 | } 285 | if resourceType, err = r.GetResourceType(); err != nil { 286 | return false, err 287 | } 288 | for _, rule := range rules { 289 | var ( 290 | ok bool 291 | err error 292 | ) 293 | if ok, err = rule.where.resourceType.contains(resourceType); err != nil { 294 | return false, err 295 | } 296 | if !ok { 297 | continue 298 | } 299 | if ok, err = rule.where.action.contains(action); err != nil { 300 | return false, err 301 | } 302 | if !ok { 303 | continue 304 | } 305 | if ok, err = rule.where.resourceMatch.evaluate(r); err != nil { 306 | return false, err 307 | } 308 | if !ok { 309 | continue 310 | } 311 | 312 | if rule.access == Allow { 313 | return true, nil 314 | } else if rule.access == Deny { 315 | return false, nil 316 | } 317 | 318 | // unknown type! 319 | panic(fmt.Sprintf("authr: unknown access type: '%s'", rule.access)) 320 | } 321 | 322 | // default to "deny all" 323 | return false, nil 324 | } 325 | 326 | // Evaluator is an abstract representation of something that is capable of 327 | // analyzing a Resource 328 | type Evaluator interface { 329 | evaluate(Resource) (bool, error) 330 | } 331 | 332 | type condition struct { 333 | left, right interface{} 334 | op string 335 | } 336 | 337 | // Cond is the basic unit of a resource match section of a rule. It represents 338 | // a single condition to be evaluated against a Resource. Constructing a 339 | // condition should be quite natural, like so: 340 | // 341 | // Cond("@id", "=", "123") 342 | // 343 | // The above condition says that the "id" attribute on a resource MUST equal 344 | // 123. References to resource attributes are prefixed with an "@" character 345 | // to distinguish them from literal values. To specify multiple conditions, use 346 | // the condition sets: 347 | // 348 | // And( 349 | // Cond("@status", "=", "active"), 350 | // Cond("@name", "$in", []string{ 351 | // "mike", 352 | // "jane", 353 | // "rachel", 354 | // }), 355 | // ) 356 | func Cond(left interface{}, op string, right interface{}) Evaluator { 357 | return condition{ 358 | left: left, 359 | right: right, 360 | op: op, 361 | } 362 | } 363 | 364 | func (c condition) evaluate(r Resource) (bool, error) { 365 | var ( 366 | _operator operator 367 | ok bool 368 | left, right interface{} 369 | err error 370 | ) 371 | if _operator, ok = operators[c.op]; !ok { 372 | return false, Error(fmt.Sprintf("unknown operator: '%s'", c.op)) 373 | } 374 | left, err = determineValue(r, c.left) 375 | if err != nil { 376 | return false, err 377 | } 378 | right, err = determineValue(r, c.right) 379 | if err != nil { 380 | return false, err 381 | } 382 | return _operator.compute(left, right) 383 | } 384 | 385 | func determineValue(r Resource, a interface{}) (interface{}, error) { 386 | if str, ok := a.(string); ok && len(str) > 0 { 387 | if str[0] == '@' { 388 | return r.GetResourceAttribute(str[1:]) 389 | } 390 | if len(str) >= 2 && str[0:2] == "\\@" { 391 | a = (str[1:]) 392 | } 393 | } 394 | return a, nil 395 | } 396 | 397 | type operator interface { 398 | compute(left, right interface{}) (bool, error) 399 | } 400 | 401 | type operatorFunc func(left, right interface{}) (bool, error) 402 | 403 | func (o operatorFunc) compute(left, right interface{}) (bool, error) { 404 | return o(left, right) 405 | } 406 | 407 | func negate(op operator) operator { 408 | return operatorFunc(func(left, right interface{}) (bool, error) { 409 | res, err := op.compute(left, right) 410 | if err != nil { 411 | return false, err 412 | } 413 | return !res, nil 414 | }) 415 | } 416 | 417 | func intersect(opsym string, inv bool) operator { 418 | return operatorFunc(func(left, right interface{}) (bool, error) { 419 | lv, rv := reflect.ValueOf(left), reflect.ValueOf(right) 420 | if !isArrayIsh(lv) { 421 | return false, Error(fmt.Sprintf("%s operator expects both operands to be an array or slice, received %T for left operand", opsym, left)) 422 | } 423 | if !isArrayIsh(rv) { 424 | return false, Error(fmt.Sprintf("%s operator expects both operands to be an array or slice, received %T for right operand", opsym, right)) 425 | } 426 | for i := 0; i < lv.Len(); i++ { 427 | for j := 0; j < rv.Len(); j++ { 428 | ok, err := looseEquality(lv.Index(i).Interface(), rv.Index(j).Interface()) 429 | if err != nil { 430 | return false, err 431 | } 432 | if ok { 433 | return !inv, nil 434 | } 435 | } 436 | } 437 | return inv, nil 438 | }) 439 | } 440 | 441 | func isArrayIsh(v reflect.Value) bool { 442 | k := v.Kind() 443 | return k == reflect.Array || k == reflect.Slice 444 | } 445 | 446 | func in(opsym string, inv bool) operator { 447 | return operatorFunc(func(left, right interface{}) (bool, error) { 448 | rv := reflect.ValueOf(right) 449 | if !isArrayIsh(rv) { 450 | return false, Error(fmt.Sprintf("%s operator expects the right operand to be an array or slice, received %T", opsym, right)) 451 | } 452 | for i := 0; i < rv.Len(); i++ { 453 | ok, err := looseEquality(left, rv.Index(i).Interface()) 454 | if err != nil { 455 | return false, err 456 | } 457 | if ok { 458 | return !inv, nil 459 | } 460 | } 461 | return inv, nil 462 | }) 463 | } 464 | 465 | func like(left, right interface{}) (bool, error) { 466 | sr, ok := right.(string) 467 | if !ok || len(sr) == 0 { 468 | return false, Error("right operand of the like operator (~=) must be a non-empty string") 469 | } 470 | var ( 471 | pleft string = "^" 472 | pright string = "$" 473 | ) 474 | if sr[0] == '*' { 475 | pleft = "" 476 | sr = sr[1:] 477 | } 478 | if sr[len(sr)-1] == '*' { 479 | pright = "" 480 | sr = sr[0 : len(sr)-2] 481 | } 482 | patstring := "(?i)" + pleft + regexp.QuoteMeta(sr) + pright 483 | r, ok := rcache.find(patstring) 484 | if !ok { 485 | r = regexp.MustCompile(patstring) 486 | rcache.add(patstring, r) 487 | } 488 | switch lv := left.(type) { 489 | case string: 490 | return r.MatchString(lv), nil 491 | default: 492 | return r.MatchString(fmt.Sprintf("%v", left)), nil 493 | } 494 | } 495 | 496 | type regexpOperator struct { 497 | ci, inv bool 498 | } 499 | 500 | func (r *regexpOperator) compute(left, right interface{}) (bool, error) { 501 | var pattern *regexp.Regexp 502 | if patstring, ok := right.(string); ok && len(patstring) > 0 { 503 | var ( 504 | err error 505 | ok bool 506 | ) 507 | if r.ci { 508 | patstring = "(?i)" + patstring 509 | } 510 | pattern, ok = rcache.find(patstring) 511 | if !ok { 512 | pattern, err = regexp.Compile(patstring) 513 | if err != nil { 514 | return false, err 515 | } 516 | rcache.add(patstring, pattern) 517 | } 518 | } else { 519 | return false, Error(fmt.Sprintf("right operand of the %s must be a non-empty string", r.operatorName())) 520 | } 521 | 522 | var ok bool 523 | // so, we can potentially avoid a LOT of allocations if we simply see 524 | // our left value is a string before jamming it into fmt.Sprintf and 525 | // needing to allocate 526 | switch l := left.(type) { 527 | case string: 528 | ok = pattern.MatchString(l) 529 | default: 530 | ok = pattern.MatchString(fmt.Sprintf("%+v", l)) 531 | } 532 | if r.inv { 533 | return !ok, nil 534 | } else { 535 | return ok, nil 536 | } 537 | } 538 | 539 | func (r *regexpOperator) operatorName() string { 540 | op := "~" 541 | name := []string{"regexp", "operator"} 542 | if r.ci { 543 | op = op + "*" 544 | name = append([]string{"case-insensitive"}, name...) 545 | } 546 | if r.inv { 547 | op = "!" + op 548 | name = append([]string{"inverse"}, name...) 549 | } 550 | 551 | return fmt.Sprintf("%s (%s)", strings.Join(name, " "), op) 552 | } 553 | 554 | func looseEquality(left, right interface{}) (bool, error) { 555 | switch l := left.(type) { 556 | case string: 557 | switch r := right.(type) { 558 | case string: 559 | return l == r, nil 560 | case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: 561 | return l == fmt.Sprintf("%v", r), nil 562 | case bool: 563 | return boolstringequal(r, l), nil 564 | case nil: 565 | return l == "", nil 566 | default: 567 | return false, Error(fmt.Sprintf("unsupported type in loose equality check: '%T'", r)) 568 | } 569 | case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: 570 | switch r := right.(type) { 571 | case string: 572 | return fmt.Sprintf("%v", l) == r, nil 573 | case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: 574 | return fmt.Sprintf("%v", l) == fmt.Sprintf("%v", r), nil 575 | case bool: 576 | n := numbertofloat64(l) 577 | if r { 578 | return n == float64(1), nil 579 | } else { 580 | return n == float64(0), nil 581 | } 582 | case nil: 583 | return numbertofloat64(l) == float64(0), nil 584 | default: 585 | return false, Error(fmt.Sprintf("unsupported type in loose equality check: '%T'", r)) 586 | } 587 | case bool: 588 | switch r := right.(type) { 589 | case string: 590 | return boolstringequal(l, r), nil 591 | case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: 592 | n := numbertofloat64(r) 593 | if l { 594 | return n == float64(1), nil 595 | } else { 596 | return n == float64(0), nil 597 | } 598 | case bool: 599 | return l == r, nil 600 | case nil: 601 | return !l, nil 602 | default: 603 | return false, Error(fmt.Sprintf("unsupported type in loose equality check: '%T'", r)) 604 | } 605 | case nil: 606 | switch r := right.(type) { 607 | case string: 608 | return r == "", nil 609 | case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: 610 | return numbertofloat64(r) == float64(0), nil 611 | case bool: 612 | return !r, nil 613 | case nil: 614 | return true, nil 615 | default: 616 | return false, Error(fmt.Sprintf("unsupported type in loose equality check: '%T'", r)) 617 | } 618 | default: 619 | return false, Error(fmt.Sprintf("unsupported type in loose equality check: '%T'", l)) 620 | } 621 | } 622 | 623 | func boolstringequal(a bool, b string) bool { 624 | if !a { 625 | return b == "" || b == "0" 626 | } else { 627 | return len(b) > 0 && b != "0" 628 | } 629 | } 630 | 631 | func numbertofloat64(n interface{}) float64 { 632 | switch _n := n.(type) { 633 | case int: 634 | return float64(_n) 635 | case int8: 636 | return float64(_n) 637 | case int16: 638 | return float64(_n) 639 | case int32: 640 | return float64(_n) 641 | case int64: 642 | return float64(_n) 643 | case uint: 644 | return float64(_n) 645 | case uint8: 646 | return float64(_n) 647 | case uint16: 648 | return float64(_n) 649 | case uint32: 650 | return float64(_n) 651 | case uint64: 652 | return float64(_n) 653 | case float32: 654 | return float64(_n) 655 | case float64: 656 | return _n 657 | } 658 | panic(fmt.Sprintf("numbertofloat64 received non-numeric type: %T", n)) 659 | } 660 | -------------------------------------------------------------------------------- /authr_test.go: -------------------------------------------------------------------------------- 1 | package authr 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "math/rand" 8 | "os" 9 | "runtime" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | type equalitytestscen struct { 16 | n string 17 | a, b interface{} 18 | r bool 19 | } 20 | 21 | func getEqualityTestScenarios() []equalitytestscen { 22 | return []equalitytestscen{ 23 | { 24 | n: `"5"==uint32(5)=>true`, 25 | a: "5", 26 | b: uint32(5), 27 | r: true, 28 | }, 29 | { 30 | n: `"hi"=="hi"=>true`, 31 | a: "hi", 32 | b: "hi", 33 | r: true, 34 | }, 35 | { 36 | n: `"hi"=="hello"=>false`, 37 | a: "hi", 38 | b: "hello", 39 | r: false, 40 | }, 41 | { 42 | n: `"hi"==true=>true`, 43 | a: "hi", 44 | b: true, 45 | r: true, 46 | }, 47 | { 48 | n: "float64(3.1415)==float32(3.1415)=>true", 49 | a: float64(3.1415), 50 | b: float32(3.1415), 51 | r: true, 52 | }, 53 | { 54 | n: "float32(0)==nil=>true", 55 | a: float32(0), 56 | b: nil, 57 | r: true, 58 | }, 59 | { 60 | n: "int32(1)==true=>true", 61 | a: int32(1), 62 | b: true, 63 | r: true, 64 | }, 65 | { 66 | n: "int16(0)==false=>true", 67 | a: int16(0), 68 | b: false, 69 | r: true, 70 | }, 71 | { 72 | n: `""==nil=>true`, 73 | a: "", 74 | b: nil, 75 | r: true, 76 | }, 77 | { 78 | n: `"hi"==nil=>false`, 79 | a: "hi", 80 | b: nil, 81 | r: false, 82 | }, 83 | { 84 | n: `true=="0"=>false`, 85 | a: true, 86 | b: "0", 87 | r: false, 88 | }, 89 | { 90 | n: `true==true=>true`, 91 | a: true, 92 | b: true, 93 | r: true, 94 | }, 95 | { 96 | n: `true==false=>false`, 97 | a: true, 98 | b: false, 99 | r: false, 100 | }, 101 | { 102 | n: `true==nil=>false`, 103 | a: true, 104 | b: nil, 105 | r: false, 106 | }, 107 | { 108 | n: `false==nil=>true`, 109 | a: false, 110 | b: nil, 111 | r: true, 112 | }, 113 | { 114 | n: `nil==nil=>true`, 115 | a: nil, 116 | b: nil, 117 | r: true, 118 | }, 119 | { 120 | n: `false=>""=>true`, 121 | a: false, 122 | b: "", 123 | r: true, 124 | }, 125 | { 126 | n: `false=>"0"=>true`, 127 | a: false, 128 | b: "0", 129 | r: true, 130 | }, 131 | } 132 | } 133 | 134 | func BenchmarkLooseEquality(b *testing.B) { 135 | for _, s := range getEqualityTestScenarios() { 136 | b.Run(s.n, func(b *testing.B) { 137 | b.ReportAllocs() 138 | b.ResetTimer() 139 | for i := 0; i < b.N; i++ { 140 | _, err := looseEquality(s.a, s.b) 141 | if err != nil { 142 | b.Fatalf("unexpected error: %s", err) 143 | } 144 | } 145 | }) 146 | } 147 | } 148 | 149 | type regexptestscen struct { 150 | n, op, p string 151 | v interface{} 152 | } 153 | 154 | func getregexpscens() []regexptestscen { 155 | return []regexptestscen{ 156 | {n: "int(33)~*^foo$=>false", op: "~*", p: "^foo$", v: 33}, 157 | {n: `"foo-one"~*^Foo=>true`, op: "~*", p: "^Foo", v: "foo-one"}, 158 | {n: `"bar-two"~^Bar=>false`, op: "~", p: "^Bar", v: "bar-two"}, 159 | } 160 | } 161 | 162 | func BenchmarkRegexpOperatorSerial(b *testing.B) { 163 | regexpOperatorBenchmark(b, func(fn func()) func(*testing.B) { 164 | return func(b *testing.B) { 165 | b.ReportAllocs() 166 | for i := 0; i < b.N; i++ { 167 | fn() 168 | } 169 | } 170 | }) 171 | } 172 | 173 | func BenchmarkRegexpOperatorParallel(b *testing.B) { 174 | regexpOperatorBenchmark(b, func(fn func()) func(*testing.B) { 175 | return func(b *testing.B) { 176 | b.ReportAllocs() 177 | b.SetParallelism(runtime.NumCPU()) 178 | b.RunParallel(func(pb *testing.PB) { 179 | for pb.Next() { 180 | fn() 181 | } 182 | }) 183 | } 184 | }) 185 | } 186 | 187 | func regexpOperatorBenchmark(b *testing.B, fn func(func()) func(*testing.B)) { 188 | for _, s := range getregexpscens() { 189 | op, ok := operators[s.op] 190 | if !ok { 191 | b.Fatalf("unknown operator: %s", s.op) 192 | } 193 | b.Run(s.n, fn(func() { 194 | _, err := op.compute(s.v, s.p) 195 | if err != nil { 196 | b.Fatalf("unexpected error: %s", err) 197 | } 198 | })) 199 | } 200 | } 201 | 202 | // This should test how the regexp cache responds to random access and eviction 203 | func BenchmarkRegexpOperatorThrash(b *testing.B) { 204 | tests := getregexpscens() 205 | l := len(tests) 206 | r := rand.New(rand.NewSource(5)) 207 | b.ReportAllocs() 208 | b.ResetTimer() 209 | for i := 0; i < b.N; i++ { 210 | t := tests[r.Intn(l)] 211 | op, ok := operators[t.op] 212 | if !ok { 213 | b.Fatalf("unknown operator: %s", t.op) 214 | } 215 | _, _ = op.compute(t.v, t.p) 216 | } 217 | } 218 | 219 | func TestLooseEquality(t *testing.T) { 220 | scenarios := getEqualityTestScenarios() 221 | for _, s := range scenarios { 222 | t.Run(s.n, func(t *testing.T) { 223 | var ( 224 | ok bool 225 | err error 226 | ) 227 | ok, err = looseEquality(s.a, s.b) 228 | require.Nil(t, err) 229 | require.Equal(t, s.r, ok) 230 | // flip the arguments 231 | ok, err = looseEquality(s.b, s.a) 232 | require.Nil(t, err) 233 | require.Equal(t, s.r, ok, "equality result was not equal when flipping arguments") 234 | }) 235 | } 236 | } 237 | 238 | type testResource struct { 239 | rtype string 240 | attributes map[string]interface{} 241 | 242 | rterr, raerr error // errors returned from either method 243 | } 244 | 245 | func (t testResource) GetResourceType() (string, error) { 246 | if t.rterr != nil { 247 | return "", t.rterr 248 | } 249 | return t.rtype, nil 250 | } 251 | 252 | func (t testResource) GetResourceAttribute(key string) (interface{}, error) { 253 | if t.raerr != nil { 254 | return nil, t.raerr 255 | } 256 | return t.attributes[key], nil 257 | } 258 | 259 | func TestInOperator(t *testing.T) { 260 | t.Parallel() 261 | tr := testResource{ 262 | rtype: "user", 263 | attributes: map[string]interface{}{ 264 | "id": int32(23), 265 | "groups": []string{"alpha", "bravo"}, 266 | "status": "active", 267 | }, 268 | } 269 | t.Run("should loosely match id attribute in polymorphic slice", func(t *testing.T) { 270 | cond := Cond("@id", "$in", []interface{}{1, "31", "55", float64(23)}) 271 | ok, err := cond.evaluate(tr) 272 | require.Nil(t, err, "unexpected error") 273 | require.True(t, ok) 274 | }) 275 | t.Run("should return err when right operand is scalar", func(t *testing.T) { 276 | _, err := Cond("@id", "$in", 5).evaluate(tr) 277 | require.NotNil(t, err) 278 | }) 279 | t.Run("should evaluate to false when value not found", func(t *testing.T) { 280 | ok, err := Cond("foo", "$in", "@groups").evaluate(tr) 281 | require.Nil(t, err, "unexpected error") 282 | require.False(t, ok) 283 | }) 284 | } 285 | 286 | func TestNotInOperator(t *testing.T) { 287 | t.Parallel() 288 | tr := testResource{ 289 | rtype: "post", 290 | attributes: map[string]interface{}{ 291 | "tags": []string{"one", "two"}, 292 | "id": int32(345), 293 | "user_id": int32(23), 294 | }, 295 | } 296 | t.Run("should loosely match id attribute in polymorphic slice", func(t *testing.T) { 297 | ok, err := Cond("@id", "$nin", []interface{}{1, "31", "55", float64(23)}).evaluate(tr) 298 | require.Nil(t, err) 299 | require.True(t, ok) 300 | }) 301 | t.Run("should return err when right operand is scalar", func(t *testing.T) { 302 | _, err := Cond("@user_id", "$nin", map[int]int{4: 2}).evaluate(tr) 303 | if err == nil { 304 | t.Errorf("test expected an error, got nil") 305 | } 306 | }) 307 | t.Run("should evaluate to false when value found in array/slice", func(t *testing.T) { 308 | ok, err := Cond("two", "$nin", "@tags").evaluate(tr) 309 | if err != nil { 310 | t.Errorf("test failed with unexpected error: %s", err) 311 | } else if ok { 312 | t.Errorf("test failed") 313 | } 314 | }) 315 | } 316 | 317 | func TestIntersectOperator(t *testing.T) { 318 | t.Parallel() 319 | r := testResource{ 320 | rtype: "user", 321 | attributes: map[string]interface{}{ 322 | "tags": []string{"one", "two"}, 323 | "is_serious": true, 324 | }, 325 | } 326 | t.Run("should return false when arrays do not intersect", func(t *testing.T) { 327 | ok, err := Cond("@tags", "&", []interface{}{1.0, 2}).evaluate(r) 328 | assertNilError(t, err) 329 | assertNotOkay(t, ok) 330 | }) 331 | t.Run("should return true when arrays do intersect", func(t *testing.T) { 332 | ok, err := Cond("@tags", "&", []interface{}{2, "one"}).evaluate(r) 333 | assertNilError(t, err) 334 | assertOkay(t, ok) 335 | }) 336 | t.Run("should return err when left operand is not array-ish", func(t *testing.T) { 337 | _, err := Cond("@is_serious", "&", []int{1, 2}).evaluate(r) 338 | assertError(t, err) 339 | }) 340 | t.Run("should return err when right operand is not array-ish", func(t *testing.T) { 341 | _, err := Cond([]int{2, 1}, "&", "@is_serious").evaluate(r) 342 | assertError(t, err) 343 | }) 344 | } 345 | 346 | func TestDifferenceOperator(t *testing.T) { 347 | t.Parallel() 348 | r := testResource{ 349 | rtype: "account", 350 | attributes: map[string]interface{}{ 351 | "groups": []string{"pro", "22.56"}, 352 | "balance": float64(23.123), 353 | }, 354 | } 355 | t.Run("should return true when arrays do not intersect", func(t *testing.T) { 356 | ok, err := Cond("@groups", "-", []string{"ent"}).evaluate(r) 357 | assertNilError(t, err) 358 | assertOkay(t, ok) 359 | }) 360 | t.Run("should return false when arrays do intersect", func(t *testing.T) { 361 | ok, err := Cond("@groups", "-", []interface{}{float32(22.56)}).evaluate(r) 362 | assertNilError(t, err) 363 | assertNotOkay(t, ok) 364 | }) 365 | t.Run("should return err when left operand is not array-sh", func(t *testing.T) { 366 | _, err := Cond("@balance", "-", []string{"23.123"}).evaluate(r) 367 | assertError(t, err) 368 | }) 369 | t.Run("should return err when right operand is not array-ish", func(t *testing.T) { 370 | _, err := Cond([]string{"pop"}, "-", "@balance").evaluate(r) 371 | assertError(t, err) 372 | }) 373 | } 374 | 375 | func assertError(t *testing.T, err error) { 376 | t.Helper() 377 | if err == nil { 378 | t.Fatalf("expected error, got nil") 379 | } 380 | } 381 | 382 | func assertNilError(t *testing.T, err error) { 383 | t.Helper() 384 | if err != nil { 385 | t.Fatalf("unexpected error: %s", err) 386 | } 387 | } 388 | 389 | func assertNotOkay(t *testing.T, ok bool) { 390 | t.Helper() 391 | if ok { 392 | t.Fatalf("unexpected okay-ness") 393 | } 394 | } 395 | 396 | func assertOkay(t *testing.T, ok bool) { 397 | t.Helper() 398 | if !ok { 399 | t.Fatalf("unexpected non-okay-ness") 400 | } 401 | } 402 | 403 | func TestLikeOperator(t *testing.T) { 404 | t.Parallel() 405 | tr := testResource{ 406 | rtype: "cart", 407 | attributes: map[string]interface{}{ 408 | "name": "linda's cart", 409 | "tag": "wish_list", 410 | }, 411 | } 412 | t.Run("should match beginning of string", func(t *testing.T) { 413 | ok, err := Cond("@name", "~=", "Linda*").evaluate(tr) 414 | if err != nil { 415 | t.Errorf("test failed with unexpected error: %s", err) 416 | } else if !ok { 417 | t.Errorf("test failed") 418 | } 419 | }) 420 | t.Run("should not match a string that does NOT end with a specified pattern", func(t *testing.T) { 421 | ok, err := Cond("@tag", "~=", "*bla").evaluate(tr) 422 | if err != nil { 423 | t.Errorf("test failed with unexpected error: %s", err) 424 | } else if ok { 425 | t.Errorf("test failed") 426 | } 427 | }) 428 | } 429 | 430 | type testSubject struct { 431 | err error 432 | rules []*Rule 433 | } 434 | 435 | func (t testSubject) GetRules() ([]*Rule, error) { 436 | if t.err != nil { 437 | return nil, t.err 438 | } 439 | return t.rules, nil 440 | } 441 | 442 | func TestFull(t *testing.T) { 443 | actor := &testSubject{ 444 | rules: []*Rule{ 445 | new(Rule).Access(Deny).Where( 446 | Action("delete"), 447 | ResourceType("zone"), 448 | ResourceMatch(Cond("@attr", "!=", nil)), 449 | ), 450 | new(Rule).Access(Allow).Where( 451 | Action("delete"), 452 | ResourceType("zone"), 453 | ResourceMatch( 454 | Or( 455 | Cond("@id", "=", 321), 456 | Cond("@zone_name", "~*", `\.com$`), 457 | ), 458 | ), 459 | ), 460 | }, 461 | } 462 | resource := testResource{ 463 | rtype: "zone", 464 | attributes: map[string]interface{}{ 465 | "id": "123", 466 | "zone_name": "example.com", 467 | }, 468 | } 469 | ok, err := Can(actor, "delete", resource) 470 | if err != nil { 471 | t.Fatalf("unexpected error: %s", err) 472 | } 473 | if !ok { 474 | t.Fatalf("unexpected access denial") 475 | } 476 | } 477 | 478 | type testCan_case struct { 479 | g, s string 480 | subject Subject 481 | act string 482 | resource Resource 483 | errcheck func(error) bool 484 | ok bool 485 | 486 | noskip bool // for debugging tests 487 | } 488 | 489 | func testCan_getCases() []testCan_case { 490 | sub := func(r []*Rule) Subject { 491 | return testSubject{rules: r} 492 | } 493 | jsonlist := func(r ...string) Subject { 494 | rules := make([]*Rule, len(r)) 495 | for i := 0; i < len(r); i++ { 496 | rule := new(Rule) 497 | if err := json.Unmarshal([]byte(r[i]), rule); err != nil { 498 | panic(err.Error()) 499 | } 500 | rules[i] = rule 501 | } 502 | if rules[0].access == "" { 503 | panic("something went wrong when unmarshaling JSON rules for tests") 504 | } 505 | return sub(rules) 506 | } 507 | msi := func(a ...interface{}) map[string]interface{} { 508 | o := make(map[string]interface{}) 509 | for i, v := range a { 510 | if i%2 != 0 { 511 | o[a[i-1].(string)] = v 512 | } 513 | } 514 | return o 515 | } 516 | testerr := errors.New("testerr") 517 | return []testCan_case{ 518 | { 519 | g: "an error being returned from subject.GetRules()", 520 | s: "return error", 521 | subject: testSubject{err: testerr}, 522 | act: "testcan1", 523 | resource: testResource{rtype: "thing"}, 524 | errcheck: func(e error) bool { return e == testerr }, 525 | }, 526 | { 527 | g: "an error being returned from resource.GetResourceType()", 528 | s: "return error", 529 | subject: testSubject{rules: []*Rule{}}, 530 | act: "testcan2", 531 | resource: testResource{rterr: testerr}, 532 | errcheck: func(e error) bool { return e == testerr }, 533 | }, 534 | { 535 | g: "a subject with NO rules", 536 | s: "default to deny all", 537 | subject: testSubject{rules: []*Rule{}}, 538 | act: "testcan3", 539 | resource: testResource{rtype: "thing", attributes: msi("id", 5)}, 540 | ok: false, 541 | }, 542 | { 543 | g: "a subject with no rsrc_type matching rule", 544 | s: "deny", 545 | subject: jsonlist( 546 | `{"access":"allow","where":{"rsrc_type":"thing","rsrc_match":[],"action":"testcan4"}}`, 547 | ), 548 | act: "testcan4", 549 | resource: testResource{rtype: "widget" /* <- different! */, attributes: msi("id", 5)}, 550 | ok: false, 551 | }, 552 | { 553 | g: "a subject with no action matching rule", 554 | s: "deny", 555 | subject: jsonlist( 556 | `{"access":"allow","where":{"rsrc_type":"thing","rsrc_match":[],"action":"NOTtestcan6"}}`, 557 | ), 558 | act: "testcan6", 559 | resource: testResource{rtype: "thing" /* <- same! */, attributes: msi("id", 5)}, 560 | ok: false, 561 | }, 562 | { 563 | g: "a subject with no resource attribute matching rule", 564 | s: "deny", 565 | subject: jsonlist( 566 | `{"access":"allow","where":{"rsrc_type":"thing","rsrc_match":[["@id","=",3]],"action":"testcan7"}}`, 567 | ), 568 | act: "testcan7", 569 | resource: testResource{rtype: "thing", attributes: msi("id", 5)}, 570 | ok: false, 571 | }, 572 | { 573 | g: "a subject with a matching rule that denies", 574 | s: "deny", 575 | subject: jsonlist( 576 | `{"access":"deny","where":{"rsrc_type":"thing","rsrc_match":[["@id","=",5]],"action":"testcan7"}}`, 577 | ), 578 | act: "testcan7", 579 | resource: testResource{rtype: "thing", attributes: msi("id", 5)}, 580 | ok: false, 581 | }, 582 | { 583 | g: "a subject with a matching rule that allows", 584 | s: "allow", 585 | subject: jsonlist( 586 | `{"access":"allow","where":{"rsrc_type":"thing","rsrc_match":[["@id","=",5]],"action":"testcan7"}}`, 587 | ), 588 | act: "testcan7", 589 | resource: testResource{rtype: "thing", attributes: msi("id", 5)}, 590 | ok: true, 591 | }, 592 | { 593 | g: "a subject with a blocklist", 594 | s: "not match the provided action", 595 | subject: sub([]*Rule{ 596 | new(Rule). 597 | Access(Allow). 598 | Where( 599 | Not(Action("delete")), 600 | ResourceType("thing"), 601 | ResourceMatch(Cond("@id", "=", 5)), 602 | ), 603 | }), 604 | act: "delete", 605 | resource: testResource{rtype: "thing", attributes: msi("id", 5)}, 606 | ok: false, 607 | }, 608 | } 609 | } 610 | 611 | func BenchmarkCan(b *testing.B) { 612 | for _, c := range testCan_getCases() { 613 | b.Run(fmt.Sprintf("given %s, Can() should %s", c.g, c.s), func(b *testing.B) { 614 | b.ReportAllocs() 615 | b.ResetTimer() 616 | for i := 0; i < b.N; i++ { 617 | _, _ = Can(c.subject, c.act, c.resource) 618 | } 619 | }) 620 | } 621 | } 622 | 623 | func TestCan(t *testing.T) { 624 | for _, c := range testCan_getCases() { 625 | t.Run(fmt.Sprintf("given %s, Can() should %s", c.g, c.s), func(t *testing.T) { 626 | // Set this env var and put the "noskip: true" on whatever test you 627 | // want to concentrate on :) 628 | if os.Getenv("TEST_CAN_SKIP") != "" && !c.noskip { 629 | t.SkipNow() 630 | return 631 | } 632 | ok, err := Can(c.subject, c.act, c.resource) 633 | if err != nil { 634 | require.NotNil(t, c.errcheck, "unexpected error returned: %s", err.Error()) 635 | require.True(t, c.errcheck(err), "error returned from Can() did not match expected error") 636 | require.False(t, ok, "Can() returned an error AND true, this should never happen") 637 | return 638 | } 639 | require.Nil(t, c.errcheck, "expected error to be returned, none returned") 640 | require.Equal(t, c.ok, ok, "Can() returned wrong result (no error)") 641 | }) 642 | } 643 | } 644 | -------------------------------------------------------------------------------- /authrutil/struct_resource.go: -------------------------------------------------------------------------------- 1 | package authrutil 2 | 3 | import ( 4 | "go/ast" 5 | "reflect" 6 | 7 | "github.com/cloudflare/authr/v3" 8 | ) 9 | 10 | type structResource struct { 11 | typ string 12 | v reflect.Value 13 | } 14 | 15 | func (s structResource) GetResourceType() (string, error) { 16 | return s.typ, nil 17 | } 18 | 19 | func (s structResource) GetResourceAttribute(key string) (interface{}, error) { 20 | if !ast.IsExported(key) { 21 | return nil, nil 22 | } 23 | f, ok := s.v.Type().FieldByName(key) 24 | if !ok { 25 | return nil, nil 26 | } 27 | return s.v.FieldByIndex(f.Index).Interface(), nil 28 | } 29 | 30 | var _ authr.Resource = structResource{} 31 | 32 | // StructResource accepts a string that indicates the "rsrc_type" of a resource, 33 | // and the struct that needs to be acceptable as an authr.Resource. This 34 | // function will panic if v is NOT a struct. 35 | func StructResource(typ string, v interface{}) authr.Resource { 36 | if reflect.TypeOf(v).Kind() != reflect.Struct { 37 | panic("authrutil.StructResource provided with a non-struct value") 38 | } 39 | return structResource{typ: typ, v: reflect.ValueOf(v)} 40 | } 41 | -------------------------------------------------------------------------------- /authrutil/struct_resource_test.go: -------------------------------------------------------------------------------- 1 | package authrutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestStructResource(t *testing.T) { 10 | t.Run("should panic if given a non-struct value", func(t *testing.T) { 11 | require.Panics(t, func() { 12 | StructResource("a", 5) 13 | }) 14 | }) 15 | t.Run("should return the provided resource type", func(t *testing.T) { 16 | rt, err := StructResource("a", struct{}{}).GetResourceType() 17 | require.Nil(t, err) 18 | require.Equal(t, "a", rt) 19 | }) 20 | t.Run("should retrieve correct values from struct", func(t *testing.T) { 21 | sr := StructResource("thing", struct { 22 | Foo int 23 | Bar string 24 | }{Foo: 5, Bar: "boom!"}) 25 | avfoo, err := sr.GetResourceAttribute("Foo") 26 | require.Nil(t, err) 27 | require.Equal(t, 5, avfoo.(int)) 28 | avbar, err := sr.GetResourceAttribute("Bar") 29 | require.Nil(t, err) 30 | require.Equal(t, "boom!", avbar.(string)) 31 | }) 32 | t.Run("should return for nonexistent struct fields", func(t *testing.T) { 33 | sr := StructResource("thing", struct { 34 | Foo int 35 | Bar string 36 | }{Foo: 7, Bar: "bam!"}) 37 | avne, err := sr.GetResourceAttribute("Baz") 38 | require.Nil(t, err) 39 | require.Nil(t, avne) 40 | }) 41 | t.Run("should not be able to read un-exported stuct fields", func(t *testing.T) { 42 | sr := StructResource("thing", struct { 43 | foo int 44 | }{foo: 9}) 45 | avnil, err := sr.GetResourceAttribute("foo") 46 | require.Nil(t, err) 47 | require.Nil(t, avnil) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudflare/authr", 3 | "description": "a flexible, expressive, language-agnostic access-control framework", 4 | "type": "library", 5 | "require-dev": { 6 | }, 7 | "archive": { 8 | "exclude": [ 9 | "/js", 10 | "/contrib", 11 | "/*.go" 12 | ] 13 | }, 14 | "authors": [ 15 | { 16 | "name": "nick comer", 17 | "email": "nicholas@cloudflare.com" 18 | } 19 | ], 20 | "autoload": { 21 | "psr-4": { 22 | "Cloudflare\\": "php/src/" 23 | } 24 | }, 25 | "autoload-dev": { 26 | "psr-4": { 27 | "Cloudflare\\Test\\": "php/test/" 28 | } 29 | }, 30 | "minimum-stability": "stable", 31 | "config": { 32 | "vendor-dir": "php/vendor" 33 | }, 34 | "require": { 35 | "psr/log": "^1.0", 36 | "phpunit/phpunit": "^9.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /contrib/semver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # https://github.com/fsaintjacques/semver-tool 4 | 5 | set -o errexit -o nounset -o pipefail 6 | 7 | SEMVER_REGEX="^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$" 8 | 9 | PROG=semver 10 | PROG_VERSION=2.0.0 11 | 12 | USAGE="\ 13 | Usage: 14 | $PROG bump (major|minor|patch|prerel |build ) 15 | $PROG compare 16 | $PROG --help 17 | $PROG --version 18 | 19 | Arguments: 20 | A version must match the following regex pattern: 21 | \"${SEMVER_REGEX}\". 22 | In english, the version must match X.Y.Z(-PRERELEASE)(+BUILD) 23 | where X, Y and Z are positive integers, PRERELEASE is an optionnal 24 | string composed of alphanumeric characters and hyphens and 25 | BUILD is also an optional string composed of alphanumeric 26 | characters and hyphens. 27 | 28 | See definition. 29 | 30 | String that must be composed of alphanumeric characters and hyphens. 31 | 32 | String that must be composed of alphanumeric characters and hyphens. 33 | 34 | Options: 35 | -v, --version Print the version of this tool. 36 | -h, --help Print this help message. 37 | 38 | Commands: 39 | bump Bump by one of major, minor, patch, prerel, build 40 | or a forced potentialy conflicting version. The bumped version is 41 | shown to stdout. 42 | 43 | compare Compare with , output to stdout the 44 | following values: -1 if is newer, 0 if equal, 1 if 45 | older." 46 | 47 | 48 | function error { 49 | echo -e "$1" >&2 50 | exit 1 51 | } 52 | 53 | function usage-help { 54 | error "$USAGE" 55 | } 56 | 57 | function usage-version { 58 | echo -e "${PROG}: $PROG_VERSION" 59 | exit 0 60 | } 61 | 62 | function validate-version { 63 | local version=$1 64 | if [[ "$version" =~ $SEMVER_REGEX ]]; then 65 | # if a second argument is passed, store the result in var named by $2 66 | if [ "$#" -eq "2" ]; then 67 | local major=${BASH_REMATCH[1]} 68 | local minor=${BASH_REMATCH[2]} 69 | local patch=${BASH_REMATCH[3]} 70 | local prere=${BASH_REMATCH[4]} 71 | local build=${BASH_REMATCH[5]} 72 | eval "$2=(\"$major\" \"$minor\" \"$patch\" \"$prere\" \"$build\")" 73 | else 74 | echo "$version" 75 | fi 76 | else 77 | error "version $version does not match the semver scheme 'X.Y.Z(-PRERELEASE)(+BUILD)'. See help for more information." 78 | fi 79 | } 80 | 81 | function compare-version { 82 | validate-version "$1" V 83 | validate-version "$2" V_ 84 | 85 | # MAJOR, MINOR and PATCH should compare numericaly 86 | for i in 0 1 2; do 87 | local diff=$((${V[$i]} - ${V_[$i]})) 88 | if [[ $diff -lt 0 ]]; then 89 | echo -1; return 0 90 | elif [[ $diff -gt 0 ]]; then 91 | echo 1; return 0 92 | fi 93 | done 94 | 95 | # PREREL should compare with the ASCII order. 96 | if [[ -z "${V[3]}" ]] && [[ -n "${V_[3]}" ]]; then 97 | echo -1; return 0; 98 | elif [[ -n "${V[3]}" ]] && [[ -z "${V_[3]}" ]]; then 99 | echo 1; return 0; 100 | elif [[ -n "${V[3]}" ]] && [[ -n "${V_[3]}" ]]; then 101 | if [[ "${V[3]}" > "${V_[3]}" ]]; then 102 | echo 1; return 0; 103 | elif [[ "${V[3]}" < "${V_[3]}" ]]; then 104 | echo -1; return 0; 105 | fi 106 | fi 107 | 108 | echo 0 109 | } 110 | 111 | function command-bump { 112 | local new; local version; local sub_version; local command; 113 | 114 | case $# in 115 | 2) case $1 in 116 | major|minor|patch) command=$1; version=$2;; 117 | *) usage-help;; 118 | esac ;; 119 | 3) case $1 in 120 | prerel|build) command=$1; sub_version=$2 version=$3 ;; 121 | *) usage-help;; 122 | esac ;; 123 | *) usage-help;; 124 | esac 125 | 126 | validate-version "$version" parts 127 | # shellcheck disable=SC2154 128 | local major="${parts[0]}" 129 | local minor="${parts[1]}" 130 | local patch="${parts[2]}" 131 | local prere="${parts[3]}" 132 | local build="${parts[4]}" 133 | 134 | case "$command" in 135 | major) new="$((major + 1)).0.0";; 136 | minor) new="${major}.$((minor + 1)).0";; 137 | patch) new="${major}.${minor}.$((patch + 1))";; 138 | prerel) new=$(validate-version "${major}.${minor}.${patch}-${sub_version}");; 139 | build) new=$(validate-version "${major}.${minor}.${patch}${prere}+${sub_version}");; 140 | *) usage-help ;; 141 | esac 142 | 143 | echo "$new" 144 | exit 0 145 | } 146 | 147 | function command-compare { 148 | local v; local v_; 149 | 150 | case $# in 151 | 2) v=$(validate-version "$1"); v_=$(validate-version "$2") ;; 152 | *) usage-help ;; 153 | esac 154 | 155 | compare-version "$v" "$v_" 156 | exit 0 157 | } 158 | 159 | case $# in 160 | 0) echo "Unknown command: $*"; usage-help;; 161 | esac 162 | 163 | case $1 in 164 | --help|-h) echo -e "$USAGE"; exit 0;; 165 | --version|-v) usage-version ;; 166 | bump) shift; command-bump "$@";; 167 | compare) shift; command-compare "$@";; 168 | *) echo "Unknown arguments: $*"; usage-help;; 169 | esac 170 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package authr is an application-level (layer 7) access control framework. 2 | package authr 3 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cloudflare/authr/v3 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/davecgh/go-spew v1.1.1 // indirect 7 | github.com/stretchr/testify v1.6.1 8 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 8 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 9 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 10 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 14 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 15 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= 16 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | -------------------------------------------------------------------------------- /json.go: -------------------------------------------------------------------------------- 1 | package authr 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | propAccess = "access" 12 | propWhere = "where" 13 | propWhereRsrcType = "rsrc_type" 14 | propWhereRsrcMatch = "rsrc_match" 15 | propWhereAction = "action" 16 | propMeta = "$meta" 17 | 18 | jtypeBool = "JSON boolean" 19 | jtypeNumber = "JSON number" 20 | jtypeString = "JSON string" 21 | jtypeArray = "JSON array" 22 | jtypeObject = "JSON object" 23 | jtypeNull = "JSON null" 24 | ) 25 | 26 | func (r *Rule) UnmarshalJSON(data []byte) error { 27 | *r = Rule{} 28 | var v interface{} 29 | if err := json.Unmarshal(data, &v); err != nil { 30 | return err 31 | } 32 | o, ok := v.(map[string]interface{}) 33 | if !ok { 34 | return Error(fmt.Sprintf("expecting %s for rule definition, got %s", jtypeObject, typename(v))) 35 | } 36 | if ai, ok := o[propAccess]; ok { 37 | a, ok := ai.(string) 38 | if !ok { 39 | return jsonInvalidType([]string{propAccess}, ai, jtypeString) 40 | } 41 | switch a { 42 | case "allow": 43 | r.access = Allow 44 | case "deny": 45 | r.access = Deny 46 | default: 47 | return jsonInvalidPropValue([]string{propAccess}, `"allow" or "deny"`, fmt.Sprintf(`"%s"`, a)) 48 | } 49 | } else { 50 | return jsonMissingProperty([]string{propAccess}) 51 | } 52 | if wi, ok := o[propWhere]; ok { 53 | w, ok := wi.(map[string]interface{}) 54 | if !ok { 55 | return jsonInvalidType([]string{propWhere}, wi, jtypeObject) 56 | } 57 | var err error 58 | err = unmarshalSlugSet(&r.where.resourceType, propWhereRsrcType, w) 59 | if err != nil { 60 | return err 61 | } 62 | err = unmarshalSlugSet(&r.where.action, propWhereAction, w) 63 | if err != nil { 64 | return err 65 | } 66 | csi, ok := w[propWhereRsrcMatch] 67 | if !ok { 68 | return jsonMissingProperty([]string{propWhere, propWhereRsrcMatch}) 69 | } 70 | r.where.resourceMatch, err = unmarshalConditionSet([]string{propWhere, propWhereRsrcMatch}, csi) 71 | if err != nil { 72 | return err 73 | } 74 | } else { 75 | return jsonMissingProperty([]string{propWhere}) 76 | } 77 | if meta, ok := o[propMeta]; ok { 78 | r.meta = meta 79 | } 80 | return nil 81 | } 82 | 83 | func unmarshalConditionSet(path []string, csi interface{}) (ConditionSet, error) { 84 | cs := ConditionSet{} 85 | cs.evaluators = []Evaluator{} 86 | switch _cs := csi.(type) { 87 | case map[string]interface{}: 88 | logic, csinneri, err := unwrapKeywordMap(path, _cs, logicalAnd.String(), logicalOr.String()) 89 | if err != nil { 90 | return ConditionSet{}, err 91 | } 92 | switch logic { 93 | case logicalAnd.String(): 94 | cs.conj = logicalAnd 95 | case logicalOr.String(): 96 | cs.conj = logicalOr 97 | } 98 | path = append(path, logic) 99 | switch csinner := csinneri.(type) { 100 | case []interface{}: 101 | var err error 102 | cs.evaluators, err = unmarshalNestedConditions( 103 | append(path, cs.conj.String()), 104 | csinner, 105 | ) 106 | if err != nil { 107 | return ConditionSet{}, err 108 | } 109 | default: 110 | return ConditionSet{}, jsonInvalidType(path, csinneri, jtypeArray) 111 | } 112 | case []interface{}: 113 | cs.conj = logicalAnd 114 | var err error 115 | cs.evaluators, err = unmarshalNestedConditions(path, _cs) 116 | if err != nil { 117 | return ConditionSet{}, err 118 | } 119 | default: 120 | return ConditionSet{}, jsonInvalidType(path, csi, jtypeObject, jtypeArray) 121 | } 122 | return cs, nil 123 | } 124 | 125 | func unmarshalNestedConditions(path []string, csinner []interface{}) ([]Evaluator, error) { 126 | evals := make([]Evaluator, len(csinner)) 127 | for i, v := range csinner { 128 | if jarr, ok := v.([]interface{}); ok && len(jarr) == 3 && isstring(jarr[1]) { 129 | // smells like a condition! 130 | evals[i] = Cond(jarr[0], jarr[1].(string), jarr[2]) 131 | continue 132 | } 133 | var err error 134 | evals[i], err = unmarshalConditionSet(append(path, strconv.Itoa(i)), v) 135 | if err != nil { 136 | return nil, err 137 | } 138 | } 139 | return evals, nil 140 | } 141 | 142 | func isstring(v interface{}) bool { 143 | _, ok := v.(string) 144 | return ok 145 | } 146 | 147 | func unwrapKeywordMap(path []string, msi map[string]interface{}, validKeys ...string) (string, interface{}, error) { 148 | if len(msi) == 1 { 149 | var k string 150 | for _k := range msi { 151 | k = _k 152 | break 153 | } 154 | for _, vk := range validKeys { 155 | if k == vk { 156 | return k, msi[k], nil 157 | } 158 | } 159 | } 160 | err := Error( 161 | fmt.Sprintf( 162 | `invalid value for property "%s": expected %s with only one of the these key(s): "%s"`, 163 | strings.Join(path, "."), 164 | jtypeObject, 165 | strings.Join(validKeys, `", "`), 166 | ), 167 | ) 168 | return "", nil, err 169 | } 170 | 171 | func unmarshalSlugSet(ss *SlugSet, prop string, w map[string]interface{}) error { 172 | path := []string{propWhere, prop} 173 | ssi, ok := w[prop] 174 | if !ok { 175 | return jsonMissingProperty(path) 176 | } 177 | switch _ss := ssi.(type) { 178 | case map[string]interface{}: 179 | _, ssni, err := unwrapKeywordMap(path, _ss, "$not") 180 | if err != nil { 181 | return err 182 | } 183 | path = append(path, "$not") 184 | ss.mode = blocklist 185 | switch ssn := ssni.(type) { 186 | case []interface{}: 187 | // empty slug set IS allowed if the slugset is a blocklist. 188 | err := unmarshalStringSlice(path, ss, ssn) 189 | if err != nil { 190 | return err 191 | } 192 | case string: 193 | ss.elements = []string{ssn} 194 | default: 195 | return jsonInvalidType(path, ssni, jtypeArray, jtypeString) 196 | } 197 | case []interface{}: 198 | // empty slug set is NOT allowed if the slug set is not a blocklist 199 | // the rule would never match anything 200 | if len(_ss) == 0 { 201 | return jsonInvalidPropValue(path, "non-empty array", "empty array") 202 | } 203 | err := unmarshalStringSlice(path, ss, _ss) 204 | if err != nil { 205 | return err 206 | } 207 | case string: 208 | if _ss == "*" { 209 | ss.mode = wildcard 210 | ss.elements = []string{} 211 | } else { 212 | ss.elements = []string{_ss} 213 | } 214 | default: 215 | return jsonInvalidType(path, ssi, jtypeObject, jtypeArray, jtypeString) 216 | } 217 | return nil 218 | } 219 | 220 | func unmarshalStringSlice(path []string, ss *SlugSet, jarr []interface{}) error { 221 | ss.elements = make([]string, len(jarr)) 222 | for i, v := range jarr { 223 | // TODO(nick): check for empty strings 224 | s, ok := v.(string) 225 | if !ok { 226 | return jsonInvalidType(append(path, fmt.Sprintf("%v", i)), v, jtypeString) 227 | } 228 | ss.elements[i] = s 229 | } 230 | return nil 231 | } 232 | 233 | func lexicalJoin(a []string) string { 234 | switch len(a) { 235 | case 0: 236 | return "" 237 | case 1: 238 | return a[0] 239 | case 2: 240 | return a[0] + " or " + a[1] 241 | default: 242 | return strings.Join(a[:len(a)-1], ", ") + " or " + a[len(a)-1] 243 | } 244 | } 245 | 246 | type ruleUnmarshalError struct { 247 | path []string 248 | } 249 | 250 | type jsonInvalidTypeError struct { 251 | ruleUnmarshalError 252 | needTypes []string 253 | v interface{} 254 | } 255 | 256 | func (j jsonInvalidTypeError) Error() string { 257 | return fmt.Sprintf(`expecting %s for property "%s", got %s`, lexicalJoin(j.needTypes), strings.Join(j.path, "."), typename(j.v)) 258 | } 259 | 260 | type jsonMissingPropertyError struct { 261 | ruleUnmarshalError 262 | } 263 | 264 | func (j jsonMissingPropertyError) Error() string { 265 | return fmt.Sprintf(`invalid rule; missing required property "%s"`, strings.Join(j.path, ".")) 266 | } 267 | 268 | func jsonMissingProperty(path []string) error { 269 | return jsonMissingPropertyError{ 270 | ruleUnmarshalError: ruleUnmarshalError{path: path}, 271 | } 272 | } 273 | 274 | type jsonInvalidPropertyValueError struct { 275 | ruleUnmarshalError 276 | expecting, got string 277 | } 278 | 279 | func (j jsonInvalidPropertyValueError) Error() string { 280 | return fmt.Sprintf(`invalid value for property "%s", expecting %s, got %s`, strings.Join(j.path, "."), j.expecting, j.got) 281 | } 282 | 283 | func jsonInvalidPropValue(path []string, e, g string) error { 284 | return jsonInvalidPropertyValueError{ 285 | ruleUnmarshalError: ruleUnmarshalError{path: path}, 286 | expecting: e, 287 | got: g, 288 | } 289 | } 290 | 291 | func jsonInvalidType(path []string, v interface{}, needType ...string) error { 292 | return jsonInvalidTypeError{ruleUnmarshalError: ruleUnmarshalError{path: path}, needTypes: needType, v: v} 293 | } 294 | 295 | func typename(v interface{}) string { 296 | switch v.(type) { 297 | case bool: 298 | return jtypeBool 299 | case float64: 300 | return jtypeNumber 301 | case string: 302 | return jtypeString 303 | case []interface{}: 304 | return jtypeArray 305 | case map[string]interface{}: 306 | return jtypeObject 307 | case nil: 308 | return jtypeNull 309 | } 310 | panic(fmt.Sprintf("unexpected go type found in unmarshaled value: %T", v)) 311 | } 312 | -------------------------------------------------------------------------------- /json_test.go: -------------------------------------------------------------------------------- 1 | package authr 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | type unmarshalScenario struct { 11 | n, d, err string 12 | r *Rule 13 | } 14 | 15 | func unmarshalScenarios() []unmarshalScenario { 16 | return []unmarshalScenario{ 17 | { 18 | n: `should err; totally invalid JSON`, 19 | d: `[{111`, 20 | err: "invalid character '1' looking for beginning of object key string", 21 | }, 22 | { 23 | n: `should err; invalid JSON type for rule def`, 24 | d: `[1, 2, 3]`, 25 | err: "expecting JSON object for rule definition, got JSON array", 26 | }, 27 | { 28 | n: `should err; missing "where" property`, 29 | d: `{"access":"allow"}`, 30 | err: `invalid rule; missing required property "where"`, 31 | }, 32 | { 33 | n: `should err; missing "where" property`, 34 | d: `{"access":"deny"}`, 35 | err: `invalid rule; missing required property "where"`, 36 | }, 37 | { 38 | n: `should err; invalid json type for "where" prop`, 39 | d: `{"access":"deny","where":4}`, 40 | err: `expecting JSON object for property "where", got JSON number`, 41 | }, 42 | { 43 | n: `should err; missing "where.rsrc_type" prop`, 44 | d: `{"access":"deny","where":{}}`, 45 | err: `invalid rule; missing required property "where.rsrc_type"`, 46 | }, 47 | { 48 | n: `should err; invalid json type for "where.rsrc_type" prop`, 49 | d: `{"access":"deny","where":{"rsrc_type":4}}`, 50 | err: `expecting JSON object, JSON array or JSON string for property "where.rsrc_type", got JSON number`, 51 | }, 52 | { 53 | n: `should err; invalid value for "where.rsrc_type" prop, missing "$not"`, 54 | d: `{"access":"deny","where":{"rsrc_type":{}}}`, 55 | err: `invalid value for property "where.rsrc_type": expected JSON object with only one of the these key(s): "$not"`, 56 | }, 57 | { 58 | n: `should err; invalid value for "where.rsrc_type" prop, extra keys`, 59 | d: `{"access":"deny","where":{"rsrc_type":{"$not":[],"$foo":3}}}`, 60 | err: `invalid value for property "where.rsrc_type": expected JSON object with only one of the these key(s): "$not"`, 61 | }, 62 | { 63 | n: `should err; invalid value for "where.rsrc_type.$not" prop`, 64 | d: `{"access":"deny","where":{"rsrc_type":{"$not":4}}}`, 65 | err: `expecting JSON array or JSON string for property "where.rsrc_type.$not", got JSON number`, 66 | }, 67 | { 68 | n: `should err; invalid value for "where.rsrc_type.$not" prop, polymorphic array`, 69 | d: `{"access":"deny","where":{"rsrc_type":{"$not":["foo",5]}}}`, 70 | err: `expecting JSON string for property "where.rsrc_type.$not.1", got JSON number`, 71 | }, 72 | { 73 | n: `should err; invalid value for "where.rsrc_type", polymorphic array`, 74 | d: `{"access":"deny","where":{"rsrc_type":["foo",false]}}`, 75 | err: `expecting JSON string for property "where.rsrc_type.1", got JSON boolean`, 76 | }, 77 | { 78 | n: `should err; but "where.rsrc_type" prop ok, case 1`, 79 | d: `{"access":"deny","where":{"rsrc_type":"zone"}}`, 80 | err: `invalid rule; missing required property "where.action"`, 81 | }, 82 | { 83 | n: `should err; but "where.rsrc_type" prop ok, case 2`, 84 | d: `{"access":"deny","where":{"rsrc_type":["zone","dns_record"]}}`, 85 | err: `invalid rule; missing required property "where.action"`, 86 | }, 87 | { 88 | n: `should err; but "where.rsrc_type" prop ok, case 3`, 89 | d: `{"access":"deny","where":{"rsrc_type":{"$not":"zone"}}}`, 90 | err: `invalid rule; missing required property "where.action"`, 91 | }, 92 | { 93 | n: `should err; missing "where.rsrc_match" prop`, 94 | d: `{"access":"deny","where":{"action":"delete","rsrc_type":"zone"}}`, 95 | err: `invalid rule; missing required property "where.rsrc_match"`, 96 | }, 97 | { 98 | n: `should err; invalid value for "where.rsrc_match" prop`, 99 | d: `{"access":"deny","where":{"action":"delete","rsrc_type":"zone","rsrc_match":4}}`, 100 | err: `expecting JSON object or JSON array for property "where.rsrc_match", got JSON number`, 101 | }, 102 | { 103 | n: `should err; invalid value for "where.rsrc_match" prop`, 104 | d: `{"access":"deny","where":{"action":"delete","rsrc_type":"zone","rsrc_match":{}}}`, 105 | err: `invalid value for property "where.rsrc_match": expected JSON object with only one of the these key(s): "$and", "$or"`, 106 | }, 107 | { 108 | n: `should err; invalid value for "where.rsrc_match" prop`, 109 | d: `{"access":"deny","where":{"action":"delete","rsrc_type":"zone","rsrc_match":{"$not":[]}}}`, 110 | err: `invalid value for property "where.rsrc_match": expected JSON object with only one of the these key(s): "$and", "$or"`, 111 | }, 112 | { 113 | n: `should err; missing "access" property`, 114 | d: `{"where":{"action":"delete","rsrc_type":"zone","rsrc_match":[]}}`, 115 | err: `invalid rule; missing required property "access"`, 116 | }, 117 | { 118 | n: `should err; invalid "access" prop type`, 119 | d: `{"access":2,"where":{"action":"delete","rsrc_type":"zone","rsrc_match":[]}}`, 120 | err: `expecting JSON string for property "access", got JSON number`, 121 | }, 122 | { 123 | n: `should err; invalid "access" property`, 124 | d: `{"access":"allw","where":{"action":"delete","rsrc_type":"zone","rsrc_match":[]}}`, 125 | err: `invalid value for property "access", expecting "allow" or "deny", got "allw"`, 126 | }, 127 | { 128 | n: `should err; invalid "where.rsrc_type" prop`, 129 | d: `{"access":"deny","where":{"action":"delete","rsrc_type":[],"rsrc_match":[["@id","&",[1,2,3]]]}}`, 130 | err: `invalid value for property "where.rsrc_type", expecting non-empty array, got empty array`, 131 | }, 132 | { 133 | n: "ok case 1", 134 | d: `{"access":"deny","where":{"action":"delete","rsrc_type":"zone","rsrc_match":[["@id","&",[1,2,3]]]}}`, 135 | r: new(Rule). 136 | Access(Deny). 137 | Where( 138 | Action("delete"), 139 | ResourceType("zone"), 140 | ResourceMatch( 141 | Cond("@id", "&", []interface{}{ 142 | float64(1), 143 | float64(2), 144 | float64(3), 145 | }), 146 | ), 147 | ), 148 | }, 149 | { 150 | n: "ok case 2", 151 | d: `{"access":"allow","where":{"action":{"$not":["delete","update"]},"rsrc_type":"zone","rsrc_match":[{"$or":[["@id","&",[1,2,3]],["@status","$in",["A","V"]]]}]}}`, 152 | r: new(Rule). 153 | Access(Allow). 154 | Where( 155 | Not(Action("delete", "update")), 156 | ResourceType("zone"), 157 | ResourceMatch( 158 | Or( 159 | Cond("@id", "&", []interface{}{ 160 | float64(1), 161 | float64(2), 162 | float64(3), 163 | }), 164 | Cond("@status", "$in", []interface{}{"A", "V"}), 165 | ), 166 | ), 167 | ), 168 | }, 169 | } 170 | } 171 | 172 | func TestRuleUnmarshalJSON(t *testing.T) { 173 | for _, s := range unmarshalScenarios() { 174 | t.Run(s.n, func(t *testing.T) { 175 | r := new(Rule) 176 | err := json.Unmarshal([]byte(s.d), r) 177 | if err != nil { 178 | if s.err == "" { 179 | t.Fatalf("unexpected error: %s", err.Error()) 180 | } else { 181 | if s.err != err.Error() { 182 | t.Fatalf(`error expectation failed: "%s" != "%s"`, s.err, err.Error()) 183 | } 184 | } 185 | return 186 | } 187 | require.Equal(t, s.r, r) 188 | }) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | RELEASE_TYPE ?= patch 2 | 3 | GO_BIN := $(shell which go) 4 | 5 | clean: 6 | rm -rf ./vendor 7 | make --directory ./ts clean 8 | make --directory ./php clean 9 | find . '(' -name \*.out -o -name \*.pprof ')' -exec rm -v {} + 10 | 11 | setup: 12 | make --directory ./ts setup 13 | make --directory ./php setup 14 | 15 | test: 16 | $(GO_BIN) test -race . 17 | make --directory ./ts test 18 | make --directory ./php test 19 | 20 | release: 21 | ./contrib/semver bump $(RELEASE_TYPE) `cat VERSION` > VERSION 22 | make --directory ./ts release 23 | git add VERSION js/package.json 24 | git commit -m "Release v`cat VERSION`" 25 | git tag `cat VERSION` 26 | git push --tags 27 | git push --all 28 | 29 | .PHONY: setup clean test release 30 | -------------------------------------------------------------------------------- /php/Makefile: -------------------------------------------------------------------------------- 1 | # dependencies 2 | # - composer 3 | 4 | setup: vendor/.ok 5 | 6 | vendor/.ok: 7 | composer install --working-dir=../ 8 | touch $@ 9 | 10 | clean: 11 | rm -rf vendor 12 | 13 | test: 14 | ./vendor/bin/phpunit 15 | 16 | .PHONY: setup clean test 17 | -------------------------------------------------------------------------------- /php/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | ./test/ 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /php/src/Authr.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 30 | } 31 | 32 | /** 33 | * {@inheritDoc} 34 | */ 35 | public function can(SubjectInterface $subject, string $action, ResourceInterface $resource): bool 36 | { 37 | $rules = $subject->getRules(); 38 | $rt = $resource->getResourceType(); 39 | $this->logger->info('checking permissions', ['action' => $action, 'rsrc_type' => $rt]); 40 | $i = 0; 41 | foreach ($rules as $rule) { 42 | if (!$rule->resourceTypes()->contains($rt)) { 43 | $this->logger->debug('continuing permission check, rsrc_type mismatch', ['rule_no' => ++$i]); 44 | continue; 45 | } 46 | if (!$rule->actions()->contains($action)) { 47 | $this->logger->debug('continuing permission check, action mismatch', ['rule_no' => ++$i]); 48 | continue; 49 | } 50 | if (!$rule->conditions()->evaluate($resource)) { 51 | $this->logger->debug('continuing permission check, rsrc_match mismatch', ['rule_no' => ++$i]); 52 | continue; 53 | } 54 | 55 | if ($rule->access() === Rule::ALLOW) { 56 | $this->logger->info('rule matched! allowing action...', ['rule_no' => ++$i]); 57 | return true; 58 | } else if ($rule->access() === Rule::DENY) { 59 | $this->logger->info('rule matched! denying action...', ['rule_no' => ++$i]); 60 | return false; 61 | } 62 | 63 | // unknown type! 64 | throw new Exception\RuntimeException(sprintf('Rule access set to unknown value: %s', strval($rule->access()))); 65 | } 66 | 67 | $this->logger->info('no rules matched. denying action...', ['action' => $action, 'rsrc_type' => $rt]); 68 | // default to "deny all" 69 | return false; 70 | } 71 | 72 | /** 73 | * {@inheritDoc} 74 | */ 75 | public function validateRule($definition): void 76 | { 77 | if (!static::isMap($definition)) { 78 | throw new Exception\ValidationException('Rule definition must be a map'); 79 | } 80 | if (array_key_exists('access', $definition)) { 81 | if (!in_array($definition['access'], [Rule::ALLOW, Rule::DENY], true)) { 82 | throw new Exception\ValidationException("Invalid access type: '{$definition['access']}'"); 83 | } 84 | if (array_key_exists('where', $definition)) { 85 | $where = $definition['where']; 86 | } else { 87 | $where = null; 88 | } 89 | } else { 90 | throw new Exception\ValidationException('Rule must specify an access type'); 91 | } 92 | if (!static::isMap($where)) { 93 | throw new Exception\ValidationException('Rule where clause must be a map'); 94 | } 95 | $needWhereKeys = ['rsrc_type', 'rsrc_match', 'action']; 96 | $haveWhereKeys = array_keys($where); 97 | $diff = array_diff($needWhereKeys, $haveWhereKeys); 98 | if (!empty($diff)) { 99 | $needKeys = implode("', '", $diff); 100 | throw new Exception\ValidationException("Missing key(s) '$needKeys' in rule where clause"); 101 | } 102 | $diff = array_diff($haveWhereKeys, $needWhereKeys); 103 | if (!empty($diff)) { 104 | $unknownKeys = implode("', '", $diff); 105 | throw new Exception\ValidationException("Unknown key(s) '$unknownKeys' in rule where clause"); 106 | } 107 | $this->validateRuleSlugSet($where, 'action'); 108 | $this->validateRuleSlugSet($where, 'rsrc_type'); 109 | $this->validateRuleConditionSet($where['rsrc_match']); 110 | } 111 | 112 | /** 113 | * @param mixed[] $where 114 | * @param string $ssKey 115 | * @return void 116 | */ 117 | private function validateRuleSlugSet($where, string $ssKey): void 118 | { 119 | $ss = $where[$ssKey]; 120 | if (static::isMap($ss)) { 121 | $needssKeys = ['$not']; 122 | $havessKeys = array_keys($ss); 123 | $diff = array_diff($needssKeys, $havessKeys); 124 | if (!empty($diff)) { 125 | $needKeys = implode("', '", $diff); 126 | throw new Exception\ValidationException("Missing key '\$not' in '$ssKey' section of rule where clause"); 127 | } 128 | $diff = array_diff($havessKeys, $needssKeys); 129 | if (!empty($diff)) { 130 | $unknownKeys = implode("', '", $diff); 131 | throw new Exception\ValidationException("Unknown key(s) '$unknownKeys' in '$ssKey' section of rule where clause"); 132 | } 133 | $ss = $ss['$not']; 134 | } 135 | if (static::isList($ss)) { 136 | foreach ($ss as $value) { 137 | if (!is_string($value)) { 138 | $uexptype = gettype($value); 139 | throw new Exception\ValidationException("Unexpected value type '$uexptype' found in '$ssKey' section of rule where clause"); 140 | } 141 | } 142 | } elseif (!is_string($ss)) { 143 | $uexptype = gettype($ss); 144 | throw new Exception\ValidationException("Unexpected value type '$uexptype' found in '$ssKey' section of rule where clause"); 145 | } 146 | } 147 | 148 | /** 149 | * @param mixed[] $conditions 150 | * @return void 151 | */ 152 | private function validateRuleConditionSet($conditions): void 153 | { 154 | if (static::isMap($conditions, static::EMPTY_IS_NOT_ASSOCIATIVE)) { 155 | $haveCondKeys = array_keys($conditions); 156 | if (count($haveCondKeys) > 1) { 157 | $otherKeys = implode("', '", array_values(array_filter($haveCondKeys, function ($key) { return $key !== '$or' && $key !== '$and'; }))); 158 | throw new Exception\ValidationException("Unknown key(s) '$otherKeys' found in a condition set in the 'rsrc_match' section of the rule where clause"); 159 | } else if (count($haveCondKeys) === 0) { 160 | throw new Exception\ValidationException("Empty map found in a set of conditions in the 'rsrc_match' section of the rule where clause"); 161 | } 162 | $logic = $haveCondKeys[0]; 163 | if ($logic !== '$and' && $logic !== '$or') { 164 | throw new Exception\ValidationException("Resource conditions (rsrc_match) must have a single key ('\$and' OR '\$or', got '$logic') if it is a map"); 165 | } 166 | $conditions = $conditions[$logic]; 167 | } 168 | if (!static::isList($conditions)) { 169 | throw new Exception\ValidationException('Resource conditions (rsrc_match) is invalid'); 170 | } 171 | foreach ($conditions as $idx => $value) { 172 | if (static::isList($value) && count($value) === 3 && is_string($value[1])) { 173 | // this is a single condition, just check the operator 174 | if (!in_array($value[1], static::getValidOperators(), true)) { 175 | throw new Exception\ValidationException("Unknown operator found in a condition in 'rsrc_match': '{$value[1]}'"); 176 | } 177 | } else { 178 | $this->validateRuleConditionSet($value); 179 | } 180 | } 181 | } 182 | 183 | /** 184 | * Retrieve a list of valid condition operators 185 | * 186 | * @return string[] 187 | */ 188 | private static function getValidOperators() 189 | { 190 | if (is_null(static::$validOperators)) { 191 | static::$validOperators = array_map(function ($cc) { return (new $cc)->jsonSerialize(); }, Condition::OPERATORS_CLASSES); 192 | } 193 | return static::$validOperators; 194 | } 195 | 196 | /** @internal */ 197 | const EMPTY_IS_ASSOCIATIVE = 0; 198 | 199 | /** @internal */ 200 | const EMPTY_IS_NOT_ASSOCIATIVE = 1; 201 | 202 | /** 203 | * isMap will inspect an array and determine if it is an associative array 204 | * with non-numeric keys. Optionally set how isMap should interpret empty 205 | * arrays with $mode. 206 | * 207 | * @param array $arr 208 | * @param int $mode 209 | * @return boolean 210 | */ 211 | private static function isMap($arr, $mode = self::EMPTY_IS_ASSOCIATIVE) 212 | { 213 | if (!is_array($arr)) { 214 | return false; 215 | } 216 | if (count($arr) === 0) { 217 | if ($mode === static::EMPTY_IS_ASSOCIATIVE) { 218 | return true; 219 | } else if ($mode === static::EMPTY_IS_NOT_ASSOCIATIVE) { 220 | return false; 221 | } 222 | throw new \InvalidArgumentException('invalid mode arg in isMap'); 223 | } 224 | $i = 0; 225 | foreach ($arr as $key => $value) { 226 | if ($key !== $i) { 227 | return true; 228 | } 229 | ++$i; 230 | } 231 | 232 | return false; 233 | } 234 | 235 | /** 236 | * isList will inspect an array and determine if it is a list array with only 237 | * ordered, integer, numeric keys. Optionally set how isList should interpret 238 | * empty arrays with $mode. 239 | * 240 | * @param array $arr 241 | * @param int $mode 242 | * @return boolean 243 | */ 244 | private static function isList($arr, $mode = self::EMPTY_IS_NOT_ASSOCIATIVE) 245 | { 246 | if (!is_array($arr)) { 247 | return false; 248 | } 249 | return !static::isMap($arr, $mode); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /php/src/Authr/Condition.php: -------------------------------------------------------------------------------- 1 | operator = static::$operators[$op]; 47 | $this->left = $left; 48 | $this->right = $right; 49 | } 50 | 51 | /** 52 | * Evaluate a condition on a resource 53 | * 54 | * @param \Cloudflare\Authr\ResourceInterface $resource 55 | * @return boolean 56 | */ 57 | public function evaluate(ResourceInterface $resource): bool 58 | { 59 | return call_user_func( 60 | $this->operator, 61 | static::determineValue($resource, $this->left), 62 | static::determineValue($resource, $this->right) 63 | ); 64 | } 65 | 66 | /** 67 | * Determine if the value passed is referring to an attribute on the resource 68 | * or is just the literal value. 69 | * 70 | * @param \Cloudflare\Authr\ResourceInterface $resource 71 | * @param mixed $value 72 | * @return mixed 73 | */ 74 | private static function determineValue(ResourceInterface $resource, $value) 75 | { 76 | if (is_string($value) && strlen($value) > 1) { 77 | if ($value[0] === '@') { 78 | return $resource->getResourceAttribute(substr($value, 1)); 79 | } 80 | // check for escaped '@' characters and remove the escape character 81 | if (substr($value, 0, 2) === '\@') { 82 | return substr($value, 1); 83 | } 84 | } 85 | return $value; 86 | } 87 | 88 | protected static function initDefaultOperators() 89 | { 90 | if (is_null(static::$operators)) { 91 | foreach (static::OPERATORS_CLASSES as $handlerClass) { 92 | /** @var \Cloudflare\Authr\Condition\OperatorInterface */ 93 | $handler = new $handlerClass; 94 | static::$operators[$handler->jsonSerialize()] = $handler; 95 | } 96 | } 97 | } 98 | 99 | public function jsonSerialize() 100 | { 101 | return [$this->left, $this->operator->jsonSerialize(), $this->right]; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /php/src/Authr/Condition/Operator/ArrayDifference.php: -------------------------------------------------------------------------------- 1 | 0; 16 | } 17 | 18 | public function jsonSerialize() 19 | { 20 | return '&'; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /php/src/Authr/Condition/Operator/Equals.php: -------------------------------------------------------------------------------- 1 | conjunction = key($spec); 34 | $spec = $spec[key($spec)]; 35 | } 36 | foreach ($spec as $rawEvaluator) { 37 | if (empty($rawEvaluator) || !is_array($rawEvaluator)) { 38 | continue; 39 | } 40 | if (count($rawEvaluator) === 3 && is_string($rawEvaluator[1])) { 41 | // this is probably a condition 42 | list($attr, $op, $val) = array_values($rawEvaluator); 43 | $this->evaluators[] = new Condition($attr, $op, $val); 44 | continue; 45 | } 46 | // probably a nested condition set, let a recursive construction do 47 | // more validation 48 | $this->evaluators[] = new static($rawEvaluator); 49 | } 50 | } 51 | 52 | /** 53 | * {@inheritDoc} 54 | */ 55 | public function evaluate(ResourceInterface $resource): bool 56 | { 57 | $result = true; // Vacuous truth: https://en.wikipedia.org/wiki/Vacuous_truth 58 | foreach ($this->evaluators as $evaluator) { 59 | $evalResult = $evaluator->evaluate($resource); 60 | if (!is_bool($evalResult)) { 61 | $t = gettype($evalResult); 62 | throw new Exception\RuntimeException("Unexpected value encountered while evaluating conditions. Expected boolean, received $t"); 63 | } 64 | if ($this->conjunction === static::LOGICAL_OR) { 65 | if ($evalResult) { 66 | return true; // short circuit 67 | } 68 | $result = false; 69 | } 70 | if ($this->conjunction === static::LOGICAL_AND) { 71 | if (!$evalResult) { 72 | return false; // short circuit 73 | } 74 | $result = true; 75 | } 76 | } 77 | 78 | return $result; 79 | } 80 | 81 | public function jsonSerialize() 82 | { 83 | $result = []; 84 | foreach ($this->evaluators as $evaluator) { 85 | $result[] = $evaluator->jsonSerialize(); 86 | } 87 | if ($this->conjunction !== static::IMPLIED_CONJUNCTION) { 88 | $result = [$this->conjunction => $result]; 89 | } 90 | 91 | return $result; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /php/src/Authr/EvaluatorInterface.php: -------------------------------------------------------------------------------- 1 | type = $type; 44 | $rsrc->attributes = $attributes; 45 | 46 | return $rsrc; 47 | } 48 | 49 | public function getResourceType(): string 50 | { 51 | return $this->type; 52 | } 53 | 54 | public function getResourceAttribute(string $key) 55 | { 56 | if (!array_key_exists($key, $this->attributes)) { 57 | return null; 58 | } 59 | $value = $this->attributes[$key]; 60 | if (is_callable($value)) { 61 | return call_user_func($value); 62 | } 63 | 64 | return $value; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /php/src/Authr/ResourceInterface.php: -------------------------------------------------------------------------------- 1 | null, 70 | self::RESOURCE_MATCH => null, 71 | self::ACTION => null, 72 | ]; 73 | 74 | /** 75 | * The rule's metadata. 76 | * 77 | * @var mixed 78 | */ 79 | private $meta; 80 | 81 | /** 82 | * Construct an allowing rule 83 | * 84 | * @param array|string $where 85 | * @param mixed $meta Set any metadata on the rule 86 | * @return self 87 | * @suppress PhanUnreferencedMethod 88 | */ 89 | public static function allow($where, $meta = null): self 90 | { 91 | return new static(static::ALLOW, $where, $meta); 92 | } 93 | 94 | /** 95 | * Construct a denying rule 96 | * 97 | * @param array|string $where 98 | * @param mixed $meta Set any metadata on the rule 99 | * @return static 100 | * @suppress PhanUnreferencedMethod 101 | */ 102 | public static function deny($where, $meta = null): self 103 | { 104 | return new static(static::DENY, $where, $meta); 105 | } 106 | 107 | /** 108 | * Create a rule from its JSON definition or raw array form 109 | * 110 | * @param array|string $spec 111 | * @return static 112 | * @throws \Cloudflare\Authr\Exception\InvalidRuleException If the policy definition is invalid 113 | * @throws \Cloudflare\Authr\Exception\RuntimeException If JSON decoding failed 114 | * @suppress PhanUnreferencedMethod 115 | */ 116 | public static function create($spec): self 117 | { 118 | if (is_string($spec)) { 119 | $spec = json_decode($spec, true); 120 | $err = json_last_error(); 121 | if ($err !== \JSON_ERROR_NONE) { 122 | throw new Exception\RuntimeException(sprintf('Failed to decode rule as JSON: %s', json_last_error_msg()), json_last_error()); 123 | } 124 | } 125 | if (!is_array($spec)) { 126 | throw new Exception\InvalidRuleException(sprintf('%s::create expects a string or array for argument 1, got %s', static::class, gettype($spec))); 127 | } 128 | 129 | $meta = null; 130 | if (array_key_exists(static::ACCESS, $spec)) { 131 | $access = $spec[static::ACCESS]; 132 | if ($access !== static::ALLOW && $access !== static::DENY) { 133 | throw new Exception\InvalidRuleException( 134 | sprintf( 135 | "Rule constructor expects '%s' or '%s' as the only values assigned to '%s', got '%s'", 136 | static::ALLOW, 137 | static::DENY, 138 | static::ACCESS, 139 | strval($access) 140 | ) 141 | ); 142 | } 143 | } 144 | if (array_key_exists(static::WHERE, $spec)) { 145 | $where = $spec[static::WHERE]; 146 | // only validate type, __construct will make sure the whole section 147 | // is valid 148 | if (!is_array($spec)) { 149 | throw new Exception\InvalidRuleException(sprintf('%s::create expects a map to be assigned to \'%s\', got a %s', static::class, static::WHERE, gettype($where))); 150 | } 151 | } 152 | if (array_key_exists(static::META, $spec)) { 153 | $meta = $spec[static::META]; 154 | } 155 | 156 | $missingkeys = []; 157 | if (!isset($access)) { 158 | $missingkeys[] = static::ACCESS; 159 | } 160 | if (!isset($where)) { 161 | $missingkeys[] = static::WHERE; 162 | } 163 | if (!empty($missingkeys)) { 164 | throw new Exception\InvalidRuleException(sprintf('Rule definition missing map keys: %s', implode(', ', $missingkeys))); 165 | } 166 | 167 | return new static($access, $where, $meta); 168 | } 169 | 170 | /** 171 | * Construct a new rule. Rule MUST be immutable after construction. 172 | * 173 | * @param string $access 174 | * @param array|string $where 175 | * @param mixed $meta 176 | */ 177 | private function __construct($access, $where, $meta) 178 | { 179 | $this->access = $access; 180 | $this->meta = $meta; 181 | if ($where === 'all') { 182 | $where = [ 183 | static::RESOURCE_TYPE => '*', 184 | static::RESOURCE_MATCH => [], 185 | static::ACTION => '*' 186 | ]; 187 | } 188 | if (!is_array($where)) { 189 | throw new Exception\InvalidRuleException(sprintf("Rule constructor expects 'all' or array for argument 2, got %s", gettype($where))); 190 | } 191 | 192 | foreach ($where as $seg => $segspec) { 193 | switch ($seg) { 194 | case static::RESOURCE_TYPE: 195 | case static::ACTION: 196 | $this->where[$seg] = new SlugSet($segspec); 197 | break; 198 | case static::RESOURCE_MATCH: 199 | $this->where[$seg] = new ConditionSet($segspec); 200 | break; 201 | default: 202 | throw new Exception\InvalidRuleException(sprintf("Rule constructor included an unknown map key in the 'where' section: %s", $seg)); 203 | } 204 | } 205 | $nullwhere = []; 206 | foreach ($this->where as $key => $value) { 207 | if (is_null($value)) { 208 | $nullwhere[] = $key; 209 | } 210 | } 211 | if (!empty($nullwhere)) { 212 | throw new Exception\InvalidRuleException(sprintf("Rule constructor is missing key(s) in the where section: %s", implode(', ', $nullwhere))); 213 | } 214 | } 215 | 216 | /** 217 | * Retrieve the rule's access 218 | * 219 | * @return string 220 | */ 221 | public function access(): string 222 | { 223 | return $this->access; 224 | } 225 | 226 | /** 227 | * Retrieve the rule's resource type segment 228 | * 229 | * @return \Cloudflare\Authr\SlugSet 230 | * @throws \Cloudflare\Authr\Exception\RuntimeException If the segment is undefined 231 | */ 232 | public function resourceTypes(): SlugSet 233 | { 234 | if (is_null($this->where[static::RESOURCE_TYPE])) { 235 | throw new Exception\RuntimeException('Cannot retrieve undefined resource type segment'); 236 | } 237 | 238 | return $this->where[static::RESOURCE_TYPE]; 239 | } 240 | 241 | /** 242 | * Retrieve the rule's conditions segment 243 | * 244 | * @return \Cloudflare\Authr\ConditionSet 245 | * @throws \Cloudflare\Authr\Exception\RuntimeException If the segment is undefined 246 | */ 247 | public function conditions(): ConditionSet 248 | { 249 | if (is_null($this->where[static::RESOURCE_MATCH])) { 250 | throw new Exception\RuntimeException('Cannot retrieve undefined resource match segment'); 251 | } 252 | 253 | return $this->where[static::RESOURCE_MATCH]; 254 | } 255 | 256 | /** 257 | * Retrieve the rule's action segment 258 | * 259 | * @return \Cloudflare\Authr\SlugSet 260 | * @throws \Cloudflare\Authr\Exception\RuntimeException If the segment is undefined 261 | */ 262 | public function actions(): SlugSet 263 | { 264 | if (is_null($this->where[static::ACTION])) { 265 | throw new Exception\RuntimeException('Cannot retrieve undefined actions segment'); 266 | } 267 | 268 | return $this->where[static::ACTION]; 269 | } 270 | 271 | /** 272 | * Retrieve the rule's metadata 273 | * 274 | * @return mixed 275 | */ 276 | public function meta() 277 | { 278 | return $this->meta; 279 | } 280 | 281 | /** 282 | * Decompose the policy to be encoded into JSON 283 | * 284 | * @return array 285 | */ 286 | public function jsonSerialize() 287 | { 288 | $raw = [ 289 | static::ACCESS => $this->access, 290 | static::WHERE => [ 291 | static::RESOURCE_TYPE => $this->resourceTypes(), 292 | static::RESOURCE_MATCH => $this->conditions(), 293 | static::ACTION => $this->actions(), 294 | ] 295 | ]; 296 | if (!is_null($this->meta)) { 297 | $raw[static::META] = $this->meta; 298 | } 299 | return $raw; 300 | } 301 | 302 | /** 303 | * Stringify the policy into JSON 304 | * 305 | * @return string 306 | */ 307 | public function __toString() 308 | { 309 | return json_encode($this); 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /php/src/Authr/RuleList.php: -------------------------------------------------------------------------------- 1 | 0) { 22 | array_push($this->rules, ...$rules); 23 | } 24 | } 25 | 26 | public function getIterator() 27 | { 28 | return new ArrayIterator($this->rules); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /php/src/Authr/SlugSet.php: -------------------------------------------------------------------------------- 1 | mode = static::MODE_WILDCARD; 28 | } else { 29 | if (is_array($spec) && key($spec) === static::NOT) { 30 | $this->mode = static::MODE_BLOCKLIST; 31 | $spec = $spec[static::NOT]; 32 | } 33 | if (is_string($spec)) { 34 | $spec = [$spec]; 35 | } 36 | if (!is_array($spec)) { 37 | throw new Exception\InvalidSlugSetException('SlugSet constructor expects a string or an array for argument 1'); 38 | } 39 | $this->items = $spec; 40 | } 41 | } 42 | 43 | /** 44 | * @param string $needle 45 | * @return bool 46 | */ 47 | public function contains(string $needle): bool 48 | { 49 | if ($this->mode === static::MODE_WILDCARD) { 50 | return true; 51 | } 52 | $doesContain = in_array($needle, $this->items, true); 53 | if ($this->mode === static::MODE_BLOCKLIST) { 54 | return !$doesContain; 55 | } 56 | 57 | return $doesContain; 58 | } 59 | 60 | /** 61 | * @return mixed 62 | */ 63 | public function jsonSerialize() 64 | { 65 | if ($this->mode === static::MODE_WILDCARD) { 66 | return '*'; 67 | } 68 | $set = $this->items; 69 | if (count($set) === 1) { 70 | $set = $set[0]; 71 | } 72 | if ($this->mode === static::MODE_BLOCKLIST) { 73 | $set = [static::NOT => $set]; 74 | } 75 | 76 | return $set; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /php/src/Authr/SubjectInterface.php: -------------------------------------------------------------------------------- 1 | assertFalse($ai(['foo', 'bar'], ['bar', 'baz'])); 14 | $this->assertFalse($ai( 15 | ['key' => 'is', 'not' => 'important'], 16 | ['just' => 'values', 'thats' => 'important'] 17 | )); 18 | $this->assertFalse($ai([5], ['5'])); // loose type equality 19 | $this->assertTrue($ai(['one', 'two'], ['three', 'four'])); 20 | $this->assertFalse($ai(5, [5])); // false returned on non-array input 21 | $this->assertTrue($ai( 22 | ['key' => 'v'], 23 | ['foo' => 'bar'] 24 | )); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /php/test/Authr/Condition/Operator/ArrayIntersectTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($ai(['foo', 'bar'], ['bar', 'baz'])); 14 | $this->assertTrue($ai( 15 | ['key' => 'is', 'not' => 'important'], 16 | ['just' => 'values', 'thats' => 'important'] 17 | )); 18 | $this->assertTrue($ai([5], ['5'])); 19 | $this->assertFalse($ai(['one', 'two'], ['three', 'four'])); 20 | $this->assertFalse($ai(5, [5])); // false returned on non-array input 21 | $this->assertFalse($ai( 22 | ['key' => 'v'], 23 | ['foo' => 'bar'] 24 | )); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /php/test/Authr/Condition/Operator/EqualsTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($eq('1', '1')); 14 | $this->assertFalse($eq('1', '0')); 15 | $this->assertTrue($eq('1', 1)); // loose equality 16 | $this->assertTrue($eq('foo', 'foo')); 17 | $this->assertFalse($eq('foo', 'bar')); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /php/test/Authr/Condition/Operator/InTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($in('foo', ['foo', 'bar'])); 14 | $this->assertFalse($in('foo', ['bar', 'baz'])); 15 | $this->assertTrue($in(1, ['1', '2'])); // testing loose equality 16 | $this->assertFalse($in(1, null)); // testing polymorphic arguments 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /php/test/Authr/Condition/Operator/LikeTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($like('foobar', 'f*')); 14 | $this->assertTrue($like('foobar', '*ba*')); 15 | $this->assertTrue($like('FOoBArRR', 'fooba*')); 16 | $this->assertFalse($like('barbaz', 'baz*')); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /php/test/Authr/Condition/Operator/NotEqualsTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($neq('1', '1')); 14 | $this->assertTrue($neq('1', '0')); 15 | $this->assertFalse($neq('1', 1)); // loose equality 16 | $this->assertFalse($neq('foo', 'foo')); 17 | $this->assertTrue($neq('foo', 'bar')); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /php/test/Authr/Condition/Operator/NotInTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($nin('foo', ['foo', 'bar'])); 14 | $this->assertTrue($nin('foo', ['bar', 'baz'])); 15 | $this->assertFalse($nin(1, ['1', '2'])); // testing loose equality 16 | $this->assertFalse($nin(1, null)); // testing polymorphic arguments 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /php/test/Authr/Condition/Operator/RegExp/CaseInsensitiveTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($ci('FoOOBaarR', '^f{1}o+ba+r+$')); 14 | $this->assertTrue($ci('AAAAA', '^a{1,}$')); 15 | $this->assertFalse($ci('bbb', '^a+$')); 16 | $this->assertFalse($ci('CaseInsensitive', '^caseinsensitive--hi$')); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /php/test/Authr/Condition/Operator/RegExp/CaseSensitiveTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($cs('FoOOBaarR', '^f{1}o+ba+r+$')); 14 | $this->assertFalse($cs('AAAAA', '^a{1,}$')); 15 | $this->assertFalse($cs('bbb', '^a+$')); 16 | $this->assertFalse($cs('CaseSensitive', '^caseSensitive--hi$')); 17 | $this->assertTrue($cs('Hello There', '^(([A-Z][a-z]+)\s?)+$')); 18 | $this->assertTrue($cs('I capitalize my eyes', '\bI\b')); 19 | $this->assertTrue($cs('Remember Remember The Fifth of November!', 'Remember')); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /php/test/Authr/Condition/Operator/RegExp/InverseCaseInsensitiveTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($ci('FoOOBaarR', '^f{1}o+ba+r+$')); 14 | $this->assertFalse($ci('AAAAA', '^a{1,}$')); 15 | $this->assertTrue($ci('bbb', '^a+$')); 16 | $this->assertTrue($ci('CaseInsensitive', '^caseinsensitive--hi$')); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /php/test/Authr/Condition/Operator/RegExp/InverseCaseSensitiveTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($cs('FoOOBaarR', '^f{1}o+ba+r+$')); 14 | $this->assertTrue($cs('AAAAA', '^a{1,}$')); 15 | $this->assertTrue($cs('bbb', '^a+$')); 16 | $this->assertTrue($cs('CaseSensitive', '^caseSensitive--hi$')); 17 | $this->assertFalse($cs('Hello There', '^(([A-Z][a-z]+)\s?)+$')); 18 | $this->assertFalse($cs('I capitalize my eyes', '\bI\b')); 19 | $this->assertFalse($cs('Remember Remember The Fifth of November!', 'Remember')); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /php/test/Authr/ConditionSetTest.php: -------------------------------------------------------------------------------- 1 | testResource = Resource::adhoc('thing', [ 18 | 'id' => '123', 19 | 'type' => 'cool', 20 | 'pop' => 'opo', 21 | ]); 22 | } 23 | 24 | public function testConstructWeirdValue() 25 | { 26 | $this->expectException(InvalidConditionSetException::class); 27 | $x = new ConditionSet(222); 28 | } 29 | 30 | public function testVacuousTruth() 31 | { 32 | $set = new ConditionSet([]); 33 | $this->assertTrue($set->evaluate($this->testResource)); 34 | } 35 | 36 | public function testShortCircuit() 37 | { 38 | $resource = Resource::adhoc('thing', [ 39 | 'id' => '123', 40 | 'type' => 'cool', 41 | 'sc_test' => function () { 42 | $this->fail('ConditionSet did not short circuit evaluation'); 43 | 44 | return 'foo'; 45 | } 46 | ]); 47 | $set = new ConditionSet([ 48 | ConditionSet::LOGICAL_OR => [ 49 | ['@id', '=', '123'], 50 | ['@sc_test', '=', 'foo'], 51 | ], 52 | ]); 53 | $this->assertTrue($set->evaluate($resource)); 54 | } 55 | 56 | /** @dataProvider provideTestEvaluateScenarios */ 57 | public function testEvaluate($result, $setPlain) 58 | { 59 | $set = new ConditionSet($setPlain); 60 | $this->assertTrue($result === $set->evaluate($this->testResource)); 61 | } 62 | 63 | public function provideTestEvaluateScenarios() 64 | { 65 | return [ 66 | [false, [ 67 | ['@id', '=', '123'], 68 | ['@pop', '=', 'p0p'], 69 | ]], 70 | // test nested sets 71 | [true, [ // (id = 321 OR (type = cool AND pop = opo)) 72 | ConditionSet::LOGICAL_OR => [ 73 | ['@id', '=', '321'], 74 | [ConditionSet::LOGICAL_AND => [ 75 | ['@type', '=', 'cool'], 76 | ['@pop', '=', 'opo'] 77 | ]] 78 | ] 79 | ]] 80 | ]; 81 | } 82 | 83 | /** 84 | * @dataProvider provideTestJsonSerializeScenarios 85 | */ 86 | public function testJsonSerialize($expected, $setRaw) 87 | { 88 | $this->assertEquals($expected, json_encode(new ConditionSet($setRaw))); 89 | } 90 | 91 | public function provideTestJsonSerializeScenarios() 92 | { 93 | return [ 94 | 'normal set' => [ 95 | '[["@id","=","321"],["@type","=","cool"]]', 96 | [ 97 | ['@id', '=', '321'], 98 | ['@type', '=', 'cool'], 99 | ], 100 | ], 101 | 'nested sets' => [ 102 | '[["@id","=","321"],{"$or":[["@type","=",null],["@pop","=","opo"]]},[["@id","=","555"],["@attr","~","foo*"]]]', 103 | [ 104 | ['@id', '=', '321'], 105 | [ConditionSet::LOGICAL_OR => [ 106 | ['@type', '=', null], 107 | ['@pop', '=', 'opo'], 108 | ]], 109 | [ConditionSet::LOGICAL_AND => [ 110 | ['@id', '=', '555'], 111 | ['@attr', '~', 'foo*'], 112 | ]], 113 | ], 114 | ], 115 | 'one more for good luck' => [ 116 | '{"$or":[["id","=","321"],[["type","=","cool"],["pop","=","opo"]]]}', 117 | [ 118 | ConditionSet::LOGICAL_OR => [ 119 | ['id', '=', '321'], 120 | [ConditionSet::LOGICAL_AND => [ 121 | ['type', '=', 'cool'], 122 | ['pop', '=', 'opo'], 123 | ]], 124 | ], 125 | ] 126 | ] 127 | ]; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /php/test/Authr/ConditionTest.php: -------------------------------------------------------------------------------- 1 | testResource = Resource::adhoc('thing', [ 17 | 'id' => '123', 18 | 'type' => 'cool', 19 | 'arr' => ['foo' => 1], 20 | 'umm' => '@wut', 21 | 'appearance' => 'Pretty' 22 | ]); 23 | } 24 | 25 | public function testUnknownOperator() 26 | { 27 | $this->expectException(InvalidConditionOperator::class); 28 | $a = new Condition('@id', '@>', '4'); 29 | } 30 | 31 | public function testEvaluateDefaultOperator() 32 | { 33 | $a = new Condition('@id', '=', '123'); 34 | $b = new Condition('not-cool', '=', '@type'); 35 | $this->assertTrue($a->evaluate($this->testResource)); 36 | $this->assertFalse($b->evaluate($this->testResource)); 37 | } 38 | 39 | private function newCondition($a, $b, $c) 40 | { 41 | return new Condition($a, $b, $c); 42 | } 43 | 44 | public function testEscapedValue() 45 | { 46 | $this->assertTrue($this->newCondition('@umm', '=', '\@wut')->evaluate($this->testResource)); 47 | } 48 | 49 | public function testNullValue() 50 | { 51 | $this->assertTrue($this->newCondition('@idk', '=', null)->evaluate($this->testResource)); 52 | } 53 | 54 | public function testRegExpConditions() 55 | { 56 | $this->assertTrue($this->newCondition('@appearance', '!~', '^pretty$')->evaluate($this->testResource)); 57 | $this->assertFalse($this->newCondition('@appearance', '!~', '^Pretty$')->evaluate($this->testResource)); 58 | 59 | $this->assertTrue($this->newCondition('@appearance', '~', '^Pre')->evaluate($this->testResource)); 60 | $this->assertFalse($this->newCondition('@appearance', '~', '^P[0-9]e')->evaluate($this->testResource)); 61 | 62 | $this->assertTrue($this->newCondition('@appearance', '~*', '^pretty')->evaluate($this->testResource)); 63 | $this->assertFalse($this->newCondition('@appearance', '~*', '^ugly$')->evaluate($this->testResource)); 64 | 65 | $this->assertTrue($this->newCondition('@appearance', '!~*', '^Ugly')->evaluate($this->testResource)); 66 | $this->assertFalse($this->newCondition('@appearance', '!~*', '^pret')->evaluate($this->testResource)); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /php/test/Authr/ResourceTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidAdHocResourceException::class); 14 | $rsrc = Resource::adhoc('', []); 15 | } 16 | 17 | public function testGetResourceType() 18 | { 19 | $rsrc = Resource::adhoc('thing', [ 20 | 'foo' => 'bar' 21 | ]); 22 | $this->assertEquals('thing', $rsrc->getResourceType()); 23 | } 24 | 25 | public function testUnknownAttribute() 26 | { 27 | $rsrc = Resource::adhoc('thing', [ 28 | 'foo' => 'bar' 29 | ]); 30 | $this->assertNull($rsrc->getResourceAttribute('lol')); 31 | } 32 | 33 | public function testCallableAttribute() 34 | { 35 | $rsrc = Resource::adhoc('thing', [ 36 | 'foo' => 'bar', 37 | 'id' => function () { 38 | return 198; 39 | } 40 | ]); 41 | $this->assertEquals('bar', $rsrc->getResourceAttribute('foo')); 42 | $this->assertEquals(198, $rsrc->getResourceAttribute('id')); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /php/test/Authr/RuleTest.php: -------------------------------------------------------------------------------- 1 | 'post', 18 | Rule::RESOURCE_MATCH => [['@id', '=', '123']], 19 | Rule::ACTION => 'update' 20 | ]); 21 | 22 | $this->assertEquals(Rule::ALLOW, $rule->access()); 23 | $this->assertTrue($rule->resourceTypes()->contains('post')); 24 | $this->assertFalse($rule->resourceTypes()->contains('user')); 25 | $this->assertTrue($rule->actions()->contains('update')); 26 | $this->assertFalse($rule->actions()->contains('delete')); 27 | } 28 | 29 | public function testDeny() 30 | { 31 | $rule = Rule::deny([ 32 | Rule::RESOURCE_TYPE => 'post', 33 | Rule::RESOURCE_MATCH => [['@id', '=', '123']], 34 | Rule::ACTION => 'update' 35 | ]); 36 | 37 | $this->assertEquals(Rule::DENY, $rule->access()); 38 | $this->assertTrue($rule->resourceTypes()->contains('post')); 39 | $this->assertFalse($rule->resourceTypes()->contains('user')); 40 | $this->assertTrue($rule->actions()->contains('update')); 41 | $this->assertFalse($rule->actions()->contains('delete')); 42 | } 43 | 44 | public function testJSONDecodeFail() 45 | { 46 | $this->expectException(RuntimeException::class); 47 | $rulejson = '{"access":"all'; // eek! bad json! 48 | $rule = Rule::create($rulejson); 49 | } 50 | 51 | public function testJSONDecode() 52 | { 53 | $rulejson = '{"access":"allow","where":{"rsrc_type":"post","rsrc_match":[["@id","=","123"]],"action":"update"},"$meta":{"rule_id":123}}'; 54 | $rule = Rule::create($rulejson); 55 | 56 | $this->assertEquals(Rule::ALLOW, $rule->access()); 57 | $this->assertEquals('post', $rule->resourceTypes()->jsonSerialize()); 58 | $this->assertEquals('update', $rule->actions()->jsonSerialize()); 59 | $this->assertEquals([['@id', '=', '123']], $rule->conditions()->jsonSerialize()); 60 | $this->assertEquals(['rule_id' => 123], $rule->meta()); 61 | } 62 | 63 | /** 64 | * @dataProvider provideInvalidRuleScenarios 65 | */ 66 | public function testInvalidRule($ruleraw) 67 | { 68 | $this->expectException(InvalidRuleException::class); 69 | $rule = Rule::create($ruleraw); 70 | } 71 | 72 | public function provideInvalidRuleScenarios() 73 | { 74 | return [ 75 | 'wrong type' => [55], 76 | 'missing "access"' => [ 77 | [ 78 | Rule::WHERE => [ 79 | Rule::RESOURCE_TYPE => 'post', 80 | Rule::RESOURCE_MATCH => [['@id', '=', '123']], 81 | Rule::ACTION => 'update' 82 | ] 83 | ] 84 | ], 85 | 'missing "where"' => [ 86 | [ 87 | Rule::ACCESS => Rule::ALLOW 88 | ] 89 | ], 90 | 'invalid "access"' => [ 91 | [ 92 | Rule::ACCESS => 'nah', 93 | Rule::WHERE => [ 94 | Rule::RESOURCE_TYPE => 'post', 95 | Rule::RESOURCE_MATCH => [['@id', '=', '123']], 96 | Rule::ACTION => 'update' 97 | ] 98 | ] 99 | ], 100 | 'missing where.rsrc_type' => [ 101 | [ 102 | Rule::ACCESS => Rule::ALLOW, 103 | Rule::WHERE => [ 104 | Rule::RESOURCE_MATCH => [['@id', '=', '123']], 105 | Rule::ACTION => 'update' 106 | ] 107 | ] 108 | ], 109 | 'missing where.rsrc_match' => [ 110 | [ 111 | Rule::ACCESS => Rule::ALLOW, 112 | Rule::WHERE => [ 113 | Rule::RESOURCE_TYPE => 'post', 114 | Rule::ACTION => 'update' 115 | ] 116 | ] 117 | ], 118 | 'missing where.action' => [ 119 | [ 120 | Rule::ACCESS => Rule::ALLOW, 121 | Rule::WHERE => [ 122 | Rule::RESOURCE_TYPE => 'post', 123 | Rule::RESOURCE_MATCH => [['@id', '=', '123']], 124 | ] 125 | ] 126 | ], 127 | 'unknown where key' => [ 128 | [ 129 | Rule::ACCESS => Rule::ALLOW, 130 | Rule::WHERE => [ 131 | Rule::RESOURCE_TYPE => 'post', 132 | Rule::RESOURCE_MATCH => [['@id', '=', '123']], 133 | Rule::ACTION => 'update', 134 | 'lol' => 'wut' 135 | ] 136 | ] 137 | ] 138 | ]; 139 | } 140 | 141 | /** @dataProvider provideRuleJsonSerializeScenarios */ 142 | public function testRuleJsonSerialize($expected, $in) 143 | { 144 | $this->assertEquals($expected, json_encode($in)); 145 | } 146 | 147 | public function provideRuleJsonSerializeScenarios() 148 | { 149 | return [ 150 | [ 151 | '{"access":"allow","where":{"rsrc_type":"post","rsrc_match":[["@id","=","123"]],"action":"update"},"$meta":{"rule_id":123}}', 152 | Rule::allow([ 153 | Rule::RESOURCE_TYPE => 'post', 154 | Rule::RESOURCE_MATCH => [['@id', '=', '123']], 155 | Rule::ACTION => 'update' 156 | ], ['rule_id' => 123]) 157 | ], 158 | [ 159 | '{"access":"allow","where":{"rsrc_type":{"$not":"post"},"rsrc_match":{"$or":[["@id","=","123"],["@name","$in",["foo","bar"]],[["@post_type","!=","pinned"],["@author_id","=","223"]]]},"action":["update","delete"]},"$meta":{"rule_id":321}}', 160 | Rule::allow([ 161 | Rule::RESOURCE_TYPE => ['$not' => ['post']], 162 | Rule::RESOURCE_MATCH => [ 163 | '$or' => [ 164 | ['@id', '=', '123'], 165 | ['@name', '$in', ['foo', 'bar']], 166 | [ 167 | '$and' => [ 168 | ['@post_type', '!=', 'pinned'], 169 | ['@author_id', '=', '223'] 170 | ] 171 | ] 172 | ] 173 | ], 174 | Rule::ACTION => ['update', 'delete'] 175 | ], ['rule_id' => 321]) 176 | ], 177 | 'no meta' => [ 178 | '{"access":"allow","where":{"rsrc_type":"post","rsrc_match":[["@id","=","123"]],"action":"update"}}', 179 | Rule::allow([ 180 | Rule::RESOURCE_TYPE => 'post', 181 | Rule::RESOURCE_MATCH => [['@id', '=', '123']], 182 | Rule::ACTION => 'update' 183 | ]) 184 | ], 185 | ]; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /php/test/Authr/SlugSetTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($set->contains('foo')); 15 | $this->assertTrue($set->contains('bar')); 16 | $this->assertTrue($set->contains('anything_and_everything')); 17 | } 18 | 19 | public function testNormalSet() 20 | { 21 | $set = new SlugSet(['foo', 'bar']); 22 | $this->assertTrue($set->contains('foo')); 23 | $this->assertTrue($set->contains('bar')); 24 | $this->assertFalse($set->contains('thisthing')); 25 | } 26 | 27 | public function testBlocklistSet() 28 | { 29 | $set = new SlugSet([ 30 | SlugSet::NOT => ['foo', 'bar'], 31 | ]); 32 | $this->assertFalse($set->contains('foo')); 33 | $this->assertFalse($set->contains('bar')); 34 | $this->assertTrue($set->contains('thisthing')); 35 | } 36 | 37 | public function testStringTransform() 38 | { 39 | $set = new SlugSet('foo'); 40 | $this->assertTrue($set->contains('foo')); 41 | $this->assertFalse($set->contains('bar')); 42 | $this->assertFalse($set->contains('thisthing')); 43 | } 44 | 45 | public function testStringTransformBlocklist() 46 | { 47 | $set = new SlugSet([SlugSet::NOT => 'foo']); 48 | $this->assertFalse($set->contains('foo')); 49 | $this->assertTrue($set->contains('bar')); 50 | $this->assertTrue($set->contains('thisthing')); 51 | } 52 | 53 | public function testConstructWeirdValue() 54 | { 55 | $this->expectException(Exception::class); 56 | new SlugSet(111); 57 | } 58 | 59 | /** 60 | * @dataProvider provideJsonSerializeScenarios 61 | */ 62 | public function testJsonSerialize(SlugSet $set, $expected) 63 | { 64 | $this->assertEquals($expected, json_encode($set)); 65 | } 66 | 67 | public function provideJsonSerializeScenarios() 68 | { 69 | return [ 70 | 'normal set' => [ 71 | new SlugSet(['foo', 'bar']), 72 | '["foo","bar"]', 73 | ], 74 | 'blocklist set' => [ 75 | new SlugSet([SlugSet::NOT => ['bar', 'foo']]), 76 | '{"$not":["bar","foo"]}', 77 | ], 78 | 'wildcard set' => [ 79 | new SlugSet('*'), 80 | '"*"', 81 | ], 82 | 'single slug set' => [ 83 | new SlugSet('foo'), 84 | '"foo"', 85 | ], 86 | ]; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /php/test/Authr/TestSubject.php: -------------------------------------------------------------------------------- 1 | rules = $rules; 19 | } 20 | 21 | public function getRules(): RuleList 22 | { 23 | $rules = new RuleList(); 24 | $rules->push(...$this->rules); 25 | return $rules; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /php/test/AuthrTest.php: -------------------------------------------------------------------------------- 1 | setRules(array_map(implode('::', [Authr\Rule::class, 'create']), $subjectRules)); 21 | foreach ($ops as $op) { 22 | list($action, $resourceDefinition, $result) = $op; 23 | $resource = Resource::adhoc($resourceDefinition['type'], $resourceDefinition['attributes']); 24 | $this->assertTrue($result === (new Authr(new NullLogger()))->can($subject, $action, $resource)); 25 | } 26 | } 27 | 28 | public function provideTestCanScenarios() 29 | { 30 | $pshort = function ($typ, $t, $m, $a) { 31 | return [ 32 | Authr\Rule::ACCESS => $typ, 33 | Authr\Rule::WHERE => [ 34 | Authr\Rule::RESOURCE_TYPE => $t, 35 | Authr\Rule::RESOURCE_MATCH => $m, 36 | Authr\Rule::ACTION => $a 37 | ] 38 | ]; 39 | }; 40 | $rshort = function ($t, $a) { 41 | return [ 42 | 'type' => $t, 43 | 'attributes' => $a, 44 | ]; 45 | }; 46 | 47 | return [ 48 | 'nominal scenario' => [ 49 | [ 50 | $pshort(Authr\Rule::ALLOW, 'dohikee', [['@status', '=', 'useful']], 'prod'), 51 | $pshort(Authr\Rule::ALLOW, 'thing', [['@status', '=', 'useless']], 'delete'), 52 | ], 53 | [['delete', $rshort('thing', ['status' => 'useless']), true]] 54 | ], 55 | 56 | 'subject has no permissions, cannot do anything' => [ 57 | [], 58 | [['delete', $rshort('zone', ['id' => '123', 'name' => 'example.com']), false]] 59 | ], 60 | 61 | 'subject can manage a few records in a particular zone' => [ 62 | [ 63 | $pshort(Authr\Rule::ALLOW, 'record', [['@zone_id', '=', '123'], ['@name', '$in', ['cdn.example.com', 'service.example.com']]], ['update', 'change_service_mode']), 64 | ], 65 | [['update', $rshort('record', ['name' => 'cdn.example.com', 'zone_id' => '123']), true]] 66 | ], 67 | 68 | 'subject can manage a few records in a particular zone, but not this one' => [ 69 | [ 70 | $pshort(Authr\Rule::ALLOW, 'record', [['zone_id', '=', '123'], ['name', '$in', ['cdn.example.com', 'service.example.com']]], ['update', 'change_service_mode']), 71 | ], 72 | [['update', $rshort('record', ['name' => 'blog.example.com', 'zone_id' => '123']), false]] 73 | ], 74 | 75 | 'possible pitfall: blocklist ignored, action granted by lower-ranked permission' => [ 76 | [ 77 | // permission evaluator will green-light resource match, then 78 | // see "delete" in blocklist. returns false, continues to 79 | // evaluate subsequent permission. 80 | $pshort(Authr\Rule::ALLOW, 'record', [['@zone_id', '=', '123']], ['$not' => ['delete', 'change_service_mode']]), 81 | 82 | // permission evaluator will green-light resource match, NOT see 83 | // "delete" in its blocklist, return true. therefore, a 84 | // permission that wanted to blocklist "delete" gets overridden 85 | // by another permission 86 | $pshort(Authr\Rule::ALLOW, 'record', [['@zone_id', '=', '123'], ['@type', '=', 'A']], ['$not' => 'change_service_mode']) 87 | ], 88 | [['delete', $rshort('record', ['zone_id' => '123', 'type' => 'A']), true]] 89 | ], 90 | 91 | 'denying permission should explicitly deny something even though there are lower-ranked permissions that would potentially allow' => [ 92 | [ 93 | $pshort(Authr\Rule::DENY, 'record', [['@zone_id', '=', '324']], 'delete'), 94 | $pshort(Authr\Rule::ALLOW, 'record', [], '*') // allow any action on any record! 95 | ], 96 | [ 97 | ['delete', $rshort('record', ['zone_id' => '324', 'type' => 'AAAA']), false], 98 | ['delete', $rshort('record', ['zone_id' => '325', 'type' => 'A']), true] 99 | ] 100 | ], 101 | 102 | 'allow => all should allow everything' => [ 103 | [[Authr\Rule::ACCESS => Authr\Rule::ALLOW, Authr\Rule::WHERE => 'all']], 104 | [ 105 | ['delete', $rshort('record', ['zone_id' => '324', 'type' => 'AAAA']), true], 106 | ['delete', $rshort('record', ['zone_id' => '325', 'type' => 'A']), true], 107 | ['destroy', $rshort('system', [['name' => 'system']]), true] 108 | ] 109 | ], 110 | 111 | 'deny => all should reject everything' => [ 112 | [[Authr\Rule::ACCESS => Authr\Rule::DENY, Authr\Rule::WHERE => 'all']], 113 | [ 114 | ['delete', $rshort('record', ['zone_id' => '324', 'type' => 'AAAA']), false], 115 | ['delete', $rshort('record', ['zone_id' => '325', 'type' => 'A']), false], 116 | ['destroy', $rshort('system', [['name' => 'system']]), false] 117 | ] 118 | ] 119 | ]; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /php/test/TestCase.php: -------------------------------------------------------------------------------- 1 | false-8 3755 336 -91.05% 30 | // BenchmarkRegexpOperatorSerial/"foo-one"~*^Foo=>true-8 5803 315 -94.57% 31 | // BenchmarkRegexpOperatorSerial/"bar-two"~^Bar=>false-8 5678 206 -96.37% 32 | // BenchmarkRegexpOperatorParallel/int(33)~*^foo$=>false-8 1428 417 -70.80% 33 | // BenchmarkRegexpOperatorParallel/"foo-one"~*^Foo=>true-8 6516 427 -93.45% 34 | // BenchmarkRegexpOperatorParallel/"bar-two"~^Bar=>false-8 6194 384 -93.80% 35 | // BenchmarkRegexpOperatorThrash-8 6019 2355 -60.87% 36 | // 37 | // benchmark old allocs new allocs delta 38 | // BenchmarkRegexpOperatorSerial/int(33)~*^foo$=>false-8 52 3 -94.23% 39 | // BenchmarkRegexpOperatorSerial/"foo-one"~*^Foo=>true-8 29 2 -93.10% 40 | // BenchmarkRegexpOperatorSerial/"bar-two"~^Bar=>false-8 28 1 -96.43% 41 | // BenchmarkRegexpOperatorParallel/int(33)~*^foo$=>false-8 52 3 -94.23% 42 | // BenchmarkRegexpOperatorParallel/"foo-one"~*^Foo=>true-8 29 2 -93.10% 43 | // BenchmarkRegexpOperatorParallel/"bar-two"~^Bar=>false-8 28 1 -96.43% 44 | // BenchmarkRegexpOperatorThrash-8 36 14 -61.11% 45 | // 46 | // benchmark old bytes new bytes delta 47 | // BenchmarkRegexpOperatorSerial/int(33)~*^foo$=>false-8 3297 32 -99.03% 48 | // BenchmarkRegexpOperatorSerial/"foo-one"~*^Foo=>true-8 39016 24 -99.94% 49 | // BenchmarkRegexpOperatorSerial/"bar-two"~^Bar=>false-8 39008 16 -99.96% 50 | // BenchmarkRegexpOperatorParallel/int(33)~*^foo$=>false-8 3299 32 -99.03% 51 | // BenchmarkRegexpOperatorParallel/"foo-one"~*^Foo=>true-8 39016 24 -99.94% 52 | // BenchmarkRegexpOperatorParallel/"bar-two"~^Bar=>false-8 39008 16 -99.96% 53 | // BenchmarkRegexpOperatorThrash-8 27071 9086 -66.44% 54 | type regexpListCache struct { 55 | sync.Mutex 56 | // capacity 57 | c int 58 | // current length 59 | s int 60 | l *list.List 61 | } 62 | 63 | func newRegexpListCache(capacity int) *regexpListCache { 64 | if capacity < 0 { 65 | panic("negative regexp cache") 66 | } 67 | return ®expListCache{ 68 | c: capacity, 69 | s: 0, 70 | l: list.New().Init(), 71 | } 72 | } 73 | 74 | func (r *regexpListCache) add(pattern string, _r *regexp.Regexp) { 75 | r.Lock() 76 | defer r.Unlock() 77 | if r.s > r.c { 78 | panic("regexpListCache overflow") 79 | } 80 | if r.s == r.c { 81 | r.l.Remove(r.l.Back()) 82 | r.s-- 83 | } 84 | r.l.PushFront(®expCacheEntry{p: pattern, r: _r}) 85 | r.s++ 86 | } 87 | 88 | func (r *regexpListCache) find(pattern string) (*regexp.Regexp, bool) { 89 | r.Lock() 90 | defer r.Unlock() 91 | var e *list.Element = r.l.Front() 92 | for e != nil { 93 | if e.Value.(*regexpCacheEntry).p == pattern { 94 | r.l.MoveToFront(e) 95 | return e.Value.(*regexpCacheEntry).r, true 96 | } 97 | e = e.Next() 98 | } 99 | return nil, false 100 | } 101 | 102 | type noopRegexpCache struct{} 103 | 104 | func (n *noopRegexpCache) add(_ string, _ *regexp.Regexp) {} 105 | func (n *noopRegexpCache) find(_ string) (*regexp.Regexp, bool) { 106 | return nil, false 107 | } 108 | -------------------------------------------------------------------------------- /regexp_cache_test.go: -------------------------------------------------------------------------------- 1 | package authr 2 | 3 | import ( 4 | "regexp" 5 | "runtime" 6 | "testing" 7 | ) 8 | 9 | func BenchmarkListCacheAddSerial(b *testing.B) { 10 | c := newRegexpListCache(5) 11 | b.ReportAllocs() 12 | var _r *regexp.Regexp 13 | for i := 0; i < b.N; i++ { 14 | c.add("a", _r) 15 | } 16 | } 17 | 18 | func BenchmarkListCacheAddParallel(b *testing.B) { 19 | c := newRegexpListCache(5) 20 | var _r *regexp.Regexp 21 | b.SetParallelism(runtime.NumCPU()) 22 | b.ReportAllocs() 23 | b.RunParallel(func(pb *testing.PB) { 24 | for pb.Next() { 25 | c.add("a", _r) 26 | } 27 | }) 28 | } 29 | 30 | func BenchmarkListCacheFindMissParallel(b *testing.B) { 31 | c := newRegexpListCache(5) 32 | var _r *regexp.Regexp 33 | c.add("a", _r) 34 | c.add("b", _r) 35 | c.add("c", _r) 36 | c.add("d", _r) 37 | c.add("e", _r) 38 | b.SetParallelism(runtime.NumCPU()) 39 | b.ReportAllocs() 40 | b.RunParallel(func(pb *testing.PB) { 41 | for pb.Next() { 42 | c.find("f") 43 | } 44 | }) 45 | } 46 | 47 | func BenchmarkListCacheFindMissSerial(b *testing.B) { 48 | c := newRegexpListCache(5) 49 | var _r *regexp.Regexp 50 | c.add("a", _r) 51 | c.add("b", _r) 52 | c.add("c", _r) 53 | c.add("d", _r) 54 | c.add("e", _r) 55 | b.ReportAllocs() 56 | for i := 0; i < b.N; i++ { 57 | c.find("f") 58 | } 59 | } 60 | 61 | func BenchmarkListCacheFindHitStartParallel(b *testing.B) { 62 | c := newRegexpListCache(5) 63 | var _r *regexp.Regexp 64 | c.add("a", _r) 65 | c.add("b", _r) 66 | b.SetParallelism(runtime.NumCPU()) 67 | b.ReportAllocs() 68 | b.RunParallel(func(pb *testing.PB) { 69 | for pb.Next() { 70 | c.find("b") 71 | } 72 | }) 73 | } 74 | 75 | func BenchmarkListCacheFindHitStartSerial(b *testing.B) { 76 | c := newRegexpListCache(5) 77 | var _r *regexp.Regexp 78 | c.add("a", _r) 79 | c.add("b", _r) 80 | b.ReportAllocs() 81 | for i := 0; i < b.N; i++ { 82 | c.find("b") 83 | } 84 | } 85 | 86 | func BenchmarkListCacheFindHitEndParallel(b *testing.B) { 87 | c := newRegexpListCache(5) 88 | var _r *regexp.Regexp 89 | c.add("a", _r) 90 | c.add("b", _r) 91 | c.add("c", _r) 92 | c.add("d", _r) 93 | c.add("e", _r) 94 | b.SetParallelism(runtime.NumCPU()) 95 | b.ReportAllocs() 96 | b.RunParallel(func(pb *testing.PB) { 97 | for pb.Next() { 98 | c.find("e") 99 | } 100 | }) 101 | } 102 | 103 | func BenchmarkListCacheFindHitEndSerial(b *testing.B) { 104 | c := newRegexpListCache(5) 105 | var _r *regexp.Regexp 106 | c.add("a", _r) 107 | c.add("b", _r) 108 | c.add("c", _r) 109 | c.add("d", _r) 110 | c.add("e", _r) 111 | b.ReportAllocs() 112 | for i := 0; i < b.N; i++ { 113 | c.find("e") 114 | } 115 | } 116 | 117 | func TestListCache(t *testing.T) { 118 | t.Parallel() 119 | t.Run("should store and be able to find", func(t *testing.T) { 120 | c := newRegexpListCache(5) 121 | var _r *regexp.Regexp 122 | c.add("ozncowoldu", _r) 123 | r, ok := c.find("ozncowoldu") 124 | if !ok { 125 | t.Fatalf("unexpected cache miss") 126 | return 127 | } 128 | if r != _r { 129 | t.Fatalf("cache returned the wrong pattern? %p != %p", _r, r) 130 | return 131 | } 132 | }) 133 | t.Run("should miss if not able to find pattern", func(t *testing.T) { 134 | c := newRegexpListCache(5) 135 | r, ok := c.find("sckvccisjm") 136 | if ok { 137 | t.Fatalf("unexpected cache hit") 138 | return 139 | } 140 | if r != nil { 141 | t.Fatalf("unexpected *regexp.Regexp returned: %+v", r) 142 | return 143 | } 144 | }) 145 | t.Run("should start overflowing and removing stuff", func(t *testing.T) { 146 | c := newRegexpListCache(5) 147 | var _r *regexp.Regexp = ®exp.Regexp{} 148 | c.add("mjepcahoxe", _r) 149 | c.add("qpafzozhjf", _r) 150 | c.add("wbdporssdz", _r) 151 | 152 | // fetch 'mjepcahoxe', this should move to the front 153 | rr, ok := c.find("mjepcahoxe") 154 | if !ok { 155 | t.Fatalf("unexpected cache miss for 'mjepcahoxe'") 156 | return 157 | } 158 | if rr == nil { 159 | t.Fatalf("unexpected nil *regexp.Regexp for 'mjepcahoxe'") 160 | return 161 | } 162 | 163 | c.add("znzqyktuuw", _r) 164 | c.add("isuteoxatj", _r) 165 | c.add("pkzbgrkdff", _r) 166 | c.add("wncwhcpjsh", _r) 167 | }) 168 | } 169 | -------------------------------------------------------------------------------- /rule-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "type": "object", 4 | "additionalProperties": false, 5 | "required": ["access", "where"], 6 | "properties": { 7 | "access": { 8 | "type": "string", 9 | "enum": ["allow", "deny"] 10 | }, 11 | "where": { 12 | "type": "object", 13 | "additionalProperties": false, 14 | "required": ["rsrc_type", "rsrc_match", "action"], 15 | "properties": { 16 | "rsrc_type": { "$ref": "#/definitions/slugSet" }, 17 | "rsrc_match": { "$ref": "#/definitions/conditionSet" }, 18 | "action": { "$ref": "#/definitions/slugSet" } 19 | } 20 | }, 21 | "$meta": {} 22 | }, 23 | "definitions": { 24 | "conditionSet": { 25 | "definitions": { 26 | "inner": { 27 | "type": "array", 28 | "items": { 29 | "oneOf": [ 30 | { "$ref": "#/definitions/conditionSet/definitions/condition" }, 31 | { "$ref": "#/definitions/conditionSet" } 32 | ] 33 | } 34 | }, 35 | "condition": { 36 | "type": "array", 37 | "maxItems": 3, 38 | "minItems": 3, 39 | "items": [ 40 | {}, 41 | { 42 | "type": "string", 43 | "enum": ["=", "!=", "~=", "~", "~*", "!~", "!~*", "$in", "$nin", "&", "-"] 44 | }, 45 | {} 46 | ] 47 | } 48 | }, 49 | "oneOf": [ 50 | { "$ref": "#/definitions/conditionSet/definitions/inner" }, 51 | { 52 | "type": "object", 53 | "additionalProperties": false, 54 | "required": ["$and"], 55 | "properties": { 56 | "$and": { "$ref": "#/definitions/conditionSet/definitions/inner" } 57 | } 58 | }, 59 | { 60 | "type": "object", 61 | "additionalProperties": false, 62 | "required": ["$or"], 63 | "properties": { 64 | "$or": { "$ref": "#/definitions/conditionSet/definitions/inner" } 65 | } 66 | } 67 | ] 68 | }, 69 | "slugSet": { 70 | "definitions": { 71 | "inner": { 72 | "oneOf": [ 73 | { "type": "string", "minLength": 1 }, 74 | { 75 | "type": "array", 76 | "minItems": 1, 77 | "items": { "type": "string", "minLength": 1 } 78 | } 79 | ] 80 | } 81 | }, 82 | "oneOf": [ 83 | { 84 | "type": "object", 85 | "additionalProperties": false, 86 | "required": ["$not"], 87 | "properties": { 88 | "$not": { "$ref": "#/definitions/slugSet/definitions/inner" } 89 | } 90 | }, 91 | { "$ref": "#/definitions/slugSet/definitions/inner" } 92 | ] 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /ts/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-0", 5 | "stage-1" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /ts/Makefile: -------------------------------------------------------------------------------- 1 | TS_SOURCES = $(shell find src -type f -name \*.ts) 2 | 3 | clean: 4 | rm -rf build 5 | rm -rf node_modules 6 | 7 | node_modules/.ok: package.json package-lock.json 8 | npm i 9 | touch $@ 10 | 11 | build/.ok: node_modules/.ok $(TS_SOURCES) 12 | rm -rf build 13 | node_modules/.bin/tsc -d 14 | touch $@ 15 | 16 | lint: node_modules/.ok 17 | node_modules/.bin/tsc --noEmit 18 | 19 | test: node_modules/.ok 20 | npm run test 21 | 22 | build: build/.ok 23 | 24 | setup: build/.ok 25 | 26 | .PHONY: clean build lint test setup 27 | -------------------------------------------------------------------------------- /ts/README.md: -------------------------------------------------------------------------------- 1 | # authr 2 | an incredibly granular and expressive permissions framework. 3 | 4 | ## getting started 5 | to get started, install the package! 6 | ``` 7 | npm install --save @cloudflare/authr 8 | ``` 9 | 10 | ### setting up objects for authr 11 | 12 | even though we use typescript for the js implementation, we must be able to bridge with js, and we still need guarantees that the objects that authr deals with have methods that were purposed for it, we need a JS interface. but since javascript doesn't have such a thing, we can use [symbols](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol). 13 | 14 | symbols have a lot of uses, but for authr's purposes, it guarantees that a certain object has explicitly implemented certain functionality specifically for authr; kind of like an interface! these symbols are made available for use by the package as exported constants. 15 | 16 | *note: a **lot** of important details about the philosophy and inner-workings of this library are glossed over. if you have not read the main README yet, it is highly recommended that you do so: [cloudflare/authr/README.md](https://github.com/cloudflare/authr/blob/master/README.md)* 17 | 18 | #### GET_RULES 19 | in order to establish an subject (also called "actor") in this system, this constant MUST be the key in an object that is assigned to a callable function. when the function is called, it must return an array of `Rule`s. 20 | 21 | the typescript interface definition is this: 22 | 23 | ```typescript 24 | interface ISubject { 25 | [GET_RULES](): Rule[]; 26 | } 27 | ``` 28 | 29 | to implement in js, it would probably end up looking like this: 30 | 31 | ```js 32 | import { GET_RULES, Rule } from '@cloudflare/authr'; 33 | 34 | var user = { 35 | [GET_RULES]: () => ([ 36 | Rule.allow({ 37 | rsrc_type: 'zone', 38 | rsrc_match: [['@id', '=', '123']], 39 | action: ['delete', 'pause'] 40 | }), 41 | Rule.deny({ 42 | rsrc_type: 'private_key', 43 | rsrc_match: [], 44 | action: '*' 45 | }) 46 | ]) 47 | }; 48 | ``` 49 | 50 | once you have this, you have just setup a subject in the authr framework! subjects are entities in a system that are capable of taking actions against specific resources. 51 | 52 | for resources, there are *two* symbols to implement on your object to make it a recognizable resource. 53 | 54 | #### GET_RESOURCE_TYPE 55 | 56 | this constant MUST be the key in an object that is assigned to a callable function that returns a string that identifies the resource type. like so: 57 | 58 | ```js 59 | import { GET_RESOURCE_TYPE } from '@cloudflare/authr'; 60 | 61 | var resource = { 62 | [GET_RESOURCE_TYPE]: () => 'zone' 63 | }; 64 | ``` 65 | 66 | pretty simple function, right? resources in the permission framework are entities that are capable of being acted upon, like a zone (a cloudflare object), or even a user. 67 | 68 | #### GET_RESOURCE_ATTRIBUTE 69 | this constant MUST be the key in an object that is assigned to a callable function. when the function is called, it will be given one string parameter that designates the key of the attribute being looked for. like so: 70 | 71 | ```js 72 | import { 73 | GET_RESOURCE_TYPE, 74 | GET_RESOURCE_ATTRIBUTE 75 | } from '@cloudflare/authr'; 76 | 77 | var resource = { 78 | [GET_RESOURCE_TYPE]: () => 'zone', 79 | [GET_RESOURCE_ATTRIBUTE]: k => { 80 | var attr = { 81 | 'id': 123, 82 | 'type': 'full' 83 | }; 84 | return attr[k] || null; 85 | } 86 | }; 87 | ``` 88 | 89 | when using these two constants in an object, you have just setup a resource in the authr framework! 90 | 91 | ##### but wait! symbols are new as hell and can't be used in old browsers! 92 | mm. true. but there are some decent [shims](https://github.com/medikoo/es6-symbol) that can achieve the same things. not ideal, but, it gets the job done. 93 | 94 | ## api 95 | when you have all the objects in place and ready with their constants, you can finally start checking access-control. in this framework, there is only one way to do that: `can`. 96 | 97 | ### can([subject], [action], [resource]): boolean 98 | this is the core functionality of authr, the ultimate question: "can this **subject** perform this **action** on this **resource**?" 99 | 100 | to get the answer to that question, we must pass all three parameters into this function 101 | 102 | - `subject` `ISubject` - the subject (actor) attempting the action 103 | - `action` string - the action being attempted 104 | - `resource` `IResource` - the resource that will be affected by the change 105 | 106 | returns `true` if they are allowed, and `false` if they are not. 107 | 108 | #### example 109 | ```js 110 | import { 111 | can 112 | GET_RULES, 113 | GET_RESOURCE_TYPE, 114 | GET_RESOURCE_ATTRIBUTE, 115 | Rule 116 | } from '@cloudflare/authr'; 117 | 118 | var admin = { 119 | [GET_RULES]: () => ([ 120 | Rule.allow({ 121 | rsrc_type: 'zone', 122 | rsrc_match: [['@status', '=', 'V']], 123 | action: '*' 124 | }) 125 | ]) 126 | }; 127 | 128 | var user = { 129 | [GET_RULES]: () => ([ 130 | Rule.allow({ 131 | rsrc_type: 'zone', 132 | rsrc_match: [['@id', '=', '123']], 133 | action: ['init', 'delete'] 134 | }) 135 | ]) 136 | }; 137 | 138 | var zones = { 139 | '123': { 140 | [GET_RESOURCE_TYPE]: () => 'zone', 141 | [GET_RESOURCE_ATTRIBUTE]: k => { 142 | var attr = { 143 | 'id': 123, 144 | 'status': 'V' 145 | }; 146 | return attr[k] || null; 147 | } 148 | }, 149 | '567': { 150 | [GET_RESOURCE_TYPE]: () => 'zone', 151 | [GET_RESOURCE_ATTRIBUTE]: k => { 152 | var attr = { 153 | 'id': 567, 154 | 'status': 'V' 155 | }; 156 | return attr[k] || null; 157 | } 158 | } 159 | }; 160 | 161 | console.log(can(admin, 'delete', zones['123'])); // => true 162 | console.log(can(user, 'delete', zones['567'])); // => false 163 | console.log(can(admin, 'do_admin_things', zones['123'])); // true 164 | 165 | ``` 166 | -------------------------------------------------------------------------------- /ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cloudflare/authr", 3 | "version": "3.0.1", 4 | "description": "a flexible, expressive, language-agnostic access-control framework.", 5 | "main": "build/index.js", 6 | "types": "build/index.d.ts", 7 | "scripts": { 8 | "test": "tsc && ava" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/cloudflare/authr.git" 13 | }, 14 | "keywords": [ 15 | "permission", 16 | "access-control" 17 | ], 18 | "author": "Nicholas Comer ", 19 | "license": "BSD-3-Clause", 20 | "dependencies": { 21 | "lodash": "^4.17.10" 22 | }, 23 | "devDependencies": { 24 | "@types/lodash": "^4.14.165", 25 | "ava": "^0.22.0", 26 | "babel-loader": "^7.1.2", 27 | "babel-preset-es2015": "^6.24.1", 28 | "babel-preset-stage-0": "^6.24.1", 29 | "babel-preset-stage-1": "^6.24.1", 30 | "babel-register": "^6.26.0", 31 | "typescript": "^3.0.1" 32 | }, 33 | "ava": { 34 | "require": [ 35 | "babel-register" 36 | ], 37 | "babel": "inherit" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ts/src/authrError.ts: -------------------------------------------------------------------------------- 1 | export default class AuthrError extends Error {} 2 | -------------------------------------------------------------------------------- /ts/src/condition.ts: -------------------------------------------------------------------------------- 1 | import { findIndex, intersectionWith } from "lodash"; 2 | import AuthrError from "./authrError"; 3 | import IResource, { SYM_GET_RSRC_ATTR } from "./resource"; 4 | import { 5 | $authr, 6 | IEvaluator, 7 | IJSONSerializable, 8 | isArray, 9 | isString, 10 | } from "./util"; 11 | 12 | interface IOperatorFunc { 13 | (left: any, right: any): boolean; 14 | } 15 | 16 | export enum OperatorSign { 17 | EQUALS = "=", 18 | NOT_EQUALS = "!=", 19 | LIKE = "~=", 20 | CASE_SENSITIVE_REGEXP = "~", 21 | CASE_INSENSITIVE_REGEXP = "~*", 22 | INV_CASE_SENSITIVE_REGEXP = "!~", 23 | INV_CASE_INSENSITIVE_REGEXP = "!~*", 24 | IN = "$in", 25 | NOT_IN = "$nin", 26 | ARRAY_INTERSECT = "&", 27 | ARRAY_DIFFERENCE = "-", 28 | } 29 | 30 | const operators: Map = new Map([ 31 | [OperatorSign.EQUALS, (left: any, right: any): boolean => left == right], 32 | [OperatorSign.NOT_EQUALS, (left: any, right: any): boolean => left != right], 33 | [ 34 | OperatorSign.LIKE, 35 | (left: any, right: any): boolean => { 36 | let pleft = "^"; 37 | let pright = "$"; 38 | right = `${right}`; 39 | if (right.startsWith("*")) { 40 | pleft = ""; 41 | } 42 | if (right.endsWith("*")) { 43 | pright = ""; 44 | } 45 | return RegExp(`${pleft}${right.replace("*", "")}${pright}`, "i").test( 46 | left 47 | ); 48 | }, 49 | ], 50 | [ 51 | OperatorSign.CASE_SENSITIVE_REGEXP, 52 | (left: any, right: any): boolean => RegExp(right).test(left), 53 | ], 54 | [ 55 | OperatorSign.CASE_INSENSITIVE_REGEXP, 56 | (left: any, right: any): boolean => RegExp(right, "i").test(left), 57 | ], 58 | [ 59 | OperatorSign.INV_CASE_SENSITIVE_REGEXP, 60 | (left: any, right: any): boolean => !RegExp(right).test(left), 61 | ], 62 | [ 63 | OperatorSign.INV_CASE_INSENSITIVE_REGEXP, 64 | (left: any, right: any): boolean => !RegExp(right, "i").test(left), 65 | ], 66 | [ 67 | OperatorSign.IN, 68 | (left: any, right: any): boolean => { 69 | if (!isArray(right)) { 70 | return false; 71 | } 72 | return findIndex(right, (v: any) => v == left) >= 0; 73 | }, 74 | ], 75 | [ 76 | OperatorSign.NOT_IN, 77 | (left: any, right: any): boolean => { 78 | if (!isArray(right)) { 79 | return false; 80 | } 81 | return findIndex(right, (v: any) => v == left) === -1; 82 | }, 83 | ], 84 | [ 85 | OperatorSign.ARRAY_INTERSECT, 86 | (left: any, right: any): boolean => { 87 | if (!isArray(left) || !isArray(right)) { 88 | return false; 89 | } 90 | return ( 91 | intersectionWith(left, right, (a: any, b: any) => a == b).length > 0 92 | ); 93 | }, 94 | ], 95 | [ 96 | OperatorSign.ARRAY_DIFFERENCE, 97 | (left: any, right: any): boolean => { 98 | if (!isArray(left) || !isArray(right)) { 99 | return false; 100 | } 101 | return ( 102 | intersectionWith(left, right, (a: any, b: any) => a == b).length === 0 103 | ); 104 | }, 105 | ], 106 | ]); 107 | 108 | function determineValue(resource: IResource, value: any): any { 109 | if (isString(value) && value.length > 1) { 110 | if (value.charAt(0) === "@") { 111 | return resource[SYM_GET_RSRC_ATTR](value.substr(1)); 112 | } 113 | if (value.substr(0, 2) === "\\@") { 114 | return value.substr(1); 115 | } 116 | } 117 | return value; 118 | } 119 | 120 | interface IConditionInternal { 121 | left: any; 122 | right: any; 123 | sign: OperatorSign; 124 | operator: IOperatorFunc; 125 | } 126 | 127 | export default class Condition implements IJSONSerializable, IEvaluator { 128 | private [$authr]: IConditionInternal; 129 | 130 | constructor(left: any, opsign: string, right: any) { 131 | const op = operators.get(opsign as OperatorSign); 132 | if (!op) { 133 | throw new AuthrError(`Unknown condition operator: '${opsign}'`); 134 | } 135 | this[$authr] = { 136 | left, 137 | right, 138 | sign: opsign as OperatorSign, 139 | operator: op, 140 | }; 141 | } 142 | 143 | evaluate(resource: IResource): boolean { 144 | return this[$authr].operator( 145 | determineValue(resource, this[$authr].left), 146 | determineValue(resource, this[$authr].right) 147 | ); 148 | } 149 | 150 | toJSON(): any { 151 | const { left, sign, right } = this[$authr]; 152 | return [left, sign, right]; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /ts/src/conditionSet.ts: -------------------------------------------------------------------------------- 1 | import { isPlainObject } from "lodash"; 2 | import AuthrError from "./authrError"; 3 | import Condition from "./condition"; 4 | import IResource from "./resource"; 5 | import { 6 | $authr, 7 | empty, 8 | IEvaluator, 9 | IJSONSerializable, 10 | isArray, 11 | isString, 12 | } from "./util"; 13 | 14 | enum Conjunction { 15 | AND = "$and", 16 | OR = "$or", 17 | } 18 | 19 | const IMPLIED_CONJUNCTION = Conjunction.AND; 20 | 21 | interface IConditionSetInternal { 22 | evaluators: IEvaluator[]; 23 | conjunction: Conjunction; 24 | } 25 | 26 | type ConditionTuple = [any, string, any]; 27 | 28 | function isConditionTuple(v?: any): v is ConditionTuple { 29 | if (!v) { 30 | return false; 31 | } 32 | return isArray(v) && v.length === 3 && isString(v[1]); 33 | } 34 | 35 | export default class ConditionSet implements IJSONSerializable, IEvaluator { 36 | private [$authr]: IConditionSetInternal = { 37 | conjunction: IMPLIED_CONJUNCTION, 38 | evaluators: [], 39 | }; 40 | 41 | constructor(spec: any) { 42 | if (isPlainObject(spec)) { 43 | const [conj] = Object.keys(spec); 44 | if (conj !== Conjunction.AND && conj !== Conjunction.OR) { 45 | throw new AuthrError(`Unknown condition set conjunction: ${conj}`); 46 | } 47 | this[$authr].conjunction = conj; 48 | spec = spec[conj]; 49 | } 50 | if (!isArray(spec)) { 51 | throw new AuthrError( 52 | "ConditionSet only takes an object or array during construction" 53 | ); 54 | } 55 | for (let rawe of spec) { 56 | if (empty(rawe)) { 57 | continue; 58 | } 59 | if (isConditionTuple(rawe)) { 60 | const [l, o, r] = rawe; 61 | this[$authr].evaluators.push(new Condition(l, o, r)); 62 | } else { 63 | this[$authr].evaluators.push(new ConditionSet(rawe)); 64 | } 65 | } 66 | } 67 | 68 | evaluate(resource: IResource): boolean { 69 | var result = true; // Vacuous truth: https://en.wikipedia.org/wiki/Vacuous_truth 70 | for (let evaluator of this[$authr].evaluators) { 71 | let evalResult = evaluator.evaluate(resource); 72 | switch (this[$authr].conjunction) { 73 | case Conjunction.OR: 74 | if (evalResult) { 75 | return true; // short circuit 76 | } 77 | result = false; 78 | break; 79 | case Conjunction.AND: 80 | if (!evalResult) { 81 | return false; // short circuit 82 | } 83 | result = true; 84 | break; 85 | } 86 | } 87 | return result; 88 | } 89 | 90 | toJSON(): any { 91 | var out: any = this[$authr].evaluators.map((e: any) => e.toJSON()); 92 | if (this[$authr].conjunction !== IMPLIED_CONJUNCTION) { 93 | out = { [this[$authr].conjunction]: out }; 94 | } 95 | return out; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /ts/src/index.ts: -------------------------------------------------------------------------------- 1 | import { isString } from "lodash"; 2 | import AuthrError from "./authrError"; 3 | import IResource, { SYM_GET_RSRC_ATTR, SYM_GET_RSRC_TYPE } from "./resource"; 4 | import Rule, { Access } from "./rule"; 5 | import ISubject, { SYM_GET_RULES } from "./subject"; 6 | import { runtimeAssertIsResource, runtimeAssertIsSubject } from "./util"; 7 | 8 | function can(subject: ISubject, action: string, resource: IResource): boolean { 9 | runtimeAssertIsSubject(subject); 10 | runtimeAssertIsResource(resource); 11 | if (!isString(action)) { 12 | throw new AuthrError('"action" must be a string'); 13 | } 14 | const rules = subject[SYM_GET_RULES](); 15 | const rt = resource[SYM_GET_RSRC_TYPE](); 16 | for (let rule of rules) { 17 | if (!rule.resourceTypes().contains(rt)) { 18 | continue; 19 | } 20 | if (!rule.actions().contains(action)) { 21 | continue; 22 | } 23 | if (!rule.conditions().evaluate(resource)) { 24 | continue; 25 | } 26 | const access = rule.access(); 27 | switch (access) { 28 | case Access.ALLOW: 29 | return true; 30 | case Access.DENY: 31 | return false; 32 | } 33 | throw new Error(`Rule access set to unknown value: '${access}'`); 34 | } 35 | // default to "deny all" 36 | return false; 37 | } 38 | 39 | export { 40 | ISubject, 41 | IResource, 42 | can, 43 | Rule, 44 | AuthrError, 45 | SYM_GET_RULES as GET_RULES, 46 | SYM_GET_RSRC_TYPE as GET_RESOURCE_TYPE, 47 | SYM_GET_RSRC_ATTR as GET_RESOURCE_ATTRIBUTE, 48 | }; 49 | -------------------------------------------------------------------------------- /ts/src/resource.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from "lodash"; 2 | 3 | export const SYM_GET_RSRC_TYPE = Symbol("authr.resource_get_resource_type"); 4 | export const SYM_GET_RSRC_ATTR = Symbol( 5 | "authr.resource_get_resource_attribute" 6 | ); 7 | 8 | interface IResource { 9 | [SYM_GET_RSRC_TYPE](): string; 10 | [SYM_GET_RSRC_ATTR](attribute: string): any; 11 | } 12 | 13 | export function isResource(v?: any): v is IResource { 14 | if (!isObject(v)) { 15 | return false; 16 | } 17 | return ( 18 | v.hasOwnProperty(SYM_GET_RSRC_ATTR) && v.hasOwnProperty(SYM_GET_RSRC_TYPE) 19 | ); 20 | } 21 | 22 | export default IResource; 23 | -------------------------------------------------------------------------------- /ts/src/rule.ts: -------------------------------------------------------------------------------- 1 | import AuthrError from "./authrError"; 2 | import ConditionSet from "./conditionSet"; 3 | import SlugSet from "./slugSet"; 4 | import { $authr, IJSONSerializable, isPlainObject } from "./util"; 5 | 6 | export enum Access { 7 | ALLOW = "allow", 8 | DENY = "deny", 9 | } 10 | 11 | function coerceToAccess(v: any): Access { 12 | if (typeof v === "string") { 13 | switch (v) { 14 | case Access.ALLOW: 15 | case Access.DENY: 16 | return v; 17 | } 18 | } 19 | throw new AuthrError(`invalid "access" value: "${v}"`); 20 | } 21 | 22 | export const RSRC_TYPE = "rsrc_type"; 23 | export const RSRC_MATCH = "rsrc_match"; 24 | export const ACTION = "action"; 25 | 26 | interface IRuleInternal { 27 | access: Access; 28 | where: { 29 | [RSRC_TYPE]?: SlugSet; 30 | [RSRC_MATCH]?: ConditionSet; 31 | [ACTION]?: SlugSet; 32 | }; 33 | meta: any; 34 | } 35 | 36 | export default class Rule implements IJSONSerializable { 37 | private [$authr]: IRuleInternal; 38 | 39 | static allow(spec: any, meta: any = null): Rule { 40 | return new Rule(Access.ALLOW, spec, meta); 41 | } 42 | 43 | static deny(spec: any, meta: any = null): Rule { 44 | return new Rule(Access.DENY, spec, meta); 45 | } 46 | 47 | static create(spec: any) { 48 | if (!isPlainObject(spec)) { 49 | throw new AuthrError('"spec" must be a plain object'); 50 | } 51 | return new Rule( 52 | (spec as any).access, 53 | (spec as any).where, 54 | (spec as any).$meta 55 | ); 56 | } 57 | 58 | constructor(access: any, spec: any, meta: any = null) { 59 | this[$authr] = { 60 | access: coerceToAccess(access), 61 | where: {}, 62 | meta: null, 63 | }; 64 | if (typeof spec === "string" && spec === "all") { 65 | spec = { 66 | [RSRC_TYPE]: "*", 67 | [RSRC_MATCH]: [], 68 | [ACTION]: "*", 69 | }; 70 | } 71 | if (!isPlainObject(spec)) { 72 | throw new AuthrError('"spec" must be a string or plain object'); 73 | } 74 | if (meta) { 75 | this[$authr].meta = meta; 76 | } 77 | for (let seg in spec) { 78 | let segspec: any = (spec as any)[seg]; 79 | switch (seg) { 80 | case RSRC_TYPE: 81 | case ACTION: 82 | this[$authr].where[seg] = new SlugSet(segspec); 83 | break; 84 | case RSRC_MATCH: 85 | this[$authr].where[RSRC_MATCH] = new ConditionSet(segspec); 86 | break; 87 | } 88 | } 89 | } 90 | 91 | access(): Access { 92 | return this[$authr].access; 93 | } 94 | 95 | resourceTypes(): SlugSet { 96 | const rt = this[$authr].where[RSRC_TYPE]; 97 | if (!rt) { 98 | throw new AuthrError('missing "where.rsrc_type" segment on rule'); 99 | } 100 | return rt; 101 | } 102 | 103 | conditions(): ConditionSet { 104 | const match = this[$authr].where[RSRC_MATCH]; 105 | if (!match) { 106 | throw new AuthrError('missing "where.rsrc_match" segment on rule'); 107 | } 108 | return match; 109 | } 110 | 111 | actions(): SlugSet { 112 | const act = this[$authr].where[ACTION]; 113 | if (!act) { 114 | throw new AuthrError('missing "where.action" segment on rule'); 115 | } 116 | return act; 117 | } 118 | 119 | toJSON(): any { 120 | interface IRaw { 121 | access: string; 122 | where: { 123 | [RSRC_TYPE]: any; 124 | [RSRC_MATCH]: any; 125 | [ACTION]: any; 126 | }; 127 | $meta?: any; 128 | } 129 | const raw: IRaw = { 130 | access: this[$authr].access, 131 | where: { 132 | [RSRC_TYPE]: this.resourceTypes().toJSON(), 133 | [RSRC_MATCH]: this.conditions().toJSON(), 134 | [ACTION]: this.actions().toJSON(), 135 | }, 136 | }; 137 | if (this[$authr].meta) { 138 | raw.$meta = this[$authr].meta; 139 | } 140 | return raw; 141 | } 142 | 143 | toString() { 144 | return JSON.stringify(this); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /ts/src/slugSet.ts: -------------------------------------------------------------------------------- 1 | import AuthrError from "./authrError"; 2 | import { $authr, isPlainObject } from "./util"; 3 | 4 | enum Mode { 5 | BLOCKLIST = 0, 6 | ALLOWLIST = 1, 7 | WILDCARD = 2, 8 | } 9 | 10 | const NOT = "$not"; 11 | 12 | interface ISlugSetInternal { 13 | mode: Mode; 14 | items: string[]; 15 | } 16 | 17 | interface IBlocklistSpec { 18 | [NOT]: any; 19 | } 20 | 21 | function isBlocklistSpec(v: any): v is IBlocklistSpec { 22 | if (isPlainObject(v)) { 23 | return v.hasOwnProperty(NOT); 24 | } 25 | return false; 26 | } 27 | 28 | export default class SlugSet { 29 | private [$authr]: ISlugSetInternal; 30 | 31 | constructor(spec: any) { 32 | this[$authr] = { 33 | mode: Mode.ALLOWLIST, 34 | items: [], 35 | }; 36 | if (spec === "*") { 37 | this[$authr].mode = Mode.WILDCARD; 38 | } else { 39 | if (isBlocklistSpec(spec)) { 40 | this[$authr].mode = Mode.BLOCKLIST; 41 | spec = spec[NOT]; 42 | } 43 | if (typeof spec === "string") { 44 | spec = [spec]; 45 | } 46 | if (!Array.isArray(spec)) { 47 | throw new AuthrError( 48 | "SlugSet constructor expects a string, array or object for argument 1" 49 | ); 50 | } 51 | this[$authr].items = spec; 52 | } 53 | } 54 | 55 | contains(needle: string): boolean { 56 | if (this[$authr].mode === Mode.WILDCARD) { 57 | return true; 58 | } 59 | const doesContain = this[$authr].items.includes(needle); 60 | if (this[$authr].mode === Mode.BLOCKLIST) { 61 | return !doesContain; 62 | } 63 | 64 | return doesContain; 65 | } 66 | 67 | toJSON() { 68 | if (this[$authr].mode === Mode.WILDCARD) { 69 | return "*"; 70 | } 71 | let set: any = this[$authr].items; 72 | if (set.length === 1) { 73 | [set] = set; 74 | } 75 | if (this[$authr].mode === Mode.BLOCKLIST) { 76 | set = { [NOT]: set }; 77 | } 78 | return set; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /ts/src/subject.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from "lodash"; 2 | import Rule from "./rule"; 3 | 4 | export const SYM_GET_RULES = Symbol("authr.subject_get_rules"); 5 | 6 | interface ISubject { 7 | [SYM_GET_RULES](): Rule[]; 8 | } 9 | 10 | export function isSubject(v?: any): v is ISubject { 11 | if (!isObject(v)) { 12 | return false; 13 | } 14 | return v.hasOwnProperty(SYM_GET_RULES); 15 | } 16 | 17 | export default ISubject; 18 | -------------------------------------------------------------------------------- /ts/src/util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isArray as _isArray, 3 | isObject as _isObject, 4 | isPlainObject as _isPlainObject, 5 | isString as _isString, 6 | keys as _keys, 7 | values as _values, 8 | } from "lodash"; 9 | import AuthrError from "./authrError"; 10 | import IResource, { isResource } from "./resource"; 11 | import ISubject, { isSubject } from "./subject"; 12 | 13 | export interface IEvaluator { 14 | evaluate(resource: IResource): boolean; 15 | } 16 | 17 | export interface IJSONSerializable { 18 | toJSON(): any; 19 | } 20 | 21 | export function keys(v: object): string[] { 22 | if (Object.keys) { 23 | return Object.keys(v); 24 | } 25 | return _keys(v); 26 | } 27 | 28 | export function values(v: object): any[] { 29 | if (Object.values) { 30 | return Object.values(v); 31 | } 32 | return _values(v); 33 | } 34 | 35 | export function isPlainObject(v?: any): v is object { 36 | return _isPlainObject(v); 37 | } 38 | 39 | export function isString(v?: any): v is string { 40 | return _isString(v); 41 | } 42 | 43 | export function isObject(v?: any): v is object { 44 | return _isObject(v); 45 | } 46 | 47 | export function isArray(v?: any): v is any[] { 48 | return _isArray(v); 49 | } 50 | 51 | export function empty(v?: any): boolean { 52 | if (v === null) { 53 | return true; 54 | } 55 | if (v === undefined) { 56 | return true; 57 | } 58 | if (isArray(v) || isString(v)) { 59 | return !v.length; 60 | } 61 | if (isPlainObject(v)) { 62 | return !keys(v).length; 63 | } 64 | return !v; 65 | } 66 | 67 | export function runtimeAssertIsSubject(v?: ISubject): void { 68 | if (!isSubject(v)) { 69 | throw new AuthrError( 70 | '"subject" argument does not implement mandatory subject methods' 71 | ); 72 | } 73 | } 74 | 75 | export function runtimeAssertIsResource(v?: IResource): void { 76 | if (!isResource(v)) { 77 | throw new AuthrError( 78 | '"resource" argument does not implement mandatory resource methods' 79 | ); 80 | } 81 | } 82 | 83 | export const $authr = Symbol("authr.admin"); // symbol to hide internal stuff 84 | -------------------------------------------------------------------------------- /ts/test/condition.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import test from 'ava'; 4 | import Condition from '../build/condition'; 5 | import { GET_RESOURCE_TYPE, GET_RESOURCE_ATTRIBUTE } from '../build'; 6 | 7 | test('undefined operator throws error', t => { 8 | t.throws(() => { 9 | Condition.create('id', '@>', 'one'); 10 | }); 11 | }); 12 | 13 | test('evaluating non-resource throws error', t => { 14 | t.throws(() => { 15 | Condition.create('id', '=', '5').evaluate({ 16 | id: '5' 17 | }); 18 | }); 19 | }); 20 | 21 | test('condition evaluate', t => { 22 | var resource = { 23 | [GET_RESOURCE_TYPE]: () => 'zone', 24 | [GET_RESOURCE_ATTRIBUTE]: key => { 25 | var attrs = { 26 | id: 123, 27 | type: 'full', 28 | kind: 'Pretty', 29 | user_id: '867', 30 | lol: '@wut', 31 | wut: '???', 32 | stuff: [1, '2', 3, 'foo', 'bar'] 33 | }; 34 | return attrs[key] || null; 35 | } 36 | }; 37 | 38 | t.true(new Condition('@id', '=', '123').evaluate(resource)); 39 | t.true(new Condition('@id', '=', 123).evaluate(resource)); 40 | t.true(new Condition('@type', '=', 'full').evaluate(resource)); 41 | t.false(new Condition('@id', '=', '321').evaluate(resource)); 42 | 43 | t.true(new Condition('@id', '!=', 321).evaluate(resource)); 44 | t.true(new Condition('@id', '!=', '321').evaluate(resource)); 45 | t.false(new Condition('@type', '!=', 'full').evaluate(resource)); 46 | 47 | t.true(new Condition('@type', '~=', 'f*').evaluate(resource)); 48 | t.false(new Condition('@type', '~=', '*r').evaluate(resource)); 49 | t.true(new Condition('@type', '~=', 'FULL').evaluate(resource)); // case insensitivity 50 | 51 | t.true(new Condition('@user_id', '$in', ['432', 867]).evaluate(resource)); 52 | t.true(new Condition('@user_id', '$in', [432, '867']).evaluate(resource)); 53 | t.false(new Condition('@user_id', '$in', [432, 987]).evaluate(resource)); 54 | 55 | t.false(new Condition('@user_id', '$nin', ['867', '233']).evaluate(resource)); 56 | t.true(new Condition('@user_id', '$nin', [925, 222, 999]).evaluate(resource)); 57 | 58 | t.true(new Condition('@lol', '=', '\\@wut').evaluate(resource)); // eslint-disable-line no-useless-escape 59 | 60 | t.false(new Condition('@kind', '~', '^pretty$').evaluate(resource)); 61 | t.true(new Condition('@kind', '~', '^[A-Z]retty$').evaluate(resource)); 62 | 63 | t.true(new Condition('@kind', '~*', '^pretty$').evaluate(resource)); 64 | t.false(new Condition('@kind', '!~*', '^pretty$').evaluate(resource)); 65 | 66 | t.true(new Condition('@kind', '!~', '^Ugly$').evaluate(resource)); 67 | 68 | t.true(new Condition('@stuff', '&', ['foo', 23]).evaluate(resource)); 69 | t.false(new Condition('@stuff', '&', ['one', 'two']).evaluate(resource)); 70 | t.true(new Condition('@stuff', '&', ['1']).evaluate(resource)); 71 | t.true(new Condition('@stuff', '-', ['three', 'four']).evaluate(resource)); 72 | t.false(new Condition('@stuff', '-', ['foo']).evaluate(resource)); 73 | t.false(new Condition('@stuff', '-', ['3']).evaluate(resource)); 74 | }); 75 | 76 | test('condition toJSON', t => { 77 | t.is(JSON.stringify(new Condition('@user_id', '=', '333')), '["@user_id","=","333"]'); 78 | t.is(JSON.stringify(new Condition('@user_id', '!=', 'null')), '["@user_id","!=","null"]'); 79 | }); 80 | -------------------------------------------------------------------------------- /ts/test/conditionSet.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import test from 'ava'; 4 | import ConditionSet from '../build/conditionSet'; 5 | import { GET_RESOURCE_TYPE, GET_RESOURCE_ATTRIBUTE } from '../build'; 6 | 7 | test('unknown logical conjunctions throws error', t => { 8 | t.throws(() => { 9 | new ConditionSet({ // eslint-disable-line no-new 10 | $xor: [['@id', '=', '1'], ['@type', '=', 'root']] 11 | }); 12 | }); 13 | }); 14 | 15 | test('weird construction values throws error', t => { 16 | t.throws(() => { 17 | new ConditionSet(8); // eslint-disable-line no-new 18 | }); 19 | t.throws(() => { 20 | new ConditionSet({ $and: { $or: ['what', 'are', 'you', 'doing?!'] } }); // eslint-disable-line no-new 21 | }); 22 | }); 23 | 24 | test('normal construction gives a normal ConditionSet', t => { 25 | var attrs = {}; 26 | var rsrc = { 27 | [GET_RESOURCE_TYPE]: () => 'user', 28 | [GET_RESOURCE_ATTRIBUTE]: k => { 29 | return attrs[k] || null; 30 | } 31 | }; 32 | 33 | var cs = new ConditionSet([ 34 | ['@type', '~=', 'root'], 35 | { 36 | $or: [ 37 | ['@id', '=', '1'], 38 | ['@id', '=', '888'] 39 | ] 40 | } 41 | ]); 42 | 43 | attrs['id'] = '44'; 44 | t.false(cs.evaluate(rsrc)); 45 | 46 | attrs['type'] = 'ROOT'; 47 | t.false(cs.evaluate(rsrc)); 48 | 49 | attrs['id'] = '1'; 50 | t.true(cs.evaluate(rsrc)); 51 | 52 | attrs['id'] = '888'; 53 | t.true(cs.evaluate(rsrc)); 54 | 55 | attrs['id'] = '90'; 56 | t.false(cs.evaluate(rsrc)); 57 | }); 58 | 59 | test('ConditionSet will skip over random falsy values', t => { 60 | var rsrc = { 61 | [GET_RESOURCE_TYPE]: () => 'user', 62 | [GET_RESOURCE_ATTRIBUTE]: k => { 63 | var attrs = { id: '5' }; 64 | return attrs[k] || null; 65 | } 66 | }; 67 | var cs = new ConditionSet([ 68 | [], 69 | ['@id', '=', '5'], 70 | false 71 | ]); 72 | t.true(cs.evaluate(rsrc)); 73 | }); 74 | 75 | test('OR evaluations can short-circuit if needed', t => { 76 | var rsrc = { 77 | [GET_RESOURCE_TYPE]: () => 'user', 78 | [GET_RESOURCE_ATTRIBUTE]: k => { 79 | var attrs = { 80 | one: 'two', 81 | three: 'four' 82 | }; 83 | if (k === 'three') { 84 | t.fail('short circuit did not work, ConditionSet continued evaluating'); 85 | } 86 | return attrs[k] || null; 87 | } 88 | }; 89 | 90 | var cs = new ConditionSet({ 91 | $or: [ 92 | ['@one', '=', 'two'], 93 | ['@three', '!=', 'four'] 94 | ] 95 | }); 96 | 97 | t.true(cs.evaluate(rsrc)); 98 | }); 99 | 100 | test('AND evaluations can short-circuit if needed', t => { 101 | var rsrc = { 102 | [GET_RESOURCE_TYPE]: () => 'user', 103 | [GET_RESOURCE_ATTRIBUTE]: k => { 104 | var attrs = { 105 | one: 'two', 106 | three: 'four' 107 | }; 108 | if (k === 'three') { 109 | t.fail('short circuit did not work, ConditionSet continued evaluating'); 110 | } 111 | return attrs[k] || null; 112 | } 113 | }; 114 | 115 | var cs = new ConditionSet([ 116 | ['@one', '=', 'five'], 117 | ['@three', '=', 'four'] 118 | ]); 119 | 120 | t.false(cs.evaluate(rsrc)); 121 | }); 122 | 123 | test('vacuous truth', t => { 124 | var rsrc = { 125 | [GET_RESOURCE_TYPE]: () => 'zone', 126 | [GET_RESOURCE_ATTRIBUTE]: k => null 127 | }; 128 | 129 | t.true(new ConditionSet([]).evaluate(rsrc)); 130 | }); 131 | 132 | test('evaluating non-resource throws error', t => { 133 | var rsrc = { 134 | id: '5', 135 | type: 'resource, trust me, im a dolphin' 136 | }; 137 | 138 | t.throws(() => { 139 | new ConditionSet(['@id', '=', '5']).evaluate(rsrc); 140 | }); 141 | }); 142 | 143 | test('ConditionSet toString', t => { 144 | var cs = new ConditionSet({ 145 | $and: [ 146 | ['@id', '=', '5'], 147 | ['@type', '=', 'admin'] 148 | ] 149 | }); 150 | t.is(JSON.stringify(cs), '[["@id","=","5"],["@type","=","admin"]]'); 151 | 152 | cs = new ConditionSet({ 153 | $or: [ 154 | ['@type', '=', 'root'], 155 | ['@id', '=', '1'] 156 | ] 157 | }); 158 | t.is(JSON.stringify(cs), '{"$or":[["@type","=","root"],["@id","=","1"]]}'); 159 | }); 160 | -------------------------------------------------------------------------------- /ts/test/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import test from 'ava'; 4 | import { 5 | can, 6 | Rule, 7 | GET_RULES, 8 | GET_RESOURCE_TYPE, 9 | GET_RESOURCE_ATTRIBUTE 10 | } from '../build'; 11 | import SlugSet from '../build/slugSet'; 12 | import ConditionSet from '../build/conditionSet'; 13 | 14 | test('normal permission construction', t => { 15 | var p = Rule.allow({ 16 | rsrc_type: 'zone', 17 | rsrc_match: [ 18 | ['@id', '=', '123'] 19 | ], 20 | action: 'enabled_service_mode' 21 | }); 22 | 23 | t.true(p.resourceTypes() instanceof SlugSet); 24 | t.true(p.resourceTypes().contains('zone')); 25 | t.false(p.resourceTypes().contains('record')); 26 | 27 | t.true(p.actions() instanceof SlugSet); 28 | t.false(p.actions().contains('delete')); 29 | t.true(p.actions().contains('enabled_service_mode')); 30 | 31 | t.true(p.conditions() instanceof ConditionSet); 32 | 33 | t.is(p.toString(), '{"access":"allow","where":{"rsrc_type":"zone","rsrc_match":[["@id","=","123"]],"action":"enabled_service_mode"}}'); 34 | }); 35 | 36 | test('undefined resource type throws error', t => { 37 | var p = Rule.allow({ 38 | rsrc_match: [['@id', '=', '123']], 39 | action: 'enabled_service_mode' 40 | }); 41 | t.throws(() => { 42 | p.resourceTypes(); 43 | }); 44 | }); 45 | 46 | test('undefined resource match throws err', t => { 47 | var p = Rule.deny({ 48 | rsrc_type: 'zone', 49 | action: 'enabled_service_mode' 50 | }); 51 | t.throws(() => { 52 | p.conditions(); 53 | }); 54 | }); 55 | 56 | test('undefined action throws error', t => { 57 | var p = Rule.allow({ 58 | rsrc_type: 'zone', 59 | rsrc_match: [['@id', '=', '123']] 60 | }); 61 | t.throws(() => { 62 | p.actions(); 63 | }); 64 | }); 65 | 66 | test('denying permissions', t => { 67 | let sub = { 68 | [GET_RULES]: () => [ 69 | Rule.deny({ 70 | rsrc_type: 'zone', 71 | rsrc_match: [['@id', '=', '254']], 72 | action: 'hack' 73 | }), 74 | Rule.allow('all') 75 | ] 76 | }; 77 | 78 | let rsrc = { 79 | [GET_RESOURCE_TYPE]: () => 'zone', 80 | [GET_RESOURCE_ATTRIBUTE]: key => { 81 | let attrs = { 82 | id: '254' 83 | }; 84 | return attrs[key] || null; 85 | } 86 | }; 87 | 88 | let otherResource = { 89 | [GET_RESOURCE_TYPE]: () => 'zone', 90 | [GET_RESOURCE_ATTRIBUTE]: key => { 91 | let attrs = { 92 | id: '255' 93 | }; 94 | return attrs[key] || null; 95 | } 96 | }; 97 | 98 | t.false(can(sub, 'hack', rsrc)); 99 | t.true(can(sub, 'hack', otherResource)); 100 | }); 101 | -------------------------------------------------------------------------------- /ts/test/slugSet.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import test from 'ava'; 4 | import SlugSet from '../build/slugSet'; 5 | 6 | test('malformed slug set throws error', t => { 7 | t.throws(() => { 8 | new SlugSet({ $lolnot: 'zone' }); // eslint-disable-line no-new 9 | }); 10 | }); 11 | 12 | test('weird construction values throw errors', t => { 13 | t.throws(() => { 14 | new SlugSet(null); // eslint-disable-line no-new 15 | }); 16 | t.throws(() => { 17 | new SlugSet({ $not: 5 }); // eslint-disable-line no-new 18 | }); 19 | t.throws(() => { 20 | new SlugSet({}); // eslint-disable-line no-new 21 | }); 22 | }); 23 | 24 | test('global matcher matches everything', t => { 25 | var set = new SlugSet('*'); 26 | t.true(set.contains('zone')); 27 | t.true(set.contains('record')); 28 | t.true(set.contains('user')); 29 | }); 30 | 31 | test('single value slug set matches just one thing', t => { 32 | var set = new SlugSet('zone'); 33 | t.true(set.contains('zone')); 34 | t.false(set.contains('record')); 35 | t.false(set.contains('user')); 36 | }); 37 | 38 | test('single value $not slug set matches everything else', t => { 39 | var set = new SlugSet({ $not: 'zone' }); 40 | t.false(set.contains('zone')); 41 | t.true(set.contains('record')); 42 | t.true(set.contains('user')); 43 | }); 44 | 45 | test('multi-value slug set matches the strings it contains', t => { 46 | var set = new SlugSet(['zone', 'record']); 47 | t.true(set.contains('zone')); 48 | t.true(set.contains('record')); 49 | t.false(set.contains('user')); 50 | }); 51 | 52 | test('multi-value $not slug set matches the strings it does NOT contain', t => { 53 | var set = new SlugSet({ $not: ['zone', 'record'] }); 54 | t.false(set.contains('zone')); 55 | t.false(set.contains('record')); 56 | t.true(set.contains('user')); 57 | }); 58 | 59 | test('SlugSet toString', t => { 60 | t.is(JSON.stringify(new SlugSet('*')), '"*"'); 61 | t.is(JSON.stringify(new SlugSet(['zone'])), '"zone"'); 62 | t.is(JSON.stringify(new SlugSet(['zone', 'record'])), '["zone","record"]'); 63 | t.is(JSON.stringify(new SlugSet({ $not: ['zone'] })), '{"$not":"zone"}'); 64 | t.is(JSON.stringify(new SlugSet({ $not: ['zone', 'record'] })), '{"$not":["zone","record"]}'); 65 | }); 66 | -------------------------------------------------------------------------------- /ts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "outDir": "build", 5 | "module": "commonjs", 6 | "noImplicitAny": true, 7 | "strict": true, 8 | "lib": [ 9 | "es6", 10 | "es2016.array.include", 11 | "es2017.object" 12 | ] 13 | }, 14 | "include": [ 15 | "src/**/*" 16 | ] 17 | } 18 | --------------------------------------------------------------------------------