Files
coder/coderd/rbac/USAGE.md
2025-01-03 13:12:59 +00:00

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.