15 KiB
Using RBAC
Overview
NOTE: you should probably read
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).
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):
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
:
...
"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
:
...
// 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
:
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:
--- 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 for a complete example
Let's add a test case:
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 for the rest.
Once we have covered all the possible scenarios, the tests will pass:
$ 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
):
--- 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
:
-- 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
.
...
func (q *querier) GetFrobulators(ctx context.Context, arg database.GetFrobulatorsParams) ([]database.Frobulator, error) {
panic("not implemented")
}
...
Let's modify this function:
...
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:
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 for a complete example
...
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
:
// 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.