├── VERSION
├── ts
├── src
│ ├── authrError.ts
│ ├── subject.ts
│ ├── resource.ts
│ ├── index.ts
│ ├── slugSet.ts
│ ├── util.ts
│ ├── conditionSet.ts
│ ├── rule.ts
│ └── condition.ts
├── .babelrc
├── tsconfig.json
├── Makefile
├── package.json
├── test
│ ├── slugSet.test.js
│ ├── index.test.js
│ ├── condition.test.js
│ └── conditionSet.test.js
└── README.md
├── OWNERS
├── doc.go
├── php
├── test
│ ├── TestCase.php
│ ├── Authr
│ │ ├── Condition
│ │ │ └── Operator
│ │ │ │ ├── LikeTest.php
│ │ │ │ ├── EqualsTest.php
│ │ │ │ ├── InTest.php
│ │ │ │ ├── NotEqualsTest.php
│ │ │ │ ├── NotInTest.php
│ │ │ │ ├── RegExp
│ │ │ │ ├── CaseInsensitiveTest.php
│ │ │ │ ├── InverseCaseInsensitiveTest.php
│ │ │ │ ├── CaseSensitiveTest.php
│ │ │ │ └── InverseCaseSensitiveTest.php
│ │ │ │ ├── ArrayIntersectTest.php
│ │ │ │ └── ArrayDifferenceTest.php
│ │ ├── TestSubject.php
│ │ ├── ResourceTest.php
│ │ ├── ConditionTest.php
│ │ ├── SlugSetTest.php
│ │ ├── ConditionSetTest.php
│ │ └── RuleTest.php
│ └── AuthrTest.php
├── src
│ ├── Authr
│ │ ├── Exception
│ │ │ ├── ValidationException.php
│ │ │ ├── InvalidRuleException.php
│ │ │ ├── InvalidConditionOperator.php
│ │ │ ├── InvalidSlugSetException.php
│ │ │ ├── InvalidAdHocResourceException.php
│ │ │ ├── InvalidConditionSetException.php
│ │ │ └── RuntimeException.php
│ │ ├── Exception.php
│ │ ├── Condition
│ │ │ ├── OperatorInterface.php
│ │ │ └── Operator
│ │ │ │ ├── Equals.php
│ │ │ │ ├── NotEquals.php
│ │ │ │ ├── RegExp
│ │ │ │ ├── CaseSensitive.php
│ │ │ │ ├── CaseInsensitive.php
│ │ │ │ ├── InverseCaseSensitive.php
│ │ │ │ └── InverseCaseInsensitive.php
│ │ │ │ ├── In.php
│ │ │ │ ├── NotIn.php
│ │ │ │ ├── ArrayIntersect.php
│ │ │ │ ├── ArrayDifference.php
│ │ │ │ └── Like.php
│ │ ├── SubjectInterface.php
│ │ ├── EvaluatorInterface.php
│ │ ├── ResourceInterface.php
│ │ ├── RuleList.php
│ │ ├── Resource.php
│ │ ├── SlugSet.php
│ │ ├── ConditionSet.php
│ │ ├── Condition.php
│ │ └── Rule.php
│ ├── AuthrInterface.php
│ └── Authr.php
├── Makefile
└── phpunit.xml
├── .gitignore
├── .phan
└── config.php
├── .editorconfig
├── go.mod
├── .github
└── workflows
│ ├── golangci-lint.yaml
│ ├── test-php.yaml
│ ├── test-js.yaml
│ ├── semgrep.yml
│ └── test-go.yaml
├── makefile
├── composer.json
├── authrutil
├── struct_resource.go
└── struct_resource_test.go
├── go.sum
├── LICENSE
├── rule-schema.json
├── regexp_cache_test.go
├── regexp_cache.go
├── contrib
└── semver
├── README.md
├── json_test.go
├── json.go
├── authr_test.go
└── authr.go
/VERSION:
--------------------------------------------------------------------------------
1 | 3.0.1
2 |
--------------------------------------------------------------------------------
/ts/src/authrError.ts:
--------------------------------------------------------------------------------
1 | export default class AuthrError extends Error {}
2 |
--------------------------------------------------------------------------------
/OWNERS:
--------------------------------------------------------------------------------
1 | # a list of people who are actively maintaining this code
2 | nicholas@cloudflare.com
3 |
--------------------------------------------------------------------------------
/ts/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "es2015",
4 | "stage-0",
5 | "stage-1"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/doc.go:
--------------------------------------------------------------------------------
1 | // Package authr is an application-level (layer 7) access control framework.
2 | package authr
3 |
--------------------------------------------------------------------------------
/php/test/TestCase.php:
--------------------------------------------------------------------------------
1 | [
5 | './php/src'
6 | ],
7 | 'exclude_analysis_directory_list' => [
8 | './php/vendor/psr/log'
9 | ]
10 | ];
11 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/php/src/Authr/Exception.php:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 | ./test/
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/php/src/Authr/Condition/Operator/RegExp/CaseSensitive.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/src/Authr/Condition/Operator/In.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/src/Authr/Condition/Operator/ArrayIntersect.php:
--------------------------------------------------------------------------------
1 | 0;
16 | }
17 |
18 | public function jsonSerialize()
19 | {
20 | return '&';
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/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/src/Authr/Condition/Operator/ArrayDifference.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/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/php/src/Authr/Condition/Operator/Like.php:
--------------------------------------------------------------------------------
1 | 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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/src/AuthrInterface.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/php/src/Authr/Resource.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/php/src/Authr/ConditionSet.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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/regexp_cache.go:
--------------------------------------------------------------------------------
1 | package authr
2 |
3 | import (
4 | "container/list"
5 | "regexp"
6 | "sync"
7 | )
8 |
9 | type regexpCacheEntry struct {
10 | p string
11 | r *regexp.Regexp
12 | }
13 |
14 | type regexpCache interface {
15 | add(pattern string, r *regexp.Regexp)
16 | find(pattern string) (*regexp.Regexp, bool)
17 | }
18 |
19 | // regexpListCache is a runtime cache for preventing needless regexp.Compile
20 | // operations since it can be expensive in hot areas.
21 | //
22 | // this cache has an LRU eviction policy and uses a double-linked list to
23 | // efficiently shift/remove entries without introducing more CPU overhead.
24 | //
25 | // this cache *significantly* reduces the expense of regexp operators in authr as
26 | // proven by benchmarks (old is noopRegexpCache, new is regexpListCache):
27 | //
28 | // benchmark old ns/op new ns/op delta
29 | // BenchmarkRegexpOperatorSerial/int(33)~*^foo$=>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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # authr
2 |
3 | [](https://github.com/cloudflare/authr/actions?query=workflow%3A%22Golang+Tests%22)
4 | [](https://github.com/cloudflare/authr/actions?query=workflow%3A%22JavaScript+Tests%22)
5 | [](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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/Rule.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------