├── renovate.json ├── policies ├── resource │ ├── vanilla │ │ └── purchase_order.yaml │ ├── regional │ │ └── purchase_order.yaml │ └── purchase_order.yaml └── derived_roles │ ├── organization_assignments.yaml │ └── tenant_assignments.yaml ├── Makefile ├── tests ├── testdata │ ├── resources.yaml │ └── principals.yaml └── purchase_order_test.yaml └── README.md /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /policies/resource/vanilla/purchase_order.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: api.cerbos.dev/v1 2 | resourcePolicy: 3 | version: default 4 | resource: purchase_order 5 | scope: vanilla 6 | -------------------------------------------------------------------------------- /policies/derived_roles/organization_assignments.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: api.cerbos.dev/v1 2 | derivedRoles: 3 | name: organization_assignments 4 | definitions: 5 | - name: manufacturer 6 | parentRoles: 7 | - user 8 | condition: 9 | match: 10 | expr: hasIntersection(R.attr.organizationAssignments.manufacturer, P.attr.organizations) 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: compile 3 | 4 | .PHONY: compile 5 | compile: 6 | @docker run \ 7 | --rm \ 8 | --interactive \ 9 | --tty \ 10 | --mount type="bind",source="$(PWD)/policies",target="/policies",readonly \ 11 | --mount type="bind",source="$(PWD)/tests",target="/tests",readonly \ 12 | ghcr.io/cerbos/cerbos:latest \ 13 | compile \ 14 | --tests /tests \ 15 | /policies 16 | -------------------------------------------------------------------------------- /policies/resource/regional/purchase_order.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: api.cerbos.dev/v1 2 | resourcePolicy: 3 | version: default 4 | resource: purchase_order 5 | scope: regional 6 | importDerivedRoles: 7 | - tenant_assignments 8 | rules: 9 | - actions: 10 | - view 11 | effect: EFFECT_DENY 12 | derivedRoles: 13 | - customer 14 | condition: 15 | match: 16 | expr: |- 17 | !(R.attr.region in P.attr.regions) 18 | -------------------------------------------------------------------------------- /policies/derived_roles/tenant_assignments.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: api.cerbos.dev/v1 2 | derivedRoles: 3 | name: tenant_assignments 4 | definitions: 5 | - name: customer 6 | parentRoles: 7 | - user 8 | condition: 9 | match: 10 | expr: R.attr.tenant in P.attr.tenantAssignments.customer 11 | 12 | - name: operations 13 | parentRoles: 14 | - user 15 | condition: 16 | match: 17 | expr: R.attr.tenant in P.attr.tenantAssignments.operations 18 | -------------------------------------------------------------------------------- /policies/resource/purchase_order.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: api.cerbos.dev/v1 2 | resourcePolicy: 3 | version: default 4 | resource: purchase_order 5 | importDerivedRoles: 6 | - organization_assignments 7 | - tenant_assignments 8 | rules: 9 | - actions: 10 | - prepareForDelivery 11 | effect: EFFECT_ALLOW 12 | derivedRoles: 13 | - manufacturer 14 | 15 | - actions: 16 | - sendInvoice 17 | effect: EFFECT_ALLOW 18 | derivedRoles: 19 | - operations 20 | 21 | - actions: 22 | - view 23 | effect: EFFECT_ALLOW 24 | derivedRoles: 25 | - customer 26 | - manufacturer 27 | - operations 28 | -------------------------------------------------------------------------------- /tests/testdata/resources.yaml: -------------------------------------------------------------------------------- 1 | 2 | resources: 3 | regional_apac_thingamabobs_purchase_order: 4 | id: regional_apac_thingamabobs 5 | kind: purchase_order 6 | scope: regional 7 | attr: 8 | tenant: regional 9 | region: apac 10 | organizationAssignments: 11 | manufacturer: 12 | - thingamabob_manufacturers 13 | 14 | regional_emea_widgets_purchase_order: 15 | id: regional_emea_widgets 16 | kind: purchase_order 17 | scope: regional 18 | attr: 19 | tenant: regional 20 | region: emea 21 | organizationAssignments: 22 | manufacturer: 23 | - widget_manufacturers 24 | 25 | vanilla_doohickeys_purchase_order: 26 | id: vanilla_doohickeys 27 | kind: purchase_order 28 | scope: vanilla 29 | attr: 30 | tenant: vanilla 31 | 32 | vanilla_thingamabobs_purchase_order: 33 | id: vanilla_thingamabobs 34 | kind: purchase_order 35 | scope: vanilla 36 | attr: 37 | tenant: vanilla 38 | organizationAssignments: 39 | manufacturer: 40 | - thingamabob_manufacturers 41 | 42 | vanilla_widgets_purchase_order: 43 | id: vanilla_widgets 44 | kind: purchase_order 45 | scope: vanilla 46 | attr: 47 | tenant: vanilla 48 | organizationAssignments: 49 | manufacturer: 50 | - widget_manufacturers 51 | -------------------------------------------------------------------------------- /tests/testdata/principals.yaml: -------------------------------------------------------------------------------- 1 | principals: 2 | regional_apac_customer: 3 | id: regional_apac_customer 4 | roles: 5 | - user 6 | attr: 7 | regions: 8 | - apac 9 | tenantAssignments: 10 | customer: 11 | - regional 12 | 13 | regional_emea_customer: 14 | id: regional_emea_customer 15 | roles: 16 | - user 17 | attr: 18 | regions: 19 | - emea 20 | tenantAssignments: 21 | customer: 22 | - regional 23 | 24 | regional_global_customer: 25 | id: regional_global_customer 26 | roles: 27 | - user 28 | attr: 29 | regions: 30 | - apac 31 | - emea 32 | tenantAssignments: 33 | customer: 34 | - regional 35 | 36 | regional_operations: 37 | id: regional_operations 38 | roles: 39 | - user 40 | attr: 41 | tenantAssignments: 42 | operations: 43 | - regional 44 | 45 | super_operations: 46 | id: super_operations 47 | roles: 48 | - user 49 | attr: 50 | tenantAssignments: 51 | operations: 52 | - regional 53 | - vanilla 54 | 55 | thingamabob_manufacturer: 56 | id: thingamabob_manufacturer 57 | roles: 58 | - user 59 | attr: 60 | organizations: 61 | - thingamabob_manufacturers 62 | 63 | vanilla_customer: 64 | id: vanilla_customer 65 | roles: 66 | - user 67 | attr: 68 | tenantAssignments: 69 | customer: 70 | - vanilla 71 | 72 | vanilla_customer_and_widget_manufacturer: 73 | id: vanilla_customer_and_widget_manufacturer 74 | roles: 75 | - user 76 | attr: 77 | organizations: 78 | - widget_manufacturers 79 | tenantAssignments: 80 | customer: 81 | - vanilla 82 | 83 | vanilla_operations: 84 | id: vanilla_operations 85 | roles: 86 | - user 87 | attr: 88 | tenantAssignments: 89 | operations: 90 | - vanilla 91 | 92 | widget_manufacturer: 93 | id: widget_manufacturer 94 | roles: 95 | - user 96 | attr: 97 | organizations: 98 | - widget_manufacturers 99 | -------------------------------------------------------------------------------- /tests/purchase_order_test.yaml: -------------------------------------------------------------------------------- 1 | name: purchase_order 2 | tests: 3 | - name: purchase_order 4 | input: 5 | principals: 6 | - regional_apac_customer 7 | - regional_emea_customer 8 | - regional_global_customer 9 | - regional_operations 10 | - super_operations 11 | - thingamabob_manufacturer 12 | - vanilla_customer 13 | - vanilla_customer_and_widget_manufacturer 14 | - vanilla_operations 15 | - widget_manufacturer 16 | 17 | resources: 18 | - regional_apac_thingamabobs_purchase_order 19 | - regional_emea_widgets_purchase_order 20 | - vanilla_doohickeys_purchase_order 21 | - vanilla_thingamabobs_purchase_order 22 | - vanilla_widgets_purchase_order 23 | 24 | actions: 25 | - prepareForDelivery 26 | - sendInvoice 27 | - view 28 | 29 | expected: 30 | - principal: regional_apac_customer 31 | resource: regional_apac_thingamabobs_purchase_order 32 | actions: 33 | prepareForDelivery: EFFECT_DENY 34 | sendInvoice: EFFECT_DENY 35 | view: EFFECT_ALLOW 36 | 37 | - principal: regional_emea_customer 38 | resource: regional_emea_widgets_purchase_order 39 | actions: 40 | prepareForDelivery: EFFECT_DENY 41 | sendInvoice: EFFECT_DENY 42 | view: EFFECT_ALLOW 43 | 44 | - principal: regional_global_customer 45 | resource: regional_apac_thingamabobs_purchase_order 46 | actions: 47 | prepareForDelivery: EFFECT_DENY 48 | sendInvoice: EFFECT_DENY 49 | view: EFFECT_ALLOW 50 | 51 | - principal: regional_global_customer 52 | resource: regional_emea_widgets_purchase_order 53 | actions: 54 | prepareForDelivery: EFFECT_DENY 55 | sendInvoice: EFFECT_DENY 56 | view: EFFECT_ALLOW 57 | 58 | - principal: regional_operations 59 | resource: regional_apac_thingamabobs_purchase_order 60 | actions: 61 | prepareForDelivery: EFFECT_DENY 62 | sendInvoice: EFFECT_ALLOW 63 | view: EFFECT_ALLOW 64 | 65 | - principal: regional_operations 66 | resource: regional_emea_widgets_purchase_order 67 | actions: 68 | prepareForDelivery: EFFECT_DENY 69 | sendInvoice: EFFECT_ALLOW 70 | view: EFFECT_ALLOW 71 | 72 | - principal: super_operations 73 | resource: regional_apac_thingamabobs_purchase_order 74 | actions: 75 | prepareForDelivery: EFFECT_DENY 76 | sendInvoice: EFFECT_ALLOW 77 | view: EFFECT_ALLOW 78 | 79 | - principal: super_operations 80 | resource: regional_emea_widgets_purchase_order 81 | actions: 82 | prepareForDelivery: EFFECT_DENY 83 | sendInvoice: EFFECT_ALLOW 84 | view: EFFECT_ALLOW 85 | 86 | - principal: super_operations 87 | resource: vanilla_doohickeys_purchase_order 88 | actions: 89 | prepareForDelivery: EFFECT_DENY 90 | sendInvoice: EFFECT_ALLOW 91 | view: EFFECT_ALLOW 92 | 93 | - principal: super_operations 94 | resource: vanilla_thingamabobs_purchase_order 95 | actions: 96 | prepareForDelivery: EFFECT_DENY 97 | sendInvoice: EFFECT_ALLOW 98 | view: EFFECT_ALLOW 99 | 100 | - principal: super_operations 101 | resource: vanilla_widgets_purchase_order 102 | actions: 103 | prepareForDelivery: EFFECT_DENY 104 | sendInvoice: EFFECT_ALLOW 105 | view: EFFECT_ALLOW 106 | 107 | - principal: thingamabob_manufacturer 108 | resource: regional_apac_thingamabobs_purchase_order 109 | actions: 110 | prepareForDelivery: EFFECT_ALLOW 111 | sendInvoice: EFFECT_DENY 112 | view: EFFECT_ALLOW 113 | 114 | - principal: thingamabob_manufacturer 115 | resource: vanilla_thingamabobs_purchase_order 116 | actions: 117 | prepareForDelivery: EFFECT_ALLOW 118 | sendInvoice: EFFECT_DENY 119 | view: EFFECT_ALLOW 120 | 121 | - principal: vanilla_customer 122 | resource: vanilla_doohickeys_purchase_order 123 | actions: 124 | prepareForDelivery: EFFECT_DENY 125 | sendInvoice: EFFECT_DENY 126 | view: EFFECT_ALLOW 127 | 128 | - principal: vanilla_customer 129 | resource: vanilla_thingamabobs_purchase_order 130 | actions: 131 | prepareForDelivery: EFFECT_DENY 132 | sendInvoice: EFFECT_DENY 133 | view: EFFECT_ALLOW 134 | 135 | - principal: vanilla_customer 136 | resource: vanilla_widgets_purchase_order 137 | actions: 138 | prepareForDelivery: EFFECT_DENY 139 | sendInvoice: EFFECT_DENY 140 | view: EFFECT_ALLOW 141 | 142 | - principal: vanilla_customer_and_widget_manufacturer 143 | resource: regional_emea_widgets_purchase_order 144 | actions: 145 | prepareForDelivery: EFFECT_ALLOW 146 | sendInvoice: EFFECT_DENY 147 | view: EFFECT_ALLOW 148 | 149 | - principal: vanilla_customer_and_widget_manufacturer 150 | resource: vanilla_doohickeys_purchase_order 151 | actions: 152 | prepareForDelivery: EFFECT_DENY 153 | sendInvoice: EFFECT_DENY 154 | view: EFFECT_ALLOW 155 | 156 | - principal: vanilla_customer_and_widget_manufacturer 157 | resource: vanilla_thingamabobs_purchase_order 158 | actions: 159 | prepareForDelivery: EFFECT_DENY 160 | sendInvoice: EFFECT_DENY 161 | view: EFFECT_ALLOW 162 | 163 | - principal: vanilla_customer_and_widget_manufacturer 164 | resource: vanilla_widgets_purchase_order 165 | actions: 166 | prepareForDelivery: EFFECT_ALLOW 167 | sendInvoice: EFFECT_DENY 168 | view: EFFECT_ALLOW 169 | 170 | - principal: vanilla_operations 171 | resource: vanilla_doohickeys_purchase_order 172 | actions: 173 | prepareForDelivery: EFFECT_DENY 174 | sendInvoice: EFFECT_ALLOW 175 | view: EFFECT_ALLOW 176 | 177 | - principal: vanilla_operations 178 | resource: vanilla_thingamabobs_purchase_order 179 | actions: 180 | prepareForDelivery: EFFECT_DENY 181 | sendInvoice: EFFECT_ALLOW 182 | view: EFFECT_ALLOW 183 | 184 | - principal: vanilla_operations 185 | resource: vanilla_widgets_purchase_order 186 | actions: 187 | prepareForDelivery: EFFECT_DENY 188 | sendInvoice: EFFECT_ALLOW 189 | view: EFFECT_ALLOW 190 | 191 | - principal: widget_manufacturer 192 | resource: regional_emea_widgets_purchase_order 193 | actions: 194 | prepareForDelivery: EFFECT_ALLOW 195 | sendInvoice: EFFECT_DENY 196 | view: EFFECT_ALLOW 197 | 198 | - principal: widget_manufacturer 199 | resource: vanilla_widgets_purchase_order 200 | actions: 201 | prepareForDelivery: EFFECT_ALLOW 202 | sendInvoice: EFFECT_DENY 203 | view: EFFECT_ALLOW 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Multitenant SaaS platform demo 2 | 3 | This demo shows how to use Cerbos's [scoped policies](https://docs.cerbos.dev/cerbos/latest/policies/scoped_policies.html) (available in 0.13+) to model a multitenant SaaS platform, where each tenant may have specific authorization requirements. 4 | 5 | ## Scenario 6 | 7 | ### Resources 8 | 9 | We're going to look at authorizing access to a single kind of resource: purchase orders. 10 | 11 | ### Principals 12 | 13 | On this SaaS platform, users can hold multiple roles, depending on how they are assigned to resources: 14 | 15 | - **customer** users are assigned to their tenant; 16 | - **operations** users (staff of the SaaS provider) are assigned to the tenants that they manage; and 17 | - **manufacturer** users belong to organizations, which are assigned to manufacture specific purchase orders. 18 | 19 | It's possible for a user to be a customer in one tenant but also to belong to an organization that manufactures purchase orders for another tenant. 20 | 21 | ### Actions 22 | 23 | There are three actions we need to authorize on a purchase order resource: 24 | 25 | - `prepareForDelivery` may be performed by manufacturers assigned to the purchase order; 26 | - `sendInvoice` may be performed by operations assigned to the purchase order's tenant; and 27 | - `view` may be performed by manufacturers assigned to the purchase order, and customers or operations assigned to the purchase order's tenant. 28 | 29 | #### Tenant-specific requirements 30 | 31 | One tenant, Vanilla Ltd., doesn't have any authorization requirements beyond the base set of permissions described above. 32 | 33 | The other, Regional Corp., is divided internally into APAC and EMEA regions. 34 | In this tenant, purchase orders are allocated to a region, and customer users are allocated to one or both regions. 35 | Customers should not be able to view purchase orders if they are not allocated to the purchase order's region. 36 | 37 | ## Solution 38 | 39 | We'll use [scoped policies](https://docs.cerbos.dev/cerbos/latest/policies/scoped_policies.html), and implement the base set of permissions in the root scope. 40 | Tenant-specific requirements can then be layered in policies scoped to the tenant's identifier. 41 | That way, applications requesting authorization decisions from Cerbos don't have to be aware of the tenant's authorization requirements - they just send the tenant identifier as the `scope` on the resources. 42 | 43 | We'll use [derived roles](https://docs.cerbos.dev/cerbos/latest/policies/derived_roles.html) to convert the user's assignments into the role they have for the purchase order being authorized. 44 | 45 | ### Principals 46 | 47 | We'll send purchase order resources to Cerbos in this format: 48 | 49 | #### Vanilla Ltd. customers 50 | 51 | ```yaml 52 | { 53 | "id": "alice@vanilla.example.com", 54 | "roles": ["user"], 55 | "attr": { 56 | "tenantAssignments": { 57 | "customer": ["vanilla"] 58 | } 59 | } 60 | } 61 | ``` 62 | 63 | #### Regional Corp. customers 64 | 65 | ```yaml 66 | { 67 | "id": "bob@regional.example.com", 68 | "roles": ["user"], 69 | "attr": { 70 | "tenantAssignments": { 71 | "customer": ["regional"] 72 | }, 73 | "regions": ["apac"] # and/or "emea" 74 | } 75 | } 76 | ``` 77 | 78 | #### SaaS provider operations 79 | 80 | ```yaml 81 | { 82 | "id": "carol@saas-provider.example.com", 83 | "roles": ["user"], 84 | "attr": { 85 | "tenantAssignments": { 86 | "operations": ["vanilla"] # and/or "regional" 87 | } 88 | } 89 | } 90 | ``` 91 | 92 | #### Acme Inc. manufacturers 93 | 94 | ```yaml 95 | { 96 | "id": "dave@acme.example.com", 97 | "roles": ["user"], 98 | "attr": { 99 | "organizations": ["acme"] 100 | } 101 | } 102 | ``` 103 | 104 | ### Resources 105 | 106 | We'll send purchase order resources to Cerbos in this format: 107 | 108 | ```yaml 109 | { 110 | "id": "ABC-123", 111 | "kind": "purchase_order", 112 | "scope": "regional", # or "vanilla" 113 | "attr": { 114 | "tenant": "regional", # or "vanilla" 115 | "organizationAssignments": { 116 | "manufacturer": ["acme"] 117 | }, 118 | "region": "apac" # or "emea"; omitted for the "vanilla" tenant 119 | } 120 | } 121 | ``` 122 | 123 | ### Derived roles 124 | 125 | We can derive the `customer` and `operations` roles by checking if the purchase order resource's `tenant` is in the principal's corresponding `tenantAssignments`: 126 | 127 | ```yaml 128 | apiVersion: api.cerbos.dev/v1 129 | derivedRoles: 130 | name: tenant_assignments 131 | definitions: 132 | - name: customer 133 | parentRoles: 134 | - user 135 | condition: 136 | match: 137 | expr: R.attr.tenant in P.attr.tenantAssignments.customer 138 | 139 | - name: operations 140 | parentRoles: 141 | - user 142 | condition: 143 | match: 144 | expr: R.attr.tenant in P.attr.tenantAssignments.operations 145 | ``` 146 | 147 | We can derive the `manufacturer` role by checking for overlap between the purchase order resource's `organizationAssignments` and the principal's `organizations`: 148 | 149 | ```yaml 150 | apiVersion: api.cerbos.dev/v1 151 | derivedRoles: 152 | name: organization_assignments 153 | definitions: 154 | - name: manufacturer 155 | parentRoles: 156 | - user 157 | condition: 158 | match: 159 | expr: hasIntersection(R.attr.organizationAssignments.manufacturer, P.attr.organizations) 160 | ``` 161 | 162 | ### Resource policies 163 | 164 | With the derived roles in place, we can define the base set of permissions in a resource policy in the root scope: 165 | 166 | ```yaml 167 | apiVersion: api.cerbos.dev/v1 168 | resourcePolicy: 169 | version: default 170 | resource: purchase_order 171 | importDerivedRoles: 172 | - organization_assignments 173 | - tenant_assignments 174 | rules: 175 | - actions: 176 | - prepareForDelivery 177 | effect: EFFECT_ALLOW 178 | derivedRoles: 179 | - manufacturer 180 | 181 | - actions: 182 | - sendInvoice 183 | effect: EFFECT_ALLOW 184 | derivedRoles: 185 | - operations 186 | 187 | - actions: 188 | - view 189 | effect: EFFECT_ALLOW 190 | derivedRoles: 191 | - customer 192 | - manufacturer 193 | - operations 194 | ``` 195 | 196 | For Vanilla Inc., we don't want to introduce any additional rules, so we can define an empty scoped policy (note that this requires Cerbos 0.14+; in 0.13, policies must define at least one rule): 197 | 198 | ```yaml 199 | apiVersion: api.cerbos.dev/v1 200 | resourcePolicy: 201 | version: default 202 | resource: purchase_order 203 | scope: vanilla 204 | ``` 205 | 206 | For Regional Corp., we'll define a scoped policy that denies `view` access to customers who aren't allocated to the purchase order resource's region: 207 | 208 | ```yaml 209 | apiVersion: api.cerbos.dev/v1 210 | resourcePolicy: 211 | version: default 212 | resource: purchase_order 213 | scope: regional 214 | importDerivedRoles: 215 | - tenant_assignments 216 | rules: 217 | - actions: 218 | - view 219 | effect: EFFECT_DENY 220 | derivedRoles: 221 | - customer 222 | condition: 223 | match: 224 | expr: |- 225 | !(R.attr.region in P.attr.regions) 226 | ``` 227 | 228 | The remaining rules will be enforced by falling back to the root policy. 229 | Note that we opted to deny non-matching regions in the scoped policy rather than allowing matching regions; this means we could introduce further conditions to the allow rule in the root policy without having to duplicate them in the scoped policy. 230 | 231 | ### Success! 232 | 233 | With these policies in place, we can now authorize the required actions, and the tenant-specific authorization requirements are encapsulated in Cerbos without leaking into our applications. 234 | 235 | ## Try it out 236 | 237 | You can clone this repository and run `make` to run a set of [tests](https://docs.cerbos.dev/cerbos/latest/policies/compile.html#_testing_policies) that exercise the policies. 238 | This requires Docker to be [installed](https://docs.docker.com/get-docker/) and [authenticated to the GitHub container registry (ghcr.io)](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry). 239 | Alternatively, if you have Cerbos 0.14+ [installed locally](https://docs.cerbos.dev/cerbos/latest/installation/binary.html), you can run 240 | 241 | ```console 242 | $ cerbos compile --tests=tests policies 243 | ``` 244 | 245 | ## Learn more 246 | 247 | - [Documentation](https://docs.cerbos.dev) 248 | - [Guide](https://book.cerbos.dev) 249 | - [Website](https://cerbos.dev) 250 | --------------------------------------------------------------------------------