mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: document RBAC usage (#14065)
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
# Authz
|
||||
|
||||
Package `authz` implements AuthoriZation for Coder.
|
||||
Package `rbac` implements Role-Based Access Control for Coder.
|
||||
|
||||
## Overview
|
||||
|
||||
Authorization defines what **permission** a **subject** has to perform **actions** to **objects**:
|
||||
|
||||
- **Permission** is binary: _yes_ (allowed) or _no_ (denied).
|
||||
- **Subject** in this case is anything that implements interface `authz.Subject`.
|
||||
- **Action** here is an enumerated list of actions, but we stick to `Create`, `Read`, `Update`, and `Delete` here.
|
||||
- **Object** here is anything that implements `authz.Object`.
|
||||
- **Subject** in this case is anything that implements interface `rbac.Subject`.
|
||||
- **Action** here is an enumerated list of actions. Actions can differ for each object type. They typically read like, `Create`, `Read`, `Update`, `Delete`, etc.
|
||||
- **Object** here is anything that implements `rbac.Object`.
|
||||
|
||||
## Permission Structure
|
||||
|
||||
@@ -38,7 +38,7 @@ This can be represented by the following truth table, where Y represents _positi
|
||||
| read | Y | \_ | Y |
|
||||
| read | Y | N | N |
|
||||
| read | \_ | \_ | \_ |
|
||||
| read | \_ | N | Y |
|
||||
| read | \_ | N | N |
|
||||
|
||||
## Permission Representation
|
||||
|
||||
@@ -49,11 +49,11 @@ This can be represented by the following truth table, where Y represents _positi
|
||||
- `object` is any valid resource type.
|
||||
- `id` is any valid UUID v4.
|
||||
- `id` is included in the permission syntax, however only scopes may use `id` to specify a specific object.
|
||||
- `action` is `create`, `read`, `modify`, or `delete`.
|
||||
- `action` is typically `create`, `read`, `modify`, `delete`, but you can define other verbs as needed.
|
||||
|
||||
## Example Permissions
|
||||
|
||||
- `+site.*.*.read`: allowed to perform the `read` action against all objects of type `app` in a given Coder deployment.
|
||||
- `+site.app.*.read`: allowed to perform the `read` action against all objects of type `app` in a given Coder deployment.
|
||||
- `-user.workspace.*.create`: user is not allowed to create workspaces.
|
||||
|
||||
## Roles
|
||||
@@ -106,7 +106,9 @@ You can test outside of golang by using the `opa` cli.
|
||||
|
||||
**Evaluation**
|
||||
|
||||
```bash
|
||||
opa eval --format=pretty "data.authz.allow" -d policy.rego -i input.json
|
||||
```
|
||||
|
||||
**Partial Evaluation**
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
# Using RBAC
|
||||
|
||||
# Overview
|
||||
|
||||
> _NOTE: you should probably read [`README.md`](README.md) beforehand, but it's
|
||||
> not essential._
|
||||
|
||||
## Basic structure
|
||||
|
||||
RBAC is made up of nouns (the objects which are protected by RBAC rules) and
|
||||
verbs (actions which can be performed on nouns).<br> For example, a
|
||||
**workspace** (noun) can be **created** (verb), provided the requester has
|
||||
appropriate permissions.
|
||||
|
||||
## Roles
|
||||
|
||||
We have a number of roles (some of which have legacy connotations back to v1).
|
||||
|
||||
These can be found in `coderd/rbac/roles.go`.
|
||||
|
||||
| Role | Description | Example resources (non-exhaustive) |
|
||||
| -------------------- | ------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| **owner** | Super-user, first user in Coder installation, has all\* permissions | all\* |
|
||||
| **member** | A regular user | workspaces, own details, provisioner daemons |
|
||||
| **auditor** | Viewer of audit log events, read-only access to a few resources | audit logs, templates, users, groups |
|
||||
| **templateAdmin** | Administrator of templates, read-only access to a few resources | templates, workspaces, users, groups |
|
||||
| **userAdmin** | Administrator of users | users, groups, role assignments |
|
||||
| **orgAdmin** | Like **owner**, but scoped to a single organization | _(org-level equivalent)_ |
|
||||
| **orgMember** | Like **member**, but scoped to a single organization | _(org-level equivalent)_ |
|
||||
| **orgAuditor** | Like **auditor**, but scoped to a single organization | _(org-level equivalent)_ |
|
||||
| **orgUserAdmin** | Like **userAdmin**, but scoped to a single organization | _(org-level equivalent)_ |
|
||||
| **orgTemplateAdmin** | Like **templateAdmin**, but scoped to a single organization | _(org-level equivalent)_ |
|
||||
|
||||
**Note an example resource indicates the role has at least 1 permission related
|
||||
to the resource. Not that the role has complete CRUD access to the resource.**
|
||||
|
||||
_\* except some, which are not important to this overview_
|
||||
|
||||
## Actions
|
||||
|
||||
Roles are collections of permissions (we call them _actions_).
|
||||
|
||||
These can be found in `coderd/rbac/policy/policy.go`.
|
||||
|
||||
| Action | Description |
|
||||
| ----------------------- | --------------------------------------- |
|
||||
| **create** | Create a resource |
|
||||
| **read** | Read a resource |
|
||||
| **update** | Update a resource |
|
||||
| **delete** | Delete a resource |
|
||||
| **use** | Use a resource |
|
||||
| **read_personal** | Read owned resource |
|
||||
| **update_personal** | Update owned resource |
|
||||
| **ssh** | SSH into a workspace |
|
||||
| **application_connect** | Connect to workspace apps via a browser |
|
||||
| **view_insights** | View deployment insights |
|
||||
| **start** | Start a workspace |
|
||||
| **stop** | Stop a workspace |
|
||||
| **assign** | Assign user to role / org |
|
||||
|
||||
# Creating a new noun
|
||||
|
||||
In the following example, we're going to create a new RBAC noun for a new entity
|
||||
called a "frobulator" _(just some nonsense word for demonstration purposes)_.
|
||||
|
||||
_Refer to https://github.com/coder/coder/pull/14055 to see a full
|
||||
implementation._
|
||||
|
||||
## Creating a new entity
|
||||
|
||||
If you're creating a new resource which has to be acted upon by users of
|
||||
differing roles, you need to create a new RBAC resource.
|
||||
|
||||
Let's say we're adding a new table called `frobulators` (we'll use this table
|
||||
later):
|
||||
|
||||
```sql
|
||||
CREATE TABLE frobulators
|
||||
(
|
||||
id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
org_id uuid NOT NULL,
|
||||
model_number TEXT NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE (model_number),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (org_id) REFERENCES organizations (id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
Let's now add our frobulator noun to `coderd/rbac/policy/policy.go`:
|
||||
|
||||
```go
|
||||
...
|
||||
"frobulator": {
|
||||
Actions: map[Action]ActionDefinition{
|
||||
ActionCreate: {Description: "create a frobulator"},
|
||||
ActionRead: {Description: "read a frobulator"},
|
||||
ActionUpdate: {Description: "update a frobulator"},
|
||||
ActionDelete: {Description: "delete a frobulator"},
|
||||
},
|
||||
},
|
||||
...
|
||||
```
|
||||
|
||||
We need to create/read/update/delete rows in the `frobulators` table, so we
|
||||
define those actions.
|
||||
|
||||
`policy.go` is used to generate code in `coderd/rbac/object_gen.go`, and we can
|
||||
execute this by running `make gen`.
|
||||
|
||||
Now we have this change in `coderd/rbac/object_gen.go`:
|
||||
|
||||
```go
|
||||
...
|
||||
// ResourceFrobulator
|
||||
// Valid Actions
|
||||
// - "ActionCreate" ::
|
||||
// - "ActionDelete" ::
|
||||
// - "ActionRead" ::
|
||||
// - "ActionUpdate" ::
|
||||
ResourceFrobulator = Object{
|
||||
Type: "frobulator",
|
||||
}
|
||||
...
|
||||
|
||||
func AllResources() []Objecter {
|
||||
...
|
||||
ResourceFrobulator,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
This creates a resource which represents this noun, and adds it to a list of all
|
||||
available resources.
|
||||
|
||||
## Role Assignment
|
||||
|
||||
In our case, we want **members** to be able to CRUD their own frobulators and we
|
||||
want **owners** to CRUD all members' frobulators. This is how most resources
|
||||
work, and the RBAC system is setup for this by default.
|
||||
|
||||
However, let's say we want **organization auditors** to have read-only access to
|
||||
all organization's frobulators; we need to add it to `coderd/rbac/roles.go`:
|
||||
|
||||
```go
|
||||
func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
...
|
||||
auditorRole := Role{
|
||||
Identifier: RoleAuditor(),
|
||||
DisplayName: "Auditor",
|
||||
Site: Permissions(map[string][]policy.Action{
|
||||
...
|
||||
// The site-wide auditor is allowed to read *all* frobulators, regardless of who owns them.
|
||||
ResourceFrobulator.Type: {policy.ActionRead},
|
||||
...
|
||||
|
||||
//
|
||||
orgAuditor: func(organizationID uuid.UUID) Role {
|
||||
...
|
||||
return Role{
|
||||
...
|
||||
Org: map[string][]Permission{
|
||||
organizationID.String(): Permissions(map[string][]policy.Action{
|
||||
...
|
||||
// The org-wide auditor is allowed to read *all* frobulators in their own org, regardless of who owns them.
|
||||
ResourceFrobulator.Type: {policy.ActionRead},
|
||||
})
|
||||
...
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Note how we added the permission to both the **site-wide** auditor role and the
|
||||
**org-level** auditor role.
|
||||
|
||||
## Testing
|
||||
|
||||
The RBAC system is configured to test all possible actions on all available
|
||||
resources.
|
||||
|
||||
Let's run the RBAC test suite:
|
||||
|
||||
`go test github.com/coder/coder/v2/coderd/rbac`
|
||||
|
||||
We'll see a failure like this:
|
||||
|
||||
```bash
|
||||
--- FAIL: TestRolePermissions (0.61s)
|
||||
--- FAIL: TestRolePermissions/frobulator-AllActions (0.00s)
|
||||
roles_test.go:705:
|
||||
Error Trace: /tmp/coder/coderd/rbac/roles_test.go:705
|
||||
Error: Not equal:
|
||||
expected: map[policy.Action]bool{}
|
||||
actual : map[policy.Action]bool{"create":true, "delete":true, "read":true, "update":true}
|
||||
|
||||
Diff:
|
||||
--- Expected
|
||||
+++ Actual
|
||||
@@ -1,2 +1,6 @@
|
||||
-(map[policy.Action]bool) {
|
||||
+(map[policy.Action]bool) (len=4) {
|
||||
+ (policy.Action) (len=6) "create": (bool) true,
|
||||
+ (policy.Action) (len=6) "delete": (bool) true,
|
||||
+ (policy.Action) (len=4) "read": (bool) true,
|
||||
+ (policy.Action) (len=6) "update": (bool) true
|
||||
}
|
||||
Test: TestRolePermissions/frobulator-AllActions
|
||||
Messages: remaining permissions should be empty for type "frobulator"
|
||||
FAIL
|
||||
FAIL github.com/coder/coder/v2/coderd/rbac 1.314s
|
||||
FAIL
|
||||
```
|
||||
|
||||
The message `remaining permissions should be empty for type "frobulator"`
|
||||
indicates that we're missing tests which validate the desired actions on our new
|
||||
noun.
|
||||
|
||||
> Take a look at `coderd/rbac/roles_test.go` in the
|
||||
> [reference PR](https://github.com/coder/coder/pull/14055) for a complete
|
||||
> example
|
||||
|
||||
Let's add a test case:
|
||||
|
||||
```go
|
||||
func TestRolePermissions(t *testing.T) {
|
||||
...
|
||||
{
|
||||
// Users should be able to modify their own frobulators
|
||||
// Admins from the current organization should be able to modify any other members' frobulators
|
||||
// Owner should be able to modify any other user's frobulators
|
||||
Name: "FrobulatorsModify",
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete},
|
||||
Resource: rbac.ResourceFrobulator.WithOwner(currentUser.String()).InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {orgMemberMe, orgAdmin, owner},
|
||||
false: {setOtherOrg, memberMe, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Admins from the current organization should be able to read any other members' frobulators
|
||||
// Auditors should be able to read any other members' frobulators
|
||||
// Owner should be able to read any other user's frobulators
|
||||
Name: "FrobulatorsReadAnyUserInOrg",
|
||||
Actions: []policy.Action{policy.ActionRead},
|
||||
Resource: rbac.ResourceFrobulator.WithOwner(uuid.New().String()).InOrg(orgID), // read frobulators of any user
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, orgAuditor},
|
||||
false: {memberMe, orgMemberMe, setOtherOrg, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
Note how the `FrobulatorsModify` test case is just validating the
|
||||
`policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete` actions, and
|
||||
only the **orgMember**, **orgAdmin**, and **owner** can access it.
|
||||
|
||||
The `FrobulatorsReadAnyUserInOrg` test case is validating that owners, org
|
||||
admins & auditors have the `policy.ActionRead` policy which enables them to read
|
||||
frobulators belonging to any user in a given organization.
|
||||
|
||||
The above tests are illustrative not exhaustive, see
|
||||
[the reference PR](https://github.com/coder/coder/pull/14055) for the rest.
|
||||
|
||||
Once we have covered all the possible scenarios, the tests will pass:
|
||||
|
||||
```bash
|
||||
$ go test github.com/coder/coder/v2/coderd/rbac -count=1
|
||||
ok github.com/coder/coder/v2/coderd/rbac 1.313s
|
||||
```
|
||||
|
||||
When a case is not covered, you'll see an error like this (I moved the
|
||||
`orgAuditor` option from `true` to `false`):
|
||||
|
||||
```bash
|
||||
--- FAIL: TestRolePermissions (0.79s)
|
||||
--- FAIL: TestRolePermissions/FrobulatorsReadOnly (0.01s)
|
||||
roles_test.go:737:
|
||||
Error Trace: /tmp/coder/coderd/rbac/roles_test.go:737
|
||||
Error: An error is expected but got nil.
|
||||
Test: TestRolePermissions/FrobulatorsReadOnly
|
||||
Messages: Should fail: FrobulatorsReadOnly as "org_auditor" doing "read" on "frobulator"
|
||||
FAIL
|
||||
FAIL github.com/coder/coder/v2/coderd/rbac 1.390s
|
||||
FAIL
|
||||
```
|
||||
|
||||
This shows you that the `org_auditor` role has `read` permissions on the
|
||||
frobulator, but no test case covered it.
|
||||
|
||||
**NOTE: don't just add cases which make the tests pass; consider all the ways in
|
||||
which your resource must be used, and test all of those scenarios!**
|
||||
|
||||
# Database authorization
|
||||
|
||||
Now that we have the RBAC system fully configured, we need to make use of it.
|
||||
|
||||
Let's add a SQL query to `coderd/database/queries/frobulators.sql`:
|
||||
|
||||
```sql
|
||||
-- name: GetFrobulators :many
|
||||
SELECT *
|
||||
FROM frobulators
|
||||
WHERE user_id = $1 AND org_id = $2;
|
||||
```
|
||||
|
||||
Once we run `make gen`, we'll find some stubbed code in
|
||||
`coderd/database/dbauthz/dbauthz.go`.
|
||||
|
||||
```go
|
||||
...
|
||||
func (q *querier) GetFrobulators(ctx context.Context, arg database.GetFrobulatorsParams) ([]database.Frobulator, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
Let's modify this function:
|
||||
|
||||
```go
|
||||
...
|
||||
func (q *querier) GetFrobulators(ctx context.Context, arg database.GetFrobulatorsParams) ([]database.Frobulator, error) {
|
||||
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetFrobulators)(ctx, arg)
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
This states that the `policy.ActionRead` permission is enforced on all entries
|
||||
returned from the database, ensuring that each requested frobulator is readable
|
||||
by the given actor.
|
||||
|
||||
In order for this to work, we need to implement the `rbac.Objector` interface.
|
||||
|
||||
`coderd/database/modelmethods.go` is where we implement this interface for all
|
||||
RBAC objects:
|
||||
|
||||
```go
|
||||
func (f Frobulator) RBACObject() rbac.Object {
|
||||
return rbac.ResourceFrobulator.
|
||||
WithID(f.ID). // Each frobulator has a unique identity.
|
||||
WithOwner(f.UserID.String()). // It is owned by one and only one user.
|
||||
InOrg(f.OrgID) // It belongs to an organization.
|
||||
}
|
||||
```
|
||||
|
||||
These values obviously have to be set on the `Frobulator` instance before this
|
||||
function can work, hence why we have to fetch the object from the store first
|
||||
before we validate (this explains the `fetchWithPostFilter` naming).
|
||||
|
||||
All queries are executed through `dbauthz`, and now our little frobulators are
|
||||
protected!
|
||||
|
||||
# API authorization
|
||||
|
||||
API authorization is not strictly required because we have database
|
||||
authorization in place, but it's a good practice to reject requests as soon as
|
||||
possible when the requester is unprivileged.
|
||||
|
||||
> Take a look at `coderd/frobulators.go` in the
|
||||
> [reference PR](https://github.com/coder/coder/pull/14055) for a complete
|
||||
> example
|
||||
|
||||
```go
|
||||
...
|
||||
func (api *API) createFrobulator(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
member := httpmw.OrganizationMemberParam(r)
|
||||
org := httpmw.OrganizationParam(r)
|
||||
|
||||
var req codersdk.InsertFrobulatorRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
frob, err := api.Database.InsertFrobulator(ctx, database.InsertFrobulatorParams{
|
||||
ID: uuid.New(),
|
||||
UserID: member.UserID,
|
||||
OrgID: org.ID,
|
||||
ModelNumber: req.ModelNumber,
|
||||
})
|
||||
|
||||
// This will catch forbidden errors as well.
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
If we look at the implementation of `httpapi.Is404Error`:
|
||||
|
||||
```go
|
||||
// Is404Error returns true if the given error should return a 404 status code.
|
||||
// Both actual 404s and unauthorized errors should return 404s to not leak
|
||||
// information about the existence of resources.
|
||||
func Is404Error(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// This tests for dbauthz.IsNotAuthorizedError and rbac.IsUnauthorizedError.
|
||||
if IsUnauthorizedError(err) {
|
||||
return true
|
||||
}
|
||||
return xerrors.Is(err, sql.ErrNoRows)
|
||||
}
|
||||
```
|
||||
|
||||
With this, we're able to handle unauthorized access to the resource but return a
|
||||
`404 Not Found` to not leak the fact that the resources exist but are not
|
||||
accessible by the given actor.
|
||||
Reference in New Issue
Block a user