Skip to content

Commit c7ca86d

Browse files
authored
feat: Implement RBAC checks on /templates endpoints (#1678)
* feat: Generic Filter method for rbac objects
1 parent fcd610e commit c7ca86d

File tree

11 files changed

+221
-73
lines changed

11 files changed

+221
-73
lines changed

coderd/authorize.go

Lines changed: 7 additions & 2 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 18 additions & 5 deletions
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

Lines changed: 19 additions & 0 deletions
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

Lines changed: 18 additions & 1 deletion
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

Lines changed: 90 additions & 3 deletions
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

Lines changed: 15 additions & 0 deletions
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

Lines changed: 9 additions & 0 deletions
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

Lines changed: 23 additions & 1 deletion
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy