mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
412 lines
15 KiB
Markdown
412 lines
15 KiB
Markdown
# 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.
|