Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 55680ca

Browse files
Emyrkkylecarbs
authored andcommitted
feat: Implement RBAC checks on /templates endpoints (#1678)
* feat: Generic Filter method for rbac objects
1 parent 6ef3f8a commit 55680ca

11 files changed

+221
-73
lines changed

coderd/authorize.go

+7-2
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@ import (
1212
"github.com/coder/coder/coderd/rbac"
1313
)
1414

15-
func (api *api) Authorize(rw http.ResponseWriter, r *http.Request, action rbac.Action, object rbac.Object) bool {
15+
func AuthorizeFilter[O rbac.Objecter](api *api, r *http.Request, action rbac.Action, objects []O) []O {
1616
roles := httpmw.UserRoles(r)
17-
err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object)
17+
return rbac.Filter(r.Context(), api.Authorizer, roles.ID.String(), roles.Roles, action, objects)
18+
}
19+
20+
func (api *api) Authorize(rw http.ResponseWriter, r *http.Request, action rbac.Action, object rbac.Objecter) bool {
21+
roles := httpmw.UserRoles(r)
22+
err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object.RBACObject())
1823
if err != nil {
1924
httpapi.Write(rw, http.StatusForbidden, httpapi.Response{
2025
Message: err.Error(),

coderd/coderd.go

+1
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ func newRouter(options *Options, a *api) chi.Router {
186186
r.Route("/templates/{template}", func(r chi.Router) {
187187
r.Use(
188188
apiKeyMiddleware,
189+
authRolesMiddleware,
189190
httpmw.ExtractTemplateParam(options.Database),
190191
)
191192

coderd/coderd_test.go

+18-5
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,6 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
100100

101101
"PUT:/api/v2/organizations/{organization}/members/{user}/roles": {NoAuthorize: true},
102102
"GET:/api/v2/organizations/{organization}/provisionerdaemons": {NoAuthorize: true},
103-
"POST:/api/v2/organizations/{organization}/templates": {NoAuthorize: true},
104-
"GET:/api/v2/organizations/{organization}/templates": {NoAuthorize: true},
105103
"GET:/api/v2/organizations/{organization}/templates/{templatename}": {NoAuthorize: true},
106104
"POST:/api/v2/organizations/{organization}/templateversions": {NoAuthorize: true},
107105
"POST:/api/v2/organizations/{organization}/workspaces": {NoAuthorize: true},
@@ -110,8 +108,6 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
110108
"GET:/api/v2/parameters/{scope}/{id}": {NoAuthorize: true},
111109
"DELETE:/api/v2/parameters/{scope}/{id}/{name}": {NoAuthorize: true},
112110

113-
"DELETE:/api/v2/templates/{template}": {NoAuthorize: true},
114-
"GET:/api/v2/templates/{template}": {NoAuthorize: true},
115111
"GET:/api/v2/templates/{template}/versions": {NoAuthorize: true},
116112
"PATCH:/api/v2/templates/{template}/versions": {NoAuthorize: true},
117113
"GET:/api/v2/templates/{template}/versions/{templateversionname}": {NoAuthorize: true},
@@ -185,7 +181,23 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
185181
AssertAction: rbac.ActionRead,
186182
AssertObject: workspaceRBACObj,
187183
},
188-
184+
"GET:/api/v2/organizations/{organization}/templates": {
185+
StatusCode: http.StatusOK,
186+
AssertAction: rbac.ActionRead,
187+
AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID).WithID(template.ID.String()),
188+
},
189+
"POST:/api/v2/organizations/{organization}/templates": {
190+
AssertAction: rbac.ActionCreate,
191+
AssertObject: rbac.ResourceTemplate.InOrg(organization.ID),
192+
},
193+
"DELETE:/api/v2/templates/{template}": {
194+
AssertAction: rbac.ActionDelete,
195+
AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID).WithID(template.ID.String()),
196+
},
197+
"GET:/api/v2/templates/{template}": {
198+
AssertAction: rbac.ActionRead,
199+
AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID).WithID(template.ID.String()),
200+
},
189201
"POST:/api/v2/files": {AssertAction: rbac.ActionCreate, AssertObject: rbac.ResourceFile},
190202
"GET:/api/v2/files/{fileHash}": {AssertAction: rbac.ActionRead,
191203
AssertObject: rbac.ResourceFile.WithOwner(admin.UserID.String()).WithID(file.Hash)},
@@ -226,6 +238,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
226238
route = strings.ReplaceAll(route, "{workspacebuild}", workspace.LatestBuild.ID.String())
227239
route = strings.ReplaceAll(route, "{workspacename}", workspace.Name)
228240
route = strings.ReplaceAll(route, "{workspacebuildname}", workspace.LatestBuild.Name)
241+
route = strings.ReplaceAll(route, "{template}", template.ID.String())
229242
route = strings.ReplaceAll(route, "{hash}", file.Hash)
230243

231244
resp, err := client.Request(context.Background(), method, route, nil)

coderd/database/modelmethods.go

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package database
2+
3+
import "github.com/coder/coder/coderd/rbac"
4+
5+
func (t Template) RBACObject() rbac.Object {
6+
return rbac.ResourceTemplate.InOrg(t.OrganizationID).WithID(t.ID.String())
7+
}
8+
9+
func (w Workspace) RBACObject() rbac.Object {
10+
return rbac.ResourceWorkspace.InOrg(w.OrganizationID).WithID(w.ID.String()).WithOwner(w.OwnerID.String())
11+
}
12+
13+
func (m OrganizationMember) RBACObject() rbac.Object {
14+
return rbac.ResourceOrganizationMember.InOrg(m.OrganizationID).WithID(m.UserID.String())
15+
}
16+
17+
func (o Organization) RBACObject() rbac.Object {
18+
return rbac.ResourceOrganization.InOrg(o.ID).WithID(o.ID.String())
19+
}

coderd/rbac/authz.go

+18-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package rbac
33
import (
44
"context"
55
_ "embed"
6-
76
"golang.org/x/xerrors"
87

98
"github.com/open-policy-agent/opa/rego"
@@ -13,6 +12,24 @@ type Authorizer interface {
1312
ByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error
1413
}
1514

15+
// Filter takes in a list of objects, and will filter the list removing all
16+
// the elements the subject does not have permission for.
17+
// Filter does not allocate a new slice, and will use the existing one
18+
// passed in. This can cause memory leaks if the slice is held for a prolonged
19+
// period of time.
20+
func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, action Action, objects []O) []O {
21+
filtered := make([]O, 0)
22+
23+
for i := range objects {
24+
object := objects[i]
25+
err := auth.ByRoleName(ctx, subjID, subjRoles, action, object.RBACObject())
26+
if err == nil {
27+
filtered = append(filtered, object)
28+
}
29+
}
30+
return filtered
31+
}
32+
1633
// RegoAuthorizer will use a prepared rego query for performing authorize()
1734
type RegoAuthorizer struct {
1835
query rego.PreparedEvalQuery

coderd/rbac/authz_test.go

+90-3
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"strconv"
78
"testing"
89

910
"github.com/google/uuid"
10-
11-
"golang.org/x/xerrors"
12-
1311
"github.com/stretchr/testify/require"
12+
"golang.org/x/xerrors"
1413

1514
"github.com/coder/coder/coderd/rbac"
1615
)
@@ -24,6 +23,94 @@ type subject struct {
2423
Roles []rbac.Role `json:"roles"`
2524
}
2625

26+
func TestFilter(t *testing.T) {
27+
t.Parallel()
28+
29+
objectList := make([]rbac.Object, 0)
30+
workspaceList := make([]rbac.Object, 0)
31+
fileList := make([]rbac.Object, 0)
32+
for i := 0; i < 10; i++ {
33+
idxStr := strconv.Itoa(i)
34+
workspace := rbac.ResourceWorkspace.WithID(idxStr).WithOwner("me")
35+
file := rbac.ResourceFile.WithID(idxStr).WithOwner("me")
36+
37+
workspaceList = append(workspaceList, workspace)
38+
fileList = append(fileList, file)
39+
40+
objectList = append(objectList, workspace)
41+
objectList = append(objectList, file)
42+
}
43+
44+
// copyList is to prevent tests from sharing the same slice
45+
copyList := func(list []rbac.Object) []rbac.Object {
46+
tmp := make([]rbac.Object, len(list))
47+
copy(tmp, list)
48+
return tmp
49+
}
50+
51+
testCases := []struct {
52+
Name string
53+
List []rbac.Object
54+
Expected []rbac.Object
55+
Auth func(o rbac.Object) error
56+
}{
57+
{
58+
Name: "FilterWorkspaceType",
59+
List: copyList(objectList),
60+
Expected: copyList(workspaceList),
61+
Auth: func(o rbac.Object) error {
62+
if o.Type != rbac.ResourceWorkspace.Type {
63+
return xerrors.New("only workspace")
64+
}
65+
return nil
66+
},
67+
},
68+
{
69+
Name: "FilterFileType",
70+
List: copyList(objectList),
71+
Expected: copyList(fileList),
72+
Auth: func(o rbac.Object) error {
73+
if o.Type != rbac.ResourceFile.Type {
74+
return xerrors.New("only file")
75+
}
76+
return nil
77+
},
78+
},
79+
{
80+
Name: "FilterAll",
81+
List: copyList(objectList),
82+
Expected: []rbac.Object{},
83+
Auth: func(o rbac.Object) error {
84+
return xerrors.New("always fail")
85+
},
86+
},
87+
{
88+
Name: "FilterNone",
89+
List: copyList(objectList),
90+
Expected: copyList(objectList),
91+
Auth: func(o rbac.Object) error {
92+
return nil
93+
},
94+
},
95+
}
96+
97+
for _, c := range testCases {
98+
c := c
99+
t.Run(c.Name, func(t *testing.T) {
100+
t.Parallel()
101+
authorizer := fakeAuthorizer{
102+
AuthFunc: func(_ context.Context, _ string, _ []string, _ rbac.Action, object rbac.Object) error {
103+
return c.Auth(object)
104+
},
105+
}
106+
107+
filtered := rbac.Filter(context.Background(), authorizer, "me", []string{}, rbac.ActionRead, c.List)
108+
require.ElementsMatch(t, c.Expected, filtered, "expect same list")
109+
require.Equal(t, len(c.Expected), len(filtered), "same length list")
110+
})
111+
}
112+
}
113+
27114
// TestAuthorizeDomain test the very basic roles that are commonly used.
28115
func TestAuthorizeDomain(t *testing.T) {
29116
t.Parallel()

coderd/rbac/fake_test.go

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package rbac_test
2+
3+
import (
4+
"context"
5+
6+
"github.com/coder/coder/coderd/rbac"
7+
)
8+
9+
type fakeAuthorizer struct {
10+
AuthFunc func(ctx context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error
11+
}
12+
13+
func (f fakeAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error {
14+
return f.AuthFunc(ctx, subjectID, roleNames, action, object)
15+
}

coderd/rbac/object.go

+9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import (
66

77
const WildcardSymbol = "*"
88

9+
// Objecter returns the RBAC object for itself.
10+
type Objecter interface {
11+
RBACObject() Object
12+
}
13+
914
// Resources are just typed objects. Making resources this way allows directly
1015
// passing them into an Authorize function and use the chaining api.
1116
var (
@@ -99,6 +104,10 @@ type Object struct {
99104
// TODO: SharedUsers?
100105
}
101106

107+
func (z Object) RBACObject() Object {
108+
return z
109+
}
110+
102111
// All returns an object matching all resources of the same type.
103112
func (z Object) All() Object {
104113
return Object{

coderd/templates.go

+23-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/coder/coder/coderd/database"
1414
"github.com/coder/coder/coderd/httpapi"
1515
"github.com/coder/coder/coderd/httpmw"
16+
"github.com/coder/coder/coderd/rbac"
1617
"github.com/coder/coder/codersdk"
1718
)
1819

@@ -30,6 +31,11 @@ func (api *api) template(rw http.ResponseWriter, r *http.Request) {
3031
})
3132
return
3233
}
34+
35+
if !api.Authorize(rw, r, rbac.ActionRead, template) {
36+
return
37+
}
38+
3339
count := uint32(0)
3440
if len(workspaceCounts) > 0 {
3541
count = uint32(workspaceCounts[0].Count)
@@ -40,6 +46,9 @@ func (api *api) template(rw http.ResponseWriter, r *http.Request) {
4046

4147
func (api *api) deleteTemplate(rw http.ResponseWriter, r *http.Request) {
4248
template := httpmw.TemplateParam(r)
49+
if !api.Authorize(rw, r, rbac.ActionDelete, template) {
50+
return
51+
}
4352

4453
workspaces, err := api.Database.GetWorkspacesByTemplateID(r.Context(), database.GetWorkspacesByTemplateIDParams{
4554
TemplateID: template.ID,
@@ -77,10 +86,14 @@ func (api *api) deleteTemplate(rw http.ResponseWriter, r *http.Request) {
7786
// Create a new template in an organization.
7887
func (api *api) postTemplateByOrganization(rw http.ResponseWriter, r *http.Request) {
7988
var createTemplate codersdk.CreateTemplateRequest
89+
organization := httpmw.OrganizationParam(r)
90+
if !api.Authorize(rw, r, rbac.ActionCreate, rbac.ResourceTemplate.InOrg(organization.ID)) {
91+
return
92+
}
93+
8094
if !httpapi.Read(rw, r, &createTemplate) {
8195
return
8296
}
83-
organization := httpmw.OrganizationParam(r)
8497
_, err := api.Database.GetTemplateByOrganizationAndName(r.Context(), database.GetTemplateByOrganizationAndNameParams{
8598
OrganizationID: organization.ID,
8699
Name: createTemplate.Name,
@@ -194,7 +207,12 @@ func (api *api) templatesByOrganization(rw http.ResponseWriter, r *http.Request)
194207
})
195208
return
196209
}
210+
211+
// Filter templates based on rbac permissions
212+
templates = AuthorizeFilter(api, r, rbac.ActionRead, templates)
213+
197214
templateIDs := make([]uuid.UUID, 0, len(templates))
215+
198216
for _, template := range templates {
199217
templateIDs = append(templateIDs, template.ID)
200218
}
@@ -233,6 +251,10 @@ func (api *api) templateByOrganizationAndName(rw http.ResponseWriter, r *http.Re
233251
return
234252
}
235253

254+
if !api.Authorize(rw, r, rbac.ActionRead, template) {
255+
return
256+
}
257+
236258
workspaceCounts, err := api.Database.GetWorkspaceOwnerCountsByTemplateIDs(r.Context(), []uuid.UUID{template.ID})
237259
if errors.Is(err, sql.ErrNoRows) {
238260
err = nil

0 commit comments

Comments
 (0)