mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Cian Johnston
parent
103d7eab14
commit
770c567123
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'")
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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[_]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user