diff --git a/coderd/rbac/README.md b/coderd/rbac/README.md index e867fa9cce50a..e4d217d303b2f 100644 --- a/coderd/rbac/README.md +++ b/coderd/rbac/README.md @@ -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** diff --git a/coderd/rbac/USAGE.md b/coderd/rbac/USAGE.md new file mode 100644 index 0000000000000..76bff69a88c5a --- /dev/null +++ b/coderd/rbac/USAGE.md @@ -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).
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.