Skip to content

Commit f620ebf

Browse files
committed
feat: Add rego policy implementation
1 parent fced411 commit f620ebf

File tree

10 files changed

+466
-245
lines changed

10 files changed

+466
-245
lines changed

coderd/authz/authz.go

Lines changed: 9 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,19 @@
11
package authz
22

3-
import "golang.org/x/xerrors"
3+
import (
4+
"context"
5+
"golang.org/x/xerrors"
6+
)
47

58
var ErrUnauthorized = xerrors.New("unauthorized")
69

710
// TODO: Implement Authorize. This will be implmented in mainly rego.
8-
func Authorize(subj Subject, obj Object, action Action) error {
9-
// TODO: Expand subject roles into their permissions as appropriate. Apply scopes.
10-
var _, _, _ = subj, obj, action
11-
roles, err := subj.GetRoles()
11+
func Authorize(ctx context.Context, subjID string, roles []Role, obj Object, action Action) error {
12+
// TODO: Cache authorizer
13+
authorizer, err := newAuthorizer()
1214
if err != nil {
13-
return ErrUnauthorized
15+
return ForbiddenWithInternal(xerrors.Errorf("new authorizer: %w", err), nil)
1416
}
1517

16-
// Merge before we send to rego to optimize the json payload.
17-
// TODO: Benchmark the rego, it might be ok to just send everything and let
18-
// rego handle it. The number of roles will be small, so it might not
19-
// matter. This code exists just to show how you can merge the roles
20-
// into a single one for evaluation if need be.
21-
// If done in rego, the roles will not be merged, and just walked over
22-
// 1 by 1.
23-
var merged Role
24-
for _, r := range roles {
25-
// Site, Org, and User permissions exist on every role. Pull out only the permissions that
26-
// are relevant to the object.
27-
28-
merged.Site = append(merged.Site, r.Site...)
29-
// Only grab user roles if the resource is owned by a user.
30-
// These roles only apply if the subject is said owner.
31-
if obj.Owner != "" && obj.Owner == subj.ID() {
32-
merged.User = append(merged.User, r.User...)
33-
}
34-
35-
// Grab org roles if the resource is owned by a given organization.
36-
if obj.OrgID != "" {
37-
orgID := obj.OrgID
38-
if v, ok := r.Org[orgID]; ok {
39-
merged.Org[orgID] = append(merged.Org[orgID], v...)
40-
}
41-
}
42-
}
43-
44-
// TODO: Send to rego policy evaluation.
45-
return nil
18+
return authorizer.Authorize(ctx, subjID, roles, obj, action)
4619
}

coderd/authz/authz_test.go

Lines changed: 174 additions & 100 deletions
Large diffs are not rendered by default.

coderd/authz/error.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package authz
2+
3+
const (
4+
// UnauthorizedErrorMessage is the error message that should be returned to
5+
// clients when an action is forbidden. It is intentionally vague to prevent
6+
// disclosing information that a client should not have access to.
7+
UnauthorizedErrorMessage = "unauthorized"
8+
)
9+
10+
// Unauthorized is the error type for authorization errors
11+
type Unauthorized struct {
12+
// internal is the internal error that should never be shown to the client.
13+
// It is only for debugging purposes.
14+
internal error
15+
input map[string]interface{}
16+
}
17+
18+
// ForbiddenWithInternal creates a new error that will return a simple
19+
// "forbidden" to the client, logging internally the more detailed message
20+
// provided.
21+
func ForbiddenWithInternal(internal error, input map[string]interface{}) *Unauthorized {
22+
if input == nil {
23+
input = map[string]interface{}{}
24+
}
25+
return &Unauthorized{
26+
internal: internal,
27+
input: input,
28+
}
29+
}
30+
31+
// Error implements the error interface.
32+
func (e *Unauthorized) Error() string {
33+
return UnauthorizedErrorMessage
34+
}
35+
36+
// Internal allows the internal error message to be logged.
37+
func (e *Unauthorized) Internal() error {
38+
return e.internal
39+
}
40+
41+
func (e *Unauthorized) Input() map[string]interface{} {
42+
return e.input
43+
}

coderd/authz/example_test.go

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package authz_test
22

33
import (
4+
"context"
45
"testing"
56

67
"github.com/coder/coder/coderd/authz"
@@ -12,10 +13,11 @@ import (
1213
func TestExample(t *testing.T) {
1314
t.Skip("TODO: unskip when rego is done")
1415
t.Parallel()
16+
ctx := context.Background()
1517

1618
// user will become an authn object, and can even be a database.User if it
1719
// fulfills the interface. Until then, use a placeholder.
18-
user := authz.SubjectTODO{
20+
user := subject{
1921
UserID: "alice",
2022
Roles: []authz.Role{
2123
authz.RoleOrgAdmin("default"),
@@ -28,32 +30,25 @@ func TestExample(t *testing.T) {
2830
//nolint:paralleltest
2931
t.Run("ReadAllWorkspaces", func(t *testing.T) {
3032
// To read all workspaces on the site
31-
err := authz.Authorize(user, authz.ResourceWorkspace.All(), authz.ActionRead)
33+
err := authz.Authorize(ctx, user.UserID, user.Roles, authz.ResourceWorkspace.All(), authz.ActionRead)
3234
var _ = err
3335
// require.Error(t, err, "this user cannot read all workspaces")
3436
})
3537

3638
//nolint:paralleltest
3739
t.Run("ReadOrgWorkspaces", func(t *testing.T) {
3840
// To read all workspaces on the org 'default'
39-
err := authz.Authorize(user, authz.ResourceWorkspace.InOrg("default"), authz.ActionRead)
41+
err := authz.Authorize(ctx, user.UserID, user.Roles, authz.ResourceWorkspace.InOrg("default"), authz.ActionRead)
4042
require.NoError(t, err, "this user can read all org workspaces in 'default'")
4143
})
4244

4345
//nolint:paralleltest
4446
t.Run("ReadMyWorkspace", func(t *testing.T) {
4547
// Note 'database.Workspace' could fulfill the object interface and be passed in directly
46-
err := authz.Authorize(user, authz.ResourceWorkspace.InOrg("default").WithOwner(user.UserID), authz.ActionRead)
48+
err := authz.Authorize(ctx, user.UserID, user.Roles, authz.ResourceWorkspace.InOrg("default").WithOwner(user.UserID), authz.ActionRead)
4749
require.NoError(t, err, "this user can their workspace")
4850

49-
err = authz.Authorize(user, authz.ResourceWorkspace.InOrg("default").WithOwner(user.UserID).WithID("1234"), authz.ActionRead)
51+
err = authz.Authorize(ctx, user.UserID, user.Roles, authz.ResourceWorkspace.InOrg("default").WithOwner(user.UserID).WithID("1234"), authz.ActionRead)
5052
require.NoError(t, err, "this user can read workspace '1234'")
5153
})
52-
53-
//nolint:paralleltest
54-
t.Run("CreateNewSiteUser", func(t *testing.T) {
55-
err := authz.Authorize(user, authz.ResourceUser.All(), authz.ActionCreate)
56-
var _ = err
57-
// require.Error(t, err, "this user cannot create new users")
58-
})
5954
}

coderd/authz/object.go

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,28 @@
11
package authz
22

3-
//type Resource interface {
4-
// ID() string
5-
// ResourceType() ResourceType
6-
//
7-
// OwnerID() string
8-
// OrgOwnerID() string
9-
//}
10-
11-
//var _ Resource = (*Object)(nil)
12-
133
// Object is used to create objects for authz checks when you have none in
144
// hand to run the check on.
155
// An example is if you want to list all workspaces, you can create a Object
166
// that represents the set of workspaces you are trying to get access too.
177
// Do not export this type, as it can be created from a resource type constant.
188
type Object struct {
19-
ID string `json:"id"`
20-
Owner string `json:"owner"`
9+
ResourceID string `json:"id"`
10+
Owner string `json:"owner"`
2111
// OrgID specifies which org the object is a part of.
2212
OrgID string `json:"org_owner"`
2313

24-
// ObjectType is "workspace", "project", "devurl", etc
25-
ObjectType ResourceType `json:"object_type"`
14+
// Type is "workspace", "project", "devurl", etc
15+
Type string `json:"type"`
2616
// TODO: SharedUsers?
2717
}
2818

19+
func (z Object) All() Object {
20+
z.OrgID = ""
21+
z.Owner = ""
22+
z.ResourceID = ""
23+
return z
24+
}
25+
2926
// InOrg adds an org OwnerID to the resource
3027
//nolint:revive
3128
func (z Object) InOrg(orgID string) Object {
@@ -42,6 +39,6 @@ func (z Object) WithOwner(id string) Object {
4239

4340
//nolint:revive
4441
func (z Object) WithID(id string) Object {
45-
z.ID = id
42+
z.ResourceID = id
4643
return z
4744
}

coderd/authz/policy.rego

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package authz
2+
import future.keywords.in
3+
import future.keywords.every
4+
5+
# https://play.openpolicyagent.org/p/Jlzq5gIjkd
6+
# grant_allow: https://play.openpolicyagent.org/p/9wpE9x6Tg2
7+
8+
# bool_flip lets you assign a value to an inverted bool.
9+
# You cannot do 'x := !false', but you can do 'x := bool_flip(false)'
10+
bool_flip(b) = flipped {
11+
b
12+
flipped = false
13+
}
14+
15+
bool_flip(b) = flipped {
16+
not b
17+
flipped = true
18+
}
19+
20+
# perms_grant returns a set of boolean values (true, false).
21+
# It will only return `bool_flip(perm.negate)` for permissions that affect a given
22+
# resource_type, resource_id, and action.
23+
# The empty set is returned if no relevant permissions are found.
24+
perms_grant(permissions) = grants {
25+
# If there are no permissions, this value is the empty set {}.
26+
grants := { x |
27+
# All permissions ...
28+
perm := permissions[_]
29+
# Such that the permission action, type, and resource_id matches
30+
perm.action in [input.action, "*"]
31+
perm.resource_type in [input.object.type, "*"]
32+
perm.resource_id in [input.object.id, "*"]
33+
x := bool_flip(perm.negate)
34+
}
35+
}
36+
37+
# Site & User are both very simple. We default both to the empty set '{}'. If no permissions are present, then the
38+
# result is the default value.
39+
default site = {}
40+
site = grant {
41+
# Boolean set for all site wide permissions
42+
grant = { v |
43+
v = perms_grant(input.subject.roles[_].site)[_]
44+
}
45+
}
46+
47+
default user = {}
48+
user = grant {
49+
# Only apply user permissions if the user owns the resource
50+
input.object.owner != ""
51+
input.object.owner == input.subject.id
52+
grant = { v |
53+
v = perms_grant(input.subject.roles[_].user)[_]
54+
}
55+
}
56+
57+
# Organizations are more complex. If the user has no roles that specifically indicate the org_id of the object,
58+
# then we want to block the action. This is because that means the user is not a member of the org.
59+
60+
# org_member returns the set of permissions associated with a user if the user is a member of the
61+
# organization
62+
org_member = grant {
63+
input.object.org_owner != ""
64+
grant = { v |
65+
v = perms_grant(input.subject.roles[_].org[input.object.org_owner])[_]
66+
}
67+
}
68+
69+
# If a user is not part of an organization, 'org_non_member' is set to true
70+
org_non_member {
71+
input.object.org_owner != ""
72+
# Identify if the user is in the org
73+
roles := input.subject.roles
74+
every role in roles {
75+
not role.org[input.object.org_owner]
76+
}
77+
}
78+
79+
# org is two rules that equate to the following
80+
# if !org_non_member { return org_member }
81+
# else {false}
82+
#
83+
# It is important both rules cannot be true, as the `org` rules cannot produce multiple outputs.
84+
default org = []
85+
org = set {
86+
not org_non_member
87+
set = org_member
88+
}
89+
90+
org = set {
91+
org_non_member
92+
set = {false}
93+
}
94+
95+
# The allow block is quite simple. Any set with `false` cascades down in levels.
96+
# Authorization looks for any `allow` statement that is true. Multiple can be true!
97+
98+
# site allow
99+
allow {
100+
# No site wide deny
101+
not false in site
102+
# And all permissions are positive
103+
site[_]
104+
}
105+
106+
# org allow
107+
allow {
108+
# No site or org deny
109+
not false in site
110+
not false in org
111+
# And all permissions are positive
112+
org[_]
113+
}
114+
115+
# user allow
116+
allow {
117+
# No site, org, or user deny
118+
not false in site
119+
not false in org
120+
not false in user
121+
# And all permissions are positive
122+
user[_]
123+
}

0 commit comments

Comments
 (0)