├── .github ├── CODEOWNERS └── workflows │ ├── post-merge.yml │ ├── release.yml │ └── review.yml ├── .gitignore ├── .goreleaser.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── api ├── openapiv2 │ └── authorizer │ │ └── accesscontroller │ │ └── v1alpha1 │ │ ├── acl.swagger.json │ │ ├── check_service.swagger.json │ │ ├── expand_service.swagger.json │ │ ├── namespace_service.swagger.json │ │ ├── read_service.swagger.json │ │ └── write_service.swagger.json └── protos │ └── authorizer │ └── accesscontroller │ └── v1alpha1 │ ├── acl.proto │ ├── check_service.proto │ ├── expand_service.proto │ ├── namespace_service.proto │ ├── read_service.proto │ └── write_service.proto ├── buf.gen.yaml ├── buf.lock ├── buf.yaml ├── cmd └── access-controller │ └── main.go ├── db └── migrations │ ├── 1_bootstrap_tables.down.sql │ └── 1_bootstrap_tables.up.sql ├── docker ├── config.yaml └── docker-compose.yml ├── go.mod ├── go.sum ├── internal ├── access-controller.go ├── access-controller_test.go ├── client-router.go ├── client-router_test.go ├── datastores │ └── sql-store.go ├── hashring │ ├── hashring.go │ └── hashring_test.go ├── healthchecker │ ├── healthchecker.go │ └── healthchecker_test.go ├── namespace-manager │ ├── namespacemgr.go │ ├── namespacemgr_test.go │ └── postgres │ │ ├── manager.go │ │ └── manager_test.go ├── node.go ├── relation-store.go ├── relation-tuple.go ├── relation-tuple_test.go ├── tree.go └── tree_test.go ├── testdata └── namespace-configs │ ├── groups.json │ ├── programs.json │ └── projects.json └── tools.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @authorizer-tech/maintainers -------------------------------------------------------------------------------- /.github/workflows/post-merge.yml: -------------------------------------------------------------------------------- 1 | name: post-merge 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | code-coverage: 10 | name: Update Go Test Coverage 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | 17 | - uses: actions/setup-go@v1 18 | with: 19 | go-version: 1.16 20 | - run: echo $(go env GOPATH)/bin >> $GITHUB_PATH 21 | 22 | - name: "Go: Test Coverage" 23 | run: | 24 | make generate ci-test-coverage 25 | 26 | - name: Upload coverage to Codecov 27 | run: bash <(curl -s https://codecov.io/bash) -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Extract Version from Tag 13 | id: tag_name 14 | run: | 15 | echo ::set-output name=current_version::${GITHUB_REF#refs/tags/v} 16 | shell: bash 17 | 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Generate Release Changes 24 | id: changelog_reader 25 | uses: mindsers/changelog-reader-action@v2 26 | with: 27 | version: ${{ steps.tag_name.outputs.current_version }} 28 | 29 | - name: Generate Release Notes 30 | id: release_notes 31 | run: | 32 | export NOTES=$(mktemp) 33 | echo ::set-output name=notes::$NOTES 34 | printf '${{ steps.changelog_reader.outputs.changes }}' > $NOTES 35 | shell: bash 36 | 37 | - name: Set up Go 38 | uses: actions/setup-go@v2 39 | with: 40 | go-version: 1.16 41 | 42 | - name: Login to gcr.io 43 | uses: docker/login-action@v1 44 | with: 45 | registry: gcr.io 46 | username: _json_key 47 | password: ${{ secrets.GCR_JSON_KEY }} 48 | 49 | - name: Run GoReleaser 50 | uses: goreleaser/goreleaser-action@v2 51 | with: 52 | distribution: goreleaser 53 | version: latest 54 | args: release --rm-dist --release-notes ${{ steps.release_notes.outputs.notes }} 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/review.yml: -------------------------------------------------------------------------------- 1 | name: review 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | job1: 7 | name: "Go: Vet, Check, & Test" 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | with: 12 | fetch-depth: 0 13 | - uses: actions/setup-go@v1 14 | with: 15 | go-version: 1.16 16 | - run: echo $(go env GOPATH)/bin >> $GITHUB_PATH 17 | - run: make generate 18 | 19 | - name: "Go: vet, fmt, lint" 20 | run: | 21 | make check 22 | 23 | - name: "Go: Test Coverage" 24 | run: | 25 | make ci-test-coverage 26 | 27 | - name: Upload coverage to Codecov 28 | run: bash <(curl -s https://codecov.io/bash) 29 | 30 | job2: 31 | name: "Buf: Lint & Breaking Changes" 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: wizhi/setup-buf@v1 36 | with: 37 | version: 0.42.1 38 | - run: buf lint 39 | - run: buf breaking --against "https://github.com/authorizer-tech/access-controller.git#branch=master" 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | dist/ 3 | genprotos/ 4 | coverage.out 5 | internal/mock_*.go 6 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: access-controller 2 | 3 | before: 4 | hooks: 5 | - make generate 6 | builds: 7 | - main: ./cmd/access-controller/main.go 8 | binary: bin/access-controller 9 | env: 10 | - CGO_ENABLED=0 11 | goarch: 12 | - amd64 13 | goos: 14 | - linux 15 | - darwin 16 | dockers: 17 | - 18 | image_templates: 19 | - "gcr.io/authorizer-tech/access-controller:latest" 20 | - "gcr.io/authorizer-tech/access-controller:{{ .Tag }}" 21 | - "gcr.io/authorizer-tech/access-controller:v{{ .Major }}" 22 | - "gcr.io/authorizer-tech/access-controller:v{{ .Major }}.{{ .Minor }}" 23 | extra_files: 24 | - testdata 25 | - db/migrations 26 | checksum: 27 | name_template: 'checksums.txt' 28 | snapshot: 29 | name_template: "{{ .Tag }}-next" 30 | changelog: 31 | sort: asc 32 | filters: 33 | exclude: 34 | - '^docs:' 35 | - '^test:' 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.1.5] - 2021-08-30 10 | ### Fixed 11 | * Allow the alias relation `...` to be implicitly defined. 12 | 13 | ## [0.1.4] - 2021-08-18 14 | ### Fixed 15 | * Bug with cross namespace config snapshots not being uniquely stored per namespace within the context. 16 | 17 | ## [0.1.3] - 2021-07-09 18 | ### Removed 19 | * `google.protobuf.FieldMask` option in the `ListRelationTuples` RPC. We can re-introduce 20 | it later if needed. 21 | 22 | ## [0.1.2] - 2021-06-28 23 | ### Added 24 | * gRPC Health Checking 25 | 26 | ## [0.1.1] - 2021-06-25 27 | ### Added 28 | * RPC input request validation 29 | 30 | ## [0.1.0] - 2021-06-18 31 | ### Added 32 | * Initial `WriteService`, `ReadService`, `CheckService`, and `ExpandService` implementations 33 | * Namespace config API and continuous namespace config snapshot monitoring 34 | * Gossip based clustering and consistent hashing with bounded load-balancing for `Check` RPCs 35 | * gRPC or HTTP/JSON (or both) API interfaces 36 | * Kubernetes Helm chart 37 | 38 | [Unreleased]: https://github.com/authorizer-tech/access-controller/compare/v0.1.5...HEAD 39 | [0.1.5]: https://github.com/authorizer-tech/access-controller/compare/v0.1.4...v0.1.5 40 | [0.1.4]: https://github.com/authorizer-tech/access-controller/compare/v0.1.3...v0.1.4 41 | [0.1.3]: https://github.com/authorizer-tech/access-controller/compare/v0.1.2...v0.1.3 42 | [0.1.2]: https://github.com/authorizer-tech/access-controller/compare/v0.1.1...v0.1.2 43 | [0.1.1]: https://github.com/authorizer-tech/access-controller/compare/v0.1.0...v0.1.1 44 | [0.1.0]: https://github.com/authorizer-tech/access-controller/releases/tag/v0.1.0 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | COPY access-controller /bin 3 | COPY testdata /bin/testdata 4 | COPY db/migrations /bin/db/migrations 5 | RUN wget -q -O /bin/grpc_health_probe https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/v0.4.2/grpc_health_probe-linux-amd64 && \ 6 | chmod +x /bin/grpc_health_probe 7 | WORKDIR /bin -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ###################################################### 2 | # 3 | # Development targets 4 | # 5 | ###################################################### 6 | 7 | .PHONY: download 8 | download: 9 | @go mod download 10 | 11 | .PHONY: install-tools 12 | install-tools: download 13 | @go list -f '{{range .Imports}}{{.}} {{end}}' tools.go | xargs go install 14 | 15 | .PHONY: generate 16 | generate: buf-generate go-generate 17 | 18 | .PHONY: go-generate 19 | go-generate: install-tools 20 | @go generate ./... 21 | 22 | .PHONY: buf-generate 23 | buf-generate: install-tools 24 | @buf generate 25 | 26 | .PHONY: test 27 | test: generate 28 | @go test -v -race ./... 29 | 30 | .PHONY: test-short 31 | test-short: generate 32 | @go test -v -race -short ./... 33 | 34 | .PHONY: test-coverage 35 | test-coverage: generate 36 | @go test -v -race -coverprofile=coverage.out ./... 37 | 38 | .PHONY: check 39 | check: check-vet check-lint 40 | 41 | .PHONY: check-vet 42 | check-vet: 43 | @go vet ./... 44 | 45 | .PHONY: check-lint 46 | check-lint: 47 | @staticcheck ./... 48 | 49 | ci-test-coverage: 50 | @go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # access-controller 2 | 3 | [![Latest Release](https://img.shields.io/github/v/release/authorizer-tech/access-controller)](https://github.com/authorizer-tech/access-controller/releases/latest) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/authorizer-tech/access-controller)](https://goreportcard.com/report/github.com/authorizer-tech/access-controller) 5 | [![Slack](https://img.shields.io/badge/slack-%23authorizer--tech-green)](https://authorizer-tech.slack.com) 6 | 7 | An implementation of a distributed access-control server that is based on [Google Zanzibar](https://research.google/pubs/pub48190/) - "Google's Consistent, Global Authorization System". 8 | 9 | An instance of an `access-controller` is similar to the `aclserver` implementation called out in the paper. A cluster of access-controllers implement the functional equivalent of the Zanzibar `aclserver` cluster. 10 | 11 | # Getting Started 12 | If you want to setup an instance of the Authorizer platform as a whole, browse the API References, or just brush up on the concepts and design of the platform, take a look at the [official platform documentation](https://authorizer-tech.github.io/docs/overview/introduction). If you're only interested in running the access-controller then continue on. 13 | 14 | ## Setup a Cluster 15 | An access-controller server supports single node or multi-node (clustered) topologies. Instructions for running the server with these topologies are outlined below. 16 | 17 | To gain the benefits of the distributed query model that the access-controller implements, it is recommend to run a large cluster. Doing so will help distribute query load across more nodes within the cluster. The underlying cluster membership list is based on Hashicorp's [`memberlist`](https://github.com/hashicorp/memberlist) 18 | 19 | > a library that manages cluster membership and member failure detection using a gossip based protocol. 20 | 21 | A cluster should be able to suport hundreds of nodes. If you find otherwise, please [submit an issue](https://github.com/authorizer-tech/access-controller/issues/new). 22 | 23 | ### Docker Compose 24 | [`docker-compose.yml`](./docker/docker-compose.yml) provides an example of how to setup a multi-node cluster using Docker and is a great way to get started quickly. 25 | 26 | ```console 27 | $ docker compose -f docker/docker-compose.yml up 28 | ``` 29 | 30 | ### Kubernetes (Recommended) 31 | Take a look at our [official Helm chart](https://authorizer-tech.github.io/helm-charts/access-controller). 32 | 33 | ### Pre-compiled Binaries 34 | Download the [latest release](https://github.com/authorizer-tech/access-controller/releases/latest) and extract it. 35 | 36 | #### Pre-requisites 37 | To run an access-controller you must have a running CockroachDB database. Take a look at setting up [CockroachDB with Docker](https://www.cockroachlabs.com/docs/stable/start-a-local-cluster-in-docker-mac.html). 38 | 39 | #### Single Node 40 | ```console 41 | $ ./bin/access-controller 42 | ``` 43 | 44 | #### Multi-node 45 | Start a multi-node cluster by starting multiple independent servers and use the `-join` flag 46 | to join the node to an existing cluster. 47 | 48 | ```console 49 | $ ./bin/access-controller -node-port 7946 -grpc-port 50052 50 | $ ./bin/access-controller -node-port 7947 -grpc-port 50053 -join 127.0.0.1:7946 51 | $ ./bin/access-controller -node-port 7948 -grpc-port 50054 -join 127.0.0.1:7947 52 | ``` 53 | 54 | ## Next Steps... 55 | Take a look at the examples of how to: 56 | * [Add a Namespace Configuration](https://authorizer-tech.github.io/docs/getting-started/add-namespace-config) 57 | * [Write a Relation Tuple](https://authorizer-tech.github.io/docs/getting-started/write-relation-tuple) 58 | * [Check a Subject's Access](https://authorizer-tech.github.io/docs/getting-started/check-access) 59 | 60 | Don't hesitate to browse the official [Documentation](https://authorizer-tech.github.io/docs/overview/introduction), [API Reference](https://authorizer-tech.github.io/docs/api-reference/overview) and [Examples](https://authorizer-tech.github.io/docs/overview/examples/examples-intro). 61 | 62 | # Community 63 | The access-controller is an open-source project and we value and welcome new contributors and members 64 | of the community. Here are ways to get in touch with the community: 65 | 66 | * Slack: [#authorizer-tech](https://authorizer-tech.slack.com) 67 | * Issue Tracker: [GitHub Issues](https://github.com/authorizer-tech/access-controller/issues) -------------------------------------------------------------------------------- /api/openapiv2/authorizer/accesscontroller/v1alpha1/acl.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "authorizer/accesscontroller/v1alpha1/acl.proto", 5 | "version": "version not set" 6 | }, 7 | "consumes": [ 8 | "application/json" 9 | ], 10 | "produces": [ 11 | "application/json" 12 | ], 13 | "paths": {}, 14 | "definitions": { 15 | "protobufAny": { 16 | "type": "object", 17 | "properties": { 18 | "typeUrl": { 19 | "type": "string" 20 | }, 21 | "value": { 22 | "type": "string", 23 | "format": "byte" 24 | } 25 | } 26 | }, 27 | "rpcStatus": { 28 | "type": "object", 29 | "properties": { 30 | "code": { 31 | "type": "integer", 32 | "format": "int32" 33 | }, 34 | "message": { 35 | "type": "string" 36 | }, 37 | "details": { 38 | "type": "array", 39 | "items": { 40 | "$ref": "#/definitions/protobufAny" 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /api/openapiv2/authorizer/accesscontroller/v1alpha1/check_service.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "authorizer/accesscontroller/v1alpha1/check_service.proto", 5 | "version": "version not set" 6 | }, 7 | "tags": [ 8 | { 9 | "name": "CheckService" 10 | } 11 | ], 12 | "consumes": [ 13 | "application/json" 14 | ], 15 | "produces": [ 16 | "application/json" 17 | ], 18 | "paths": { 19 | "/authorizer/access-controller/v1alpha1/check": { 20 | "get": { 21 | "summary": "Check performs an access-control check by looking up if a specific subject\nis related to an object.", 22 | "operationId": "CheckService_Check", 23 | "responses": { 24 | "200": { 25 | "description": "A successful response.", 26 | "schema": { 27 | "$ref": "#/definitions/v1alpha1CheckResponse" 28 | } 29 | }, 30 | "default": { 31 | "description": "An unexpected error response.", 32 | "schema": { 33 | "$ref": "#/definitions/rpcStatus" 34 | } 35 | } 36 | }, 37 | "parameters": [ 38 | { 39 | "name": "namespace", 40 | "description": "The namespace to evaluate the check within.", 41 | "in": "query", 42 | "required": false, 43 | "type": "string" 44 | }, 45 | { 46 | "name": "object", 47 | "description": "The object to check.", 48 | "in": "query", 49 | "required": false, 50 | "type": "string" 51 | }, 52 | { 53 | "name": "relation", 54 | "description": "The relation between the object and the subject.", 55 | "in": "query", 56 | "required": false, 57 | "type": "string" 58 | }, 59 | { 60 | "name": "subject.id", 61 | "description": "A concrete subject id string for the subject.", 62 | "in": "query", 63 | "required": false, 64 | "type": "string" 65 | }, 66 | { 67 | "name": "subject.set.namespace", 68 | "description": "The namespace of the object and relation referenced in this SubjectSet.", 69 | "in": "query", 70 | "required": false, 71 | "type": "string" 72 | }, 73 | { 74 | "name": "subject.set.object", 75 | "description": "The object selected by the subjects.", 76 | "in": "query", 77 | "required": false, 78 | "type": "string" 79 | }, 80 | { 81 | "name": "subject.set.relation", 82 | "description": "The relation between the object and the subject(s).", 83 | "in": "query", 84 | "required": false, 85 | "type": "string" 86 | }, 87 | { 88 | "name": "snaptoken", 89 | "description": "Optional. The snapshot token that encodes the evaluation timestamp that this request will be evaluated no earlier than.\n\nIf no snapshot token is provided the check request is evaluated against the most recently replicated version of the relation\ntuple storage. Leaving an empty snapshot token will reflect the latest changes, but it may incur a read penalty because the\nreads have to be directed toward the leaseholder of the replica that serves the data.\n\nWe call requests without a snapshot token a content-change check, because such requests require a round-trip read but return\nthe most recent writes and are thus good candidates for checking real-time content changes before an object is persisted.", 90 | "in": "query", 91 | "required": false, 92 | "type": "string" 93 | } 94 | ], 95 | "tags": [ 96 | "CheckService" 97 | ] 98 | } 99 | } 100 | }, 101 | "definitions": { 102 | "protobufAny": { 103 | "type": "object", 104 | "properties": { 105 | "typeUrl": { 106 | "type": "string" 107 | }, 108 | "value": { 109 | "type": "string", 110 | "format": "byte" 111 | } 112 | } 113 | }, 114 | "rpcStatus": { 115 | "type": "object", 116 | "properties": { 117 | "code": { 118 | "type": "integer", 119 | "format": "int32" 120 | }, 121 | "message": { 122 | "type": "string" 123 | }, 124 | "details": { 125 | "type": "array", 126 | "items": { 127 | "$ref": "#/definitions/protobufAny" 128 | } 129 | } 130 | } 131 | }, 132 | "v1alpha1CheckResponse": { 133 | "type": "object", 134 | "properties": { 135 | "allowed": { 136 | "type": "boolean", 137 | "description": "A boolean indicating if the specified subject is related to the requested object.\n\nIt is false by default if no ACL matches." 138 | }, 139 | "snaptoken": { 140 | "type": "string", 141 | "description": "A snapshot token encoding the snapshot evaluation timestamp that the request was evaluated at." 142 | } 143 | }, 144 | "description": "The response for a CheckService.Check rpc." 145 | }, 146 | "v1alpha1Subject": { 147 | "type": "object", 148 | "properties": { 149 | "id": { 150 | "type": "string", 151 | "description": "A concrete subject id string for the subject." 152 | }, 153 | "set": { 154 | "$ref": "#/definitions/v1alpha1SubjectSet", 155 | "description": "A SubjectSet that expands to more Subjects." 156 | } 157 | }, 158 | "description": "Subject is either a concrete subject id string or\na SubjectSet expanding to more Subjects." 159 | }, 160 | "v1alpha1SubjectSet": { 161 | "type": "object", 162 | "properties": { 163 | "namespace": { 164 | "type": "string", 165 | "description": "The namespace of the object and relation referenced in this SubjectSet." 166 | }, 167 | "object": { 168 | "type": "string", 169 | "description": "The object selected by the subjects." 170 | }, 171 | "relation": { 172 | "type": "string", 173 | "description": "The relation between the object and the subject(s)." 174 | } 175 | }, 176 | "description": "A SubjectSet refers to all subjects which have the same\nrelation to an object." 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /api/openapiv2/authorizer/accesscontroller/v1alpha1/expand_service.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "authorizer/accesscontroller/v1alpha1/expand_service.proto", 5 | "version": "version not set" 6 | }, 7 | "tags": [ 8 | { 9 | "name": "ExpandService" 10 | } 11 | ], 12 | "consumes": [ 13 | "application/json" 14 | ], 15 | "produces": [ 16 | "application/json" 17 | ], 18 | "paths": {}, 19 | "definitions": { 20 | "protobufAny": { 21 | "type": "object", 22 | "properties": { 23 | "typeUrl": { 24 | "type": "string" 25 | }, 26 | "value": { 27 | "type": "string", 28 | "format": "byte" 29 | } 30 | } 31 | }, 32 | "rpcStatus": { 33 | "type": "object", 34 | "properties": { 35 | "code": { 36 | "type": "integer", 37 | "format": "int32" 38 | }, 39 | "message": { 40 | "type": "string" 41 | }, 42 | "details": { 43 | "type": "array", 44 | "items": { 45 | "$ref": "#/definitions/protobufAny" 46 | } 47 | } 48 | } 49 | }, 50 | "v1alpha1ExpandResponse": { 51 | "type": "object", 52 | "properties": { 53 | "tree": { 54 | "$ref": "#/definitions/v1alpha1SubjectTree", 55 | "description": "The tree the requested SubjectSet expands to. The requested\nSubjectSet is the subject of the root.\n\nThis field can be nil in some circumstances." 56 | } 57 | }, 58 | "description": "The response for an ExpandService.Expand rpc." 59 | }, 60 | "v1alpha1NodeType": { 61 | "type": "string", 62 | "enum": [ 63 | "NODE_TYPE_UNSPECIFIED", 64 | "NODE_TYPE_UNION", 65 | "NODE_TYPE_INTERSECTION", 66 | "NODE_TYPE_LEAF" 67 | ], 68 | "default": "NODE_TYPE_UNSPECIFIED", 69 | "description": "An enumeration defining types of nodes within a SubjectTree.\n\n - NODE_TYPE_UNION: A node type which expands to a union of all children.\n - NODE_TYPE_INTERSECTION: A node type which expands to an intersection of the children.\n - NODE_TYPE_LEAF: A node type which is a leaf and contains no children.\n\nIts Subject is a subject id string unless the maximum call depth was reached." 70 | }, 71 | "v1alpha1Subject": { 72 | "type": "object", 73 | "properties": { 74 | "id": { 75 | "type": "string", 76 | "description": "A concrete subject id string for the subject." 77 | }, 78 | "set": { 79 | "$ref": "#/definitions/v1alpha1SubjectSet", 80 | "description": "A SubjectSet that expands to more Subjects." 81 | } 82 | }, 83 | "description": "Subject is either a concrete subject id string or\na SubjectSet expanding to more Subjects." 84 | }, 85 | "v1alpha1SubjectSet": { 86 | "type": "object", 87 | "properties": { 88 | "namespace": { 89 | "type": "string", 90 | "description": "The namespace of the object and relation referenced in this SubjectSet." 91 | }, 92 | "object": { 93 | "type": "string", 94 | "description": "The object selected by the subjects." 95 | }, 96 | "relation": { 97 | "type": "string", 98 | "description": "The relation between the object and the subject(s)." 99 | } 100 | }, 101 | "description": "A SubjectSet refers to all subjects which have the same\nrelation to an object." 102 | }, 103 | "v1alpha1SubjectTree": { 104 | "type": "object", 105 | "properties": { 106 | "nodeType": { 107 | "$ref": "#/definitions/v1alpha1NodeType", 108 | "description": "The type of the node." 109 | }, 110 | "subject": { 111 | "$ref": "#/definitions/v1alpha1Subject", 112 | "description": "The subject this node represents." 113 | }, 114 | "children": { 115 | "type": "array", 116 | "items": { 117 | "$ref": "#/definitions/v1alpha1SubjectTree" 118 | }, 119 | "description": "The children of this node.\n\nThis is unset if `node_type` is `NODE_TYPE_LEAF`." 120 | } 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /api/openapiv2/authorizer/accesscontroller/v1alpha1/namespace_service.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "authorizer/accesscontroller/v1alpha1/namespace_service.proto", 5 | "version": "version not set" 6 | }, 7 | "tags": [ 8 | { 9 | "name": "NamespaceConfigService" 10 | } 11 | ], 12 | "consumes": [ 13 | "application/json" 14 | ], 15 | "produces": [ 16 | "application/json" 17 | ], 18 | "paths": { 19 | "/authorizer/access-controller/v1alpha1/namespace-configs": { 20 | "get": { 21 | "summary": "Read a namespace configuration.", 22 | "operationId": "NamespaceConfigService_ReadConfig", 23 | "responses": { 24 | "200": { 25 | "description": "A successful response.", 26 | "schema": { 27 | "$ref": "#/definitions/v1alpha1ReadConfigResponse" 28 | } 29 | }, 30 | "default": { 31 | "description": "An unexpected error response.", 32 | "schema": { 33 | "$ref": "#/definitions/rpcStatus" 34 | } 35 | } 36 | }, 37 | "parameters": [ 38 | { 39 | "name": "namespace", 40 | "description": "The namespace whose config should be read.", 41 | "in": "query", 42 | "required": false, 43 | "type": "string" 44 | } 45 | ], 46 | "tags": [ 47 | "NamespaceConfigService" 48 | ] 49 | }, 50 | "put": { 51 | "summary": "WriteConfig upserts a namespace configuration.", 52 | "description": "If the namespace config already exists, the existing one is overwritten. If the new\nnamespace config removes an existing relation, there must not be any relation tuples\nthat reference it. Otherwise a FAILED_PRECONDITION status is returned.\n\nTo migrate away from a relation, please move all existing relation tuples referencing\nit over to the new relation and then delete the old relation once all tuples have been\nmigrated.", 53 | "operationId": "NamespaceConfigService_WriteConfig", 54 | "responses": { 55 | "200": { 56 | "description": "A successful response.", 57 | "schema": { 58 | "$ref": "#/definitions/v1alpha1WriteConfigResponse" 59 | } 60 | }, 61 | "default": { 62 | "description": "An unexpected error response.", 63 | "schema": { 64 | "$ref": "#/definitions/rpcStatus" 65 | } 66 | } 67 | }, 68 | "parameters": [ 69 | { 70 | "name": "body", 71 | "in": "body", 72 | "required": true, 73 | "schema": { 74 | "$ref": "#/definitions/v1alpha1WriteConfigRequest" 75 | } 76 | } 77 | ], 78 | "tags": [ 79 | "NamespaceConfigService" 80 | ] 81 | } 82 | } 83 | }, 84 | "definitions": { 85 | "ChildThis": { 86 | "type": "object", 87 | "description": "This references the defined relation directly." 88 | }, 89 | "SetOperationChild": { 90 | "type": "object", 91 | "properties": { 92 | "this": { 93 | "$ref": "#/definitions/ChildThis" 94 | }, 95 | "computedSubjectset": { 96 | "$ref": "#/definitions/v1alpha1ComputedSubjectset" 97 | }, 98 | "tupleToSubjectset": { 99 | "$ref": "#/definitions/v1alpha1TupleToSubjectset" 100 | }, 101 | "rewrite": { 102 | "$ref": "#/definitions/v1alpha1Rewrite" 103 | } 104 | } 105 | }, 106 | "TupleToSubjectsetTupleset": { 107 | "type": "object", 108 | "properties": { 109 | "relation": { 110 | "type": "string" 111 | } 112 | } 113 | }, 114 | "protobufAny": { 115 | "type": "object", 116 | "properties": { 117 | "typeUrl": { 118 | "type": "string" 119 | }, 120 | "value": { 121 | "type": "string", 122 | "format": "byte" 123 | } 124 | } 125 | }, 126 | "rpcStatus": { 127 | "type": "object", 128 | "properties": { 129 | "code": { 130 | "type": "integer", 131 | "format": "int32" 132 | }, 133 | "message": { 134 | "type": "string" 135 | }, 136 | "details": { 137 | "type": "array", 138 | "items": { 139 | "$ref": "#/definitions/protobufAny" 140 | } 141 | } 142 | } 143 | }, 144 | "v1alpha1ComputedSubjectset": { 145 | "type": "object", 146 | "properties": { 147 | "relation": { 148 | "type": "string" 149 | } 150 | }, 151 | "description": "Computes the set of subjects that have the included relation within the\nsame namespace.\n\nThis is useful to follow relations between an object and subject within\nthe same namespace. If you want anyone with an 'editor' relation to also\nhave 'viewer' this would be a good fit." 152 | }, 153 | "v1alpha1NamespaceConfig": { 154 | "type": "object", 155 | "properties": { 156 | "name": { 157 | "type": "string", 158 | "description": "The name of the namespace." 159 | }, 160 | "relations": { 161 | "type": "array", 162 | "items": { 163 | "$ref": "#/definitions/v1alpha1Relation" 164 | }, 165 | "description": "The relations that this namespace defines." 166 | } 167 | }, 168 | "description": "A namespace config defines the relations that exist between objects and subjects in\nin a namespace." 169 | }, 170 | "v1alpha1ReadConfigResponse": { 171 | "type": "object", 172 | "properties": { 173 | "namespace": { 174 | "type": "string", 175 | "description": "The namespace of the config." 176 | }, 177 | "config": { 178 | "$ref": "#/definitions/v1alpha1NamespaceConfig", 179 | "description": "The namespace config for the given namespace." 180 | } 181 | }, 182 | "description": "The response for a NamespaceConfigService.ReadConfig rpc." 183 | }, 184 | "v1alpha1Relation": { 185 | "type": "object", 186 | "properties": { 187 | "name": { 188 | "type": "string", 189 | "description": "The name of the relation (e.g. viewer, editor, or member)." 190 | }, 191 | "rewrite": { 192 | "$ref": "#/definitions/v1alpha1Rewrite", 193 | "description": "The rewrite rule for this relation, or nil if it references itself." 194 | } 195 | }, 196 | "description": "A Relation defines a type of relationship between an object and subject.\n\nRelations can have rewrite rules that specify how the relation is\ncomputed relative to other relations defined within the same namespace\nor across other namespaces." 197 | }, 198 | "v1alpha1Rewrite": { 199 | "type": "object", 200 | "properties": { 201 | "union": { 202 | "$ref": "#/definitions/v1alpha1SetOperation", 203 | "description": "Joins the children of the rewrite via set union." 204 | }, 205 | "intersection": { 206 | "$ref": "#/definitions/v1alpha1SetOperation", 207 | "description": "Joins the children of the rewrite via set intersection." 208 | } 209 | }, 210 | "description": "Rewrites define sub-expressions that combine operations such as union or intersection. A rewrite\nsub-expression can be recursive and thus allows arbitrary logical expressions to be constructed." 211 | }, 212 | "v1alpha1SetOperation": { 213 | "type": "object", 214 | "properties": { 215 | "children": { 216 | "type": "array", 217 | "items": { 218 | "$ref": "#/definitions/SetOperationChild" 219 | } 220 | } 221 | } 222 | }, 223 | "v1alpha1TupleToSubjectset": { 224 | "type": "object", 225 | "properties": { 226 | "tupleset": { 227 | "$ref": "#/definitions/TupleToSubjectsetTupleset", 228 | "description": "A tupleset defining the relation tuples that relate to the set of subjects that\nthis TupleToSubjectset applies to." 229 | }, 230 | "computedSubjectset": { 231 | "$ref": "#/definitions/v1alpha1ComputedSubjectset", 232 | "description": "The computed set of subjects that are looked up based on the expanded tupleset." 233 | } 234 | }, 235 | "description": "Computes a tupleset from the input object, fetches relation tuples matching the\ntupleset, and computes the set of subjects from every fetched relation tuple.\n\nThis is useful to lookup relations in other namespaces or to create complex hierarchies\nbetween objects in multiple namespaces." 236 | }, 237 | "v1alpha1WriteConfigRequest": { 238 | "type": "object", 239 | "properties": { 240 | "config": { 241 | "$ref": "#/definitions/v1alpha1NamespaceConfig", 242 | "description": "The namespace config to upsert." 243 | } 244 | }, 245 | "description": "The request for a NamespaceConfigService.WriteConfig rpc." 246 | }, 247 | "v1alpha1WriteConfigResponse": { 248 | "type": "object", 249 | "description": "The response for a NamespaceConfigService.WriteConfig rpc." 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /api/openapiv2/authorizer/accesscontroller/v1alpha1/read_service.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "authorizer/accesscontroller/v1alpha1/read_service.proto", 5 | "version": "version not set" 6 | }, 7 | "tags": [ 8 | { 9 | "name": "ReadService" 10 | } 11 | ], 12 | "consumes": [ 13 | "application/json" 14 | ], 15 | "produces": [ 16 | "application/json" 17 | ], 18 | "paths": { 19 | "/authorizer/access-controller/v1alpha1/tuples": { 20 | "get": { 21 | "summary": "Lists relation tuples.", 22 | "description": "**NOTE**: This does not follow direct or indirect references through rewrites. If youq\nneed to follow indirect references through rewrites, please use the Expand API.", 23 | "operationId": "ReadService_ListRelationTuples", 24 | "responses": { 25 | "200": { 26 | "description": "A successful response.", 27 | "schema": { 28 | "$ref": "#/definitions/v1alpha1ListRelationTuplesResponse" 29 | } 30 | }, 31 | "default": { 32 | "description": "An unexpected error response.", 33 | "schema": { 34 | "$ref": "#/definitions/rpcStatus" 35 | } 36 | } 37 | }, 38 | "parameters": [ 39 | { 40 | "name": "query.namespace", 41 | "description": "Required. The namespace to query.", 42 | "in": "query", 43 | "required": false, 44 | "type": "string" 45 | }, 46 | { 47 | "name": "query.object", 48 | "description": "Optional.", 49 | "in": "query", 50 | "required": false, 51 | "type": "string" 52 | }, 53 | { 54 | "name": "query.relations", 55 | "description": "Optional.", 56 | "in": "query", 57 | "required": false, 58 | "type": "array", 59 | "items": { 60 | "type": "string" 61 | }, 62 | "collectionFormat": "multi" 63 | }, 64 | { 65 | "name": "query.subject.id", 66 | "description": "A concrete subject id string for the subject.", 67 | "in": "query", 68 | "required": false, 69 | "type": "string" 70 | }, 71 | { 72 | "name": "query.subject.set.namespace", 73 | "description": "The namespace of the object and relation referenced in this SubjectSet.", 74 | "in": "query", 75 | "required": false, 76 | "type": "string" 77 | }, 78 | { 79 | "name": "query.subject.set.object", 80 | "description": "The object selected by the subjects.", 81 | "in": "query", 82 | "required": false, 83 | "type": "string" 84 | }, 85 | { 86 | "name": "query.subject.set.relation", 87 | "description": "The relation between the object and the subject(s).", 88 | "in": "query", 89 | "required": false, 90 | "type": "string" 91 | }, 92 | { 93 | "name": "snaptoken", 94 | "description": "Optional. The snapshot token that encodes the evaluation timestamp that this request will be evaluated no earlier than.", 95 | "in": "query", 96 | "required": false, 97 | "type": "string" 98 | }, 99 | { 100 | "name": "pageSize", 101 | "description": "Optional. The maximum number of RelationTuples to return in\nthe response.", 102 | "in": "query", 103 | "required": false, 104 | "type": "integer", 105 | "format": "int32" 106 | }, 107 | { 108 | "name": "pageToken", 109 | "description": "Optional. A pagination token returned from a previous call to\n`ListRelationTuples` that indicates where the page should start\nat.", 110 | "in": "query", 111 | "required": false, 112 | "type": "string" 113 | } 114 | ], 115 | "tags": [ 116 | "ReadService" 117 | ] 118 | } 119 | } 120 | }, 121 | "definitions": { 122 | "ListRelationTuplesRequestQuery": { 123 | "type": "object", 124 | "properties": { 125 | "namespace": { 126 | "type": "string", 127 | "description": "Required. The namespace to query." 128 | }, 129 | "object": { 130 | "type": "string", 131 | "description": "Optional." 132 | }, 133 | "relations": { 134 | "type": "array", 135 | "items": { 136 | "type": "string" 137 | }, 138 | "description": "Optional." 139 | }, 140 | "subject": { 141 | "$ref": "#/definitions/v1alpha1Subject", 142 | "description": "Optional." 143 | } 144 | }, 145 | "description": "The query for listing relation tuples. Clients can\nspecify any optional field to partially filter for\nspecific relation tuples.\n\nExample use cases:\n - object only: display a list of all ACLs of one object\n - relation only: get all groups that have members; e.g. get all directories that have content\n - object \u0026 relation: display all subjects that have e.g. write relation\n - subject \u0026 relation: display all groups a subject belongs to/display all objects a subject has access to\n - object \u0026 relation \u0026 subject: check whether the relation tuple already exists, before writing it" 146 | }, 147 | "protobufAny": { 148 | "type": "object", 149 | "properties": { 150 | "typeUrl": { 151 | "type": "string" 152 | }, 153 | "value": { 154 | "type": "string", 155 | "format": "byte" 156 | } 157 | } 158 | }, 159 | "rpcStatus": { 160 | "type": "object", 161 | "properties": { 162 | "code": { 163 | "type": "integer", 164 | "format": "int32" 165 | }, 166 | "message": { 167 | "type": "string" 168 | }, 169 | "details": { 170 | "type": "array", 171 | "items": { 172 | "$ref": "#/definitions/protobufAny" 173 | } 174 | } 175 | } 176 | }, 177 | "v1alpha1ListRelationTuplesResponse": { 178 | "type": "object", 179 | "properties": { 180 | "relationTuples": { 181 | "type": "array", 182 | "items": { 183 | "$ref": "#/definitions/v1alpha1RelationTuple" 184 | }, 185 | "description": "The relation tuples matching the request query.\n\nThe RelationTuple list is ordered from the newest\nRelationTuple to the oldest." 186 | }, 187 | "nextPageToken": { 188 | "type": "string", 189 | "description": "The token required to paginate to the next page." 190 | }, 191 | "isLastPage": { 192 | "type": "boolean", 193 | "description": "Indicates if this is the last page of paginated data.\nIf `is_last_page` is true then using `next_page_token`\nin subsequent requests will return an error." 194 | } 195 | } 196 | }, 197 | "v1alpha1RelationTuple": { 198 | "type": "object", 199 | "properties": { 200 | "namespace": { 201 | "type": "string", 202 | "description": "The namespace this relation tuple lives in." 203 | }, 204 | "object": { 205 | "type": "string", 206 | "description": "The object identifier related by this tuple.\n\nObjects live within the namespace of the tuple." 207 | }, 208 | "relation": { 209 | "type": "string", 210 | "description": "The relation between the Object and the Subject." 211 | }, 212 | "subject": { 213 | "$ref": "#/definitions/v1alpha1Subject", 214 | "description": "The subject related by this tuple." 215 | } 216 | }, 217 | "description": "RelationTuple relates an object with a subject.\n\nWhile a tuple reflects a relationship between object\nand subject, they do not completely define the effective ACLs." 218 | }, 219 | "v1alpha1Subject": { 220 | "type": "object", 221 | "properties": { 222 | "id": { 223 | "type": "string", 224 | "description": "A concrete subject id string for the subject." 225 | }, 226 | "set": { 227 | "$ref": "#/definitions/v1alpha1SubjectSet", 228 | "description": "A SubjectSet that expands to more Subjects." 229 | } 230 | }, 231 | "description": "Subject is either a concrete subject id string or\na SubjectSet expanding to more Subjects." 232 | }, 233 | "v1alpha1SubjectSet": { 234 | "type": "object", 235 | "properties": { 236 | "namespace": { 237 | "type": "string", 238 | "description": "The namespace of the object and relation referenced in this SubjectSet." 239 | }, 240 | "object": { 241 | "type": "string", 242 | "description": "The object selected by the subjects." 243 | }, 244 | "relation": { 245 | "type": "string", 246 | "description": "The relation between the object and the subject(s)." 247 | } 248 | }, 249 | "description": "A SubjectSet refers to all subjects which have the same\nrelation to an object." 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /api/openapiv2/authorizer/accesscontroller/v1alpha1/write_service.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "authorizer/accesscontroller/v1alpha1/write_service.proto", 5 | "version": "version not set" 6 | }, 7 | "tags": [ 8 | { 9 | "name": "WriteService" 10 | } 11 | ], 12 | "consumes": [ 13 | "application/json" 14 | ], 15 | "produces": [ 16 | "application/json" 17 | ], 18 | "paths": { 19 | "/authorizer/access-controller/v1alpha1/write": { 20 | "post": { 21 | "summary": "Mutates one or more relation tuples within a single transaction.", 22 | "operationId": "WriteService_WriteRelationTuplesTxn", 23 | "responses": { 24 | "200": { 25 | "description": "A successful response.", 26 | "schema": { 27 | "$ref": "#/definitions/v1alpha1WriteRelationTuplesTxnResponse" 28 | } 29 | }, 30 | "default": { 31 | "description": "An unexpected error response.", 32 | "schema": { 33 | "$ref": "#/definitions/rpcStatus" 34 | } 35 | } 36 | }, 37 | "parameters": [ 38 | { 39 | "name": "body", 40 | "in": "body", 41 | "required": true, 42 | "schema": { 43 | "$ref": "#/definitions/v1alpha1WriteRelationTuplesTxnRequest" 44 | } 45 | } 46 | ], 47 | "tags": [ 48 | "WriteService" 49 | ] 50 | } 51 | } 52 | }, 53 | "definitions": { 54 | "RelationTupleDeltaAction": { 55 | "type": "string", 56 | "enum": [ 57 | "ACTION_UNSPECIFIED", 58 | "ACTION_INSERT", 59 | "ACTION_DELETE" 60 | ], 61 | "default": "ACTION_UNSPECIFIED", 62 | "description": "An enumeration defining the actions or mutations that can be done on a RelationTuple.\n\n - ACTION_UNSPECIFIED: An unspecified action.\n\nThe `WriteRelationTuplesTxn` rpc ignores RelationTupleDeltas with\nan unspecified action.\n - ACTION_INSERT: Upserts a new RelationTuple.\n\nIf the RelationTuple already exists no modification is done.\n - ACTION_DELETE: Deletes the RelationTuple.\n\nIf the RelationTuple does not exist it's a no-op." 63 | }, 64 | "protobufAny": { 65 | "type": "object", 66 | "properties": { 67 | "typeUrl": { 68 | "type": "string" 69 | }, 70 | "value": { 71 | "type": "string", 72 | "format": "byte" 73 | } 74 | } 75 | }, 76 | "rpcStatus": { 77 | "type": "object", 78 | "properties": { 79 | "code": { 80 | "type": "integer", 81 | "format": "int32" 82 | }, 83 | "message": { 84 | "type": "string" 85 | }, 86 | "details": { 87 | "type": "array", 88 | "items": { 89 | "$ref": "#/definitions/protobufAny" 90 | } 91 | } 92 | } 93 | }, 94 | "v1alpha1RelationTuple": { 95 | "type": "object", 96 | "properties": { 97 | "namespace": { 98 | "type": "string", 99 | "description": "The namespace this relation tuple lives in." 100 | }, 101 | "object": { 102 | "type": "string", 103 | "description": "The object identifier related by this tuple.\n\nObjects live within the namespace of the tuple." 104 | }, 105 | "relation": { 106 | "type": "string", 107 | "description": "The relation between the Object and the Subject." 108 | }, 109 | "subject": { 110 | "$ref": "#/definitions/v1alpha1Subject", 111 | "description": "The subject related by this tuple." 112 | } 113 | }, 114 | "description": "RelationTuple relates an object with a subject.\n\nWhile a tuple reflects a relationship between object\nand subject, they do not completely define the effective ACLs." 115 | }, 116 | "v1alpha1RelationTupleDelta": { 117 | "type": "object", 118 | "properties": { 119 | "action": { 120 | "$ref": "#/definitions/RelationTupleDeltaAction", 121 | "description": "The action to do on the RelationTuple." 122 | }, 123 | "relationTuple": { 124 | "$ref": "#/definitions/v1alpha1RelationTuple", 125 | "description": "The target RelationTuple." 126 | } 127 | }, 128 | "description": "Write-delta for a WriteRelationTuplesTxnRequest." 129 | }, 130 | "v1alpha1Subject": { 131 | "type": "object", 132 | "properties": { 133 | "id": { 134 | "type": "string", 135 | "description": "A concrete subject id string for the subject." 136 | }, 137 | "set": { 138 | "$ref": "#/definitions/v1alpha1SubjectSet", 139 | "description": "A SubjectSet that expands to more Subjects." 140 | } 141 | }, 142 | "description": "Subject is either a concrete subject id string or\na SubjectSet expanding to more Subjects." 143 | }, 144 | "v1alpha1SubjectSet": { 145 | "type": "object", 146 | "properties": { 147 | "namespace": { 148 | "type": "string", 149 | "description": "The namespace of the object and relation referenced in this SubjectSet." 150 | }, 151 | "object": { 152 | "type": "string", 153 | "description": "The object selected by the subjects." 154 | }, 155 | "relation": { 156 | "type": "string", 157 | "description": "The relation between the object and the subject(s)." 158 | } 159 | }, 160 | "description": "A SubjectSet refers to all subjects which have the same\nrelation to an object." 161 | }, 162 | "v1alpha1WriteRelationTuplesTxnRequest": { 163 | "type": "object", 164 | "properties": { 165 | "relationTupleDeltas": { 166 | "type": "array", 167 | "items": { 168 | "$ref": "#/definitions/v1alpha1RelationTupleDelta" 169 | }, 170 | "description": "The write delta for the relation tuples operated in one single transaction.\nEither all actions commit or no changes take effect on error." 171 | } 172 | }, 173 | "description": "The request of a WriteService.WriteRelationTuplesTxn rpc." 174 | }, 175 | "v1alpha1WriteRelationTuplesTxnResponse": { 176 | "type": "object", 177 | "properties": { 178 | "snaptokens": { 179 | "type": "array", 180 | "items": { 181 | "type": "string" 182 | }, 183 | "description": "The list of the new latest snapshot tokens of the affected RelationTuple,\nwith the same index as specified in the `relation_tuple_deltas` field of\nthe WriteRelationTuplesTxnRequest request.\n\nIf the RelationTupleDelta_Action was DELETE\nthe snaptoken is empty at the same index." 184 | } 185 | }, 186 | "description": "The response of a WriteService.WriteRelationTuplesTxn rpc." 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /api/protos/authorizer/accesscontroller/v1alpha1/acl.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package authorizer.accesscontroller.v1alpha1; 4 | 5 | option go_package = "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1;acl"; 6 | 7 | // RelationTuple relates an object with a subject. 8 | // 9 | // While a tuple reflects a relationship between object 10 | // and subject, they do not completely define the effective ACLs. 11 | message RelationTuple { 12 | 13 | // The namespace this relation tuple lives in. 14 | string namespace = 1; 15 | 16 | // The object identifier related by this tuple. 17 | // 18 | // Objects live within the namespace of the tuple. 19 | string object = 2; 20 | 21 | // The relation between the Object and the Subject. 22 | string relation = 3; 23 | 24 | // The subject related by this tuple. 25 | Subject subject = 4; 26 | } 27 | 28 | // Subject is either a concrete subject id string or 29 | // a SubjectSet expanding to more Subjects. 30 | message Subject { 31 | 32 | // The reference of this abstract subject. 33 | oneof ref { 34 | 35 | // A concrete subject id string for the subject. 36 | string id = 1; 37 | 38 | // A SubjectSet that expands to more Subjects. 39 | SubjectSet set = 2; 40 | } 41 | } 42 | 43 | // A SubjectSet refers to all subjects which have the same 44 | // relation to an object. 45 | message SubjectSet { 46 | 47 | // The namespace of the object and relation referenced in this SubjectSet. 48 | string namespace = 1; 49 | 50 | // The object selected by the subjects. 51 | string object = 2; 52 | 53 | // The relation between the object and the subject(s). 54 | string relation = 3; 55 | } -------------------------------------------------------------------------------- /api/protos/authorizer/accesscontroller/v1alpha1/check_service.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package authorizer.accesscontroller.v1alpha1; 4 | 5 | import "authorizer/accesscontroller/v1alpha1/acl.proto"; 6 | import "google/api/annotations.proto"; 7 | 8 | option go_package = "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1;acl"; 9 | 10 | // The service that performs access-control checks 11 | // based on stored Access Control Lists. 12 | service CheckService { 13 | 14 | // Check performs an access-control check by looking up if a specific subject 15 | // is related to an object. 16 | rpc Check(CheckRequest) returns (CheckResponse) { 17 | option (google.api.http) = { 18 | get: "/authorizer/access-controller/v1alpha1/check" 19 | }; 20 | } 21 | } 22 | 23 | // The request for a CheckService.Check rpc. 24 | message CheckRequest { 25 | 26 | // The namespace to evaluate the check within. 27 | string namespace = 1; 28 | 29 | // The object to check. 30 | string object = 2; 31 | 32 | // The relation between the object and the subject. 33 | string relation = 3; 34 | 35 | // The subject to check. 36 | Subject subject = 4; 37 | 38 | // Optional. The snapshot token that encodes the evaluation timestamp that this request will be evaluated no earlier than. 39 | // 40 | // If no snapshot token is provided the check request is evaluated against the most recently replicated version of the relation 41 | // tuple storage. Leaving an empty snapshot token will reflect the latest changes, but it may incur a read penalty because the 42 | // reads have to be directed toward the leaseholder of the replica that serves the data. 43 | // 44 | // We call requests without a snapshot token a content-change check, because such requests require a round-trip read but return 45 | // the most recent writes and are thus good candidates for checking real-time content changes before an object is persisted. 46 | string snaptoken = 5; 47 | } 48 | 49 | // The response for a CheckService.Check rpc. 50 | message CheckResponse { 51 | 52 | // A boolean indicating if the specified subject is related to the requested object. 53 | // 54 | // It is false by default if no ACL matches. 55 | bool allowed = 1; 56 | 57 | // A snapshot token encoding the snapshot evaluation timestamp that the request was evaluated at. 58 | string snaptoken = 2; 59 | } 60 | -------------------------------------------------------------------------------- /api/protos/authorizer/accesscontroller/v1alpha1/expand_service.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package authorizer.accesscontroller.v1alpha1; 4 | 5 | import "authorizer/accesscontroller/v1alpha1/acl.proto"; 6 | 7 | option go_package = "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1;acl"; 8 | 9 | // The service to serve Expand RPC requests. 10 | service ExpandService { 11 | 12 | // Expands all the relation tuples for all of the subjects given 13 | // in the SubjectSet. 14 | // 15 | // Expand follows direct and indirect SubjectSets in a depth-first fashion. 16 | rpc Expand(ExpandRequest) returns (ExpandResponse); 17 | } 18 | 19 | // The request for an ExpandService.Expand rpc. 20 | message ExpandRequest { 21 | 22 | // The SubjectSet to expand. 23 | SubjectSet subject_set = 1; 24 | 25 | // Optional. The snapshot token that encodes the evaluation timestamp that this request will be evaluated no earlier than. 26 | // 27 | // If no snapshot token is provided the expand request is evaluated against the most recently replicated version of the relation 28 | // tuple storage. Leaving an empty snapshot token will reflect the latest changes, but it may incur a read penalty because the 29 | // reads have to be directed toward the leaseholder of the replica that serves the data. 30 | string snaptoken = 2; 31 | } 32 | 33 | // The response for an ExpandService.Expand rpc. 34 | message ExpandResponse { 35 | 36 | // The tree the requested SubjectSet expands to. The requested 37 | // SubjectSet is the subject of the root. 38 | // 39 | // This field can be nil in some circumstances. 40 | SubjectTree tree = 1; 41 | } 42 | 43 | // An enumeration defining types of nodes within a SubjectTree. 44 | enum NodeType { 45 | 46 | NODE_TYPE_UNSPECIFIED = 0; 47 | 48 | // A node type which expands to a union of all children. 49 | NODE_TYPE_UNION = 1; 50 | 51 | // A node type which expands to an intersection of the children. 52 | NODE_TYPE_INTERSECTION = 3; 53 | 54 | // A node type which is a leaf and contains no children. 55 | // 56 | // Its Subject is a subject id string unless the maximum call depth was reached. 57 | NODE_TYPE_LEAF = 4; 58 | } 59 | 60 | message SubjectTree { 61 | 62 | // The type of the node. 63 | NodeType node_type = 1; 64 | 65 | // The subject this node represents. 66 | Subject subject = 2; 67 | 68 | // The children of this node. 69 | // 70 | // This is unset if `node_type` is `NODE_TYPE_LEAF`. 71 | repeated SubjectTree children = 3; 72 | } -------------------------------------------------------------------------------- /api/protos/authorizer/accesscontroller/v1alpha1/namespace_service.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package authorizer.accesscontroller.v1alpha1; 4 | 5 | import "authorizer/accesscontroller/v1alpha1/acl.proto"; 6 | import "google/api/annotations.proto"; 7 | 8 | option go_package = "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1;acl"; 9 | 10 | // The service to administer namespace configurations. 11 | service NamespaceConfigService { 12 | 13 | // WriteConfig upserts a namespace configuration. 14 | // 15 | // If the namespace config already exists, the existing one is overwritten. If the new 16 | // namespace config removes an existing relation, there must not be any relation tuples 17 | // that reference it. Otherwise a FAILED_PRECONDITION status is returned. 18 | // 19 | // To migrate away from a relation, please move all existing relation tuples referencing 20 | // it over to the new relation and then delete the old relation once all tuples have been 21 | // migrated. 22 | rpc WriteConfig(WriteConfigRequest) returns (WriteConfigResponse) { 23 | option (google.api.http) = { 24 | put: "/authorizer/access-controller/v1alpha1/namespace-configs", 25 | body: "*" 26 | }; 27 | } 28 | 29 | // Read a namespace configuration. 30 | rpc ReadConfig(ReadConfigRequest) returns (ReadConfigResponse) { 31 | option (google.api.http) = { 32 | get: "/authorizer/access-controller/v1alpha1/namespace-configs" 33 | }; 34 | } 35 | } 36 | 37 | // The request for a NamespaceConfigService.WriteConfig rpc. 38 | message WriteConfigRequest { 39 | 40 | // The namespace config to upsert. 41 | NamespaceConfig config = 1; 42 | } 43 | 44 | // The response for a NamespaceConfigService.WriteConfig rpc. 45 | message WriteConfigResponse {} 46 | 47 | // The request for a NamespaceConfigService.ReadConfig rpc. 48 | message ReadConfigRequest { 49 | 50 | // The namespace whose config should be read. 51 | string namespace = 1; 52 | } 53 | 54 | // The response for a NamespaceConfigService.ReadConfig rpc. 55 | message ReadConfigResponse { 56 | 57 | // The namespace of the config. 58 | string namespace = 1; 59 | 60 | // The namespace config for the given namespace. 61 | NamespaceConfig config = 2; 62 | } 63 | 64 | // A namespace config defines the relations that exist between objects and subjects in 65 | // in a namespace. 66 | message NamespaceConfig { 67 | 68 | // The name of the namespace. 69 | string name = 1; 70 | 71 | // The relations that this namespace defines. 72 | repeated Relation relations = 2; 73 | } 74 | 75 | // A Relation defines a type of relationship between an object and subject. 76 | // 77 | // Relations can have rewrite rules that specify how the relation is 78 | // computed relative to other relations defined within the same namespace 79 | // or across other namespaces. 80 | message Relation { 81 | 82 | // The name of the relation (e.g. viewer, editor, or member). 83 | string name = 1; 84 | 85 | // The rewrite rule for this relation, or nil if it references itself. 86 | Rewrite rewrite = 2; 87 | } 88 | 89 | // Rewrites define sub-expressions that combine operations such as union or intersection. A rewrite 90 | // sub-expression can be recursive and thus allows arbitrary logical expressions to be constructed. 91 | message Rewrite { 92 | oneof rewrite_operation { 93 | 94 | // Joins the children of the rewrite via set union. 95 | SetOperation union = 1; 96 | 97 | // Joins the children of the rewrite via set intersection. 98 | SetOperation intersection = 2; 99 | } 100 | } 101 | 102 | message SetOperation { 103 | message Child { 104 | 105 | // This references the defined relation directly. 106 | message This {} 107 | 108 | oneof child_type { 109 | This this = 1; 110 | ComputedSubjectset computed_subjectset = 2; 111 | TupleToSubjectset tuple_to_subjectset = 3; 112 | Rewrite rewrite = 4; 113 | } 114 | } 115 | 116 | repeated Child children = 1; 117 | } 118 | 119 | // Computes a tupleset from the input object, fetches relation tuples matching the 120 | // tupleset, and computes the set of subjects from every fetched relation tuple. 121 | // 122 | // This is useful to lookup relations in other namespaces or to create complex hierarchies 123 | // between objects in multiple namespaces. 124 | message TupleToSubjectset { 125 | 126 | message Tupleset { string relation = 1; } 127 | 128 | // A tupleset defining the relation tuples that relate to the set of subjects that 129 | // this TupleToSubjectset applies to. 130 | Tupleset tupleset = 1; 131 | 132 | // The computed set of subjects that are looked up based on the expanded tupleset. 133 | ComputedSubjectset computed_subjectset = 2; 134 | } 135 | 136 | // Computes the set of subjects that have the included relation within the 137 | // same namespace. 138 | // 139 | // This is useful to follow relations between an object and subject within 140 | // the same namespace. If you want anyone with an 'editor' relation to also 141 | // have 'viewer' this would be a good fit. 142 | message ComputedSubjectset { 143 | string relation = 2; 144 | } -------------------------------------------------------------------------------- /api/protos/authorizer/accesscontroller/v1alpha1/read_service.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package authorizer.accesscontroller.v1alpha1; 4 | 5 | import "authorizer/accesscontroller/v1alpha1/acl.proto"; 6 | import "google/protobuf/field_mask.proto"; 7 | import "google/api/annotations.proto"; 8 | 9 | option go_package = "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1;acl"; 10 | 11 | // A service to query Access Control Lists. 12 | service ReadService { 13 | 14 | // Lists relation tuples. 15 | // 16 | // **NOTE**: This does not follow direct or indirect references through rewrites. If youq 17 | // need to follow indirect references through rewrites, please use the Expand API. 18 | rpc ListRelationTuples(ListRelationTuplesRequest) returns (ListRelationTuplesResponse) { 19 | option (google.api.http) = { 20 | get: "/authorizer/access-controller/v1alpha1/tuples" 21 | }; 22 | } 23 | } 24 | 25 | message ListRelationTuplesRequest { 26 | 27 | // The query for listing relation tuples. Clients can 28 | // specify any optional field to partially filter for 29 | // specific relation tuples. 30 | // 31 | // Example use cases: 32 | // - object only: display a list of all ACLs of one object 33 | // - relation only: get all groups that have members; e.g. get all directories that have content 34 | // - object & relation: display all subjects that have e.g. write relation 35 | // - subject & relation: display all groups a subject belongs to/display all objects a subject has access to 36 | // - object & relation & subject: check whether the relation tuple already exists, before writing it 37 | message Query { 38 | // Required. The namespace to query. 39 | string namespace = 1; 40 | // Optional. 41 | string object = 2; 42 | // Optional. 43 | repeated string relations = 3; 44 | // Optional. 45 | Subject subject = 4; 46 | } 47 | 48 | // All field constraints are concatenated with a logical 49 | // AND operator. 50 | Query query = 1; 51 | 52 | // Optional. The snapshot token that encodes the evaluation timestamp that this request will be evaluated no earlier than. 53 | string snaptoken = 3; 54 | 55 | // Optional. The maximum number of RelationTuples to return in 56 | // the response. 57 | int32 page_size = 4; 58 | 59 | // Optional. A pagination token returned from a previous call to 60 | // `ListRelationTuples` that indicates where the page should start 61 | // at. 62 | string page_token = 5; 63 | } 64 | 65 | message ListRelationTuplesResponse { 66 | 67 | // The relation tuples matching the request query. 68 | // 69 | // The RelationTuple list is ordered from the newest 70 | // RelationTuple to the oldest. 71 | repeated RelationTuple relation_tuples = 1; 72 | 73 | // The token required to paginate to the next page. 74 | string next_page_token = 2; 75 | 76 | // Indicates if this is the last page of paginated data. 77 | // If `is_last_page` is true then using `next_page_token` 78 | // in subsequent requests will return an error. 79 | bool is_last_page = 3; 80 | } -------------------------------------------------------------------------------- /api/protos/authorizer/accesscontroller/v1alpha1/write_service.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package authorizer.accesscontroller.v1alpha1; 4 | 5 | import "authorizer/accesscontroller/v1alpha1/acl.proto"; 6 | import "google/api/annotations.proto"; 7 | 8 | option go_package = "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1;acl"; 9 | 10 | // A service that defines APIs to manage relation tuple ACLs. 11 | service WriteService { 12 | 13 | // Mutates one or more relation tuples within a single transaction. 14 | rpc WriteRelationTuplesTxn(WriteRelationTuplesTxnRequest) returns (WriteRelationTuplesTxnResponse) { 15 | option (google.api.http) = { 16 | post: "/authorizer/access-controller/v1alpha1/write", 17 | body: "*" 18 | }; 19 | } 20 | } 21 | 22 | // The request of a WriteService.WriteRelationTuplesTxn rpc. 23 | message WriteRelationTuplesTxnRequest { 24 | 25 | // The write delta for the relation tuples operated in one single transaction. 26 | // Either all actions commit or no changes take effect on error. 27 | repeated RelationTupleDelta relation_tuple_deltas = 1; 28 | } 29 | 30 | // Write-delta for a WriteRelationTuplesTxnRequest. 31 | message RelationTupleDelta { 32 | 33 | // An enumeration defining the actions or mutations that can be done on a RelationTuple. 34 | enum Action { 35 | // An unspecified action. 36 | // 37 | // The `WriteRelationTuplesTxn` rpc ignores RelationTupleDeltas with 38 | // an unspecified action. 39 | ACTION_UNSPECIFIED = 0; 40 | 41 | // Upserts a new RelationTuple. 42 | // 43 | // If the RelationTuple already exists no modification is done. 44 | ACTION_INSERT = 1; 45 | 46 | // Deletes the RelationTuple. 47 | // 48 | // If the RelationTuple does not exist it's a no-op. 49 | ACTION_DELETE = 4; 50 | } 51 | 52 | // The action to do on the RelationTuple. 53 | Action action = 1; 54 | 55 | // The target RelationTuple. 56 | RelationTuple relation_tuple = 2; 57 | } 58 | 59 | // The response of a WriteService.WriteRelationTuplesTxn rpc. 60 | message WriteRelationTuplesTxnResponse { 61 | 62 | // The list of the new latest snapshot tokens of the affected RelationTuple, 63 | // with the same index as specified in the `relation_tuple_deltas` field of 64 | // the WriteRelationTuplesTxnRequest request. 65 | // 66 | // If the RelationTupleDelta_Action was DELETE 67 | // the snaptoken is empty at the same index. 68 | repeated string snaptokens = 1; 69 | } -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v1beta1 2 | plugins: 3 | - name: go 4 | out: . 5 | opt: 6 | - module=github.com/authorizer-tech/access-controller 7 | - name: go-grpc 8 | out: . 9 | opt: 10 | - module=github.com/authorizer-tech/access-controller 11 | - name: grpc-gateway 12 | out: . 13 | opt: 14 | - module=github.com/authorizer-tech/access-controller 15 | - name: openapiv2 16 | out: api/openapiv2 -------------------------------------------------------------------------------- /buf.lock: -------------------------------------------------------------------------------- 1 | # Generated by buf. DO NOT EDIT. 2 | deps: 3 | - remote: buf.build 4 | owner: beta 5 | repository: googleapis 6 | branch: main 7 | commit: 2e73676eef8642dfba4ed782b7c8d6fe 8 | digest: b1-vB11w98W2vFtEP4Veknm56Pi6DU6MpOuocESiOzvbqw= 9 | create_time: 2021-04-26T14:55:30.644663Z 10 | - remote: buf.build 11 | owner: grpc-ecosystem 12 | repository: grpc-gateway 13 | branch: main 14 | commit: d19475fa22444a289c46af009acce62c 15 | digest: b1-_zhDPyr_Ctc1QRAKuad6_0xvoyPd6QaB22ldm9gzS0Q= 16 | create_time: 2021-04-26T15:19:26.742789Z 17 | -------------------------------------------------------------------------------- /buf.yaml: -------------------------------------------------------------------------------- 1 | version: v1beta1 2 | name: buf.build/authorizer-tech/access-controller 3 | build: 4 | roots: 5 | - api/protos 6 | deps: 7 | - buf.build/beta/googleapis 8 | - buf.build/grpc-ecosystem/grpc-gateway 9 | breaking: 10 | ignore_unstable_packages: true 11 | -------------------------------------------------------------------------------- /cmd/access-controller/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "flag" 7 | "fmt" 8 | "net" 9 | "net/http" 10 | "os" 11 | "os/signal" 12 | "runtime" 13 | "strings" 14 | "syscall" 15 | "time" 16 | 17 | "github.com/cenkalti/backoff/v4" 18 | "github.com/golang-migrate/migrate/v4" 19 | "github.com/golang-migrate/migrate/v4/database/cockroachdb" 20 | _ "github.com/golang-migrate/migrate/v4/source/file" 21 | "github.com/google/uuid" 22 | gwruntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" 23 | _ "github.com/lib/pq" 24 | "github.com/spf13/viper" 25 | 26 | aclpb "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1" 27 | ac "github.com/authorizer-tech/access-controller/internal" 28 | "github.com/authorizer-tech/access-controller/internal/datastores" 29 | "github.com/authorizer-tech/access-controller/internal/healthchecker" 30 | "github.com/authorizer-tech/access-controller/internal/namespace-manager/postgres" 31 | log "github.com/sirupsen/logrus" 32 | "google.golang.org/grpc" 33 | "google.golang.org/grpc/health/grpc_health_v1" 34 | "google.golang.org/grpc/reflection" 35 | ) 36 | 37 | var ( 38 | version = "dev" 39 | commit = "none" 40 | date = "unknown" 41 | ) 42 | 43 | var serverID = flag.String("id", uuid.New().String(), "A unique identifier for the server. Defaults to a new uuid.") 44 | var nodePort = flag.Int("node-port", 7946, "The bind port for the cluster node") 45 | var advertise = flag.String("advertise", "", "The address that this node advertises on within the cluster") 46 | var grpcPort = flag.Int("grpc-port", 50052, "The bind port for the grpc server") 47 | var httpPort = flag.Int("http-port", 8082, "The bind port for the grpc-gateway http server") 48 | var join = flag.String("join", "", "A comma-separated list of 'host:port' addresses for nodes in the cluster to join to") 49 | var migrations = flag.String("migrations", "./db/migrations", "The absolute path to the database migrations directory") 50 | 51 | type config struct { 52 | GrpcGateway struct { 53 | Enabled bool 54 | } 55 | 56 | CockroachDB struct { 57 | Host string 58 | Port int 59 | Database string 60 | } 61 | } 62 | 63 | func main() { 64 | 65 | flag.Parse() 66 | 67 | configPaths := []string{"/etc/authorizer/access-controller", "$HOME/.authorizer/access-controller", "."} 68 | for _, path := range configPaths { 69 | viper.AddConfigPath(path) 70 | } 71 | viper.AutomaticEnv() 72 | 73 | if err := viper.ReadInConfig(); err != nil { 74 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 75 | log.Fatalf("server config not found in paths [%s]", strings.Join(configPaths, ",")) 76 | } 77 | 78 | log.Fatalf("Failed to load server config file: %v", err) 79 | } 80 | 81 | var cfg config 82 | if err := viper.Unmarshal(&cfg); err != nil { 83 | log.Fatalf("Failed to Unmarshal server config: %v", err) 84 | } 85 | 86 | pgUsername := viper.GetString("POSTGRES_USERNAME") 87 | pgPassword := viper.GetString("POSTGRES_PASSWORD") 88 | 89 | dbHost := cfg.CockroachDB.Host 90 | if dbHost == "" { 91 | dbHost = "localhost" 92 | log.Warn("The database host was not configured. Defaulting to 'localhost'") 93 | } 94 | 95 | dbPort := cfg.CockroachDB.Port 96 | if dbPort == 0 { 97 | dbPort = 26257 98 | log.Warn("The database port was not configured. Defaulting to '26257'") 99 | } 100 | 101 | dbName := cfg.CockroachDB.Database 102 | if dbName == "" { 103 | dbName = "postgres" 104 | log.Warn("The database name was not configured. Defaulting to 'postgres'") 105 | } 106 | 107 | dsn := fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=disable", 108 | pgUsername, 109 | pgPassword, 110 | dbHost, 111 | dbPort, 112 | dbName, 113 | ) 114 | 115 | db, err := sql.Open("postgres", dsn) 116 | if err != nil { 117 | log.Fatalf("Failed to establish a connection to the database: %v", err) 118 | } 119 | 120 | backoffPolicy := backoff.NewExponentialBackOff() 121 | backoffPolicy.MaxElapsedTime = 15 * time.Second 122 | 123 | err = backoff.Retry(func() error { 124 | if err := db.Ping(); err != nil { 125 | log.Error("Failed to Ping the database. Retrying again soon...") 126 | return err 127 | } 128 | 129 | return nil 130 | }, backoffPolicy) 131 | if err != nil { 132 | log.Fatalf("Failed to connect to the database: %v", err) 133 | } 134 | 135 | driver, err := cockroachdb.WithInstance(db, &cockroachdb.Config{}) 136 | if err != nil { 137 | log.Fatalf("Failed to create migrator database driver instance: %v", err) 138 | } 139 | 140 | migrator, err := migrate.NewWithDatabaseInstance( 141 | fmt.Sprintf("file://%s", *migrations), 142 | "cockroachdb", driver) 143 | if err != nil { 144 | log.Fatalf("Failed to initialize the database migrator: %v", err) 145 | } 146 | 147 | if err := migrator.Up(); err != nil { 148 | if err != migrate.ErrNoChange { 149 | log.Fatalf("Failed to migrate up to the latest database schema: %v", err) 150 | } 151 | } 152 | 153 | datastore := &datastores.SQLStore{ 154 | DB: db, 155 | } 156 | 157 | m, err := postgres.NewNamespaceManager(db) 158 | if err != nil { 159 | log.Fatalf("Failed to initialize postgres NamespaceManager: %v", err) 160 | } 161 | 162 | log.Info("Starting access-controller") 163 | log.Infof(" Version: %s", version) 164 | log.Infof(" Date: %s", date) 165 | log.Infof(" Commit: %s", commit) 166 | log.Infof(" Go version: %s", runtime.Version()) 167 | 168 | ctrlOpts := []ac.AccessControllerOption{ 169 | ac.WithStore(datastore), 170 | ac.WithNamespaceManager(m), 171 | ac.WithNodeConfigs(ac.NodeConfigs{ 172 | ServerID: *serverID, 173 | Advertise: *advertise, 174 | Join: *join, 175 | NodePort: *nodePort, 176 | ServerPort: *grpcPort, 177 | }), 178 | } 179 | controller, err := ac.NewAccessController(ctrlOpts...) 180 | if err != nil { 181 | log.Fatalf("Failed to initialize the access-controller: %v", err) 182 | } 183 | 184 | healthChecker := healthchecker.NewHealthChecker(controller.HealthCheck) 185 | 186 | addr := fmt.Sprintf(":%d", *grpcPort) 187 | listener, err := net.Listen("tcp", addr) 188 | if err != nil { 189 | log.Fatalf("Failed to start the TCP listener on '%v': %v", addr, err) 190 | } 191 | 192 | grpcOpts := []grpc.ServerOption{} 193 | server := grpc.NewServer(grpcOpts...) 194 | aclpb.RegisterCheckServiceServer(server, controller) 195 | aclpb.RegisterWriteServiceServer(server, controller) 196 | aclpb.RegisterReadServiceServer(server, controller) 197 | aclpb.RegisterExpandServiceServer(server, controller) 198 | aclpb.RegisterNamespaceConfigServiceServer(server, controller) 199 | grpc_health_v1.RegisterHealthServer(server, healthChecker) 200 | 201 | go func() { 202 | reflection.Register(server) 203 | 204 | log.Infof("Starting grpc server at '%v'..", addr) 205 | 206 | if err := server.Serve(listener); err != nil { 207 | log.Fatalf("Failed to start the gRPC server: %v", err) 208 | } 209 | }() 210 | 211 | var gateway *http.Server 212 | if cfg.GrpcGateway.Enabled { 213 | ctx, cancel := context.WithCancel(context.Background()) 214 | defer cancel() 215 | 216 | // Register gRPC server endpoint 217 | // Note: Make sure the gRPC server is running properly and accessible 218 | mux := gwruntime.NewServeMux() 219 | 220 | opts := []grpc.DialOption{grpc.WithInsecure()} 221 | 222 | if err := aclpb.RegisterCheckServiceHandlerFromEndpoint(ctx, mux, addr, opts); err != nil { 223 | log.Fatalf("Failed to initialize grpc-gateway CheckService handler: %v", err) 224 | } 225 | 226 | if err := aclpb.RegisterWriteServiceHandlerFromEndpoint(ctx, mux, addr, opts); err != nil { 227 | log.Fatalf("Failed to initialize grpc-gateway WriteService handler: %v", err) 228 | } 229 | 230 | if err := aclpb.RegisterReadServiceHandlerFromEndpoint(ctx, mux, addr, opts); err != nil { 231 | log.Fatalf("Failed to initialize grpc-gateway ReadService handler: %v", err) 232 | } 233 | 234 | if err := aclpb.RegisterNamespaceConfigServiceHandlerFromEndpoint(ctx, mux, addr, opts); err != nil { 235 | log.Fatalf("Failed to initialize grpc-gateway NamespaceConfig handler: %v", err) 236 | } 237 | 238 | gateway = &http.Server{ 239 | Addr: fmt.Sprintf(":%d", *httpPort), 240 | Handler: mux, 241 | } 242 | 243 | go func() { 244 | log.Infof("Starting grpc-gateway server at '%v'..", gateway.Addr) 245 | 246 | // Start HTTP server (and proxy calls to gRPC server endpoint) 247 | if err := gateway.ListenAndServe(); err != http.ErrServerClosed { 248 | log.Fatalf("Failed to start grpc-gateway HTTP server: %v", err) 249 | } 250 | }() 251 | } 252 | 253 | exit := make(chan os.Signal, 1) 254 | signal.Notify(exit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) 255 | 256 | <-exit 257 | 258 | log.Info("Shutting Down..") 259 | 260 | if cfg.GrpcGateway.Enabled { 261 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 262 | defer cancel() 263 | 264 | if err := gateway.Shutdown(ctx); err != nil { 265 | log.Errorf("Failed to gracefully shutdown the grpc-gateway server: %v", err) 266 | } 267 | } 268 | 269 | server.Stop() 270 | if err := controller.Close(); err != nil { 271 | log.Errorf("Failed to gracefully close the access-controller: %v", err) 272 | } 273 | 274 | log.Info("Shutdown Complete. Goodbye 👋") 275 | } 276 | -------------------------------------------------------------------------------- /db/migrations/1_bootstrap_tables.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS "namespace-relation-lookup"; 2 | DROP TABLE IF EXISTS "changelog"; 3 | DROP TABLE IF EXISTS "namespace-changelog"; 4 | DROP TABLE IF EXISTS "namespace-configs"; -------------------------------------------------------------------------------- /db/migrations/1_bootstrap_tables.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "namespace-configs" ( 2 | namespace text NOT NULL, 3 | config jsonb, 4 | "timestamp" timestamp with time zone NOT NULL, 5 | PRIMARY KEY (namespace, "timestamp") 6 | ); 7 | 8 | CREATE TABLE IF NOT EXISTS "namespace-changelog" ( 9 | namespace text NOT NULL, 10 | operation text NOT NULL, 11 | config jsonb, 12 | "timestamp" timestamp with time zone NOT NULL, 13 | PRIMARY KEY (namespace, operation, "timestamp") 14 | ); 15 | 16 | CREATE TABLE IF NOT EXISTS "changelog" ( 17 | namespace text NOT NULL, 18 | operation text NOT NULL, 19 | relationtuple text NOT NULL, 20 | "timestamp" timestamp with time zone NOT NULL, 21 | PRIMARY KEY (namespace, operation, relationtuple, "timestamp") 22 | ); 23 | 24 | CREATE TABLE IF NOT EXISTS "namespace-relation-lookup" ( 25 | namespace text NOT NULL, 26 | relation text NOT NULL, 27 | relationtuple text NOT NULL, 28 | PRIMARY KEY (namespace, relation, relationtuple) 29 | ); -------------------------------------------------------------------------------- /docker/config.yaml: -------------------------------------------------------------------------------- 1 | grpcGateway: 2 | enabled: true 3 | 4 | cockroachdb: 5 | host: cockroachdb 6 | port: 26257 7 | database: postgres -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | access-controller0: 4 | image: gcr.io/authorizer-tech/access-controller:latest 5 | ports: 6 | - "50052:50052" 7 | - "8082:8082" 8 | command: access-controller 9 | volumes: 10 | - "${PWD}/docker:/etc/authorizer/access-controller" 11 | depends_on: 12 | - cockroachdb 13 | 14 | access-controller1: 15 | image: gcr.io/authorizer-tech/access-controller:latest 16 | ports: 17 | - "50053:50053" 18 | - "8083:8083" 19 | command: access-controller -grpc-port 50053 -http-port 8083 -node-port 7947 -join access-controller0:7946 20 | volumes: 21 | - "${PWD}/docker:/etc/authorizer/access-controller" 22 | depends_on: 23 | - access-controller0 24 | 25 | access-controller2: 26 | image: gcr.io/authorizer-tech/access-controller:latest 27 | ports: 28 | - "50054:50054" 29 | - "8084:8084" 30 | command: access-controller -grpc-port 50054 -http-port 8084 -node-port 7948 -join access-controller0:7946,access-controller1:7947 31 | volumes: 32 | - "${PWD}/docker:/etc/authorizer/access-controller" 33 | depends_on: 34 | - access-controller1 35 | 36 | cockroachdb: 37 | image: cockroachdb/cockroach:v21.1.1 38 | ports: 39 | - "26257:26257" 40 | - "8080:8080" 41 | command: start-single-node --insecure 42 | volumes: 43 | - "cockroach_data:/cockroach/cockroach-data" 44 | 45 | volumes: 46 | cockroach_data: 47 | driver: local -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/authorizer-tech/access-controller 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/bufbuild/buf v0.43.3-0.20210628222303-5e30cfa7db9f 7 | github.com/buraksezer/consistent v0.0.0-20191006190839-693edf70fd72 8 | github.com/cenkalti/backoff/v4 v4.1.1 9 | github.com/cespare/xxhash v1.1.0 10 | github.com/doug-martin/goqu/v9 v9.10.0 11 | github.com/gogo/protobuf v1.3.2 // indirect 12 | github.com/golang-migrate/migrate/v4 v4.14.1 13 | github.com/golang/mock v1.5.0 14 | github.com/google/uuid v1.2.0 15 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.3.0 16 | github.com/hashicorp/memberlist v0.2.2 17 | github.com/kr/pretty v0.2.0 // indirect 18 | github.com/lib/pq v1.10.2 19 | github.com/ory/dockertest/v3 v3.6.5 20 | github.com/pkg/errors v0.9.1 21 | github.com/sirupsen/logrus v1.7.0 22 | github.com/spf13/afero v1.3.4 // indirect 23 | github.com/spf13/viper v1.7.1 24 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e // indirect 25 | google.golang.org/genproto v0.0.0-20210630183607-d20f26d13c79 26 | google.golang.org/grpc v1.39.0-dev.0.20210519181852-3dd75a6888ce 27 | google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 28 | google.golang.org/protobuf v1.27.1 29 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 30 | honnef.co/go/tools v0.0.1-2020.1.4 31 | ) 32 | -------------------------------------------------------------------------------- /internal/client-router.go: -------------------------------------------------------------------------------- 1 | package accesscontroller 2 | 3 | //go:generate mockgen -self_package github.com/authorizer-tech/access-controller/internal -destination=./mock_clientrouter_test.go -package accesscontroller . ClientRouter 4 | 5 | import ( 6 | "fmt" 7 | "sync" 8 | ) 9 | 10 | // ErrClientNotFound is an error that occurrs if a ClientRouter.GetClient call is 11 | // invoked with a nodeID that does not exist. 12 | var ErrClientNotFound = fmt.Errorf("the client with the provided identifier was not found") 13 | 14 | // ClientRouter defines an interface to manage RPC clients for nodes/peers 15 | // within a cluster. 16 | type ClientRouter interface { 17 | AddClient(nodeID string, client interface{}) 18 | GetClient(nodeID string) (interface{}, error) 19 | RemoveClient(nodeID string) 20 | } 21 | 22 | // mapClientRouter implements the ClientRouter interface ontop of a simple 23 | // map structure. 24 | type mapClientRouter struct { 25 | rw sync.RWMutex 26 | cache map[string]interface{} 27 | } 28 | 29 | // NewMapClientRouter creates an in-memory, map based, ClientRouter. It is 30 | // safe for concurrent use. 31 | func NewMapClientRouter() ClientRouter { 32 | r := mapClientRouter{ 33 | cache: map[string]interface{}{}, 34 | } 35 | 36 | return &r 37 | } 38 | 39 | // AddClient adds the client for the given nodeID to the underlying 40 | // map cache. 41 | // 42 | // This method is safe for concurrent use. 43 | func (r *mapClientRouter) AddClient(nodeID string, client interface{}) { 44 | defer r.rw.Unlock() 45 | r.rw.Lock() 46 | r.cache[nodeID] = client 47 | } 48 | 49 | // GetClient fetches the client for the given nodeID or returns an 50 | // error if none exists. 51 | // 52 | // This method is safe for concurrent use. 53 | func (r *mapClientRouter) GetClient(nodeID string) (interface{}, error) { 54 | defer r.rw.RUnlock() 55 | r.rw.RLock() 56 | 57 | client, ok := r.cache[nodeID] 58 | if !ok { 59 | return nil, ErrClientNotFound 60 | } 61 | return client, nil 62 | } 63 | 64 | // RemoveClient removes the client for the given nodeID from the map 65 | // cache. 66 | // 67 | // This method is safe for concurrent use. 68 | func (r *mapClientRouter) RemoveClient(nodeID string) { 69 | defer r.rw.Unlock() 70 | r.rw.Lock() 71 | delete(r.cache, nodeID) 72 | } 73 | 74 | // Always verify that we implement the interface 75 | var _ ClientRouter = &mapClientRouter{} 76 | -------------------------------------------------------------------------------- /internal/client-router_test.go: -------------------------------------------------------------------------------- 1 | package accesscontroller 2 | 3 | import "testing" 4 | 5 | func TestMapClientRouter(t *testing.T) { 6 | 7 | router := NewMapClientRouter() 8 | 9 | router.AddClient("node1", struct{}{}) 10 | 11 | val, err := router.GetClient("node1") 12 | if err != nil { 13 | t.Errorf("Expected nil error, but got '%s'", err) 14 | } else { 15 | if val != struct{}{} { 16 | t.Errorf("Expected empty struct, but got '%v'", val) 17 | } 18 | } 19 | 20 | _, err = router.GetClient("missing") 21 | if err != ErrClientNotFound { 22 | t.Errorf("Expected error '%s', but got '%s'", ErrClientNotFound, err) 23 | } 24 | 25 | router.RemoveClient("node1") 26 | _, err = router.GetClient("node1") 27 | if err != ErrClientNotFound { 28 | t.Errorf("Expected error '%s', but got '%s'", ErrClientNotFound, err) 29 | } 30 | 31 | router.RemoveClient("missing") // removing a non-existing key doesn't panic 32 | } 33 | -------------------------------------------------------------------------------- /internal/datastores/sql-store.go: -------------------------------------------------------------------------------- 1 | package datastores 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/doug-martin/goqu/v9" 8 | _ "github.com/doug-martin/goqu/v9/dialect/postgres" // use postgres dialect driver 9 | "github.com/doug-martin/goqu/v9/exp" 10 | _ "github.com/lib/pq" 11 | 12 | aclpb "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1" 13 | ac "github.com/authorizer-tech/access-controller/internal" 14 | ) 15 | 16 | // SQLStore implements the RelationTupleStore interface for a sql storage adapter. 17 | type SQLStore struct { 18 | DB *sql.DB 19 | } 20 | 21 | // SubjectSets fetches the subject sets for all of the (object, relation) pairs provided. 22 | func (s *SQLStore) SubjectSets(ctx context.Context, object ac.Object, relations ...string) ([]ac.SubjectSet, error) { 23 | 24 | sqlbuilder := goqu.Dialect("postgres").From(object.Namespace).Select("subject").Where( 25 | goqu.Ex{ 26 | "object": object.ID, 27 | "relation": relations, 28 | "subject": goqu.Op{"like": "_%%:_%%#_%%"}, 29 | }, 30 | ) 31 | 32 | sql, args, err := sqlbuilder.ToSQL() 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | rows, err := s.DB.Query(sql, args...) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | subjects := []ac.SubjectSet{} 43 | for rows.Next() { 44 | var s string 45 | if err := rows.Scan(&s); err != nil { 46 | return nil, err 47 | } 48 | 49 | subjectSet, err := ac.SubjectSetFromString(s) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | subjects = append(subjects, subjectSet) 55 | } 56 | rows.Close() 57 | 58 | return subjects, nil 59 | } 60 | 61 | // RowCount returns the number of rows matching the relation tuple query provided. 62 | func (s *SQLStore) RowCount(ctx context.Context, query ac.RelationTupleQuery) (int64, error) { 63 | 64 | sqlbuilder := goqu.Dialect("postgres").From(query.Object.Namespace).Select( 65 | goqu.COUNT("*"), 66 | ).Where(goqu.Ex{ 67 | "object": query.Object.ID, 68 | "relation": query.Relations, 69 | "subject": query.Subject.String(), 70 | }) 71 | 72 | sql, args, err := sqlbuilder.ToSQL() 73 | if err != nil { 74 | return -1, err 75 | } 76 | 77 | row := s.DB.QueryRow(sql, args...) 78 | 79 | var count int64 80 | if err := row.Scan(&count); err != nil { 81 | return -1, err 82 | } 83 | 84 | return count, nil 85 | } 86 | 87 | // ListRelationTuples lists the relation tuples matching the request query and filters the response fields 88 | // by the provided field mask. 89 | func (s *SQLStore) ListRelationTuples(ctx context.Context, query *aclpb.ListRelationTuplesRequest_Query) ([]ac.InternalRelationTuple, error) { 90 | 91 | sqlbuilder := goqu.Dialect("postgres").From(query.GetNamespace()).Prepared(true) 92 | 93 | if query.GetObject() != "" { 94 | sqlbuilder = sqlbuilder.Where(goqu.Ex{ 95 | "object": query.GetObject(), 96 | }) 97 | } 98 | 99 | if len(query.GetRelations()) > 0 { 100 | sqlbuilder = sqlbuilder.Where(goqu.Ex{ 101 | "relation": query.GetRelations(), 102 | }) 103 | } 104 | 105 | if query.GetSubject() != nil { 106 | sqlbuilder = sqlbuilder.Where(goqu.Ex{ 107 | "subject": query.GetSubject().String(), 108 | }) 109 | } 110 | 111 | sql, args, err := sqlbuilder.ToSQL() 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | rows, err := s.DB.Query(sql, args...) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | tuples := []ac.InternalRelationTuple{} 122 | for rows.Next() { 123 | var object, relation, s string 124 | if err := rows.Scan(&object, &relation, &s); err != nil { 125 | return nil, err 126 | } 127 | 128 | subject, err := ac.SubjectFromString(s) 129 | if err != nil { 130 | return nil, err 131 | } 132 | 133 | tuples = append(tuples, ac.InternalRelationTuple{ 134 | Namespace: query.GetNamespace(), 135 | Object: object, 136 | Relation: relation, 137 | Subject: subject, 138 | }) 139 | } 140 | rows.Close() 141 | 142 | return tuples, nil 143 | } 144 | 145 | // TransactRelationTuples applies, with the same txn, the relation tuple inserts and deletions provided. 146 | // Each insertion/deletion includes a corresponding changelog entry with an operation indicating what was 147 | // applied. 148 | func (s *SQLStore) TransactRelationTuples(ctx context.Context, tupleInserts []*ac.InternalRelationTuple, tupleDeletes []*ac.InternalRelationTuple) error { 149 | 150 | txn, err := s.DB.Begin() 151 | if err != nil { 152 | return err 153 | } 154 | 155 | for _, tuple := range tupleInserts { 156 | sqlbuilder := goqu.Dialect("postgres").Insert(tuple.Namespace).Cols("object", "relation", "subject").Vals( 157 | goqu.Vals{tuple.Object, tuple.Relation, tuple.Subject.String()}, 158 | ).OnConflict(goqu.DoNothing()) 159 | 160 | sql, args, err := sqlbuilder.ToSQL() 161 | if err != nil { 162 | return err 163 | } 164 | 165 | _, err = txn.Exec(sql, args...) 166 | if err != nil { 167 | return err 168 | } 169 | 170 | sql, args, err = goqu.Dialect("postgres").Insert("changelog").Cols( 171 | "namespace", "operation", "relationtuple", "timestamp", 172 | ).Vals( 173 | goqu.Vals{tuple.Namespace, "INSERT", tuple.String(), goqu.L("NOW()")}, 174 | ).OnConflict( 175 | goqu.DoNothing(), 176 | ).ToSQL() 177 | if err != nil { 178 | return err 179 | } 180 | 181 | _, err = txn.Exec(sql, args...) 182 | if err != nil { 183 | return err 184 | } 185 | 186 | sqlbuilder = goqu.Dialect("postgres").Insert("namespace-relation-lookup").Cols( 187 | "namespace", "relation", "relationtuple", 188 | ).OnConflict(goqu.DoNothing()) 189 | 190 | values := [][]interface{}{ 191 | goqu.Vals{tuple.Namespace, tuple.Relation, tuple.String()}, 192 | } 193 | 194 | if subjectSet, ok := tuple.Subject.(*ac.SubjectSet); ok { 195 | values = append(values, 196 | goqu.Vals{subjectSet.Namespace, subjectSet.Relation, tuple.String()}, 197 | ) 198 | } 199 | 200 | sqlbuilder = sqlbuilder.Vals(values...) 201 | 202 | sql, args, err = sqlbuilder.ToSQL() 203 | if err != nil { 204 | return err 205 | } 206 | 207 | _, err = txn.Exec(sql, args...) 208 | if err != nil { 209 | return err 210 | } 211 | } 212 | 213 | for _, tuple := range tupleDeletes { 214 | sqlbuilder := goqu.Dialect("postgres").Delete(tuple.Namespace).Where(goqu.Ex{ 215 | "object": tuple.Object, 216 | "relation": tuple.Relation, 217 | "subject": tuple.Subject.String(), 218 | }) 219 | 220 | sql, args, err := sqlbuilder.ToSQL() 221 | if err != nil { 222 | return err 223 | } 224 | 225 | _, err = txn.Exec(sql, args...) 226 | if err != nil { 227 | return err 228 | } 229 | 230 | expressions := []exp.Expression{ 231 | goqu.Ex{ 232 | "namespace": tuple.Namespace, 233 | "relation": tuple.Relation, 234 | "relationtuple": tuple.String(), 235 | }, 236 | } 237 | 238 | if subjectSet, ok := tuple.Subject.(*ac.SubjectSet); ok { 239 | expressions = append(expressions, 240 | goqu.Ex{ 241 | "namespace": subjectSet.Namespace, 242 | "relation": subjectSet.Relation, 243 | "relationtuple": tuple.String(), 244 | }, 245 | ) 246 | } 247 | 248 | sqlbuilder = goqu.Dialect("postgres").Delete("namespace-relation-lookup").Where(expressions...) 249 | 250 | sql, args, err = sqlbuilder.ToSQL() 251 | if err != nil { 252 | return err 253 | } 254 | 255 | _, err = txn.Exec(sql, args...) 256 | if err != nil { 257 | return err 258 | } 259 | 260 | sql, args, err = goqu.Dialect("postgres").Insert("changelog").Cols( 261 | "namespace", "operation", "relationtuple", "timestamp", 262 | ).Vals( 263 | goqu.Vals{tuple.Namespace, "DELETE", tuple.String(), goqu.L("NOW()")}, 264 | ).OnConflict( 265 | goqu.DoNothing(), 266 | ).ToSQL() 267 | if err != nil { 268 | return err 269 | } 270 | 271 | _, err = txn.Exec(sql, args...) 272 | if err != nil { 273 | return err 274 | } 275 | } 276 | 277 | return txn.Commit() 278 | } 279 | 280 | // Always verify that we implement the interface 281 | var _ ac.RelationTupleStore = &SQLStore{} 282 | -------------------------------------------------------------------------------- /internal/hashring/hashring.go: -------------------------------------------------------------------------------- 1 | package hashring 2 | 3 | //go:generate mockgen -self_package github.com/authorizer-tech/access-controller/internal -destination=../mock_hashring_test.go -package accesscontroller . Hashring 4 | 5 | import ( 6 | "context" 7 | "hash/crc32" 8 | "sort" 9 | "strings" 10 | "sync" 11 | 12 | "github.com/buraksezer/consistent" 13 | "github.com/cespare/xxhash" 14 | ) 15 | 16 | type ctxKey int 17 | 18 | var hashringChecksumKey ctxKey 19 | 20 | type hasher struct{} 21 | 22 | func (h hasher) Sum64(data []byte) uint64 { 23 | return xxhash.Sum64(data) 24 | } 25 | 26 | // HashringMember represents an interface that types must implement to be a member of 27 | // a Hashring. 28 | type HashringMember interface { 29 | String() string 30 | } 31 | 32 | // Hashring defines an interface to manage a consistent hashring. 33 | type Hashring interface { 34 | 35 | // Add adds a new member to the hashring. 36 | Add(member HashringMember) 37 | 38 | // Remove removes a member from the hashring. 39 | Remove(member HashringMember) 40 | 41 | // LocateKey finds the nearest hashring member for a given key. 42 | LocateKey(key []byte) HashringMember 43 | 44 | // Checksum computes the CRC32 checksum of the Hashring. 45 | // 46 | // This can be used to compare the relative state of two 47 | // hash rings on remote servers. If the checksum is the 48 | // same, then the two members can trust their memberlist 49 | // is identical. If not, then at some point in the future 50 | // the hashring memberlist should converge and then the 51 | // checksums will be identical. 52 | Checksum() uint32 53 | } 54 | 55 | // NewContextWithChecksum returns a new Context that carries the hashring checksum. 56 | func NewContextWithChecksum(ctx context.Context, hashringChecksum uint32) context.Context { 57 | return context.WithValue(ctx, hashringChecksumKey, hashringChecksum) 58 | } 59 | 60 | // ChecksumFromContext extracts the hashring checksum from the provided 61 | // ctx or returns false if none was found in the ctx. 62 | func ChecksumFromContext(ctx context.Context) (uint32, bool) { 63 | checksum, ok := ctx.Value(hashringChecksumKey).(uint32) 64 | return checksum, ok 65 | } 66 | 67 | // ConsistentHashring implements a Hashring using consistent hashing with bounded loads. 68 | type ConsistentHashring struct { 69 | rw sync.RWMutex 70 | ring *consistent.Consistent 71 | } 72 | 73 | // NewConsistentHashring returns a Hashring using consistent hashing with bounded loads. The 74 | // distribution of the load in the hashring is specified via the config provided. If the cfg 75 | // is nil, defaults are used. 76 | func NewConsistentHashring(cfg *consistent.Config) Hashring { 77 | 78 | if cfg == nil { 79 | cfg = &consistent.Config{ 80 | Hasher: &hasher{}, 81 | PartitionCount: 31, 82 | ReplicationFactor: 3, 83 | Load: 1.25, 84 | } 85 | } 86 | 87 | hashring := &ConsistentHashring{ 88 | ring: consistent.New(nil, *cfg), 89 | } 90 | return hashring 91 | } 92 | 93 | // Add adds the provided hashring member to the hashring 94 | // memberlist. 95 | func (ch *ConsistentHashring) Add(member HashringMember) { 96 | defer ch.rw.Unlock() 97 | ch.rw.Lock() 98 | ch.ring.Add(consistent.Member(member)) 99 | } 100 | 101 | // Remove removes the provided hashring member from the hashring 102 | // memberlist. 103 | func (ch *ConsistentHashring) Remove(member HashringMember) { 104 | defer ch.rw.Unlock() 105 | ch.rw.Lock() 106 | ch.ring.Remove(member.String()) 107 | } 108 | 109 | // LocateKey locates the nearest hashring member to the given 110 | // key. 111 | func (ch *ConsistentHashring) LocateKey(key []byte) HashringMember { 112 | defer ch.rw.RUnlock() 113 | ch.rw.RLock() 114 | return ch.ring.LocateKey(key) 115 | } 116 | 117 | // Checksum computes a consistent CRC32 checksum of the hashring 118 | // members using the IEEE polynomial. 119 | func (ch *ConsistentHashring) Checksum() uint32 { 120 | defer ch.rw.RUnlock() 121 | ch.rw.RLock() 122 | 123 | memberSet := make(map[string]struct{}) 124 | for _, member := range ch.ring.GetMembers() { 125 | memberSet[member.String()] = struct{}{} 126 | } 127 | 128 | members := make([]string, 0, len(memberSet)) 129 | for member := range memberSet { 130 | members = append(members, member) 131 | } 132 | 133 | sort.Strings(members) 134 | bytes := []byte(strings.Join(members, ",")) 135 | return crc32.ChecksumIEEE(bytes) 136 | } 137 | 138 | // Always verify that we implement the interface 139 | var _ Hashring = &ConsistentHashring{} 140 | -------------------------------------------------------------------------------- /internal/hashring/hashring_test.go: -------------------------------------------------------------------------------- 1 | package hashring 2 | 3 | import ( 4 | "context" 5 | "hash/crc32" 6 | "sort" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/cespare/xxhash" 11 | "github.com/hashicorp/memberlist" 12 | ) 13 | 14 | func TestHasher_Sum64(t *testing.T) { 15 | 16 | sum := hasher{}.Sum64([]byte("test")) 17 | expected := xxhash.Sum64([]byte("test")) 18 | 19 | if sum != expected { 20 | t.Errorf("Expected '%v', but got '%v'", expected, sum) 21 | } 22 | } 23 | 24 | func TestChecksumFromContext(t *testing.T) { 25 | 26 | type output struct { 27 | checksum uint32 28 | ok bool 29 | } 30 | 31 | tests := []struct { 32 | input context.Context 33 | output output 34 | }{ 35 | { 36 | input: context.Background(), 37 | output: output{ 38 | ok: false, 39 | }, 40 | }, 41 | { 42 | input: NewContextWithChecksum(context.Background(), 1), 43 | output: output{ 44 | checksum: 1, 45 | ok: true, 46 | }, 47 | }, 48 | } 49 | 50 | for _, test := range tests { 51 | checksum, ok := ChecksumFromContext(test.input) 52 | 53 | if ok != test.output.ok { 54 | t.Errorf("Expected ok to be '%v', but got '%v'", test.output.ok, ok) 55 | } else { 56 | if checksum != test.output.checksum { 57 | t.Errorf("Expected timestamp '%v', but got '%v'", test.output.checksum, checksum) 58 | } 59 | } 60 | } 61 | } 62 | 63 | func TestConsistentHashring_Remove(t *testing.T) { 64 | 65 | node1 := &memberlist.Node{Name: "node1"} 66 | node2 := &memberlist.Node{Name: "node2"} 67 | 68 | ring := NewConsistentHashring(nil) 69 | 70 | ring.Add(node1) 71 | ring.Add(node2) 72 | 73 | checksum1 := checksum(node1, node2) 74 | if checksum1 != ring.Checksum() { 75 | t.Errorf("Expected checksum '%d', but got '%d'", ring.Checksum(), checksum1) 76 | } 77 | 78 | ring.Remove(node2) 79 | checksum2 := checksum(node1) 80 | if checksum2 != ring.Checksum() { 81 | t.Errorf("Expected checksum '%d', but got '%d'", ring.Checksum(), checksum1) 82 | } 83 | 84 | if expected := crc32.ChecksumIEEE([]byte(node1.String())); checksum2 != expected { 85 | t.Errorf("Expected checksum '%d', but got '%d'", expected, checksum2) 86 | } 87 | } 88 | 89 | func TestConsistentHashring_LocateKey(t *testing.T) { 90 | 91 | node1 := &memberlist.Node{Name: "node1"} 92 | node2 := &memberlist.Node{Name: "node2"} 93 | 94 | ring := NewConsistentHashring(nil) 95 | 96 | ring.Add(node1) 97 | 98 | member := ring.LocateKey([]byte("key1")).String() 99 | if member != "node1" { 100 | t.Errorf("Expected '%s', but got '%s'", "node1", member) 101 | } 102 | 103 | ring.Add(node2) 104 | 105 | member = ring.LocateKey([]byte("key1")).String() 106 | if member != "node2" { 107 | t.Errorf("Expected '%s', but got '%s'", "node2", member) 108 | } 109 | } 110 | 111 | func TestConsistentHashring_Checksum(t *testing.T) { 112 | 113 | node1 := &memberlist.Node{Name: "node1"} 114 | node2 := &memberlist.Node{Name: "node2"} 115 | 116 | ring := NewConsistentHashring(nil) 117 | ring.Add(node2) 118 | ring.Add(node1) 119 | 120 | expected := checksum(node1, node2) 121 | 122 | if expected != ring.Checksum() { 123 | t.Errorf("Expected checksum '%d', but got '%d'", ring.Checksum(), expected) 124 | } 125 | } 126 | 127 | func checksum(members ...*memberlist.Node) uint32 { 128 | 129 | memberSet := make(map[string]struct{}) 130 | for _, member := range members { 131 | memberSet[member.String()] = struct{}{} 132 | } 133 | 134 | m := make([]string, 0, len(memberSet)) 135 | for member := range memberSet { 136 | m = append(m, member) 137 | } 138 | 139 | sort.Strings(m) 140 | bytes := []byte(strings.Join(m, ",")) 141 | 142 | return crc32.ChecksumIEEE(bytes) 143 | } 144 | -------------------------------------------------------------------------------- /internal/healthchecker/healthchecker.go: -------------------------------------------------------------------------------- 1 | package healthchecker 2 | 3 | import ( 4 | "context" 5 | 6 | "google.golang.org/grpc/codes" 7 | "google.golang.org/grpc/health/grpc_health_v1" 8 | "google.golang.org/grpc/status" 9 | ) 10 | 11 | type healthCheckHandler func(ctx context.Context, req *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) 12 | 13 | // HealthChecker implements the gRPC Health Checking Protocol. 14 | // 15 | // The health check behavior is injected into the HealthChecker by passing a 16 | // healthCheckHandler. 17 | // 18 | // For more information about the gRPC Health Checking Protocol see: 19 | // https://github.com/grpc/grpc/blob/master/doc/health-checking.md 20 | type HealthChecker struct { 21 | grpc_health_v1.UnimplementedHealthServer 22 | healthCheckHandler 23 | } 24 | 25 | // NewHealthChecker returns a new HealthChecker instance 26 | func NewHealthChecker(handler healthCheckHandler) *HealthChecker { 27 | return &HealthChecker{healthCheckHandler: handler} 28 | } 29 | 30 | // Check returns the server's current health status. 31 | func (s *HealthChecker) Check(ctx context.Context, req *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) { 32 | return s.healthCheckHandler(ctx, req) 33 | } 34 | 35 | // Watch is left unimplemented. Please use the HealthChecker.Check RPC instead. 36 | // If this RPC is needed in the future, an implementation will be provided at 37 | // that time. 38 | func (s *HealthChecker) Watch(req *grpc_health_v1.HealthCheckRequest, srv grpc_health_v1.Health_WatchServer) error { 39 | return status.Error(codes.Unimplemented, "unimplemented") 40 | } 41 | 42 | // Always verify that we implement the interface 43 | var _ grpc_health_v1.HealthServer = &HealthChecker{} 44 | -------------------------------------------------------------------------------- /internal/healthchecker/healthchecker_test.go: -------------------------------------------------------------------------------- 1 | package healthchecker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/pkg/errors" 9 | "google.golang.org/grpc/codes" 10 | "google.golang.org/grpc/health/grpc_health_v1" 11 | "google.golang.org/grpc/status" 12 | "google.golang.org/protobuf/proto" 13 | ) 14 | 15 | func TestHealthChecker_Check(t *testing.T) { 16 | 17 | handlerErr := fmt.Errorf("some error") 18 | 19 | type input struct { 20 | handler healthCheckHandler 21 | ctx context.Context 22 | request *grpc_health_v1.HealthCheckRequest 23 | } 24 | 25 | type output struct { 26 | response *grpc_health_v1.HealthCheckResponse 27 | err error 28 | } 29 | 30 | tests := []struct { 31 | name string 32 | input 33 | output 34 | }{ 35 | { 36 | name: "Test-1", 37 | input: input{ 38 | handler: func(ctx context.Context, req *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) { 39 | return nil, handlerErr 40 | }, 41 | }, 42 | output: output{ 43 | err: handlerErr, 44 | }, 45 | }, 46 | { 47 | name: "Test-2", 48 | input: input{ 49 | handler: func(ctx context.Context, req *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) { 50 | return nil, nil 51 | }, 52 | }, 53 | output: output{}, 54 | }, 55 | { 56 | name: "Test-3", 57 | input: input{ 58 | handler: func(ctx context.Context, req *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) { 59 | return &grpc_health_v1.HealthCheckResponse{ 60 | Status: grpc_health_v1.HealthCheckResponse_SERVING, 61 | }, nil 62 | }, 63 | }, 64 | output: output{ 65 | response: &grpc_health_v1.HealthCheckResponse{ 66 | Status: grpc_health_v1.HealthCheckResponse_SERVING, 67 | }, 68 | }, 69 | }, 70 | { 71 | name: "Test-4", 72 | input: input{ 73 | handler: func(ctx context.Context, req *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) { 74 | return &grpc_health_v1.HealthCheckResponse{ 75 | Status: grpc_health_v1.HealthCheckResponse_NOT_SERVING, 76 | }, nil 77 | }, 78 | }, 79 | output: output{ 80 | response: &grpc_health_v1.HealthCheckResponse{ 81 | Status: grpc_health_v1.HealthCheckResponse_NOT_SERVING, 82 | }, 83 | }, 84 | }, 85 | } 86 | 87 | for _, test := range tests { 88 | t.Run(test.name, func(t *testing.T) { 89 | checker := NewHealthChecker(test.input.handler) 90 | 91 | ctx := test.input.ctx 92 | if ctx == nil { 93 | ctx = context.Background() 94 | } 95 | 96 | response, err := checker.Check(ctx, test.input.request) 97 | 98 | if !errors.Is(err, test.output.err) { 99 | t.Errorf("Expected error '%v', but got '%v'", test.output.err, err) 100 | } 101 | 102 | if !proto.Equal(response, test.output.response) { 103 | t.Errorf("Expected response '%v', but got '%v'", test.output.response, response) 104 | } 105 | }) 106 | } 107 | } 108 | 109 | func TestHealthCheck_Watch(t *testing.T) { 110 | 111 | checker := NewHealthChecker(nil) 112 | 113 | expected := status.Error(codes.Unimplemented, "unimplemented") 114 | 115 | err := checker.Watch(nil, nil) 116 | if !errors.Is(err, expected) { 117 | t.Errorf("Expected error '%v', but got '%v'", expected, err) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /internal/namespace-manager/namespacemgr.go: -------------------------------------------------------------------------------- 1 | package namespacemgr 2 | 3 | //go:generate mockgen -self_package github.com/authorizer-tech/access-controller/internal -destination=../mock_namespace_manager_test.go -package accesscontroller . NamespaceManager,PeerNamespaceConfigStore 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "sync" 9 | "time" 10 | 11 | aclpb "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1" 12 | "google.golang.org/grpc/codes" 13 | "google.golang.org/grpc/status" 14 | ) 15 | 16 | // NamespaceConfigErrorType defines an enumeration over various types of Namespace Config 17 | // errors that can occur. 18 | type NamespaceConfigErrorType int 19 | 20 | const ( 21 | 22 | // NamespaceAlreadyExists is an error that occurrs when attempting to add a namespace config 23 | // for a namespace that has been previously added. 24 | NamespaceAlreadyExists NamespaceConfigErrorType = iota 25 | 26 | // NamespaceDoesntExist is an error that occurrs when attempting to fetch a namespace config 27 | // for a namespace that doesn't exist. 28 | NamespaceDoesntExist 29 | 30 | // NamespaceRelationUndefined is an error that occurrs when referencing an undefined relation 31 | // in a namespace config. 32 | NamespaceRelationUndefined 33 | 34 | // NamespaceUpdateFailedPrecondition is an error that occurrs when an update to a namespace config 35 | // fails precondition checks. 36 | NamespaceUpdateFailedPrecondition 37 | ) 38 | 39 | // NamespaceConfigError represents an error type that is surfaced when Namespace Config 40 | // errors are encountered. 41 | type NamespaceConfigError struct { 42 | Message string 43 | Type NamespaceConfigErrorType 44 | } 45 | 46 | // Error returns the NamespaceConfigError as an error string. 47 | func (e NamespaceConfigError) Error() string { 48 | return e.Message 49 | } 50 | 51 | // ToStatus returns the namespace config error as a grpc status. 52 | func (e NamespaceConfigError) ToStatus() *status.Status { 53 | switch e.Type { 54 | case NamespaceDoesntExist, NamespaceUpdateFailedPrecondition, NamespaceRelationUndefined: 55 | return status.New(codes.InvalidArgument, e.Message) 56 | default: 57 | return status.New(codes.Unknown, e.Message) 58 | } 59 | } 60 | 61 | // ErrNamespaceDoesntExist is an error that occurrs when attempting to fetch a namespace config 62 | // for a namespace that doesn't exist. 63 | var ErrNamespaceDoesntExist error = errors.New("the provided namespace doesn't exist, please add it first") 64 | 65 | // ErrNoLocalNamespacesDefined is an error that occurrs when attempting to fetch a namespace config 66 | // and no local namespaces have been defined. 67 | var ErrNoLocalNamespacesDefined error = errors.New("no local namespace configs have been defined at this time") 68 | 69 | type nsConfigSnapshotTimestampKey string 70 | 71 | // NewContextWithNamespaceConfigTimestamp returns a new Context that carries the namespace config name and timestamp. 72 | func NewContextWithNamespaceConfigTimestamp(ctx context.Context, namespace string, timestamp time.Time) context.Context { 73 | return context.WithValue(ctx, nsConfigSnapshotTimestampKey(namespace), timestamp) 74 | } 75 | 76 | // NamespaceConfigTimestampFromContext extracts the snapshot timestamp for a namespace 77 | // configuration from the provided context. If none is present, a boolean false is returned. 78 | func NamespaceConfigTimestampFromContext(ctx context.Context, namespace string) (time.Time, bool) { 79 | timestamp, ok := ctx.Value(nsConfigSnapshotTimestampKey(namespace)).(time.Time) 80 | return timestamp, ok 81 | } 82 | 83 | // NamespaceOperation represents the operations that can be taken on namespace configs. 84 | type NamespaceOperation string 85 | 86 | const ( 87 | 88 | // AddNamespace is the operation when a new namespace config is added. 89 | AddNamespace NamespaceOperation = "ADD" 90 | 91 | // UpdateNamespace is the operation when a namespace config is updated. 92 | UpdateNamespace NamespaceOperation = "UPDATE" 93 | ) 94 | 95 | // NamespaceConfigSnapshot represents a namespace configuration at a specific point in time. 96 | type NamespaceConfigSnapshot struct { 97 | Config *aclpb.NamespaceConfig `json:"config"` 98 | Timestamp time.Time `json:"timestamp"` 99 | } 100 | 101 | // ChangelogIterator is used to iterate over namespace changelog entries as they are yielded. 102 | type ChangelogIterator interface { 103 | 104 | // Next prepares the next changelog entry for reading. It returns true 105 | // if there is another entry and false if no more entries are available. 106 | Next() bool 107 | 108 | // Value returns the current most changelog entry that the iterator is 109 | // iterating over. 110 | Value() (*NamespaceChangelogEntry, error) 111 | 112 | // Close closes the iterator. 113 | Close(ctx context.Context) error 114 | } 115 | 116 | // NamespaceChangelogEntry represents an entry in the namespace configurations 117 | // changelog. 118 | type NamespaceChangelogEntry struct { 119 | Namespace string 120 | Operation NamespaceOperation 121 | Config *aclpb.NamespaceConfig 122 | Timestamp time.Time 123 | } 124 | 125 | // NamespaceManager defines an interface to manage/administer namespace configs. 126 | type NamespaceManager interface { 127 | 128 | // UpsertConfig upserts the provided namespace configuration along with a timestamp 129 | // capturing the time at which the txn was committed. 130 | UpsertConfig(ctx context.Context, cfg *aclpb.NamespaceConfig) error 131 | 132 | // GetConfig fetches the latest namespace config. 133 | GetConfig(ctx context.Context, namespace string) (*aclpb.NamespaceConfig, error) 134 | 135 | // GetRewrite fetches the rewrite rule for the given (namespace, relation) tuple using 136 | // the latest namespace config available. 137 | GetRewrite(ctx context.Context, namespace, relation string) (*aclpb.Rewrite, error) 138 | 139 | // TopChanges returns the top n most recent changes for each namespace configuration(s). 140 | // 141 | // For example, suppose you have M number of namespace configs and each config has X 142 | // number of snapshots. This yields an iterator that will iterate over at most M * n 143 | // values. If n >= X, then the iterator will iterate over at most M * X values. 144 | TopChanges(ctx context.Context, n uint) (ChangelogIterator, error) 145 | 146 | // LookupRelationReferencesByCount does a reverse lookup by the (namespace, relation...) pairs 147 | // and returns a map whose keys are the relations and whose values indicate the number of relation 148 | // tuples that reference the (namespace, relation) pair. If a (namespace, relation) pair is not 149 | // referenced at all, it's key is omitted in the output map. 150 | LookupRelationReferencesByCount(ctx context.Context, namespace string, relations ...string) (map[string]int, error) 151 | 152 | // WrapTransaction wraps the provided fn in a single transaction using the context. 153 | // If fn returns an error the transaction is rolled back and aborted. Otherwise it 154 | // is committed. 155 | WrapTransaction(ctx context.Context, fn func(ctx context.Context) error) error 156 | } 157 | 158 | // PeerNamespaceConfigStore defines the interface to store the namespace config snapshots 159 | // for each peer within a cluster. 160 | type PeerNamespaceConfigStore interface { 161 | 162 | // SetNamespaceConfigSnapshot stores the namespace config snapshot for the given peer. 163 | SetNamespaceConfigSnapshot(peerID string, namespace string, config *aclpb.NamespaceConfig, ts time.Time) error 164 | 165 | // ListNamespaceConfigSnapshots returns a map whose keys are the peers and whose values are another 166 | // map storing the timestamps of the snapshots for each namespace config. 167 | ListNamespaceConfigSnapshots(namespace string) (map[string]map[time.Time]*aclpb.NamespaceConfig, error) 168 | 169 | // GetNamespaceConfigSnapshots returns a map of namespaces and that namespace's snapshot configs for 170 | // a given peer. 171 | GetNamespaceConfigSnapshots(peerID string) (map[string]map[time.Time]*aclpb.NamespaceConfig, error) 172 | 173 | // GetNamespaceConfigSnapshot returns the specific namespace config for a peer from the snapshot timestamp 174 | // provided, or nil if one didn't exist at that point in time. 175 | GetNamespaceConfigSnapshot(peerID, namespace string, timestamp time.Time) (*aclpb.NamespaceConfig, error) 176 | 177 | // DeleteNamespaceConfigSnapshots deletes all of the namespaces configs for the given peer. 178 | DeleteNamespaceConfigSnapshots(peerID string) error 179 | } 180 | 181 | type inmemPeerNamespaceConfigStore struct { 182 | rwmu sync.RWMutex 183 | configs map[string]map[string]map[time.Time]*aclpb.NamespaceConfig 184 | } 185 | 186 | func NewInMemoryPeerNamespaceConfigStore() PeerNamespaceConfigStore { 187 | return &inmemPeerNamespaceConfigStore{ 188 | configs: make(map[string]map[string]map[time.Time]*aclpb.NamespaceConfig), 189 | } 190 | } 191 | 192 | func (p *inmemPeerNamespaceConfigStore) SetNamespaceConfigSnapshot(peerID string, namespace string, config *aclpb.NamespaceConfig, ts time.Time) error { 193 | p.rwmu.Lock() 194 | defer p.rwmu.Unlock() 195 | 196 | // To guarantee map key equality, time values must have identical Locations 197 | // and the monotonic clock reading should be stripped. 198 | ts = ts.Round(0).UTC() 199 | 200 | namespaceConfigs, ok := p.configs[peerID] 201 | if !ok { 202 | // key doesn't exist yet, write it! 203 | p.configs[peerID] = map[string]map[time.Time]*aclpb.NamespaceConfig{ 204 | namespace: { 205 | ts: config, 206 | }, 207 | } 208 | } else { 209 | // key exists, update the underlying map 210 | snapshots, ok := namespaceConfigs[namespace] 211 | if !ok { 212 | namespaceConfigs[namespace] = map[time.Time]*aclpb.NamespaceConfig{ 213 | ts: config, 214 | } 215 | } else { 216 | snapshots[ts] = config 217 | } 218 | } 219 | 220 | return nil 221 | } 222 | 223 | func (p *inmemPeerNamespaceConfigStore) ListNamespaceConfigSnapshots(namespace string) (map[string]map[time.Time]*aclpb.NamespaceConfig, error) { 224 | p.rwmu.RLock() 225 | defer p.rwmu.RUnlock() 226 | 227 | peerSnapshots := map[string]map[time.Time]*aclpb.NamespaceConfig{} 228 | for peer, configs := range p.configs { 229 | snapshots, ok := configs[namespace] 230 | if !ok { 231 | // peer isn't aware of the namespace yet 232 | peerSnapshots[peer] = map[time.Time]*aclpb.NamespaceConfig{} 233 | } else { 234 | peerSnapshots[peer] = snapshots 235 | } 236 | } 237 | 238 | return peerSnapshots, nil 239 | } 240 | 241 | func (p *inmemPeerNamespaceConfigStore) GetNamespaceConfigSnapshot(peerID, namespace string, ts time.Time) (*aclpb.NamespaceConfig, error) { 242 | p.rwmu.RLock() 243 | defer p.rwmu.RUnlock() 244 | 245 | configs, ok := p.configs[peerID] 246 | if !ok { 247 | return nil, nil 248 | } 249 | 250 | snapshots, ok := configs[namespace] 251 | if !ok { 252 | return nil, nil 253 | } 254 | 255 | return snapshots[ts], nil 256 | } 257 | 258 | func (p *inmemPeerNamespaceConfigStore) GetNamespaceConfigSnapshots(peerID string) (map[string]map[time.Time]*aclpb.NamespaceConfig, error) { 259 | p.rwmu.RLock() 260 | defer p.rwmu.RUnlock() 261 | 262 | configs, ok := p.configs[peerID] 263 | if !ok { 264 | return map[string]map[time.Time]*aclpb.NamespaceConfig{}, nil 265 | } 266 | 267 | return configs, nil 268 | } 269 | 270 | func (p *inmemPeerNamespaceConfigStore) DeleteNamespaceConfigSnapshots(peerID string) error { 271 | p.rwmu.Lock() 272 | defer p.rwmu.Unlock() 273 | 274 | delete(p.configs, peerID) 275 | 276 | return nil 277 | } 278 | 279 | // Always verify that we implement the interface 280 | var _ PeerNamespaceConfigStore = &inmemPeerNamespaceConfigStore{} 281 | -------------------------------------------------------------------------------- /internal/namespace-manager/namespacemgr_test.go: -------------------------------------------------------------------------------- 1 | package namespacemgr 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | aclpb "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | "google.golang.org/protobuf/proto" 13 | ) 14 | 15 | func TestNamespaceConfigTimestampFromContext(t *testing.T) { 16 | 17 | type output struct { 18 | timestamp time.Time 19 | ok bool 20 | } 21 | 22 | ts := time.Now() 23 | 24 | tests := []struct { 25 | input context.Context 26 | output output 27 | }{ 28 | { 29 | input: context.Background(), 30 | output: output{ 31 | timestamp: time.Time{}, 32 | ok: false, 33 | }, 34 | }, 35 | { 36 | input: NewContextWithNamespaceConfigTimestamp(context.Background(), "namespace2", ts), 37 | output: output{ 38 | timestamp: time.Time{}, 39 | ok: false, 40 | }, 41 | }, 42 | { 43 | input: NewContextWithNamespaceConfigTimestamp(context.Background(), "namespace1", ts), 44 | output: output{ 45 | timestamp: ts, 46 | ok: true, 47 | }, 48 | }, 49 | } 50 | 51 | for _, test := range tests { 52 | timestamp, ok := NamespaceConfigTimestampFromContext(test.input, "namespace1") 53 | 54 | if ok != test.output.ok { 55 | t.Errorf("Expected ok to be '%v', but got '%v'", test.output.ok, ok) 56 | } else { 57 | if timestamp != test.output.timestamp { 58 | t.Errorf("Expected timestamp '%v', but got '%v'", test.output.timestamp, timestamp) 59 | } 60 | } 61 | } 62 | } 63 | 64 | func TestInMemPeerNamespaceConfigStore_SetNamespaceConfigSnapshot(t *testing.T) { 65 | 66 | store := &inmemPeerNamespaceConfigStore{ 67 | configs: make(map[string]map[string]map[time.Time]*aclpb.NamespaceConfig), 68 | } 69 | 70 | timestamp1 := time.Now() 71 | timestamp2 := timestamp1.Add(1 * time.Minute) 72 | 73 | config1 := &aclpb.NamespaceConfig{ 74 | Name: "namespace1", 75 | } 76 | config2 := &aclpb.NamespaceConfig{ 77 | Name: "namespace2", 78 | } 79 | config3 := &aclpb.NamespaceConfig{ 80 | Name: "namespace1", 81 | Relations: []*aclpb.Relation{ 82 | {Name: "relation1"}, 83 | }, 84 | } 85 | 86 | // add a completely new peer and namespace config 87 | if err := store.SetNamespaceConfigSnapshot("peer1", config1.Name, config1, timestamp1); err != nil { 88 | t.Errorf("Expected nil error, but got '%v'", err) 89 | } 90 | 91 | // add a new namespace config for an existing peer 92 | if err := store.SetNamespaceConfigSnapshot("peer1", config2.Name, config2, timestamp1); err != nil { 93 | t.Errorf("Expected nil error, but got '%v'", err) 94 | } 95 | 96 | // add a new config snapshot for an existing namespace 97 | if err := store.SetNamespaceConfigSnapshot("peer1", config3.Name, config3, timestamp2); err != nil { 98 | t.Errorf("Expected nil error, but got '%v'", err) 99 | } 100 | 101 | cfg1, ok := store.configs["peer1"][config1.Name][timestamp1.Round(0).UTC()] 102 | if !ok { 103 | t.Errorf("Expected ok to be true, but got false") 104 | } else { 105 | if !proto.Equal(config1, cfg1) { 106 | t.Errorf("Expected namespace config '%v', but got '%v'", config1, cfg1) 107 | } 108 | } 109 | 110 | cfg2, ok := store.configs["peer1"][config2.Name][timestamp1.Round(0).UTC()] 111 | if !ok { 112 | t.Errorf("Expected ok to be true, but got false") 113 | } else { 114 | if !proto.Equal(config2, cfg2) { 115 | t.Errorf("Expected namespace config '%v', but got '%v'", config2, cfg2) 116 | } 117 | } 118 | 119 | cfg3, ok := store.configs["peer1"][config3.Name][timestamp2.Round(0).UTC()] 120 | if !ok { 121 | t.Errorf("Expected ok to be true, but got false") 122 | } else { 123 | if !proto.Equal(config3, cfg3) { 124 | t.Errorf("Expected namespace config '%v', but got '%v'", config3, cfg3) 125 | } 126 | } 127 | } 128 | 129 | func TestInMemPeerNamespaceConfigStore_ListNamespaceConfigSnapshots(t *testing.T) { 130 | 131 | type output struct { 132 | snapshots map[string]map[time.Time]*aclpb.NamespaceConfig 133 | err error 134 | } 135 | 136 | timestamp := time.Now() 137 | config := &aclpb.NamespaceConfig{} 138 | 139 | store := &inmemPeerNamespaceConfigStore{ 140 | configs: map[string]map[string]map[time.Time]*aclpb.NamespaceConfig{ 141 | "peer1": { 142 | "namespace1": { 143 | timestamp: config, 144 | }, 145 | }, 146 | "peer2": { 147 | "namespace1": { 148 | timestamp: config, 149 | }, 150 | }, 151 | }, 152 | } 153 | 154 | tests := []struct { 155 | name string 156 | namespace string 157 | output output 158 | }{ 159 | { 160 | name: "Test-1", 161 | namespace: "namespace1", 162 | output: output{ 163 | snapshots: map[string]map[time.Time]*aclpb.NamespaceConfig{ 164 | "peer1": { 165 | timestamp: config, 166 | }, 167 | "peer2": { 168 | timestamp: config, 169 | }, 170 | }, 171 | }, 172 | }, 173 | { 174 | name: "Test-2", 175 | namespace: "namespace2", 176 | output: output{ 177 | snapshots: map[string]map[time.Time]*aclpb.NamespaceConfig{ 178 | "peer1": {}, 179 | "peer2": {}, 180 | }, 181 | }, 182 | }, 183 | } 184 | 185 | for _, test := range tests { 186 | t.Run(test.name, func(t *testing.T) { 187 | snapshots, err := store.ListNamespaceConfigSnapshots(test.namespace) 188 | 189 | if err != test.output.err { 190 | t.Errorf("Expected error '%v', but got '%v'", test.output.err, err) 191 | } else { 192 | if !reflect.DeepEqual(test.output.snapshots, snapshots) { 193 | t.Errorf("Expected '%v', but got '%v'", test.output.snapshots, snapshots) 194 | } 195 | } 196 | }) 197 | } 198 | } 199 | 200 | func TestInMemPeerNamespaceConfigStore_GetNamespaceConfigSnapshot(t *testing.T) { 201 | 202 | type input struct { 203 | peerID string 204 | namespace string 205 | timestamp time.Time 206 | } 207 | 208 | type output struct { 209 | config *aclpb.NamespaceConfig 210 | err error 211 | } 212 | 213 | timestamp := time.Now() 214 | 215 | config1 := &aclpb.NamespaceConfig{ 216 | Name: "namespace1", 217 | } 218 | 219 | store := &inmemPeerNamespaceConfigStore{ 220 | configs: map[string]map[string]map[time.Time]*aclpb.NamespaceConfig{ 221 | "peer1": { 222 | config1.Name: { 223 | timestamp: config1, 224 | }, 225 | }, 226 | }, 227 | } 228 | 229 | tests := []struct { 230 | name string 231 | input input 232 | output output 233 | }{ 234 | { 235 | name: "Test-1", 236 | input: input{ 237 | peerID: "peer1", 238 | namespace: "namespace1", 239 | timestamp: timestamp, 240 | }, 241 | output: output{ 242 | config: config1, 243 | }, 244 | }, 245 | { 246 | name: "Test-2", 247 | input: input{ 248 | peerID: "peer2", 249 | }, 250 | output: output{}, 251 | }, 252 | { 253 | name: "Test-3", 254 | input: input{ 255 | peerID: "peer1", 256 | namespace: "namespace2", 257 | }, 258 | output: output{}, 259 | }, 260 | { 261 | name: "Test-4", 262 | input: input{ 263 | peerID: "peer1", 264 | namespace: "namespace1", 265 | timestamp: timestamp.Add(1 * time.Minute), 266 | }, 267 | output: output{}, 268 | }, 269 | } 270 | 271 | for _, test := range tests { 272 | t.Run(test.name, func(t *testing.T) { 273 | cfg, err := store.GetNamespaceConfigSnapshot(test.input.peerID, test.input.namespace, test.input.timestamp) 274 | 275 | if err != test.output.err { 276 | t.Errorf("Expected error '%v', but got '%v'", test.output.err, err) 277 | } else { 278 | if !proto.Equal(test.output.config, cfg) { 279 | t.Errorf("Expected config '%v', but got '%v'", test.output.config, cfg) 280 | } 281 | } 282 | }) 283 | } 284 | } 285 | 286 | func TestInMemPeerNamespaceConfigStore_GetNamespaceConfigSnapshots(t *testing.T) { 287 | 288 | type output struct { 289 | snapshots map[string]map[time.Time]*aclpb.NamespaceConfig 290 | err error 291 | } 292 | 293 | timestamp := time.Now() 294 | 295 | config1 := &aclpb.NamespaceConfig{ 296 | Name: "namespace1", 297 | } 298 | 299 | snapshots := map[string]map[time.Time]*aclpb.NamespaceConfig{ 300 | config1.Name: { 301 | timestamp: config1, 302 | }, 303 | } 304 | 305 | store := &inmemPeerNamespaceConfigStore{ 306 | configs: map[string]map[string]map[time.Time]*aclpb.NamespaceConfig{ 307 | "peer1": { 308 | config1.Name: { 309 | timestamp: config1, 310 | }, 311 | }, 312 | }, 313 | } 314 | 315 | tests := []struct { 316 | name string 317 | peerID string 318 | output output 319 | }{ 320 | { 321 | name: "Test-1", 322 | peerID: "peer1", 323 | output: output{ 324 | snapshots: snapshots, 325 | }, 326 | }, 327 | { 328 | name: "Test-2", 329 | peerID: "peer2", 330 | output: output{ 331 | snapshots: map[string]map[time.Time]*aclpb.NamespaceConfig{}, 332 | }, 333 | }, 334 | } 335 | 336 | for _, test := range tests { 337 | t.Run(test.name, func(t *testing.T) { 338 | snapshots, err := store.GetNamespaceConfigSnapshots(test.peerID) 339 | 340 | if err != test.output.err { 341 | t.Errorf("Expected error '%v', but got '%v'", test.output.err, err) 342 | } else { 343 | if !reflect.DeepEqual(test.output.snapshots, snapshots) { 344 | t.Errorf("Expected '%v', but got '%v'", test.output.snapshots, snapshots) 345 | } 346 | } 347 | }) 348 | } 349 | } 350 | 351 | func TestInMemPeerNamespaceConfigStore_DeleteNamespaceConfigSnapshots(t *testing.T) { 352 | 353 | store := &inmemPeerNamespaceConfigStore{ 354 | configs: make(map[string]map[string]map[time.Time]*aclpb.NamespaceConfig), 355 | } 356 | 357 | // deleting a peer that doesn't exist shouldn't panic 358 | if err := store.DeleteNamespaceConfigSnapshots("peer1"); err != nil { 359 | t.Errorf("Expected nil error, but got '%v'", err) 360 | } 361 | 362 | timestamp := time.Now() 363 | if err := store.SetNamespaceConfigSnapshot("peer2", "namespace1", &aclpb.NamespaceConfig{}, timestamp); err != nil { 364 | t.Fatalf("Failed to set namespace config snapshot") 365 | } 366 | 367 | if err := store.DeleteNamespaceConfigSnapshots("peer2"); err != nil { 368 | t.Errorf("Expected nil error, but got '%v'", err) 369 | } 370 | 371 | _, ok := store.configs["peer2"]["namespace1"][timestamp] 372 | if ok { 373 | t.Errorf("Expected ok to be false, but got true") 374 | } 375 | } 376 | 377 | func TestNamespaceConfigError_Error(t *testing.T) { 378 | 379 | err := NamespaceConfigError{ 380 | Message: "some error", 381 | } 382 | 383 | if err.Error() != "some error" { 384 | t.Errorf("Expected error message 'some error', but got '%v'", err) 385 | } 386 | } 387 | 388 | func TestNamespaceConfigError_ToStatus(t *testing.T) { 389 | 390 | tests := []struct { 391 | name string 392 | input NamespaceConfigError 393 | output *status.Status 394 | }{ 395 | { 396 | name: "Test-1", 397 | input: NamespaceConfigError{ 398 | Message: "some error", 399 | Type: NamespaceDoesntExist, 400 | }, 401 | output: status.New(codes.InvalidArgument, "some error"), 402 | }, 403 | { 404 | name: "Test-2", 405 | input: NamespaceConfigError{ 406 | Message: "some error", 407 | Type: NamespaceAlreadyExists, 408 | }, 409 | output: status.New(codes.Unknown, "some error"), 410 | }, 411 | { 412 | name: "Test-3", 413 | input: NamespaceConfigError{ 414 | Message: "some error", 415 | Type: NamespaceRelationUndefined, 416 | }, 417 | output: status.New(codes.InvalidArgument, "some error"), 418 | }, 419 | { 420 | name: "Test-4", 421 | input: NamespaceConfigError{ 422 | Message: "some error", 423 | Type: NamespaceUpdateFailedPrecondition, 424 | }, 425 | output: status.New(codes.InvalidArgument, "some error"), 426 | }, 427 | } 428 | 429 | for _, test := range tests { 430 | t.Run(test.name, func(t *testing.T) { 431 | 432 | status := test.input.ToStatus() 433 | 434 | if !reflect.DeepEqual(status, test.output) { 435 | t.Errorf("Expected status '%v', but got '%v'", test.output, status) 436 | } 437 | }) 438 | } 439 | } 440 | -------------------------------------------------------------------------------- /internal/namespace-manager/postgres/manager.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/doug-martin/goqu/v9" 10 | "google.golang.org/protobuf/encoding/protojson" 11 | 12 | aclpb "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1" 13 | namespacemgr "github.com/authorizer-tech/access-controller/internal/namespace-manager" 14 | ) 15 | 16 | type txnKey struct{} 17 | 18 | type sqlNamespaceManager struct { 19 | db *sql.DB 20 | } 21 | 22 | // NewNamespaceManager instantiates a namespace manager that is backed by postgres 23 | // for persistence. 24 | func NewNamespaceManager(db *sql.DB) (namespacemgr.NamespaceManager, error) { 25 | 26 | m := sqlNamespaceManager{ 27 | db, 28 | } 29 | 30 | return &m, nil 31 | } 32 | 33 | // UpsertConfig upserts the provided namespace config. If the namespace config already exists, the existing 34 | // one is overwritten with the new cfg. Otherwise the cfg is added to the list of namespace configs. 35 | func (m *sqlNamespaceManager) UpsertConfig(ctx context.Context, cfg *aclpb.NamespaceConfig) error { 36 | 37 | jsonConfig, err := protojson.Marshal(cfg) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | err = m.withTransaction(ctx, func(ctx context.Context, txn *sql.Tx) error { 43 | 44 | row := txn.QueryRow(`SELECT COUNT(namespace) FROM "namespace-configs" WHERE namespace=$1`, cfg.GetName()) 45 | 46 | var count int 47 | if err := row.Scan(&count); err != nil { 48 | return err 49 | } 50 | 51 | var operation namespacemgr.NamespaceOperation 52 | if count > 0 { 53 | operation = namespacemgr.UpdateNamespace 54 | } else { 55 | operation = namespacemgr.AddNamespace 56 | 57 | stmt := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s ( 58 | object text, 59 | relation text, 60 | subject text, 61 | PRIMARY KEY (object, relation, subject) 62 | )`, cfg.GetName()) 63 | 64 | _, err := txn.Exec(stmt) 65 | if err != nil { 66 | return err 67 | } 68 | } 69 | 70 | ins1 := goqu.Insert("namespace-configs"). 71 | Cols("namespace", "config", "timestamp"). 72 | Vals( 73 | goqu.Vals{cfg.GetName(), jsonConfig, goqu.L("NOW()")}, 74 | ) 75 | 76 | ins2 := goqu.Insert("namespace-changelog"). 77 | Cols("namespace", "operation", "config", "timestamp"). 78 | Vals( 79 | goqu.Vals{cfg.GetName(), operation, jsonConfig, goqu.L("NOW()")}, // todo: make sure this is an appropriate txn commit timestamp 80 | ) 81 | 82 | sql1, args1, err := ins1.ToSQL() 83 | if err != nil { 84 | return err 85 | } 86 | 87 | sql2, args2, err := ins2.ToSQL() 88 | if err != nil { 89 | return err 90 | } 91 | 92 | _, err = txn.Exec(sql1, args1...) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | _, err = txn.Exec(sql2, args2...) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | return nil 103 | }) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | return nil 109 | } 110 | 111 | // GetConfig fetches the latest namespace configuration snapshot. If no namespace config exists for the provided 112 | // namespace 'nil' is returned. If any error occurs with the underlying txn an error is returned. 113 | func (m *sqlNamespaceManager) GetConfig(ctx context.Context, namespace string) (*aclpb.NamespaceConfig, error) { 114 | 115 | query := `SELECT config FROM "namespace-configs" WHERE namespace = $1 AND timestamp = (SELECT MAX(timestamp) FROM "namespace-configs" WHERE namespace = $1)` 116 | 117 | var jsonConfig string 118 | 119 | err := m.withTransaction(ctx, func(ctx context.Context, txn *sql.Tx) error { 120 | 121 | row := txn.QueryRow(query, namespace) 122 | 123 | if err := row.Scan(&jsonConfig); err != nil { 124 | if err == sql.ErrNoRows { 125 | return namespacemgr.ErrNamespaceDoesntExist 126 | } 127 | 128 | return err 129 | } 130 | 131 | return nil 132 | }) 133 | if err != nil { 134 | return nil, err 135 | } 136 | 137 | var config aclpb.NamespaceConfig 138 | if err := protojson.Unmarshal([]byte(jsonConfig), &config); err != nil { 139 | return nil, err 140 | } 141 | 142 | return &config, nil 143 | } 144 | 145 | // GetRewrite fetches the latest namespace config rewrite for the (namespace, relation) pair. If no namespace 146 | // config exists for the provided namespace 'nil' is returned. If any error occurs with the underlying txn 147 | // an error is returned. 148 | func (m *sqlNamespaceManager) GetRewrite(ctx context.Context, namespace, relation string) (*aclpb.Rewrite, error) { 149 | 150 | cfg, err := m.GetConfig(ctx, namespace) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | if cfg == nil { 156 | return nil, nil 157 | } 158 | 159 | relations := cfg.GetRelations() 160 | for _, r := range relations { 161 | 162 | if r.GetName() == relation { 163 | rewrite := r.GetRewrite() 164 | 165 | if rewrite == nil { 166 | rewrite = &aclpb.Rewrite{ 167 | RewriteOperation: &aclpb.Rewrite_Union{ 168 | Union: &aclpb.SetOperation{ 169 | Children: []*aclpb.SetOperation_Child{ 170 | {ChildType: &aclpb.SetOperation_Child_This_{}}, 171 | }, 172 | }, 173 | }, 174 | } 175 | } 176 | 177 | return rewrite, nil 178 | } 179 | } 180 | 181 | return nil, nil 182 | } 183 | 184 | // TopChanges fetches the top n most recent namespace config changes for each namespace. If any error 185 | // occurs with the underlying txn an error is returned. Otherwise an iterator that iterates over the 186 | // changes is returned. 187 | func (m *sqlNamespaceManager) TopChanges(ctx context.Context, n uint) (namespacemgr.ChangelogIterator, error) { 188 | 189 | sql := ` 190 | SELECT "namespace", "operation", "config", "timestamp" FROM "namespace-changelog" AS "cfg1" 191 | WHERE "timestamp" IN (SELECT "timestamp" FROM "namespace-changelog" AS "cfg2" 192 | WHERE ("cfg1"."namespace" = "cfg2"."namespace") ORDER BY "timestamp" DESC LIMIT $1) 193 | ORDER BY "namespace" ASC, "timestamp" ASC` 194 | 195 | rows, err := m.db.Query(sql, n) 196 | if err != nil { 197 | return nil, err 198 | } 199 | 200 | i := &iterator{rows} 201 | 202 | return i, nil 203 | } 204 | 205 | // LookupRelationReferencesByCount does a reverse-lookup for a one or more namespace config relations and 206 | // returns a map whose keys are the relation and whose value is a count indicating the number of times that 207 | // relation was referenced by a relation tuple. 208 | func (m *sqlNamespaceManager) LookupRelationReferencesByCount(ctx context.Context, namespace string, relations ...string) (map[string]int, error) { 209 | 210 | references := map[string]int{} 211 | 212 | builder := goqu.Dialect("postgres").From("namespace-relation-lookup"). 213 | Select( 214 | "relation", 215 | goqu.COUNT("*"), 216 | ). 217 | Where(goqu.Ex{ 218 | "namespace": namespace, 219 | "relation": relations, 220 | }). 221 | GroupBy("relation") 222 | 223 | query, args, err := builder.ToSQL() 224 | if err != nil { 225 | return nil, err 226 | } 227 | 228 | err = m.withTransaction(ctx, func(ctx context.Context, txn *sql.Tx) error { 229 | 230 | rows, err := txn.Query(query, args...) 231 | if err != nil { 232 | return err 233 | } 234 | 235 | for rows.Next() { 236 | var relation string 237 | var count int 238 | 239 | if err := rows.Scan(&relation, &count); err != nil { 240 | return err 241 | } 242 | 243 | references[relation] = count 244 | } 245 | 246 | return nil 247 | }) 248 | if err != nil { 249 | return nil, err 250 | } 251 | 252 | return references, nil 253 | } 254 | 255 | // WrapTransaction wraps the fn inside a single transaction. If fn returns an error, the transaction is aborted 256 | // and rolled back. 257 | func (m *sqlNamespaceManager) WrapTransaction(ctx context.Context, fn func(ctx context.Context) error) error { 258 | 259 | var err error 260 | 261 | txn, err := m.db.BeginTx(ctx, nil) 262 | if err != nil { 263 | return err 264 | } 265 | 266 | defer func() { 267 | if err == nil { 268 | return 269 | } 270 | 271 | err = txn.Rollback() 272 | }() 273 | 274 | ctx = context.WithValue(ctx, txnKey{}, txn) 275 | 276 | if err = fn(ctx); err != nil { 277 | return err 278 | } 279 | 280 | if err = txn.Commit(); err != nil { 281 | return err 282 | } 283 | 284 | return err 285 | } 286 | 287 | // withTransaction wraps fn in a transaction and commits it if and only if it is not already part of another 288 | // transaction. 289 | func (m *sqlNamespaceManager) withTransaction(ctx context.Context, fn func(ctx context.Context, txn *sql.Tx) error) error { 290 | 291 | var txn *sql.Tx 292 | var err error 293 | 294 | if val := ctx.Value(txnKey{}); val != nil { 295 | txn = val.(*sql.Tx) 296 | } 297 | 298 | if txn == nil { 299 | txn, err = m.db.BeginTx(ctx, nil) 300 | if err != nil { 301 | return err 302 | } 303 | 304 | defer func() { 305 | err = txn.Commit() 306 | }() 307 | } 308 | 309 | if err := fn(ctx, txn); err != nil { 310 | return err 311 | } 312 | 313 | return err 314 | } 315 | 316 | // iterator provides an implementation of a ChangelogIterator ontop of a standard 317 | // pgx.Rows iterator. 318 | type iterator struct { 319 | rows *sql.Rows 320 | } 321 | 322 | // Next prepares the next row for reading. It returns true if there is another row and false if no more rows are available. 323 | func (i *iterator) Next() bool { 324 | return i.rows.Next() 325 | } 326 | 327 | // Value reads the current ChangelogEntry value the iterator is pointing at. Any read 328 | // errors are returned immediately. 329 | func (i *iterator) Value() (*namespacemgr.NamespaceChangelogEntry, error) { 330 | 331 | var namespace, operation, configJSON string 332 | var timestamp time.Time 333 | if err := i.rows.Scan(&namespace, &operation, &configJSON, ×tamp); err != nil { 334 | return nil, err 335 | } 336 | 337 | var op namespacemgr.NamespaceOperation 338 | switch operation { 339 | case "ADD": 340 | op = namespacemgr.AddNamespace 341 | case "UPDATE": 342 | op = namespacemgr.UpdateNamespace 343 | default: 344 | panic("An unexpected namespace operation was encountered. Underlying system invariants were not met!") 345 | } 346 | 347 | var cfg aclpb.NamespaceConfig 348 | if err := protojson.Unmarshal([]byte(configJSON), &cfg); err != nil { 349 | return nil, err 350 | } 351 | 352 | entry := &namespacemgr.NamespaceChangelogEntry{ 353 | Namespace: namespace, 354 | Operation: op, 355 | Config: &cfg, 356 | Timestamp: timestamp, 357 | } 358 | 359 | return entry, nil 360 | } 361 | 362 | // Close closes the iterator and frees up all resources used by it. 363 | func (i *iterator) Close(ctx context.Context) error { 364 | i.rows.Close() 365 | return nil 366 | } 367 | 368 | // Always verify that we implement the interface 369 | var _ namespacemgr.NamespaceManager = &sqlNamespaceManager{} 370 | -------------------------------------------------------------------------------- /internal/namespace-manager/postgres/manager_test.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "reflect" 10 | "testing" 11 | 12 | "database/sql" 13 | 14 | aclpb "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1" 15 | namespacemgr "github.com/authorizer-tech/access-controller/internal/namespace-manager" 16 | "github.com/golang-migrate/migrate/v4" 17 | "github.com/golang-migrate/migrate/v4/database/cockroachdb" 18 | _ "github.com/golang-migrate/migrate/v4/source/file" 19 | _ "github.com/lib/pq" 20 | "github.com/ory/dockertest/v3" 21 | "github.com/ory/dockertest/v3/docker" 22 | "google.golang.org/protobuf/proto" 23 | ) 24 | 25 | var ( 26 | username = "admin" 27 | password = "" 28 | database = "postgres" 29 | port = "26258" 30 | dialect = "postgres" 31 | ) 32 | 33 | var db *sql.DB 34 | 35 | func TestMain(m *testing.M) { 36 | 37 | flag.Parse() 38 | 39 | if testing.Short() { 40 | return 41 | } 42 | 43 | dockerPool, err := dockertest.NewPool("") 44 | if err != nil { 45 | log.Fatalf("Failed to connect to docker pool: %s", err) 46 | } 47 | 48 | opts := dockertest.RunOptions{ 49 | Repository: "cockroachdb/cockroach", 50 | Tag: "latest-v21.1", 51 | Env: []string{ 52 | "POSTGRES_USER=" + username, 53 | "POSTGRES_PASSWORD=" + password, 54 | "POSTGRES_DB=" + database, 55 | }, 56 | ExposedPorts: []string{"26258"}, 57 | PortBindings: map[docker.Port][]docker.PortBinding{ 58 | "26258": { 59 | {HostIP: "0.0.0.0", HostPort: port}, 60 | }, 61 | }, 62 | Cmd: []string{"start-single-node", "--listen-addr", fmt.Sprintf(":%s", port), "--insecure", "--store=type=mem,size=2GB"}, 63 | } 64 | 65 | resource, err := dockerPool.RunWithOptions(&opts, func(config *docker.HostConfig) { 66 | // set AutoRemove to true so that stopped container goes away by itself 67 | config.AutoRemove = true 68 | config.RestartPolicy = docker.RestartPolicy{ 69 | Name: "no", 70 | } 71 | }) 72 | if err != nil { 73 | log.Fatalf("Failed to start docker container: %s", err.Error()) 74 | } 75 | 76 | dsn := fmt.Sprintf("%s://%s:%s@localhost:%s/%s?sslmode=disable", dialect, username, password, port, database) 77 | if err = dockerPool.Retry(func() error { 78 | db, err = sql.Open("postgres", dsn) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | return db.Ping() 84 | }); err != nil { 85 | log.Fatalf("Failed to establish a conn to database: %v", err) 86 | } 87 | 88 | driver, err := cockroachdb.WithInstance(db, &cockroachdb.Config{}) 89 | 90 | migrator, err := migrate.NewWithDatabaseInstance( 91 | "file://../../../db/migrations", 92 | "cockroachdb", driver) 93 | if err != nil { 94 | log.Fatalf("Failed to initialize migrator database instance: %v", err) 95 | } 96 | 97 | if err := migrator.Up(); err != nil { 98 | log.Fatalf("Failed to migrate up to the latest database schema: %v", err) 99 | } 100 | 101 | code := m.Run() 102 | 103 | if err1, err2 := migrator.Close(); err1 != nil || err2 != nil { 104 | log.Fatalf("Failed to close migrator source or database: %v", err) 105 | } 106 | 107 | if err := dockerPool.Purge(resource); err != nil { 108 | log.Fatalf("Failed to purge docker resource: %s", err) 109 | } 110 | 111 | os.Exit(code) 112 | } 113 | 114 | var rewrite1 *aclpb.Rewrite = &aclpb.Rewrite{ 115 | RewriteOperation: &aclpb.Rewrite_Union{ 116 | Union: &aclpb.SetOperation{ 117 | Children: []*aclpb.SetOperation_Child{ 118 | { 119 | ChildType: &aclpb.SetOperation_Child_ComputedSubjectset{ 120 | ComputedSubjectset: &aclpb.ComputedSubjectset{ 121 | Relation: "relation3", 122 | }, 123 | }, 124 | }, 125 | }, 126 | }, 127 | }, 128 | } 129 | 130 | var cfg1 *aclpb.NamespaceConfig = &aclpb.NamespaceConfig{ 131 | Name: "namespace1", 132 | Relations: []*aclpb.Relation{ 133 | { 134 | Name: "relation1", 135 | }, 136 | { 137 | Name: "relation2", 138 | Rewrite: rewrite1, 139 | }, 140 | }, 141 | } 142 | 143 | var cfg2 *aclpb.NamespaceConfig = &aclpb.NamespaceConfig{ 144 | Name: cfg1.Name, 145 | Relations: []*aclpb.Relation{ 146 | {Name: "relationX"}, 147 | }, 148 | } 149 | 150 | var defaultRewrite *aclpb.Rewrite = &aclpb.Rewrite{ 151 | RewriteOperation: &aclpb.Rewrite_Union{ 152 | Union: &aclpb.SetOperation{ 153 | Children: []*aclpb.SetOperation_Child{ 154 | {ChildType: &aclpb.SetOperation_Child_This_{}}, 155 | }, 156 | }, 157 | }, 158 | } 159 | 160 | func TestNamespaceManager(t *testing.T) { 161 | 162 | m, err := NewNamespaceManager(db) 163 | if err != nil { 164 | log.Fatalf("Failed to instantiate the Postgres NamespaceManager: %v", err) 165 | } 166 | 167 | // Add a new namespace configuration, and verify it by reading it 168 | // back 169 | err = m.UpsertConfig(context.Background(), cfg1) 170 | if err != nil { 171 | t.Errorf("Expected nil error, but got '%v'", err) 172 | } 173 | 174 | config, err := m.GetConfig(context.Background(), cfg1.Name) 175 | if err != nil { 176 | t.Errorf("Expected nil error, but got '%v'", err) 177 | } 178 | if !proto.Equal(cfg1, config) { 179 | t.Errorf("Expected '%v', but got '%v'", cfg1, config) 180 | } 181 | 182 | // Attempt to get a namespace config for a namespace that doesn't exist, verify 183 | // it's nil 184 | config, err = m.GetConfig(context.Background(), "missing-namespace") 185 | if err != namespacemgr.ErrNamespaceDoesntExist { 186 | t.Errorf("Expected error '%v', but got '%v'", namespacemgr.ErrNamespaceDoesntExist, err) 187 | } 188 | if config != nil { 189 | t.Errorf("Expected nil config, but got '%v", config) 190 | } 191 | 192 | // Verify the rewrite rule for 'relation2' 193 | r1, err := m.GetRewrite(context.Background(), cfg1.Name, "relation2") 194 | if err != nil { 195 | t.Errorf("Expected nil error, but got '%v'", err) 196 | } 197 | if !proto.Equal(r1, rewrite1) { 198 | t.Errorf("Expected '%v', but got '%v'", rewrite1, r1) 199 | } 200 | 201 | // Verify the rewrite rule for 'relation1' 202 | r2, err := m.GetRewrite(context.Background(), cfg1.Name, "relation1") 203 | if err != nil { 204 | t.Errorf("Expected nil error, but got '%v'", err) 205 | } 206 | 207 | if !proto.Equal(r2, defaultRewrite) { 208 | t.Errorf("Expected '%v', but got '%v'", defaultRewrite, r2) 209 | } 210 | 211 | // Overwrite the namespace config, and verify it by reading it back 212 | err = m.UpsertConfig(context.Background(), cfg2) 213 | if err != nil { 214 | t.Errorf("Expected nil error, but got '%v'", err) 215 | } 216 | 217 | config, err = m.GetConfig(context.Background(), cfg2.Name) 218 | if err != nil { 219 | t.Errorf("Expected nil error, but got '%v'", err) 220 | } 221 | if !proto.Equal(cfg2, config) { 222 | t.Errorf("Expected '%v', but got '%v'", cfg1, config) 223 | } 224 | 225 | rewrite, err := m.GetRewrite(context.Background(), cfg2.Name, "relationX") 226 | if err != nil { 227 | t.Errorf("Expected nil error, but got '%v'", err) 228 | } 229 | if !proto.Equal(rewrite, defaultRewrite) { 230 | t.Errorf("Expected rewrite '%v', but got '%v'", defaultRewrite, rewrite) 231 | } 232 | 233 | // Fetch (at most) the top 4 most recent namespace config changelog entries 234 | iter, err := m.TopChanges(context.Background(), 4) 235 | if err != nil { 236 | t.Errorf("Expected error to be nil, but got '%v'", err) 237 | } 238 | 239 | changelog := []*namespacemgr.NamespaceChangelogEntry{} 240 | for iter.Next() { 241 | 242 | entry, err := iter.Value() 243 | if err != nil { 244 | t.Fatalf("Expected nil error, but got '%v'", err) 245 | } 246 | 247 | changelog = append(changelog, entry) 248 | 249 | } 250 | if err := iter.Close(context.Background()); err != nil { 251 | t.Fatalf("Failed to close the Changelog iterator: %v", err) 252 | } 253 | 254 | if len(changelog) != 2 { 255 | t.Errorf("Expected 2 changelog entries, but got '%d'", len(changelog)) 256 | } 257 | 258 | // changelog entries are sorted in timestamp acending order (least recent first) 259 | expected := []*namespacemgr.NamespaceChangelogEntry{ 260 | { 261 | Namespace: cfg1.Name, 262 | Operation: namespacemgr.AddNamespace, 263 | Config: changelog[0].Config, // todo: assert the correct value here too 264 | Timestamp: changelog[0].Timestamp, 265 | }, 266 | { 267 | Namespace: cfg2.Name, 268 | Operation: namespacemgr.UpdateNamespace, 269 | Config: changelog[1].Config, // todo: assert the correct value here too 270 | Timestamp: changelog[1].Timestamp, 271 | }, 272 | } 273 | 274 | if !reflect.DeepEqual(expected, changelog) { 275 | t.Errorf("Changelogs were different") 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /internal/node.go: -------------------------------------------------------------------------------- 1 | package accesscontroller 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // NodeConfigs represent the configurations for an individual node withn a gossip 8 | // cluster. 9 | type NodeConfigs struct { 10 | 11 | // A unique identifier for this node in the cluster. 12 | ServerID string 13 | 14 | // The address used to advertise to other cluster members. Used 15 | // for nat traversal. 16 | Advertise string 17 | 18 | // A comma-separated list of existing nodes in the cluster to 19 | // join this node to. 20 | Join string 21 | 22 | // The port that cluster membership gossip is occurring on. 23 | NodePort int 24 | 25 | // The port serving the access-controller RPCs. 26 | ServerPort int 27 | } 28 | 29 | // NodeMetadata is local data specific to this node within the cluster. The 30 | // node's metadata is broadcasted periodically to all peers/nodes in the cluster. 31 | type NodeMetadata struct { 32 | NodeID string `json:"node-id"` 33 | ServerPort int `json:"port"` 34 | 35 | // A map of namespace config snapshots in their JSON serialized form. 36 | NamespaceConfigSnapshots map[string]map[time.Time][]byte `json:"namespace-snapshots"` 37 | } 38 | -------------------------------------------------------------------------------- /internal/relation-store.go: -------------------------------------------------------------------------------- 1 | package accesscontroller 2 | 3 | //go:generate mockgen -self_package github.com/authorizer-tech/access-controller/internal -destination=./mock_relationstore_test.go -package accesscontroller . RelationTupleStore 4 | 5 | import ( 6 | "context" 7 | 8 | aclpb "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1" 9 | ) 10 | 11 | // RelationTupleStore defines an interface to manage the storage of relation tuples. 12 | type RelationTupleStore interface { 13 | SubjectSets(ctx context.Context, object Object, relations ...string) ([]SubjectSet, error) 14 | ListRelationTuples(ctx context.Context, query *aclpb.ListRelationTuplesRequest_Query) ([]InternalRelationTuple, error) 15 | RowCount(ctx context.Context, query RelationTupleQuery) (int64, error) 16 | TransactRelationTuples(ctx context.Context, insert []*InternalRelationTuple, delete []*InternalRelationTuple) error 17 | } 18 | -------------------------------------------------------------------------------- /internal/relation-tuple.go: -------------------------------------------------------------------------------- 1 | package accesscontroller 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | pb "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1" 9 | ) 10 | 11 | // ErrInvalidSubjectSetString is an error that is returned if a malformed SubjectSet string 12 | // is encountered. 13 | var ErrInvalidSubjectSetString = fmt.Errorf("the provided SubjectSet string is malformed") 14 | 15 | // Object represents a namespace and object id. 16 | type Object struct { 17 | Namespace string 18 | ID string 19 | } 20 | 21 | type Subject interface { 22 | json.Marshaler 23 | 24 | String() string 25 | FromString(string) (Subject, error) 26 | Equals(interface{}) bool 27 | ToProto() *pb.Subject 28 | } 29 | 30 | // SubjectID is a unique identifier of some subject. 31 | type SubjectID struct { 32 | ID string `json:"id"` 33 | } 34 | 35 | // MarshalJSON returns the SubjectID as a json byte slice. 36 | func (s SubjectID) MarshalJSON() ([]byte, error) { 37 | return []byte(`"` + s.String() + `"`), nil 38 | } 39 | 40 | // Equals returns a bool indicating if the provided interface and 41 | // the SubjectID are equivalent. Two SubjectIDs are equivalent 42 | // if they have the same ID. 43 | func (s *SubjectID) Equals(v interface{}) bool { 44 | uv, ok := v.(*SubjectID) 45 | if !ok { 46 | return false 47 | } 48 | return uv.ID == s.ID 49 | } 50 | 51 | // FromString parses str and returns the Subject (SubjectID specifically). 52 | func (s *SubjectID) FromString(str string) (Subject, error) { 53 | s.ID = str 54 | return s, nil 55 | } 56 | 57 | // String returns the string representation of the SubjectID. 58 | func (s *SubjectID) String() string { 59 | return s.ID 60 | } 61 | 62 | // ToProto returns the protobuf Subject representation of the given SubjectID. 63 | func (s *SubjectID) ToProto() *pb.Subject { 64 | 65 | if s == nil { 66 | return nil 67 | } 68 | 69 | return &pb.Subject{ 70 | Ref: &pb.Subject_Id{ 71 | Id: s.ID, 72 | }, 73 | } 74 | } 75 | 76 | // SubjectSet defines the set of all subjects that have a specific relation to an object 77 | // within some namespace. 78 | type SubjectSet struct { 79 | Namespace string `json:"namespace"` 80 | Object string `json:"object"` 81 | Relation string `json:"relation"` 82 | } 83 | 84 | // Equals returns a bool indicating if the provided interface and 85 | // the SubjectSet are equivalent. Two SubjectSets are equivalent 86 | // if they define the same (namespace, object, relation) tuple. 87 | func (s *SubjectSet) Equals(v interface{}) bool { 88 | 89 | switch ss := v.(type) { 90 | case *SubjectSet: 91 | return ss.Relation == s.Relation && ss.Object == s.Object && ss.Namespace == s.Namespace 92 | case SubjectSet: 93 | return ss.Relation == s.Relation && ss.Object == s.Object && ss.Namespace == s.Namespace 94 | } 95 | 96 | return false 97 | } 98 | 99 | // String returns the string representation of the SubjectSet. 100 | func (s *SubjectSet) String() string { 101 | return fmt.Sprintf("%s:%s#%s", s.Namespace, s.Object, s.Relation) 102 | } 103 | 104 | // MarshalJSON returns the SubjectSet as a json byte slice. 105 | func (s SubjectSet) MarshalJSON() ([]byte, error) { 106 | return []byte(`"` + s.String() + `"`), nil 107 | } 108 | 109 | // ToProto returns the protobuf Subject representation of the given SubjectSet. 110 | func (s *SubjectSet) ToProto() *pb.Subject { 111 | if s == nil { 112 | return nil 113 | } 114 | 115 | return &pb.Subject{ 116 | Ref: &pb.Subject_Set{ 117 | Set: &pb.SubjectSet{ 118 | Namespace: s.Namespace, 119 | Object: s.Object, 120 | Relation: s.Relation, 121 | }, 122 | }, 123 | } 124 | } 125 | 126 | // FromString parses str and returns the Subject (SubjectSet specifically) 127 | // or an error if the string was malformed in some way. 128 | func (s *SubjectSet) FromString(str string) (Subject, error) { 129 | parts := strings.Split(str, "#") 130 | if len(parts) != 2 { 131 | return nil, ErrInvalidSubjectSetString 132 | } 133 | 134 | innerParts := strings.Split(parts[0], ":") 135 | if len(innerParts) != 2 { 136 | return nil, ErrInvalidSubjectSetString 137 | } 138 | 139 | s.Namespace = innerParts[0] 140 | s.Object = innerParts[1] 141 | s.Relation = parts[1] 142 | 143 | return s, nil 144 | } 145 | 146 | type InternalRelationTuple struct { 147 | Namespace string `json:"namespace"` 148 | Object string `json:"object"` 149 | Relation string `json:"relation"` 150 | Subject Subject `json:"subject"` 151 | } 152 | 153 | // String returns r as a relation tuple in string format. 154 | func (r InternalRelationTuple) String() string { 155 | return fmt.Sprintf("%s:%s#%s@%s", r.Namespace, r.Object, r.Relation, r.Subject) 156 | } 157 | 158 | // ToProto serializes r in it's equivalent protobuf format. 159 | func (r *InternalRelationTuple) ToProto() *pb.RelationTuple { 160 | 161 | if r == nil { 162 | return nil 163 | } 164 | 165 | return &pb.RelationTuple{ 166 | Namespace: r.Namespace, 167 | Object: r.Object, 168 | Relation: r.Relation, 169 | Subject: r.Subject.ToProto(), 170 | } 171 | } 172 | 173 | // SubjectSetFromString takes a string `s` and attempts to decode it into 174 | // a SubjectSet (namespace:object#relation). If the string is not formatted 175 | // as a SubjectSet then an error is returned. 176 | func SubjectSetFromString(s string) (SubjectSet, error) { 177 | 178 | subjectSet := SubjectSet{} 179 | 180 | parts := strings.Split(s, "#") 181 | if len(parts) != 2 { 182 | return subjectSet, ErrInvalidSubjectSetString 183 | } 184 | 185 | innerParts := strings.Split(parts[0], ":") 186 | if len(innerParts) != 2 { 187 | return subjectSet, ErrInvalidSubjectSetString 188 | } 189 | 190 | subjectSet.Namespace = innerParts[0] 191 | subjectSet.Object = innerParts[1] 192 | subjectSet.Relation = parts[1] 193 | 194 | return subjectSet, nil 195 | } 196 | 197 | // SubjectFromString parses the string s and returns a Subject - either 198 | // a SubjectSet or an explicit SubjectID. 199 | func SubjectFromString(s string) (Subject, error) { 200 | if strings.Contains(s, "#") { 201 | return (&SubjectSet{}).FromString(s) 202 | } 203 | return (&SubjectID{}).FromString(s) 204 | } 205 | 206 | // SubjectFromProto deserializes the protobuf subject `sub` into 207 | // it's equivalent Subject structure. 208 | func SubjectFromProto(sub *pb.Subject) Subject { 209 | switch s := sub.GetRef().(type) { 210 | case *pb.Subject_Id: 211 | return &SubjectID{ 212 | ID: s.Id, 213 | } 214 | case *pb.Subject_Set: 215 | return &SubjectSet{ 216 | Namespace: s.Set.Namespace, 217 | Object: s.Set.Object, 218 | Relation: s.Set.Relation, 219 | } 220 | default: 221 | return nil 222 | } 223 | } 224 | 225 | type RelationTupleQuery struct { 226 | Object Object 227 | Relations []string 228 | Subject Subject 229 | } 230 | 231 | // Always verify that we implement the Subject interface 232 | var _ Subject = &SubjectID{} 233 | var _ Subject = &SubjectSet{} 234 | -------------------------------------------------------------------------------- /internal/relation-tuple_test.go: -------------------------------------------------------------------------------- 1 | package accesscontroller 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "testing" 7 | 8 | aclpb "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1" 9 | "google.golang.org/protobuf/proto" 10 | ) 11 | 12 | func TestSubjectID_ToProto(t *testing.T) { 13 | 14 | tests := []struct { 15 | name string 16 | input *SubjectID 17 | output *aclpb.Subject 18 | }{ 19 | { 20 | name: "Test-1", 21 | output: nil, 22 | }, 23 | { 24 | name: "Test-2", 25 | input: &SubjectID{"user1"}, 26 | output: &aclpb.Subject{ 27 | Ref: &aclpb.Subject_Id{Id: "user1"}, 28 | }, 29 | }, 30 | } 31 | 32 | for _, test := range tests { 33 | t.Run(test.name, func(t *testing.T) { 34 | actual := test.input.ToProto() 35 | 36 | if !proto.Equal(actual, test.output) { 37 | t.Errorf("Expected '%v', but got '%v'", test.output, actual) 38 | } 39 | }) 40 | } 41 | } 42 | 43 | func TestSubjectID_MarshalJSON(t *testing.T) { 44 | 45 | type output struct { 46 | bytes []byte 47 | err error 48 | } 49 | 50 | tests := []struct { 51 | name string 52 | input SubjectID 53 | output output 54 | }{ 55 | { 56 | name: "Test-1", 57 | input: SubjectID{"user1"}, 58 | output: output{ 59 | bytes: []byte(`"user1"`), 60 | }, 61 | }, 62 | } 63 | 64 | for _, test := range tests { 65 | t.Run(test.name, func(t *testing.T) { 66 | actual, err := test.input.MarshalJSON() 67 | 68 | if !errors.Is(err, test.output.err) { 69 | t.Errorf("Errors were not equal. Expected '%v', but got '%v'", test.output.err, err) 70 | } else { 71 | if err == nil { 72 | if !reflect.DeepEqual(actual, test.output.bytes) { 73 | t.Errorf("Expected '%s', but got '%s'", test.output.bytes, actual) 74 | } 75 | } 76 | } 77 | }) 78 | } 79 | } 80 | 81 | func TestSubjectID_Equals(t *testing.T) { 82 | 83 | tests := []struct { 84 | name string 85 | input interface{} 86 | output bool 87 | }{ 88 | { 89 | name: "Test-1", 90 | input: "some-string", 91 | output: false, 92 | }, 93 | { 94 | name: "Test-2", 95 | input: &SubjectID{"user2"}, 96 | output: false, 97 | }, 98 | { 99 | name: "Test-3", 100 | input: &SubjectID{"user1"}, 101 | output: true, 102 | }, 103 | } 104 | 105 | for _, test := range tests { 106 | t.Run(test.name, func(t *testing.T) { 107 | actual := (&SubjectID{"user1"}).Equals(test.input) 108 | 109 | if actual != test.output { 110 | t.Errorf("Expected '%v', but got '%v'", test.output, actual) 111 | } 112 | }) 113 | } 114 | } 115 | 116 | func TestSubjectID_String(t *testing.T) { 117 | 118 | s := &SubjectID{"user1"} 119 | if s.String() != "user1" { 120 | t.Errorf("Expected 'user1', but got '%s'", s) 121 | } 122 | 123 | s = &SubjectID{} 124 | if s.String() != "" { 125 | t.Errorf("Expected empty string, but got '%s'", s) 126 | } 127 | } 128 | 129 | func TestSubjectSet_ToProto(t *testing.T) { 130 | 131 | tests := []struct { 132 | name string 133 | input *SubjectSet 134 | output *aclpb.Subject 135 | }{ 136 | { 137 | name: "Test-1", 138 | output: nil, 139 | }, 140 | { 141 | name: "Test-2", 142 | input: &SubjectSet{ 143 | Namespace: "namespace1", 144 | Object: "object1", 145 | Relation: "relation1", 146 | }, 147 | output: &aclpb.Subject{ 148 | Ref: &aclpb.Subject_Set{ 149 | Set: &aclpb.SubjectSet{ 150 | Namespace: "namespace1", 151 | Object: "object1", 152 | Relation: "relation1", 153 | }, 154 | }, 155 | }, 156 | }, 157 | } 158 | 159 | for _, test := range tests { 160 | t.Run(test.name, func(t *testing.T) { 161 | actual := test.input.ToProto() 162 | 163 | if !proto.Equal(actual, test.output) { 164 | t.Errorf("Expected '%v', but got '%v'", test.output, actual) 165 | } 166 | }) 167 | } 168 | } 169 | 170 | func TestSubjectSet_String(t *testing.T) { 171 | 172 | s := &SubjectSet{ 173 | Namespace: "namespace1", 174 | Object: "object1", 175 | Relation: "relation1", 176 | } 177 | if s.String() != "namespace1:object1#relation1" { 178 | t.Errorf("Expected 'namespace1:object1#relation1', but got '%s'", s) 179 | } 180 | 181 | s = &SubjectSet{} 182 | if s.String() != ":#" { 183 | t.Errorf("Expected ':#' string, but got '%s'", s) 184 | } 185 | } 186 | 187 | func TestSubjectSet_MarshalJSON(t *testing.T) { 188 | 189 | type output struct { 190 | bytes []byte 191 | err error 192 | } 193 | 194 | tests := []struct { 195 | name string 196 | input SubjectSet 197 | output output 198 | }{ 199 | { 200 | name: "Test-1", 201 | input: SubjectSet{ 202 | Namespace: "namespace1", 203 | Object: "object1", 204 | Relation: "relation1", 205 | }, 206 | output: output{ 207 | bytes: []byte(`"namespace1:object1#relation1"`), 208 | }, 209 | }, 210 | } 211 | 212 | for _, test := range tests { 213 | t.Run(test.name, func(t *testing.T) { 214 | actual, err := test.input.MarshalJSON() 215 | 216 | if !errors.Is(err, test.output.err) { 217 | t.Errorf("Errors were not equal. Expected '%v', but got '%v'", test.output.err, err) 218 | } else { 219 | if err == nil { 220 | if !reflect.DeepEqual(actual, test.output.bytes) { 221 | t.Errorf("Expected '%s', but got '%s'", test.output.bytes, actual) 222 | } 223 | } 224 | } 225 | }) 226 | } 227 | } 228 | 229 | func TestSubjectSet_Equals(t *testing.T) { 230 | 231 | ss := &SubjectSet{ 232 | Namespace: "namespace1", 233 | Object: "object1", 234 | Relation: "relation1", 235 | } 236 | 237 | tests := []struct { 238 | name string 239 | input interface{} 240 | output bool 241 | }{ 242 | { 243 | name: "Test-1", 244 | input: "some-string", 245 | output: false, 246 | }, 247 | { 248 | name: "Test-2", 249 | input: &SubjectSet{}, 250 | output: false, 251 | }, 252 | { 253 | name: "Test-3", 254 | input: &SubjectSet{ 255 | Namespace: "namespace1", 256 | Object: "object1", 257 | Relation: "relation1", 258 | }, 259 | output: true, 260 | }, 261 | } 262 | 263 | for _, test := range tests { 264 | t.Run(test.name, func(t *testing.T) { 265 | actual := ss.Equals(test.input) 266 | 267 | if actual != test.output { 268 | t.Errorf("Expected '%v', but got '%v'", test.output, actual) 269 | } 270 | }) 271 | } 272 | } 273 | 274 | func TestSubjectSet_FromString(t *testing.T) { 275 | 276 | type output struct { 277 | subject Subject 278 | err error 279 | } 280 | 281 | tests := []struct { 282 | name string 283 | s string 284 | output output 285 | }{ 286 | { 287 | name: "Test-1", 288 | s: "bad", 289 | output: output{ 290 | err: ErrInvalidSubjectSetString, 291 | }, 292 | }, 293 | { 294 | name: "Test-2", 295 | s: "namespace1:object1#relation1", 296 | output: output{ 297 | subject: &SubjectSet{ 298 | Namespace: "namespace1", 299 | Object: "object1", 300 | Relation: "relation1", 301 | }, 302 | }, 303 | }, 304 | { 305 | name: "Test-3", 306 | s: "part1#relation1", 307 | output: output{ 308 | err: ErrInvalidSubjectSetString, 309 | }, 310 | }, 311 | } 312 | 313 | for _, test := range tests { 314 | t.Run(test.name, func(t *testing.T) { 315 | ss := SubjectSet{} 316 | subject, err := ss.FromString(test.s) 317 | 318 | if err != test.output.err { 319 | t.Errorf("Expected error '%v', but got '%v'", test.output.err, err) 320 | } else { 321 | if err == nil { 322 | if !test.output.subject.Equals(subject) { 323 | t.Errorf("Expected subject '%v', but got '%v'", test.output.subject, subject) 324 | } 325 | } 326 | } 327 | }) 328 | } 329 | } 330 | 331 | func TestInternalRelationTuple_ToProto(t *testing.T) { 332 | 333 | tests := []struct { 334 | name string 335 | input *InternalRelationTuple 336 | output *aclpb.RelationTuple 337 | }{ 338 | { 339 | name: "Test-1", 340 | output: nil, 341 | }, 342 | { 343 | name: "Test-2", 344 | input: &InternalRelationTuple{ 345 | Namespace: "namespace1", 346 | Object: "object1", 347 | Relation: "relation1", 348 | Subject: &SubjectID{"user1"}, 349 | }, 350 | output: &aclpb.RelationTuple{ 351 | Namespace: "namespace1", 352 | Object: "object1", 353 | Relation: "relation1", 354 | Subject: &aclpb.Subject{ 355 | Ref: &aclpb.Subject_Id{Id: "user1"}, 356 | }, 357 | }, 358 | }, 359 | } 360 | 361 | for _, test := range tests { 362 | t.Run(test.name, func(t *testing.T) { 363 | actual := test.input.ToProto() 364 | 365 | if !proto.Equal(actual, test.output) { 366 | t.Errorf("Expected '%v', but got '%v'", test.output, actual) 367 | } 368 | }) 369 | } 370 | } 371 | 372 | func TestInternalRelationTuple_String(t *testing.T) { 373 | 374 | s := InternalRelationTuple{ 375 | Namespace: "namespace1", 376 | Object: "object1", 377 | Relation: "relation1", 378 | Subject: &SubjectID{"user1"}, 379 | } 380 | if s.String() != "namespace1:object1#relation1@user1" { 381 | t.Errorf("Expected 'namespace1:object1#relation1@user1', but got '%s'", s) 382 | } 383 | 384 | s = InternalRelationTuple{ 385 | Namespace: "namespace1", 386 | Object: "object1", 387 | Relation: "relation1", 388 | Subject: &SubjectSet{ 389 | Namespace: "namespace2", 390 | Object: "object2", 391 | Relation: "relation2", 392 | }, 393 | } 394 | if s.String() != "namespace1:object1#relation1@namespace2:object2#relation2" { 395 | t.Errorf("Expected 'namespace1:object1#relation1@namespace2:object2#relation2' string, but got '%s'", s) 396 | } 397 | } 398 | 399 | func TestSubjectFromString(t *testing.T) { 400 | 401 | type output struct { 402 | subject Subject 403 | err error 404 | } 405 | 406 | tests := []struct { 407 | name string 408 | input string 409 | output output 410 | }{ 411 | { 412 | name: "Test-1", 413 | input: "groups:group1#member", 414 | output: output{ 415 | subject: &SubjectSet{ 416 | Namespace: "groups", 417 | Object: "group1", 418 | Relation: "member", 419 | }, 420 | }, 421 | }, 422 | { 423 | name: "Test-2", 424 | input: "user1", 425 | output: output{ 426 | subject: &SubjectID{"user1"}, 427 | }, 428 | }, 429 | { 430 | name: "Test-3", 431 | input: "group1#", 432 | output: output{ 433 | err: ErrInvalidSubjectSetString, 434 | }, 435 | }, 436 | } 437 | 438 | for _, test := range tests { 439 | t.Run(test.name, func(t *testing.T) { 440 | subject, err := SubjectFromString(test.input) 441 | 442 | if !errors.Is(err, test.output.err) { 443 | t.Errorf("Errors were not equal. Expected '%v', but got '%v'", test.output.err, err) 444 | } else { 445 | if err == nil { 446 | if !subject.Equals(test.output.subject) { 447 | t.Errorf("Subjects were not equal. Expected '%v', but got '%v'", test.output.subject, subject) 448 | } 449 | } 450 | } 451 | }) 452 | } 453 | } 454 | 455 | func TestSubjectSetFromString(t *testing.T) { 456 | 457 | type output struct { 458 | subject SubjectSet 459 | err error 460 | } 461 | 462 | tests := []struct { 463 | name string 464 | input string 465 | output 466 | }{ 467 | { 468 | name: "Test-1", 469 | input: "bad", 470 | output: output{ 471 | err: ErrInvalidSubjectSetString, 472 | }, 473 | }, 474 | { 475 | name: "Test-2", 476 | input: "invalidpart1#part2", 477 | output: output{ 478 | err: ErrInvalidSubjectSetString, 479 | }, 480 | }, 481 | { 482 | name: "Test-3", 483 | input: "namespace1:object1#relation1", 484 | output: output{ 485 | subject: SubjectSet{ 486 | Namespace: "namespace1", 487 | Object: "object1", 488 | Relation: "relation1", 489 | }, 490 | }, 491 | }, 492 | } 493 | 494 | for _, test := range tests { 495 | t.Run(test.name, func(t *testing.T) { 496 | ss, err := SubjectSetFromString(test.input) 497 | 498 | if !errors.Is(err, test.output.err) { 499 | t.Errorf("Expected error '%v', but got '%v'", test.output.err, err) 500 | } else { 501 | if err == nil { 502 | if !ss.Equals(test.output.subject) { 503 | t.Errorf("Expected '%s', but got '%s'", test.output.subject, ss) 504 | } 505 | } 506 | } 507 | }) 508 | } 509 | } 510 | 511 | func TestSubjectFromProto(t *testing.T) { 512 | 513 | tests := []struct { 514 | name string 515 | input *aclpb.Subject 516 | output Subject 517 | }{ 518 | { 519 | name: "Test-1", 520 | input: &aclpb.Subject{ 521 | Ref: &aclpb.Subject_Id{Id: "user1"}, 522 | }, 523 | output: &SubjectID{"user1"}, 524 | }, 525 | { 526 | name: "Test-2", 527 | input: &aclpb.Subject{ 528 | Ref: &aclpb.Subject_Set{ 529 | Set: &aclpb.SubjectSet{ 530 | Namespace: "namespace1", 531 | Object: "object1", 532 | Relation: "relation1", 533 | }, 534 | }, 535 | }, 536 | output: &SubjectSet{ 537 | Namespace: "namespace1", 538 | Object: "object1", 539 | Relation: "relation1", 540 | }, 541 | }, 542 | } 543 | 544 | for _, test := range tests { 545 | t.Run(test.name, func(t *testing.T) { 546 | subject := SubjectFromProto(test.input) 547 | 548 | if !subject.Equals(test.output) { 549 | t.Errorf("Expected subject '%v', but got '%v'", test.output, subject) 550 | } 551 | }) 552 | } 553 | } 554 | -------------------------------------------------------------------------------- /internal/tree.go: -------------------------------------------------------------------------------- 1 | package accesscontroller 2 | 3 | import ( 4 | aclpb "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1" 5 | ) 6 | 7 | // NodeType represents a specific type of node within a SubjectTree structure. 8 | type NodeType string 9 | 10 | const ( 11 | 12 | // UnionNode represents a SubjectTree node that joins it's children via a union. 13 | UnionNode NodeType = "union" 14 | 15 | // IntersectionNode represents a SubjectTree node that joins it's children via an intersection. 16 | IntersectionNode NodeType = "intersection" 17 | 18 | // LeafNode represents a SubjectTree node with no children. 19 | LeafNode NodeType = "leaf" 20 | ) 21 | 22 | // ToProto returns the protobuf representation of the NodeType. 23 | func (t NodeType) ToProto() aclpb.NodeType { 24 | switch t { 25 | case LeafNode: 26 | return aclpb.NodeType_NODE_TYPE_LEAF 27 | case UnionNode: 28 | return aclpb.NodeType_NODE_TYPE_UNION 29 | case IntersectionNode: 30 | return aclpb.NodeType_NODE_TYPE_INTERSECTION 31 | } 32 | return aclpb.NodeType_NODE_TYPE_UNSPECIFIED 33 | } 34 | 35 | // SubjectTree represents a tree datastructure that stores relationships between Subjects. 36 | type SubjectTree struct { 37 | Type NodeType `json:"type"` 38 | 39 | Subject Subject `json:"subject"` 40 | Children []*SubjectTree `json:"children,omitempty"` 41 | } 42 | 43 | // ToProto returns the protobuf representation of the SubjectTree. 44 | func (t *SubjectTree) ToProto() *aclpb.SubjectTree { 45 | if t == nil { 46 | return nil 47 | } 48 | 49 | if t.Type == LeafNode { 50 | return &aclpb.SubjectTree{ 51 | NodeType: aclpb.NodeType_NODE_TYPE_LEAF, 52 | Subject: t.Subject.ToProto(), 53 | } 54 | } 55 | 56 | children := make([]*aclpb.SubjectTree, len(t.Children)) 57 | for i, c := range t.Children { 58 | children[i] = c.ToProto() 59 | } 60 | 61 | return &aclpb.SubjectTree{ 62 | NodeType: t.Type.ToProto(), 63 | Subject: t.Subject.ToProto(), 64 | Children: children, 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/tree_test.go: -------------------------------------------------------------------------------- 1 | package accesscontroller 2 | 3 | import ( 4 | "testing" 5 | 6 | aclpb "github.com/authorizer-tech/access-controller/genprotos/authorizer/accesscontroller/v1alpha1" 7 | "google.golang.org/protobuf/proto" 8 | ) 9 | 10 | func TestTree_ToProto(t *testing.T) { 11 | 12 | tests := []struct { 13 | input *SubjectTree 14 | output *aclpb.SubjectTree 15 | }{ 16 | { 17 | output: nil, 18 | }, 19 | { 20 | input: &SubjectTree{ 21 | Type: UnionNode, 22 | Subject: &SubjectSet{ 23 | Namespace: "groups", 24 | Object: "group1", 25 | Relation: "member", 26 | }, 27 | Children: []*SubjectTree{ 28 | { 29 | Type: LeafNode, 30 | Subject: &SubjectID{"user1"}, 31 | }, 32 | }, 33 | }, 34 | output: &aclpb.SubjectTree{ 35 | NodeType: aclpb.NodeType_NODE_TYPE_UNION, 36 | Subject: &aclpb.Subject{ 37 | Ref: &aclpb.Subject_Set{ 38 | Set: &aclpb.SubjectSet{ 39 | Namespace: "groups", 40 | Object: "group1", 41 | Relation: "member", 42 | }, 43 | }, 44 | }, 45 | Children: []*aclpb.SubjectTree{ 46 | { 47 | NodeType: aclpb.NodeType_NODE_TYPE_LEAF, 48 | Subject: &aclpb.Subject{ 49 | Ref: &aclpb.Subject_Id{Id: "user1"}, 50 | }, 51 | }, 52 | }, 53 | }, 54 | }, 55 | } 56 | 57 | for _, test := range tests { 58 | actual := test.input.ToProto() 59 | 60 | if !proto.Equal(actual, test.output) { 61 | t.Errorf("Expected '%v', but got '%v'", test.output, actual) 62 | } 63 | } 64 | } 65 | 66 | func TestNodeType_ToProto(t *testing.T) { 67 | 68 | tests := []struct { 69 | input NodeType 70 | output aclpb.NodeType 71 | }{ 72 | { 73 | input: UnionNode, 74 | output: aclpb.NodeType_NODE_TYPE_UNION, 75 | }, 76 | { 77 | input: IntersectionNode, 78 | output: aclpb.NodeType_NODE_TYPE_INTERSECTION, 79 | }, 80 | { 81 | input: LeafNode, 82 | output: aclpb.NodeType_NODE_TYPE_LEAF, 83 | }, 84 | { 85 | input: NodeType("unspecified-type"), 86 | output: aclpb.NodeType_NODE_TYPE_UNSPECIFIED, 87 | }, 88 | } 89 | 90 | for _, test := range tests { 91 | proto := test.input.ToProto() 92 | 93 | if proto != test.output { 94 | t.Errorf("Expected '%v', but got '%v'", test.output, proto) 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /testdata/namespace-configs/groups.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "groups", 3 | "relations": [ 4 | { 5 | "name": "member", 6 | "rewrite": { 7 | "union": { 8 | "children": [ 9 | { "this": {} } 10 | ] 11 | } 12 | } 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /testdata/namespace-configs/programs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "programs", 3 | "relations": [ 4 | { 5 | "name": "owner" 6 | }, 7 | { 8 | "name": "parent" 9 | }, 10 | { 11 | "name": "editor", 12 | "rewrite": { 13 | "union": { 14 | "children": [ 15 | { "this": {} }, 16 | { 17 | "computedSubjectset": { 18 | "relation": "owner" 19 | } 20 | } 21 | ] 22 | } 23 | } 24 | }, 25 | { 26 | "name": "viewer", 27 | "rewrite": { 28 | "union": { 29 | "children": [ 30 | { "this": {} }, 31 | { 32 | "computedSubjectset": { 33 | "relation": "editor" 34 | } 35 | }, 36 | { 37 | "tupleToSubjectset": { 38 | "tupleset": { 39 | "relation": "parent" 40 | }, 41 | "computedSubjectset": { 42 | "relation": "viewer" 43 | } 44 | } 45 | } 46 | ] 47 | } 48 | } 49 | } 50 | ] 51 | } -------------------------------------------------------------------------------- /testdata/namespace-configs/projects.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "projects", 3 | "relations": [ 4 | { 5 | "name": "owner" 6 | }, 7 | { 8 | "name": "parent" 9 | }, 10 | { 11 | "name": "editor", 12 | "rewrite": { 13 | "union": { 14 | "children": [ 15 | { "this": {} }, 16 | { 17 | "computedSubjectset": { 18 | "relation": "owner" 19 | } 20 | } 21 | ] 22 | } 23 | } 24 | }, 25 | { 26 | "name": "viewer", 27 | "rewrite": { 28 | "union": { 29 | "children": [ 30 | { "this": {} }, 31 | { 32 | "computedSubjectset": { 33 | "relation": "editor" 34 | } 35 | }, 36 | { 37 | "tupleToSubjectset": { 38 | "tupleset": { 39 | "relation": "parent" 40 | }, 41 | "computedSubjectset": { 42 | "relation": "viewer" 43 | } 44 | } 45 | } 46 | ] 47 | } 48 | } 49 | } 50 | ] 51 | } -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | // +build tools 2 | 3 | package tools 4 | 5 | // See https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module 6 | import ( 7 | _ "github.com/bufbuild/buf/cmd/buf" 8 | _ "github.com/bufbuild/buf/cmd/protoc-gen-buf-breaking" 9 | _ "github.com/bufbuild/buf/cmd/protoc-gen-buf-lint" 10 | _ "github.com/golang/mock/mockgen" 11 | _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway" 12 | _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2" 13 | _ "google.golang.org/grpc/cmd/protoc-gen-go-grpc" 14 | _ "google.golang.org/protobuf/cmd/protoc-gen-go" 15 | _ "honnef.co/go/tools/cmd/staticcheck" 16 | ) 17 | --------------------------------------------------------------------------------