Skip to content

Commit 64e408c

Browse files
authored
feat: Check permissions endpoint (#1389)
* feat: Check permissions endpoint Allows FE to query backend for permission capabilities. Batch requests supported
1 parent 75a5877 commit 64e408c

File tree

8 files changed

+281
-19
lines changed

8 files changed

+281
-19
lines changed

coderd/coderd.go

+2
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,8 @@ func New(options *Options) (http.Handler, func()) {
250250
r.Put("/roles", api.putUserRoles)
251251
r.Get("/roles", api.userRoles)
252252

253+
r.Post("/authorization", api.checkPermissions)
254+
253255
r.Post("/keys", api.postAPIKey)
254256
r.Route("/organizations", func(r chi.Router) {
255257
r.Post("/", api.postOrganizationsByUser)

coderd/coderdtest/coderdtest.go

+39-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"encoding/base64"
1414
"encoding/json"
1515
"encoding/pem"
16+
"fmt"
1617
"io"
1718
"math/big"
1819
"net"
@@ -24,6 +25,8 @@ import (
2425
"testing"
2526
"time"
2627

28+
"github.com/coder/coder/coderd/rbac"
29+
2730
"cloud.google.com/go/compute/metadata"
2831
"github.com/fullsailor/pkcs7"
2932
"github.com/golang-jwt/jwt"
@@ -212,14 +215,14 @@ func CreateFirstUser(t *testing.T, client *codersdk.Client) codersdk.CreateFirst
212215
}
213216

214217
// CreateAnotherUser creates and authenticates a new user.
215-
func CreateAnotherUser(t *testing.T, client *codersdk.Client, organizationID uuid.UUID) *codersdk.Client {
218+
func CreateAnotherUser(t *testing.T, client *codersdk.Client, organizationID uuid.UUID, roles ...string) *codersdk.Client {
216219
req := codersdk.CreateUserRequest{
217220
Email: namesgenerator.GetRandomName(1) + "@coder.com",
218221
Username: randomUsername(),
219222
Password: "testpass",
220223
OrganizationID: organizationID,
221224
}
222-
_, err := client.CreateUser(context.Background(), req)
225+
user, err := client.CreateUser(context.Background(), req)
223226
require.NoError(t, err)
224227

225228
login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
@@ -230,6 +233,40 @@ func CreateAnotherUser(t *testing.T, client *codersdk.Client, organizationID uui
230233

231234
other := codersdk.New(client.URL)
232235
other.SessionToken = login.SessionToken
236+
237+
if len(roles) > 0 {
238+
// Find the roles for the org vs the site wide roles
239+
orgRoles := make(map[string][]string)
240+
var siteRoles []string
241+
242+
for _, roleName := range roles {
243+
roleName := roleName
244+
orgID, ok := rbac.IsOrgRole(roleName)
245+
if ok {
246+
orgRoles[orgID] = append(orgRoles[orgID], roleName)
247+
} else {
248+
siteRoles = append(siteRoles, roleName)
249+
}
250+
}
251+
// Update the roles
252+
for _, r := range user.Roles {
253+
siteRoles = append(siteRoles, r.Name)
254+
}
255+
// TODO: @emyrk switch "other" to "client" when we support updating other
256+
// users.
257+
_, err := other.UpdateUserRoles(context.Background(), user.ID, codersdk.UpdateRoles{Roles: siteRoles})
258+
require.NoError(t, err, "update site roles")
259+
260+
// Update org roles
261+
for orgID, roles := range orgRoles {
262+
organizationID, err := uuid.Parse(orgID)
263+
require.NoError(t, err, fmt.Sprintf("parse org id %q", orgID))
264+
// TODO: @Emyrk add the member to the organization if they do not already belong.
265+
_, err = other.UpdateOrganizationMemberRoles(context.Background(), organizationID, user.ID,
266+
codersdk.UpdateRoles{Roles: append(roles, rbac.RoleOrgMember(organizationID))})
267+
require.NoError(t, err, "update org membership roles")
268+
}
269+
}
233270
return other
234271
}
235272

coderd/roles.go

+43
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, rbac.Action(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

+85-12
Original file line numberDiff line numberDiff line change
@@ -12,27 +12,100 @@ import (
1212
"github.com/coder/coder/codersdk"
1313
)
1414

15-
func TestListRoles(t *testing.T) {
15+
func TestPermissionCheck(t *testing.T) {
1616
t.Parallel()
1717

18-
ctx := context.Background()
1918
client := coderdtest.New(t, nil)
2019
// Create admin, member, and org admin
2120
admin := coderdtest.CreateFirstUser(t, client)
2221
member := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
22+
orgAdmin := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, rbac.RoleOrgAdmin(admin.OrganizationID))
2323

24-
orgAdmin := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
25-
orgAdminUser, err := orgAdmin.User(ctx, codersdk.Me)
26-
require.NoError(t, err)
24+
// With admin, member, and org admin
25+
const (
26+
allUsers = "read-all-users"
27+
readOrgWorkspaces = "read-org-workspaces"
28+
myself = "read-myself"
29+
myWorkspace = "read-my-workspace"
30+
)
31+
params := map[string]codersdk.UserPermissionCheck{
32+
allUsers: {
33+
Object: codersdk.UserPermissionCheckObject{
34+
ResourceType: "users",
35+
},
36+
Action: "read",
37+
},
38+
myself: {
39+
Object: codersdk.UserPermissionCheckObject{
40+
ResourceType: "users",
41+
OwnerID: "me",
42+
},
43+
Action: "read",
44+
},
45+
myWorkspace: {
46+
Object: codersdk.UserPermissionCheckObject{
47+
ResourceType: "workspaces",
48+
OwnerID: "me",
49+
},
50+
Action: "read",
51+
},
52+
readOrgWorkspaces: {
53+
Object: codersdk.UserPermissionCheckObject{
54+
ResourceType: "workspaces",
55+
OrganizationID: admin.OrganizationID.String(),
56+
},
57+
Action: "read",
58+
},
59+
}
2760

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)},
61+
testCases := []struct {
62+
Name string
63+
Client *codersdk.Client
64+
Check codersdk.UserPermissionCheckResponse
65+
}{
66+
{
67+
Name: "Admin",
68+
Client: client,
69+
Check: map[string]bool{
70+
allUsers: true, myself: true, myWorkspace: true, readOrgWorkspaces: true,
71+
},
3372
},
34-
)
35-
require.NoError(t, err, "update org member roles")
73+
{
74+
Name: "Member",
75+
Client: member,
76+
Check: map[string]bool{
77+
allUsers: false, myself: true, myWorkspace: true, readOrgWorkspaces: false,
78+
},
79+
},
80+
{
81+
Name: "OrgAdmin",
82+
Client: orgAdmin,
83+
Check: map[string]bool{
84+
allUsers: false, myself: true, myWorkspace: true, readOrgWorkspaces: true,
85+
},
86+
},
87+
}
88+
89+
for _, c := range testCases {
90+
c := c
91+
t.Run(c.Name, func(t *testing.T) {
92+
t.Parallel()
93+
resp, err := c.Client.CheckPermissions(context.Background(), codersdk.UserPermissionCheckRequest{Checks: params})
94+
require.NoError(t, err, "check perms")
95+
require.Equal(t, resp, c.Check)
96+
})
97+
}
98+
}
99+
100+
func TestListRoles(t *testing.T) {
101+
t.Parallel()
102+
103+
ctx := context.Background()
104+
client := coderdtest.New(t, nil)
105+
// Create admin, member, and org admin
106+
admin := coderdtest.CreateFirstUser(t, client)
107+
member := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
108+
orgAdmin := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, rbac.RoleOrgAdmin(admin.OrganizationID))
36109

37110
otherOrg, err := client.CreateOrganization(ctx, admin.UserID, codersdk.CreateOrganizationRequest{
38111
Name: "other",

codersdk/roles.go

+13
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/authorization", 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

+50
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,56 @@ type UserRoles struct {
7676
OrganizationRoles map[uuid.UUID][]string `json:"organization_roles"`
7777
}
7878

79+
type UserPermissionCheckResponse map[string]bool
80+
81+
// UserPermissionCheckRequest is a structure instead of a map because
82+
// go-playground/validate can only validate structs. If you attempt to pass
83+
// a map into 'httpapi.Read', you will get an invalid type error.
84+
type UserPermissionCheckRequest struct {
85+
// Checks is a map keyed with an arbitrary string to a permission check.
86+
// The key can be any string that is helpful to the caller, and allows
87+
// multiple permission checks to be run in a single request.
88+
// The key ensures that each permission check has the same key in the
89+
// response.
90+
Checks map[string]UserPermissionCheck `json:"checks"`
91+
}
92+
93+
// UserPermissionCheck is used to check if a user can do a given action
94+
// to a given set of objects.
95+
type UserPermissionCheck struct {
96+
// Object can represent a "set" of objects, such as:
97+
// - All workspaces in an organization
98+
// - All workspaces owned by me
99+
// - All workspaces across the entire product
100+
// When defining an object, use the most specific language when possible to
101+
// produce the smallest set. Meaning to set as many fields on 'Object' as
102+
// you can. Example, if you want to check if you can update all workspaces
103+
// owned by 'me', try to also add an 'OrganizationID' to the settings.
104+
// Omitting the 'OrganizationID' could produce the incorrect value, as
105+
// workspaces have both `user` and `organization` owners.
106+
Object UserPermissionCheckObject `json:"object"`
107+
// Action can be 'create', 'read', 'update', or 'delete'
108+
Action string `json:"action"`
109+
}
110+
111+
type UserPermissionCheckObject struct {
112+
// ResourceType is the name of the resource.
113+
// './coderd/rbac/object.go' has the list of valid resource types.
114+
ResourceType string `json:"resource_type"`
115+
// OwnerID (optional) is a user_id. It adds the set constraint to all resources owned
116+
// by a given user.
117+
OwnerID string `json:"owner_id,omitempty"`
118+
// OrganizationID (optional) is an organization_id. It adds the set constraint to
119+
// all resources owned by a given organization.
120+
OrganizationID string `json:"organization_id,omitempty"`
121+
// ResourceID (optional) reduces the set to a singular resource. This assigns
122+
// a resource ID to the resource type, eg: a single workspace.
123+
// The rbac library will not fetch the resource from the database, so if you
124+
// are using this option, you should also set the 'OwnerID' and 'OrganizationID'
125+
// if possible. Be as specific as possible using all the fields relevant.
126+
ResourceID string `json:"resource_id,omitempty"`
127+
}
128+
79129
// LoginWithPasswordRequest enables callers to authenticate with email and password.
80130
type LoginWithPasswordRequest struct {
81131
Email string `json:"email" validate:"required,email"`

scripts/apitypings/main.go

+22
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,28 @@ func (g *Generator) generateAll() (*TypescriptTypes, error) {
183183
// type <Name> string
184184
// These are enums. Store to expand later.
185185
enums[obj.Name()] = obj
186+
case *types.Map:
187+
// Declared maps that are not structs are still valid codersdk objects.
188+
// Handle them custom by calling 'typescriptType' directly instead of
189+
// iterating through each struct field.
190+
// These types support no json/typescript tags.
191+
// These are **NOT** enums, as a map in Go would never be used for an enum.
192+
ts, err := g.typescriptType(obj.Type().Underlying())
193+
if err != nil {
194+
return nil, xerrors.Errorf("(map) generate %q: %w", obj.Name(), err)
195+
}
196+
197+
var str strings.Builder
198+
_, _ = str.WriteString(g.posLine(obj))
199+
if ts.AboveTypeLine != "" {
200+
str.WriteString(ts.AboveTypeLine)
201+
str.WriteRune('\n')
202+
}
203+
// Use similar output syntax to enums.
204+
str.WriteString(fmt.Sprintf("export type %s = %s\n", obj.Name(), ts.ValueType))
205+
structs[obj.Name()] = str.String()
206+
case *types.Array, *types.Slice:
207+
// TODO: @emyrk if you need this, follow the same design as "*types.Map" case.
186208
}
187209
case *types.Var:
188210
// TODO: Are any enums var declarations? This is also codersdk.Me.

0 commit comments

Comments
 (0)