feat: Add RBAC package for managing user permissions (#929)

This PR adds an RBAC package for managing using permissions:
- The top-level `authz.Authorize` function is the main user-facing entrypoint to the package.
- Actual permission evaluation is handled in `policy.rego`.
- Unit tests for `authz.Authorize` are in `authz_test.go`
- Documentation for the package is in `README.md`.

Co-authored-by: Cian Johnston <cian@coder.com>
This commit is contained in:
Steven Masley
2022-04-13 08:35:35 -05:00
committed by GitHub
co-authored by Cian Johnston
parent 103d7eab14
commit 770c567123
11 changed files with 1318 additions and 3 deletions
+73
View File
@@ -0,0 +1,73 @@
# Authz
Package `authz` implements AuthoriZation 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`.
## Permission Structure
A **permission** is a rule that grants or denies access for a **subject** to perform an **action** on a **object**.
A **permission** is always applied at a given **level**:
- **site** level applies to all objects in a given Coder deployment.
- **org** level applies to all objects that have an organization owner (`org_owner`)
- **user** level applies to all objects that have an owner with the same ID as the subject.
**Permissions** at a higher **level** always override permissions at a **lower** level.
The effect of a **permission** can be:
- **positive** (allows)
- **negative** (denies)
- **abstain** (neither allows or denies, not applicable)
**Negative** permissions **always** override **positive** permissions at the same level.
Both **negative** and **positive** permissions override **abstain** at the same level.
This can be represented by the following truth table, where Y represents *positive*, N represents *negative*, and _ represents *abstain*:
| Action | Positive | Negative | Result |
|--------|----------|----------|--------|
| read | Y | _ | Y |
| read | Y | N | N |
| read | _ | _ | _ |
| read | _ | N | Y |
## Permission Representation
**Permissions** are represented in string format as `<sign>?<level>.<object>.<id>.<action>`, where:
- `negated` can be either `+` or `-`. If it is omitted, sign is assumed to be `+`.
- `level` is either `site`, `org`, or `user`.
- `object` is any valid resource type.
- `id` is any valid UUID v4.
- `action` is `create`, `read`, `modify`, or `delete`.
## Example Permissions
- `+site.*.*.read`: allowed to perform the `read` action against all objects of type `devurl` in a given Coder deployment.
- `-user.workspace.*.create`: user is not allowed to create workspaces.
## Roles
A *role* is a set of permissions. When evaluating a role's permission to form an action, all the relevant permissions for the role are combined at each level. Permissions at a higher level override permissions at a lower level.
The following table shows the per-level role evaluation.
Y indicates that the role provides positive permissions, N indicates the role provides negative permissions, and _ indicates the role does not provide positive or negative permissions. YN_ indicates that the value in the cell does not matter for the access result.
| Role (example) | Site | Org | User | Result |
|-----------------|------|-----|------|--------|
| site-admin | Y | YN_ | YN_ | Y |
| no-permission | N | YN_ | YN_ | N |
| org-admin | _ | Y | YN_ | Y |
| non-org-member | _ | N | YN_ | N |
| user | _ | _ | Y | Y |
| | _ | _ | N | N |
| unauthenticated | _ | _ | _ | N |
+11
View File
@@ -0,0 +1,11 @@
package rbac
// Action represents the allowed actions to be done on an object.
type Action string
const (
ActionCreate = "create"
ActionRead = "read"
ActionUpdate = "update"
ActionDelete = "delete"
)
+70
View File
@@ -0,0 +1,70 @@
package rbac
import (
"context"
_ "embed"
"golang.org/x/xerrors"
"github.com/open-policy-agent/opa/rego"
)
// RegoAuthorizer will use a prepared rego query for performing authorize()
type RegoAuthorizer struct {
query rego.PreparedEvalQuery
}
// Load the policy from policy.rego in this directory.
//go:embed policy.rego
var policy string
func NewAuthorizer() (*RegoAuthorizer, error) {
ctx := context.Background()
query, err := rego.New(
// allowed is the `allow` field from the prepared query. This is the field to check if authorization is
// granted.
rego.Query("allowed = data.authz.allow"),
rego.Module("policy.rego", policy),
).PrepareForEval(ctx)
if err != nil {
return nil, xerrors.Errorf("prepare query: %w", err)
}
return &RegoAuthorizer{query: query}, nil
}
type authSubject struct {
ID string `json:"id"`
Roles []Role `json:"roles"`
}
func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles []Role, action Action, object Object) error {
input := map[string]interface{}{
"subject": authSubject{
ID: subjectID,
Roles: roles,
},
"object": object,
"action": action,
}
results, err := a.query.Eval(ctx, rego.EvalInput(input))
if err != nil {
return ForbiddenWithInternal(xerrors.Errorf("eval rego: %w, err"), input, results)
}
if len(results) != 1 {
return ForbiddenWithInternal(xerrors.Errorf("expect only 1 result, got %d", len(results)), input, results)
}
allowedResult, ok := (results[0].Bindings["allowed"]).(bool)
if !ok {
return ForbiddenWithInternal(xerrors.Errorf("expected allowed to be a bool but got %T", allowedResult), input, results)
}
if !allowedResult {
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results)
}
return nil
}
+633
View File
@@ -0,0 +1,633 @@
package rbac_test
import (
"context"
"encoding/json"
"testing"
"golang.org/x/xerrors"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/rbac"
)
// subject is required because rego needs
type subject struct {
UserID string `json:"id"`
Roles []rbac.Role `json:"roles"`
}
// TestAuthorizeDomain test the very basic roles that are commonly used.
func TestAuthorizeDomain(t *testing.T) {
t.Parallel()
defOrg := "default"
wrkID := "1234"
user := subject{
UserID: "me",
Roles: []rbac.Role{rbac.RoleMember, rbac.RoleOrgMember(defOrg)},
}
testAuthorize(t, "Member", user, []authTestCase{
// Org + me + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.All(), actions: allActions(), allow: false},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: false},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithID("not-id"), actions: allActions(), allow: false},
})
user = subject{
UserID: "me",
Roles: []rbac.Role{{
Name: "deny-all",
// List out deny permissions explicitly
Site: []rbac.Permission{
{
Negate: true,
ResourceType: rbac.WildcardSymbol,
ResourceID: rbac.WildcardSymbol,
Action: rbac.WildcardSymbol,
},
},
}},
}
testAuthorize(t, "DeletedMember", user, []authTestCase{
// Org + me + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.All(), actions: allActions(), allow: false},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: false},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithID("not-id"), actions: allActions(), allow: false},
})
user = subject{
UserID: "me",
Roles: []rbac.Role{
rbac.RoleOrgAdmin(defOrg),
rbac.RoleMember,
},
}
testAuthorize(t, "OrgAdmin", user, []authTestCase{
// Org + me + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.All(), actions: allActions(), allow: false},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: false},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID(wrkID), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
{resource: rbac.ResourceWorkspace.WithID("not-id"), actions: allActions(), allow: false},
})
user = subject{
UserID: "me",
Roles: []rbac.Role{
rbac.RoleAdmin,
rbac.RoleMember,
},
}
testAuthorize(t, "SiteAdmin", user, []authTestCase{
// Org + me + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.All(), actions: allActions(), allow: true},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: true},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID(wrkID), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: true},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: true},
{resource: rbac.ResourceWorkspace.WithID("not-id"), actions: allActions(), allow: true},
})
// In practice this is a token scope on a regular subject
user = subject{
UserID: "me",
Roles: []rbac.Role{
rbac.RoleWorkspaceAgent(wrkID),
},
}
testAuthorize(t, "WorkspaceAgentToken", user,
// Read Actions
cases(func(c authTestCase) authTestCase {
c.actions = []rbac.Action{rbac.ActionRead}
return c
}, []authTestCase{
// Org + me + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID).WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID).WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.All(), allow: false},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg("other"), allow: false},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.WithID("not-id"), allow: false},
}),
// Not read actions
cases(func(c authTestCase) authTestCase {
c.actions = []rbac.Action{rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete}
c.allow = false
return c
}, []authTestCase{
// Org + me + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg)},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.WithID(wrkID)},
{resource: rbac.ResourceWorkspace.All()},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg("other")},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID(wrkID)},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg("other")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.WithID("not-id")},
}),
)
// In practice this is a token scope on a regular subject
user = subject{
UserID: "me",
Roles: []rbac.Role{
{
Name: "ReadOnlyOrgAndUser",
Site: []rbac.Permission{},
Org: map[string][]rbac.Permission{
defOrg: {{
Negate: false,
ResourceType: "*",
ResourceID: "*",
Action: rbac.ActionRead,
}},
},
User: []rbac.Permission{
{
Negate: false,
ResourceType: "*",
ResourceID: "*",
Action: rbac.ActionRead,
},
},
},
},
}
testAuthorize(t, "ReadOnly", user,
cases(func(c authTestCase) authTestCase {
c.actions = []rbac.Action{rbac.ActionRead}
return c
}, []authTestCase{
// Read
// Org + me + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID).WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID).WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), allow: true},
{resource: rbac.ResourceWorkspace.WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.All(), allow: false},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), allow: false},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.WithID("not-id"), allow: false},
}),
// Pass non-read actions
cases(func(c authTestCase) authTestCase {
c.actions = []rbac.Action{rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete}
c.allow = false
return c
}, []authTestCase{
// Read
// Org + me + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg)},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.WithID(wrkID)},
{resource: rbac.ResourceWorkspace.All()},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg("other")},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID(wrkID)},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg("other")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.WithID("not-id")},
}))
}
// TestAuthorizeLevels ensures level overrides are acting appropriately
//nolint:paralleltest
func TestAuthorizeLevels(t *testing.T) {
defOrg := "default"
wrkID := "1234"
user := subject{
UserID: "me",
Roles: []rbac.Role{
rbac.RoleAdmin,
rbac.RoleOrgDenyAll(defOrg),
{
Name: "user-deny-all",
// List out deny permissions explicitly
User: []rbac.Permission{
{
Negate: true,
ResourceType: rbac.WildcardSymbol,
ResourceID: rbac.WildcardSymbol,
Action: rbac.WildcardSymbol,
},
},
},
},
}
testAuthorize(t, "AdminAlwaysAllow", user,
cases(func(c authTestCase) authTestCase {
c.actions = allActions()
c.allow = true
return c
}, []authTestCase{
// Org + me + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg)},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.WithID(wrkID)},
{resource: rbac.ResourceWorkspace.All()},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID)},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg("other")},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID)},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID(wrkID)},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id")},
{resource: rbac.ResourceWorkspace.InOrg("other")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id")},
{resource: rbac.ResourceWorkspace.WithOwner("not-me")},
{resource: rbac.ResourceWorkspace.WithID("not-id")},
}))
user = subject{
UserID: "me",
Roles: []rbac.Role{
{
Name: "site-noise",
Site: []rbac.Permission{
{
Negate: true,
ResourceType: "random",
ResourceID: rbac.WildcardSymbol,
Action: rbac.WildcardSymbol,
},
},
},
rbac.RoleOrgAdmin(defOrg),
{
Name: "user-deny-all",
// List out deny permissions explicitly
User: []rbac.Permission{
{
Negate: true,
ResourceType: rbac.WildcardSymbol,
ResourceID: rbac.WildcardSymbol,
Action: rbac.WildcardSymbol,
},
},
},
},
}
testAuthorize(t, "OrgAllowAll", user,
cases(func(c authTestCase) authTestCase {
c.actions = allActions()
return c
}, []authTestCase{
// Org + me + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID).WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID).WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.All(), allow: false},
// Other org + me + id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID).WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner(user.UserID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), allow: false},
// Other org + other user + id
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me").WithID(wrkID), allow: true},
{resource: rbac.ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: true},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID(wrkID), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
// Other org + other use + other id
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.InOrg("other"), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me").WithID("not-id"), allow: false},
{resource: rbac.ResourceWorkspace.WithOwner("not-me"), allow: false},
{resource: rbac.ResourceWorkspace.WithID("not-id"), allow: false},
}))
}
// cases applies a given function to all test cases. This makes generalities easier to create.
func cases(opt func(c authTestCase) authTestCase, cases []authTestCase) []authTestCase {
if opt == nil {
return cases
}
for i := range cases {
cases[i] = opt(cases[i])
}
return cases
}
type authTestCase struct {
resource rbac.Object
actions []rbac.Action
allow bool
}
func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTestCase) {
authorizer, err := rbac.NewAuthorizer()
require.NoError(t, err)
for _, cases := range sets {
for _, c := range cases {
t.Run(name, func(t *testing.T) {
for _, a := range c.actions {
err := authorizer.Authorize(context.Background(), subject.UserID, subject.Roles, a, c.resource)
if c.allow {
if err != nil {
var uerr *rbac.UnauthorizedError
xerrors.As(err, &uerr)
d, _ := json.Marshal(uerr.Input())
t.Logf("input: %s", string(d))
t.Logf("internal error: %+v", uerr.Internal().Error())
t.Logf("output: %+v", uerr.Output())
}
require.NoError(t, err, "expected no error for testcase action %s", a)
continue
}
if err == nil {
d, _ := json.Marshal(map[string]interface{}{
"subject": subject,
"object": c.resource,
"action": a,
})
t.Log(string(d))
}
require.Error(t, err, "expected unauthorized")
}
})
}
}
}
// allActions is a helper function to return all the possible actions types.
func allActions() []rbac.Action {
return []rbac.Action{rbac.ActionCreate, rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete}
}
+52
View File
@@ -0,0 +1,52 @@
package rbac
import "github.com/open-policy-agent/opa/rego"
const (
// errUnauthorized is the error message that should be returned to
// clients when an action is forbidden. It is intentionally vague to prevent
// disclosing information that a client should not have access to.
errUnauthorized = "unauthorized"
)
// UnauthorizedError is the error type for authorization errors
type UnauthorizedError struct {
// internal is the internal error that should never be shown to the client.
// It is only for debugging purposes.
internal error
input map[string]interface{}
output rego.ResultSet
}
// ForbiddenWithInternal creates a new error that will return a simple
// "forbidden" to the client, logging internally the more detailed message
// provided.
func ForbiddenWithInternal(internal error, input map[string]interface{}, output rego.ResultSet) *UnauthorizedError {
if input == nil {
input = map[string]interface{}{}
}
return &UnauthorizedError{
internal: internal,
input: input,
output: output,
}
}
// Error implements the error interface.
func (UnauthorizedError) Error() string {
return errUnauthorized
}
// Internal allows the internal error message to be logged.
func (e *UnauthorizedError) Internal() error {
return e.internal
}
func (e *UnauthorizedError) Input() map[string]interface{} {
return e.input
}
// Output contains the results of the Rego query for debugging.
func (e *UnauthorizedError) Output() rego.ResultSet {
return e.output
}
+54
View File
@@ -0,0 +1,54 @@
package rbac_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/rbac"
)
// TestExample gives some examples on how to use the authz library.
// This serves to test syntax more than functionality.
func TestExample(t *testing.T) {
t.Parallel()
ctx := context.Background()
authorizer, err := rbac.NewAuthorizer()
require.NoError(t, err)
// user will become an authn object, and can even be a database.User if it
// fulfills the interface. Until then, use a placeholder.
user := subject{
UserID: "alice",
Roles: []rbac.Role{
rbac.RoleOrgAdmin("default"),
rbac.RoleMember,
},
}
//nolint:paralleltest
t.Run("ReadAllWorkspaces", func(t *testing.T) {
// To read all workspaces on the site
err := authorizer.Authorize(ctx, user.UserID, user.Roles, rbac.ActionRead, rbac.ResourceWorkspace.All())
var _ = err
require.Error(t, err, "this user cannot read all workspaces")
})
//nolint:paralleltest
t.Run("ReadOrgWorkspaces", func(t *testing.T) {
// To read all workspaces on the org 'default'
err := authorizer.Authorize(ctx, user.UserID, user.Roles, rbac.ActionRead, rbac.ResourceWorkspace.InOrg("default"))
require.NoError(t, err, "this user can read all org workspaces in 'default'")
})
//nolint:paralleltest
t.Run("ReadMyWorkspace", func(t *testing.T) {
// Note 'database.Workspace' could fulfill the object interface and be passed in directly
err := authorizer.Authorize(ctx, user.UserID, user.Roles, rbac.ActionRead, rbac.ResourceWorkspace.InOrg("default").WithOwner(user.UserID))
require.NoError(t, err, "this user can their workspace")
err = authorizer.Authorize(ctx, user.UserID, user.Roles, rbac.ActionRead, rbac.ResourceWorkspace.InOrg("default").WithOwner(user.UserID).WithID("1234"))
require.NoError(t, err, "this user can read workspace '1234'")
})
}
+76
View File
@@ -0,0 +1,76 @@
package rbac
const WildcardSymbol = "*"
// Resources are just typed objects. Making resources this way allows directly
// passing them into an Authorize function and use the chaining api.
var (
ResourceWorkspace = Object{
Type: "workspace",
}
ResourceTemplate = Object{
Type: "template",
}
// ResourceWildcard represents all resource types
ResourceWildcard = Object{
Type: WildcardSymbol,
}
)
// Object is used to create objects for authz checks when you have none in
// hand to run the check on.
// An example is if you want to list all workspaces, you can create a Object
// that represents the set of workspaces you are trying to get access too.
// Do not export this type, as it can be created from a resource type constant.
type Object struct {
ResourceID string `json:"id"`
Owner string `json:"owner"`
// OrgID specifies which org the object is a part of.
OrgID string `json:"org_owner"`
// Type is "workspace", "project", "devurl", etc
Type string `json:"type"`
// TODO: SharedUsers?
}
// All returns an object matching all resources of the same type.
func (z Object) All() Object {
return Object{
ResourceID: "",
Owner: "",
OrgID: "",
Type: z.Type,
}
}
// InOrg adds an org OwnerID to the resource
func (z Object) InOrg(orgID string) Object {
return Object{
ResourceID: z.ResourceID,
Owner: z.Owner,
OrgID: orgID,
Type: z.Type,
}
}
// WithOwner adds an OwnerID to the resource
func (z Object) WithOwner(ownerID string) Object {
return Object{
ResourceID: z.ResourceID,
Owner: ownerID,
OrgID: z.OrgID,
Type: z.Type,
}
}
// WithID adds a ResourceID to the resource
func (z Object) WithID(resourceID string) Object {
return Object{
ResourceID: resourceID,
Owner: z.Owner,
OrgID: z.OrgID,
Type: z.Type,
}
}
+140
View File
@@ -0,0 +1,140 @@
package authz
import future.keywords.in
import future.keywords.every
# A great playground: https://play.openpolicyagent.org/
# TODO: Add debug instructions to do in the cli. Running really short on time, the
# playground is sufficient for now imo. In the future we can provide a tidy bash
# script for running this against predefined input.
# bool_flip lets you assign a value to an inverted bool.
# You cannot do 'x := !false', but you can do 'x := bool_flip(false)'
bool_flip(b) = flipped {
b
flipped = false
}
bool_flip(b) = flipped {
not b
flipped = true
}
# perms_grant returns a set of boolean values {true, false}.
# True means a positive permission in the set, false is a negative permission.
# It will only return `bool_flip(perm.negate)` for permissions that affect a given
# resource_type, resource_id, and action.
# The empty set is returned if no relevant permissions are found.
perms_grant(permissions) = grants {
# If there are no permissions, this value is the empty set {}.
grants := { x |
# All permissions ...
perm := permissions[_]
# Such that the permission action, type, and resource_id matches
perm.action in [input.action, "*"]
perm.resource_type in [input.object.type, "*"]
perm.resource_id in [input.object.id, "*"]
x := bool_flip(perm.negate)
}
}
# Site & User are both very simple. We default both to the empty set '{}'. If no permissions are present, then the
# result is the default value.
default site = {}
site = grant {
# Boolean set for all site wide permissions.
grant = { v | # Use set comprehension to remove duplicate values
# For each role, grab the site permission.
# Find the grants on this permission list.
v = perms_grant(input.subject.roles[_].site)[_]
}
}
default user = {}
user = grant {
# Only apply user permissions if the user owns the resource
input.object.owner != ""
input.object.owner == input.subject.id
grant = { v |
# For each role, grab the user permissions.
# Find the grants on this permission list.
v = perms_grant(input.subject.roles[_].user)[_]
}
}
# Organizations are more complex. If the user has no roles that specifically indicate the org_id of the object,
# then we want to block the action. This is because that means the user is not a member of the org.
# A non-member cannot access any org resources.
# org_member returns the set of permissions associated with a user if the user is a member of the
# organization
org_member = grant {
input.object.org_owner != ""
grant = { v |
v = perms_grant(input.subject.roles[_].org[input.object.org_owner])[_]
}
}
# If a user is not part of an organization, 'org_non_member' is set to true
org_non_member {
input.object.org_owner != ""
# Identify if the user is in the org
roles := input.subject.roles
every role in roles {
not role.org[input.object.org_owner]
}
}
# org is two rules that equate to the following
# if org_non_member { return {false} }
# else { org_member }
#
# It is important both rules cannot be true, as the `org` rules cannot produce multiple outputs.
default org = {}
org = set {
# We have to do !org_non_member because rego rules must evaluate to 'true'
# to have a value set.
# So we do "not not-org-member" which means "subject is in org"
not org_non_member
set = org_member
}
org = set {
org_non_member
set = {false}
}
# The allow block is quite simple. Any set with `false` cascades down in levels.
# Authorization looks for any `allow` statement that is true. Multiple can be true!
# Note that the absense of `allow` means "unauthorized".
# An explicit `"allow": true` is required.
# site allow
allow {
# No site wide deny
not false in site
# And all permissions are positive
site[_]
}
# OR
# org allow
allow {
# No site or org deny
not false in site
not false in org
# And all permissions are positive
org[_]
}
# OR
# user allow
allow {
# No site, org, or user deny
not false in site
not false in org
not false in user
# And all permissions are positive
user[_]
}
+138
View File
@@ -0,0 +1,138 @@
package rbac
import "fmt"
type Permission struct {
// Negate makes this a negative permission
Negate bool `json:"negate"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
Action Action `json:"action"`
}
// Role is a set of permissions at multiple levels:
// - Site level permissions apply EVERYWHERE
// - Org level permissions apply to EVERYTHING in a given ORG
// - User level permissions are the lowest
// In most cases, you will just want to use the pre-defined roles
// below.
type Role struct {
Name string `json:"name"`
Site []Permission `json:"site"`
// Org is a map of orgid to permissions. We represent orgid as a string.
// TODO: Maybe switch to uuid, but tokens might need to support a "wildcard" org
// which could be a special uuid (like all 0s?)
Org map[string][]Permission `json:"org"`
User []Permission `json:"user"`
}
// Roles are stored as structs, so they can be serialized and stored. Until we store them elsewhere,
// const's will do just fine.
var (
// RoleAdmin is a role that allows everything everywhere.
RoleAdmin = Role{
Name: "admin",
Site: permissions(map[Object][]Action{
ResourceWildcard: {WildcardSymbol},
}),
}
// RoleMember is a role that allows access to user-level resources.
RoleMember = Role{
Name: "member",
User: permissions(map[Object][]Action{
ResourceWildcard: {WildcardSymbol},
}),
}
// RoleAuditor is an example on how to give more precise permissions
RoleAuditor = Role{
Name: "auditor",
Site: permissions(map[Object][]Action{
// TODO: @emyrk when audit logs are added, add back a read perm
//ResourceAuditLogs: {ActionRead},
// Should be able to read user details to associate with logs.
// Without this the user-id in logs is not very helpful
ResourceWorkspace: {ActionRead},
}),
}
)
func RoleOrgDenyAll(orgID string) Role {
return Role{
Name: "org-deny-" + orgID,
Org: map[string][]Permission{
orgID: {
{
Negate: true,
ResourceType: "*",
ResourceID: "*",
Action: "*",
},
},
},
}
}
// RoleOrgAdmin returns a role with all actions allows in a given
// organization scope.
func RoleOrgAdmin(orgID string) Role {
return Role{
Name: "org-admin-" + orgID,
Org: map[string][]Permission{
orgID: {
{
Negate: false,
ResourceType: "*",
ResourceID: "*",
Action: "*",
},
},
},
}
}
// RoleOrgMember returns a role with default permissions in a given
// organization scope.
func RoleOrgMember(orgID string) Role {
return Role{
Name: "org-member-" + orgID,
Org: map[string][]Permission{
orgID: {},
},
}
}
// RoleWorkspaceAgent returns a role with permission to read a given
// workspace.
func RoleWorkspaceAgent(workspaceID string) Role {
return Role{
Name: fmt.Sprintf("agent-%s", workspaceID),
// This is at the site level to prevent the token from losing access if the user
// is kicked from the org
Site: []Permission{
{
Negate: false,
ResourceType: ResourceWorkspace.Type,
ResourceID: workspaceID,
Action: ActionRead,
},
},
}
}
func permissions(perms map[Object][]Action) []Permission {
list := make([]Permission, 0, len(perms))
for k, actions := range perms {
for _, act := range actions {
act := act
list = append(list, Permission{
Negate: false,
ResourceType: k.Type,
ResourceID: WildcardSymbol,
Action: act,
})
}
}
return list
}