Skip to content

Commit 212ebce

Browse files
committed
feat: Implement allow_list for scopes for resource specific permissions
Feature that adds an allow_list for scopes to specify particular resources. This enables workspace agent tokens to use the same RBAC system as users.
1 parent 6252f78 commit 212ebce

File tree

8 files changed

+97
-29
lines changed

8 files changed

+97
-29
lines changed

coderd/rbac/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ This can be represented by the following truth table, where Y represents _positi
4848
- `level` is either `site`, `org`, or `user`.
4949
- `object` is any valid resource type.
5050
- `id` is any valid UUID v4.
51+
- `id` is included in the permission syntax, however only scopes may use `id` to specify a specific object.
5152
- `action` is `create`, `read`, `modify`, or `delete`.
5253

5354
## Example Permissions
@@ -72,6 +73,20 @@ Y indicates that the role provides positive permissions, N indicates the role pr
7273
| | \_ | \_ | N | N |
7374
| unauthenticated | \_ | \_ | \_ | N |
7475

76+
## Scopes
77+
78+
Scopes can restrict a given set of permissions. The format of a scope matches a role with the addition of a list of resource ids. For a authorization call to be successful, the subject's roles and the subject's scopes must both allow the action. This means the resulting permissions is the intersection of the subject's roles and the subject's scopes.
79+
80+
An example to give a readonly token is to grant a readonly scope across all resources `+site.*.*.read`. The intersection with the user's permissions will be the readonly set of their permissions.
81+
82+
83+
### Resource IDs
84+
85+
There exists use cases that require specifying a specific resource. If resource IDs are allowed in the roles, then there is
86+
an unbounded set of resource IDs that be added to an "allow_list", as the number of roles a user can have is unbounded. This also adds a level of complexity to the role evaluation logic that has large costs at scale.
87+
88+
The use case for specifying this type of permission in a role is limited, and does not justify the extra cost. To solve this for the remaining cases (eg. workspace agent tokens), we can apply an `allow_list` on a scope. For most cases, the `allow_list` will just be `["*"]` which means the scope is allowed to be applied to any resource. This adds negligible cost to the role evaluation logic and 0 cost to partial evaluations.
89+
7590
# Testing
7691

7792
You can test outside of golang by using the `opa` cli.

coderd/rbac/authz.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNa
197197
return err
198198
}
199199

200-
scopeRole, err := ScopeRole(scope)
200+
scopeRole, err := ExpandScope(scope)
201201
if err != nil {
202202
return err
203203
}
@@ -252,7 +252,7 @@ func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string,
252252
return nil, err
253253
}
254254

255-
scopeRole, err := ScopeRole(scope)
255+
scopeRole, err := ExpandScope(scope)
256256
if err != nil {
257257
return nil, err
258258
}

