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

Skip to content

Commit 695ef05

Browse files
BrunoQuaresmakylecarbs
authored andcommitted
feat: Add permissions for links (#1407)
1 parent 11f184f commit 695ef05

File tree

16 files changed

+192
-47
lines changed

16 files changed

+192
-47
lines changed

coderd/rbac/object.go

+4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ var (
1717
Type: "template",
1818
}
1919

20+
ResourceUser = Object{
21+
Type: "user",
22+
}
23+
2024
// ResourceUserRole might be expanded later to allow more granular permissions
2125
// to modifying roles. For now, this covers all possible roles, so having this permission
2226
// allows granting/deleting **ALL** roles.

coderd/roles.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,12 @@ func (api *api) checkPermissions(rw http.ResponseWriter, r *http.Request) {
4040
return
4141
}
4242

43-
var params codersdk.UserPermissionCheckRequest
43+
var params codersdk.UserAuthorizationRequest
4444
if !httpapi.Read(rw, r, &params) {
4545
return
4646
}
4747

48-
response := make(codersdk.UserPermissionCheckResponse)
48+
response := make(codersdk.UserAuthorizationResponse)
4949
for k, v := range params.Checks {
5050
if v.Object.ResourceType == "" {
5151
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{

coderd/roles_test.go

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

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

1818
client := coderdtest.New(t, nil)
@@ -28,29 +28,29 @@ func TestPermissionCheck(t *testing.T) {
2828
myself = "read-myself"
2929
myWorkspace = "read-my-workspace"
3030
)
31-
params := map[string]codersdk.UserPermissionCheck{
31+
params := map[string]codersdk.UserAuthorization{
3232
allUsers: {
33-
Object: codersdk.UserPermissionCheckObject{
33+
Object: codersdk.UserAuthorizationObject{
3434
ResourceType: "users",
3535
},
3636
Action: "read",
3737
},
3838
myself: {
39-
Object: codersdk.UserPermissionCheckObject{
39+
Object: codersdk.UserAuthorizationObject{
4040
ResourceType: "users",
4141
OwnerID: "me",
4242
},
4343
Action: "read",
4444
},
4545
myWorkspace: {
46-
Object: codersdk.UserPermissionCheckObject{
46+
Object: codersdk.UserAuthorizationObject{
4747
ResourceType: "workspaces",
4848
OwnerID: "me",
4949
},
5050
Action: "read",
5151
},
5252
readOrgWorkspaces: {
53-
Object: codersdk.UserPermissionCheckObject{
53+
Object: codersdk.UserAuthorizationObject{
5454
ResourceType: "workspaces",
5555
OrganizationID: admin.OrganizationID.String(),
5656
},
@@ -61,7 +61,7 @@ func TestPermissionCheck(t *testing.T) {
6161
testCases := []struct {
6262
Name string
6363
Client *codersdk.Client
64-
Check codersdk.UserPermissionCheckResponse
64+
Check codersdk.UserAuthorizationResponse
6565
}{
6666
{
6767
Name: "Admin",
@@ -90,7 +90,7 @@ func TestPermissionCheck(t *testing.T) {
9090
c := c
9191
t.Run(c.Name, func(t *testing.T) {
9292
t.Parallel()
93-
resp, err := c.Client.CheckPermissions(context.Background(), codersdk.UserPermissionCheckRequest{Checks: params})
93+
resp, err := c.Client.CheckPermissions(context.Background(), codersdk.UserAuthorizationRequest{Checks: params})
9494
require.NoError(t, err, "check perms")
9595
require.Equal(t, resp, c.Check)
9696
})

codersdk/roles.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func (c *Client) ListOrganizationRoles(ctx context.Context, org uuid.UUID) ([]Ro
4444
return roles, json.NewDecoder(res.Body).Decode(&roles)
4545
}
4646

47-
func (c *Client) CheckPermissions(ctx context.Context, checks UserPermissionCheckRequest) (UserPermissionCheckResponse, error) {
47+
func (c *Client) CheckPermissions(ctx context.Context, checks UserAuthorizationRequest) (UserAuthorizationResponse, error) {
4848
res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/authorization", uuidOrMe(Me)), checks)
4949
if err != nil {
5050
return nil, err
@@ -53,6 +53,6 @@ func (c *Client) CheckPermissions(ctx context.Context, checks UserPermissionChec
5353
if res.StatusCode != http.StatusOK {
5454
return nil, readBodyAsError(res)
5555
}
56-
var roles UserPermissionCheckResponse
56+
var roles UserAuthorizationResponse
5757
return roles, json.NewDecoder(res.Body).Decode(&roles)
5858
}

codersdk/users.go

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

79-
type UserPermissionCheckResponse map[string]bool
79+
type UserAuthorizationResponse map[string]bool
8080

81-
// UserPermissionCheckRequest is a structure instead of a map because
81+
// UserAuthorizationRequest is a structure instead of a map because
8282
// go-playground/validate can only validate structs. If you attempt to pass
8383
// a map into 'httpapi.Read', you will get an invalid type error.
84-
type UserPermissionCheckRequest struct {
84+
type UserAuthorizationRequest struct {
8585
// Checks is a map keyed with an arbitrary string to a permission check.
8686
// The key can be any string that is helpful to the caller, and allows
8787
// multiple permission checks to be run in a single request.
8888
// The key ensures that each permission check has the same key in the
8989
// response.
90-
Checks map[string]UserPermissionCheck `json:"checks"`
90+
Checks map[string]UserAuthorization `json:"checks"`
9191
}
9292

93-
// UserPermissionCheck is used to check if a user can do a given action
93+
// UserAuthorization is used to check if a user can do a given action
9494
// to a given set of objects.
95-
type UserPermissionCheck struct {
95+
type UserAuthorization struct {
9696
// Object can represent a "set" of objects, such as:
9797
// - All workspaces in an organization
9898
// - All workspaces owned by me
@@ -103,12 +103,12 @@ type UserPermissionCheck struct {
103103
// owned by 'me', try to also add an 'OrganizationID' to the settings.
104104
// Omitting the 'OrganizationID' could produce the incorrect value, as
105105
// workspaces have both `user` and `organization` owners.
106-
Object UserPermissionCheckObject `json:"object"`
106+
Object UserAuthorizationObject `json:"object"`
107107
// Action can be 'create', 'read', 'update', or 'delete'
108108
Action string `json:"action"`
109109
}
110110

111-
type UserPermissionCheckObject struct {
111+
type UserAuthorizationObject struct {
112112
// ResourceType is the name of the resource.
113113
// './coderd/rbac/object.go' has the list of valid resource types.
114114
ResourceType string `json:"resource_type"`

site/src/api/api.test.ts

+2-11
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,20 @@ import axios from "axios"
22
import { getApiKey, login, logout } from "./api"
33
import * as TypesGen from "./typesGenerated"
44

5-
// Mock the axios module so that no real network requests are made, but rather
6-
// we swap in a resolved or rejected value
7-
//
8-
// See: https://jestjs.io/docs/mock-functions#mocking-modules
9-
jest.mock("axios")
10-
115
describe("api.ts", () => {
126
describe("login", () => {
137
it("should return LoginResponse", async () => {
148
// given
159
const loginResponse: TypesGen.LoginWithPasswordResponse = {
1610
session_token: "abc_123_test",
1711
}
18-
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
19-
return Promise.resolve({ data: loginResponse })
20-
})
21-
axios.post = axiosMockPost
12+
jest.spyOn(axios, "post").mockResolvedValueOnce({ data: loginResponse })
2213

2314
// when
2415
const result = await login("test", "123")
2516

2617
// then
27-
expect(axiosMockPost).toHaveBeenCalled()
18+
expect(axios.post).toHaveBeenCalled()
2819
expect(result).toStrictEqual(loginResponse)
2920
})
3021

site/src/api/api.ts

+8
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ export const getAuthMethods = async (): Promise<TypesGen.AuthMethods> => {
7676
return response.data
7777
}
7878

79+
export const checkUserPermissions = async (
80+
userId: string,
81+
params: TypesGen.UserAuthorizationRequest,
82+
): Promise<TypesGen.UserAuthorizationResponse> => {
83+
const response = await axios.post<TypesGen.UserAuthorizationResponse>(`/api/v2/users/${userId}/authorization`, params)
84+
return response.data
85+
}
86+
7987
export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => {
8088
const response = await axios.post<TypesGen.GenerateAPIKeyResponse>("/api/v2/users/me/keys")
8189
return response.data

site/src/api/typesGenerated.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -316,26 +316,26 @@ export interface User {
316316
}
317317

318318
// From codersdk/users.go:95:6
319-
export interface UserPermissionCheck {
320-
readonly object: UserPermissionCheckObject
319+
export interface UserAuthorization {
320+
readonly object: UserAuthorizationObject
321321
readonly action: string
322322
}
323323

324324
// From codersdk/users.go:111:6
325-
export interface UserPermissionCheckObject {
325+
export interface UserAuthorizationObject {
326326
readonly resource_type: string
327327
readonly owner_id?: string
328328
readonly organization_id?: string
329329
readonly resource_id?: string
330330
}
331331

332332
// From codersdk/users.go:84:6
333-
export interface UserPermissionCheckRequest {
334-
readonly checks: Record<string, UserPermissionCheck>
333+
export interface UserAuthorizationRequest {
334+
readonly checks: Record<string, UserAuthorization>
335335
}
336336

337337
// From codersdk/users.go:79:6
338-
export type UserPermissionCheckResponse = Record<string, boolean>
338+
export type UserAuthorizationResponse = Record<string, boolean>
339339

340340
// From codersdk/users.go:74:6
341341
export interface UserRoles {
+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { screen, waitFor } from "@testing-library/react"
2+
import React from "react"
3+
import * as API from "../../api/api"
4+
import { renderWithAuth } from "../../testHelpers/renderHelpers"
5+
import { checks } from "../../xServices/auth/authXService"
6+
import { Language as AdminDropdownLanguage } from "../AdminDropdown/AdminDropdown"
7+
import { Navbar } from "./Navbar"
8+
9+
beforeEach(() => {
10+
jest.resetAllMocks()
11+
})
12+
13+
describe("Navbar", () => {
14+
describe("when user has permission to read all users", () => {
15+
it("displays the admin menu", async () => {
16+
const checkUserPermissionsSpy = jest.spyOn(API, "checkUserPermissions").mockResolvedValueOnce({
17+
[checks.readAllUsers]: true,
18+
})
19+
20+
renderWithAuth(<Navbar />)
21+
22+
// Wait for the request is done
23+
await waitFor(() => expect(checkUserPermissionsSpy).toBeCalledTimes(1))
24+
await screen.findByRole("button", { name: AdminDropdownLanguage.menuTitle })
25+
})
26+
})
27+
28+
describe("when user has NO permission to read all users", () => {
29+
it("does not display the admin menu", async () => {
30+
const checkUserPermissionsSpy = jest.spyOn(API, "checkUserPermissions").mockResolvedValueOnce({
31+
[checks.readAllUsers]: false,
32+
})
33+
renderWithAuth(<Navbar />)
34+
35+
// Wait for the request is done
36+
await waitFor(() => expect(checkUserPermissionsSpy).toBeCalledTimes(1))
37+
expect(screen.queryByRole("button", { name: AdminDropdownLanguage.menuTitle })).not.toBeInTheDocument()
38+
})
39+
})
40+
})

site/src/components/Navbar/Navbar.tsx

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1-
import { useActor } from "@xstate/react"
1+
import { useActor, useSelector } from "@xstate/react"
22
import React, { useContext } from "react"
3+
import { selectPermissions } from "../../xServices/auth/authSelectors"
34
import { XServiceContext } from "../../xServices/StateContext"
45
import { NavbarView } from "../NavbarView/NavbarView"
56

67
export const Navbar: React.FC = () => {
78
const xServices = useContext(XServiceContext)
89
const [authState, authSend] = useActor(xServices.authXService)
910
const { me } = authState.context
11+
const permissions = useSelector(xServices.authXService, selectPermissions)
12+
// When we have more options in the admin dropdown we may want to check this
13+
// for more permissions
14+
const displayAdminDropdown = !!permissions?.readAllUsers
1015
const onSignOut = () => authSend("SIGN_OUT")
1116

12-
return <NavbarView user={me} onSignOut={onSignOut} />
17+
return <NavbarView user={me} onSignOut={onSignOut} displayAdminDropdown={displayAdminDropdown} />
1318
}

site/src/components/NavbarView/NavbarView.stories.tsx

+20-2
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,33 @@ const Template: Story<NavbarViewProps> = (args: NavbarViewProps) => <NavbarView
1414

1515
export const ForAdmin = Template.bind({})
1616
ForAdmin.args = {
17-
user: { id: "1", username: "Administrator", email: "[email protected]", created_at: "dawn" },
17+
user: {
18+
id: "1",
19+
username: "Administrator",
20+
21+
created_at: "dawn",
22+
status: "active",
23+
organization_ids: [],
24+
roles: [],
25+
},
26+
displayAdminDropdown: true,
1827
onSignOut: () => {
1928
return Promise.resolve()
2029
},
2130
}
2231

2332
export const ForMember = Template.bind({})
2433
ForMember.args = {
25-
user: { id: "1", username: "CathyCoder", email: "[email protected]", created_at: "dawn" },
34+
user: {
35+
id: "1",
36+
username: "CathyCoder",
37+
38+
created_at: "dawn",
39+
status: "active",
40+
organization_ids: [],
41+
roles: [],
42+
},
43+
displayAdminDropdown: false,
2644
onSignOut: () => {
2745
return Promise.resolve()
2846
},

site/src/components/NavbarView/NavbarView.test.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ describe("NavbarView", () => {
1010
}
1111
it("renders content", async () => {
1212
// When
13-
render(<NavbarView user={MockUser} onSignOut={noop} />)
13+
render(<NavbarView user={MockUser} onSignOut={noop} displayAdminDropdown />)
1414

1515
// Then
1616
await screen.findAllByText("Coder", { exact: false })
@@ -24,7 +24,7 @@ describe("NavbarView", () => {
2424
}
2525

2626
// When
27-
render(<NavbarView user={mockUser} onSignOut={noop} />)
27+
render(<NavbarView user={mockUser} onSignOut={noop} displayAdminDropdown />)
2828

2929
// Then
3030
// There should be a 'B' avatar!

site/src/components/NavbarView/NavbarView.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import { UserDropdown } from "../UserDropdown/UsersDropdown"
1212
export interface NavbarViewProps {
1313
user?: TypesGen.User
1414
onSignOut: () => void
15+
displayAdminDropdown: boolean
1516
}
1617

17-
export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut }) => {
18+
export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut, displayAdminDropdown }) => {
1819
const styles = useStyles()
1920
return (
2021
<nav className={styles.root}>
@@ -31,7 +32,7 @@ export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut }) => {
3132
</ListItem>
3233
</List>
3334
<div className={styles.fullWidth} />
34-
{user && user.email === "[email protected]" && <AdminDropdown />}
35+
{displayAdminDropdown && <AdminDropdown />}
3536
<div className={styles.fixed}>{user && <UserDropdown user={user} onSignOut={onSignOut} />}</div>
3637
</nav>
3738
)

site/src/testHelpers/handlers.ts

+12
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { rest } from "msw"
2+
import { permissionsToCheck } from "../xServices/auth/authXService"
23
import * as M from "./entities"
34

45
export const handlers = [
@@ -54,6 +55,17 @@ export const handlers = [
5455
rest.get("/api/v2/users/roles", async (req, res, ctx) => {
5556
return res(ctx.status(200), ctx.json(M.MockSiteRoles))
5657
}),
58+
rest.post("/api/v2/users/:userId/authorization", async (req, res, ctx) => {
59+
const permissions = Object.keys(permissionsToCheck)
60+
const response = permissions.reduce((obj, permission) => {
61+
return {
62+
...obj,
63+
[permission]: true,
64+
}
65+
}, {})
66+
67+
return res(ctx.status(200), ctx.json(response))
68+
}),
5769

5870
// workspaces
5971
rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => {

0 commit comments

Comments
 (0)