Skip to content

Commit fb7adf5

Browse files
committed
feat: Check permissions endpoint
Allows FE to query backend for permission capabilities. Batch requests supported
1 parent 9d94f4f commit fb7adf5

File tree

5 files changed

+180
-0
lines changed

5 files changed

+180
-0
lines changed

coderd/coderd.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,10 @@ func New(options *Options) (http.Handler, func()) {
250250
r.Put("/roles", api.putUserRoles)
251251
r.Get("/roles", api.userRoles)
252252

253+
r.Route("/permissions", func(r chi.Router) {
254+
r.Post("/check", api.checkPermissions)
255+
})
256+
253257
r.Post("/keys", api.postAPIKey)
254258
r.Route("/organizations", func(r chi.Router) {
255259
r.Post("/", api.postOrganizationsByUser)

coderd/roles.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,49 @@ func (*api) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) {
2727
httpapi.Write(rw, http.StatusOK, convertRoles(roles))
2828
}
2929

30+
func (api *api) checkPermissions(rw http.ResponseWriter, r *http.Request) {
31+
roles := httpmw.UserRoles(r)
32+
user := httpmw.UserParam(r)
33+
if user.ID != roles.ID {
34+
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
35+
// TODO: @Emyrk in the future we could have an rbac check here.
36+
// If the user can masquerade/impersonate as the user passed in,
37+
// we could allow this or something like that.
38+
Message: "only allowed to check permissions on yourself",
39+
})
40+
return
41+
}
42+
43+
var params codersdk.UserPermissionCheckRequest
44+
if !httpapi.Read(rw, r, &params) {
45+
return
46+
}
47+
48+
response := make(codersdk.UserPermissionCheckResponse)
49+
for k, v := range params.Checks {
50+
if v.Object.ResourceType == "" {
51+
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
52+
Message: "'resource_type' must be defined",
53+
})
54+
return
55+
}
56+
57+
if v.Object.OwnerID == "me" {
58+
v.Object.OwnerID = roles.ID.String()
59+
}
60+
err := api.Authorizer.AuthorizeByRoleName(r.Context(), roles.ID.String(), roles.Roles, v.Action,
61+
rbac.Object{
62+
ResourceID: v.Object.ResourceID,
63+
Owner: v.Object.OwnerID,
64+
OrgID: v.Object.OrganizationID,
65+
Type: v.Object.ResourceType,
66+
})
67+
response[k] = err == nil
68+
}
69+
70+
httpapi.Write(rw, http.StatusOK, response)
71+
}
72+
3073
func convertRole(role rbac.Role) codersdk.Role {
3174
return codersdk.Role{
3275
DisplayName: role.DisplayName,

coderd/roles_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,103 @@ import (
1212
"github.com/coder/coder/codersdk"
1313
)
1414

15+
func TestPermissionCheck(t *testing.T) {
16+
t.Parallel()
17+
18+
ctx := context.Background()
19+
client := coderdtest.New(t, nil)
20+
// Create admin, member, and org admin
21+
admin := coderdtest.CreateFirstUser(t, client)
22+
member := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
23+
24+
orgAdmin := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
25+
orgAdminUser, err := orgAdmin.User(ctx, codersdk.Me)
26+
require.NoError(t, err)
27+
28+
// TODO: @emyrk switch this to the admin when getting non-personal users is
29+
// supported. `client.UpdateOrganizationMemberRoles(...)`
30+
_, err = orgAdmin.UpdateOrganizationMemberRoles(ctx, admin.OrganizationID, orgAdminUser.ID,
31+
codersdk.UpdateRoles{
32+
Roles: []string{rbac.RoleOrgMember(admin.OrganizationID), rbac.RoleOrgAdmin(admin.OrganizationID)},
33+
},
34+
)
35+
require.NoError(t, err, "update org member roles")
36+
37+
// With admin, member, and org admin
38+
const (
39+
allUsers = "read-all-users"
40+
readOrgWorkspaces = "read-org-workspaces"
41+
myself = "read-myself"
42+
myWorkspace = "read-my-workspace"
43+
)
44+
params := map[string]codersdk.UserPermissionCheck{
45+
allUsers: {
46+
Object: codersdk.UserPermissionCheckObject{
47+
ResourceType: "users",
48+
},
49+
Action: "read",
50+
},
51+
myself: {
52+
Object: codersdk.UserPermissionCheckObject{
53+
ResourceType: "users",
54+
OwnerID: "me",
55+
},
56+
Action: "read",
57+
},
58+
myWorkspace: {
59+
Object: codersdk.UserPermissionCheckObject{
60+
ResourceType: "workspaces",
61+
OwnerID: "me",
62+
},
63+
Action: "read",
64+
},
65+
readOrgWorkspaces: {
66+
Object: codersdk.UserPermissionCheckObject{
67+
ResourceType: "workspaces",
68+
OrganizationID: admin.OrganizationID.String(),
69+
},
70+
Action: "read",
71+
},
72+
}
73+
74+
testCases := []struct {
75+
Name string
76+
Client *codersdk.Client
77+
Check codersdk.UserPermissionCheckResponse
78+
}{
79+
{
80+
Name: "Admin",
81+
Client: client,
82+
Check: map[string]bool{
83+
allUsers: true, myself: true, myWorkspace: true, readOrgWorkspaces: true,
84+
},
85+
},
86+
{
87+
Name: "Member",
88+
Client: member,
89+
Check: map[string]bool{
90+
allUsers: false, myself: true, myWorkspace: true, readOrgWorkspaces: false,
91+
},
92+
},
93+
{
94+
Name: "OrgAdmin",
95+
Client: orgAdmin,
96+
Check: map[string]bool{
97+
allUsers: false, myself: true, myWorkspace: true, readOrgWorkspaces: true,
98+
},
99+
},
100+
}
101+
102+
for _, c := range testCases {
103+
c := c
104+
t.Run(c.Name, func(t *testing.T) {
105+
resp, err := c.Client.CheckPermissions(context.Background(), codersdk.UserPermissionCheckRequest{Checks: params})
106+
require.NoError(t, err, "check perms")
107+
require.Equal(t, resp, c.Check)
108+
})
109+
}
110+
}
111+
15112
func TestListRoles(t *testing.T) {
16113
t.Parallel()
17114

codersdk/roles.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,16 @@ func (c *Client) ListOrganizationRoles(ctx context.Context, org uuid.UUID) ([]Ro
4343
var roles []Role
4444
return roles, json.NewDecoder(res.Body).Decode(&roles)
4545
}
46+
47+
func (c *Client) CheckPermissions(ctx context.Context, checks UserPermissionCheckRequest) (UserPermissionCheckResponse, error) {
48+
res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/permissions/check", uuidOrMe(Me)), checks)
49+
if err != nil {
50+
return nil, err
51+
}
52+
defer res.Body.Close()
53+
if res.StatusCode != http.StatusOK {
54+
return nil, readBodyAsError(res)
55+
}
56+
var roles UserPermissionCheckResponse
57+
return roles, json.NewDecoder(res.Body).Decode(&roles)
58+
}

codersdk/users.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"time"
99

1010
"github.com/google/uuid"
11+
12+
"github.com/coder/coder/coderd/rbac"
1113
)
1214

1315
// Me is used as a replacement for your own ID.
@@ -76,6 +78,27 @@ type UserRoles struct {
7678
OrganizationRoles map[uuid.UUID][]string `json:"organization_roles"`
7779
}
7880

81+
type UserPermissionCheckObject struct {
82+
ResourceType string `json:"resource_type,omitempty"`
83+
OwnerID string `json:"owner_id,omitempty"`
84+
OrganizationID string `json:"organization_id,omitempty"`
85+
ResourceID string `json:"resource_id,omitempty"`
86+
}
87+
88+
type UserPermissionCheckResponse map[string]bool
89+
90+
// UserPermissionCheckRequest is a structure instead of a map because
91+
// go-playground/validate can only validate structs. If you attempt to pass
92+
// a map into 'httpapi.Read', you will get an invalid type error.
93+
type UserPermissionCheckRequest struct {
94+
Checks map[string]UserPermissionCheck `json:"checks"`
95+
}
96+
97+
type UserPermissionCheck struct {
98+
Object UserPermissionCheckObject `json:"object"`
99+
Action rbac.Action `json:"action"`
100+
}
101+
79102
// LoginWithPasswordRequest enables callers to authenticate with email and password.
80103
type LoginWithPasswordRequest struct {
81104
Email string `json:"email" validate:"required,email"`

0 commit comments

Comments
 (0)