coderd/rbac/authz_internal_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ func TestAuthorizeDomain(t *testing.T) {
200200

201201
user := subject{
202202
UserID: "me",
203-
Scope: must(ScopeRole(ScopeAll)),
203+
Scope: must(ExpandScope(ScopeAll)),
204204
Groups: []string{allUsersGroup},
205205
Roles: []Role{
206206
must(RoleByName(RoleMember())),
@@ -299,7 +299,7 @@ func TestAuthorizeDomain(t *testing.T) {
299299

300300
user = subject{
301301
UserID: "me",
302-
Scope: must(ScopeRole(ScopeAll)),
302+
Scope: must(ExpandScope(ScopeAll)),
303303
Roles: []Role{{
304304
Name: "deny-all",
305305
// List out deny permissions explicitly
@@ -340,7 +340,7 @@ func TestAuthorizeDomain(t *testing.T) {
340340

341341
user = subject{
342342
UserID: "me",
343-
Scope: must(ScopeRole(ScopeAll)),
343+
Scope: must(ExpandScope(ScopeAll)),
344344
Roles: []Role{
345345
must(RoleByName(RoleOrgAdmin(defOrg))),
346346
must(RoleByName(RoleMember())),
@@ -374,7 +374,7 @@ func TestAuthorizeDomain(t *testing.T) {
374374

375375
user = subject{
376376
UserID: "me",
377-
Scope: must(ScopeRole(ScopeAll)),
377+
Scope: must(ExpandScope(ScopeAll)),
378378
Roles: []Role{
379379
must(RoleByName(RoleOwner())),
380380
must(RoleByName(RoleMember())),
@@ -408,7 +408,7 @@ func TestAuthorizeDomain(t *testing.T) {
408408

409409
user = subject{
410410
UserID: "me",
411-
Scope: must(ScopeRole(ScopeApplicationConnect)),
411+
Scope: must(ExpandScope(ScopeApplicationConnect)),
412412
Roles: []Role{
413413
must(RoleByName(RoleOrgMember(defOrg))),
414414
must(RoleByName(RoleMember())),
@@ -507,7 +507,7 @@ func TestAuthorizeDomain(t *testing.T) {
507507
// In practice this is a token scope on a regular subject
508508
user = subject{
509509
UserID: "me",
510-
Scope: must(ScopeRole(ScopeAll)),
510+
Scope: must(ExpandScope(ScopeAll)),
511511
Roles: []Role{
512512
{
513513
Name: "ReadOnlyOrgAndUser",
@@ -600,7 +600,7 @@ func TestAuthorizeLevels(t *testing.T) {
600600

601601
user := subject{
602602
UserID: "me",
603-
Scope: must(ScopeRole(ScopeAll)),
603+
Scope: must(ExpandScope(ScopeAll)),
604604
Roles: []Role{
605605
must(RoleByName(RoleOwner())),
606606
{
@@ -661,7 +661,7 @@ func TestAuthorizeLevels(t *testing.T) {
661661

662662
user = subject{
663663
UserID: "me",
664-
Scope: must(ScopeRole(ScopeAll)),
664+
Scope: must(ExpandScope(ScopeAll)),
665665
Roles: []Role{
666666
{
667667
Name: "site-noise",
@@ -726,7 +726,7 @@ func TestAuthorizeScope(t *testing.T) {
726726
user := subject{
727727
UserID: "me",
728728
Roles: []Role{must(RoleByName(RoleOwner()))},
729-
Scope: must(ScopeRole(ScopeApplicationConnect)),
729+
Scope: must(ExpandScope(ScopeApplicationConnect)),
730730
}
731731

732732
testAuthorize(t, "Admin_ScopeApplicationConnect", user,
@@ -760,7 +760,7 @@ func TestAuthorizeScope(t *testing.T) {
760760
must(RoleByName(RoleMember())),
761761
must(RoleByName(RoleOrgMember(defOrg))),
762762
},
763-
Scope: must(ScopeRole(ScopeApplicationConnect)),
763+
Scope: must(ExpandScope(ScopeApplicationConnect)),
764764
}
765765

766766
testAuthorize(t, "User_ScopeApplicationConnect", user,

coderd/rbac/input.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"action": "never-match-action",
33
"object": {
4+
"id": "00000000-0000-0000-0000-000000000000",
45
"owner": "00000000-0000-0000-0000-000000000000",
56
"org_owner": "bf7b72bd-a2b1-4ef2-962c-1d698e0483f6",
67
"type": "workspace",
@@ -16,6 +17,7 @@
1617
"scope": {
1718
"name": "Scope_all",
1819
"display_name": "All operations",
20+
"allow_list": ["123", "*"],
1921
"site": [
2022
{
2123
"negate": false,

coderd/rbac/object.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ var (
158158
// that represents the set of workspaces you are trying to get access too.
159159
// Do not export this type, as it can be created from a resource type constant.
160160
type Object struct {
161+
// ID is the resource's uuid
162+
ID string `json:"id"`
161163
Owner string `json:"owner"`
162164
// OrgID specifies which org the object is a part of.
163165
OrgID string `json:"org_owner"`
@@ -184,9 +186,21 @@ func (z Object) All() Object {
184186
}
185187
}
186188

189+
func (z Object) WithID(id uuid.UUID) Object {
190+
return Object{
191+
ID: id.String(),
192+
Owner: z.Owner,
193+
OrgID: z.OrgID,
194+
Type: z.Type,
195+
ACLUserList: z.ACLUserList,
196+
ACLGroupList: z.ACLGroupList,
197+
}
198+
}
199+
187200
// InOrg adds an org OwnerID to the resource
188201
func (z Object) InOrg(orgID uuid.UUID) Object {
189202
return Object{
203+
ID: z.ID,
190204
Owner: z.Owner,
191205
OrgID: orgID.String(),
192206
Type: z.Type,
@@ -198,6 +212,7 @@ func (z Object) InOrg(orgID uuid.UUID) Object {
198212
// WithOwner adds an OwnerID to the resource
199213
func (z Object) WithOwner(ownerID string) Object {
200214
return Object{
215+
ID: z.ID,
201216
Owner: ownerID,
202217
OrgID: z.OrgID,
203218
Type: z.Type,
@@ -209,6 +224,7 @@ func (z Object) WithOwner(ownerID string) Object {
209224
// WithACLUserList adds an ACL list to a given object
210225
func (z Object) WithACLUserList(acl map[string][]Action) Object {
211226
return Object{
227+
ID: z.ID,
212228
Owner: z.Owner,
213229
OrgID: z.OrgID,
214230
Type: z.Type,
@@ -219,6 +235,7 @@ func (z Object) WithACLUserList(acl map[string][]Action) Object {
219235

220236
func (z Object) WithGroupACL(groups map[string][]Action) Object {
221237
return Object{
238+
ID: z.ID,
222239
Owner: z.Owner,
223240
OrgID: z.OrgID,
224241
Type: z.Type,

coderd/rbac/partial.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, s
141141
rego.Query("data.authz.allow = true"),
142142
rego.Module("policy.rego", policy),
143143
rego.Unknowns([]string{
144+
"input.object.id",
144145
"input.object.owner",
145146
"input.object.org_owner",
146147
"input.object.acl_user_list",

coderd/rbac/policy.rego

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,23 @@ user_allow(roles) := num {
148148
num := number(allow)
149149
}
150150

151+
# Scope allow_list is a list of resource IDs explicitly allowed by the scope.
152+
# If the list is '*', then all resources are allowed.
153+
scope_allow_list {
154+
"*" in input.subject.scope.allow_list
155+
}
156+
157+
scope_allow_list {
158+
# If the wildcard is listed in the allow_list, we do not care about the
159+
# object.id. This line is included to prevent partial compliations from
160+
# ever needing to include the object.id.
161+
not "*" in input.subject.scope.allow_list
162+
input.object.id != ""
163+
input.object.id in input.subject.scope.allow_list
164+
}
165+
166+
167+
151168
# The allow block is quite simple. Any set with `-1` cascades down in levels.
152169
# Authorization looks for any `allow` statement that is true. Multiple can be true!
153170
# Note that the absence of `allow` means "unauthorized".
@@ -179,15 +196,18 @@ role_allow {
179196
}
180197

181198
scope_allow {
199+
scope_allow_list
182200
scope_site = 1
183201
}
184202

185203
scope_allow {
204+
scope_allow_list
186205
not scope_site = -1
187206
scope_org = 1
188207
}
189208

190209
scope_allow {
210+
scope_allow_list
191211
not scope_site = -1
192212
not scope_org = -1
193213
# If we are not a member of an org, and the object has an org, then we are

coderd/rbac/scopes.go

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,39 +8,52 @@ import (
88

99
type Scope string
1010

11+
// TODO: @emyrk rename this struct
12+
type ScopeRole struct {
13+
Role
14+
AllowIDList []string `json:"allow_list"`
15+
}
16+
1117
const (
1218
ScopeAll Scope = "all"
1319
ScopeApplicationConnect Scope = "application_connect"
1420
)
1521

16-
var builtinScopes map[Scope]Role = map[Scope]Role{
22+
// TODO: Support passing in scopeID list for whitelisting allowed resources.
23+
var builtinScopes map[Scope]ScopeRole = map[Scope]ScopeRole{
1724
// ScopeAll is a special scope that allows access to all resources. During
1825
// authorize checks it is usually not used directly and skips scope checks.
1926
ScopeAll: {
20-
Name: fmt.Sprintf("Scope_%s", ScopeAll),
21-
DisplayName: "All operations",
22-
Site: permissions(map[string][]Action{
23-
ResourceWildcard.Type: {WildcardSymbol},
24-
}),
25-
Org: map[string][]Permission{},
26-
User: []Permission{},
27+
Role: Role{
28+
Name: fmt.Sprintf("Scope_%s", ScopeAll),
29+
DisplayName: "All operations",
30+
Site: permissions(map[string][]Action{
31+
ResourceWildcard.Type: {WildcardSymbol},
32+
}),
33+
Org: map[string][]Permission{},
34+
User: []Permission{},
35+
},
36+
AllowIDList: []string{WildcardSymbol},
2737
},
2838

2939
ScopeApplicationConnect: {
30-
Name: fmt.Sprintf("Scope_%s", ScopeApplicationConnect),
31-
DisplayName: "Ability to connect to applications",
32-
Site: permissions(map[string][]Action{
33-
ResourceWorkspaceApplicationConnect.Type: {ActionCreate},
34-
}),
35-
Org: map[string][]Permission{},
36-
User: []Permission{},
40+
Role: Role{
41+
Name: fmt.Sprintf("Scope_%s", ScopeApplicationConnect),
42+
DisplayName: "Ability to connect to applications",
43+
Site: permissions(map[string][]Action{
44+
ResourceWorkspaceApplicationConnect.Type: {ActionCreate},
45+
}),
46+
Org: map[string][]Permission{},
47+
User: []Permission{},
48+
},
49+
AllowIDList: []string{WildcardSymbol},
3750
},
3851
}
3952

40-
func ScopeRole(scope Scope) (Role, error) {
53+
func ExpandScope(scope Scope) (ScopeRole, error) {
4154
role, ok := builtinScopes[scope]
4255
if !ok {
43-
return Role{}, xerrors.Errorf("no scope named %q", scope)
56+
return ScopeRole{}, xerrors.Errorf("no scope named %q", scope)
4457
}
4558
return role, nil
4659
}

0 commit comments

Comments
 (0)