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

Skip to content

feat: Add permissions for links #1407

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions coderd/rbac/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ var (
Type: "template",
}

ResourceUser = Object{
Type: "user",
}

// ResourceUserRole might be expanded later to allow more granular permissions
// to modifying roles. For now, this covers all possible roles, so having this permission
// allows granting/deleting **ALL** roles.
Expand Down
4 changes: 2 additions & 2 deletions coderd/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ func (api *api) checkPermissions(rw http.ResponseWriter, r *http.Request) {
return
}

var params codersdk.UserPermissionCheckRequest
var params codersdk.UserAuthorizationRequest
if !httpapi.Read(rw, r, &params) {
return
}

response := make(codersdk.UserPermissionCheckResponse)
response := make(codersdk.UserAuthorizationResponse)
for k, v := range params.Checks {
if v.Object.ResourceType == "" {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Expand Down
16 changes: 8 additions & 8 deletions coderd/roles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"github.com/coder/coder/codersdk"
)

func TestPermissionCheck(t *testing.T) {
func TestAuthorization(t *testing.T) {
t.Parallel()

client := coderdtest.New(t, nil)
Expand All @@ -28,29 +28,29 @@ func TestPermissionCheck(t *testing.T) {
myself = "read-myself"
myWorkspace = "read-my-workspace"
)
params := map[string]codersdk.UserPermissionCheck{
params := map[string]codersdk.UserAuthorization{
allUsers: {
Object: codersdk.UserPermissionCheckObject{
Object: codersdk.UserAuthorizationObject{
ResourceType: "users",
},
Action: "read",
},
myself: {
Object: codersdk.UserPermissionCheckObject{
Object: codersdk.UserAuthorizationObject{
ResourceType: "users",
OwnerID: "me",
},
Action: "read",
},
myWorkspace: {
Object: codersdk.UserPermissionCheckObject{
Object: codersdk.UserAuthorizationObject{
ResourceType: "workspaces",
OwnerID: "me",
},
Action: "read",
},
readOrgWorkspaces: {
Object: codersdk.UserPermissionCheckObject{
Object: codersdk.UserAuthorizationObject{
ResourceType: "workspaces",
OrganizationID: admin.OrganizationID.String(),
},
Expand All @@ -61,7 +61,7 @@ func TestPermissionCheck(t *testing.T) {
testCases := []struct {
Name string
Client *codersdk.Client
Check codersdk.UserPermissionCheckResponse
Check codersdk.UserAuthorizationResponse
}{
{
Name: "Admin",
Expand Down Expand Up @@ -90,7 +90,7 @@ func TestPermissionCheck(t *testing.T) {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
resp, err := c.Client.CheckPermissions(context.Background(), codersdk.UserPermissionCheckRequest{Checks: params})
resp, err := c.Client.CheckPermissions(context.Background(), codersdk.UserAuthorizationRequest{Checks: params})
require.NoError(t, err, "check perms")
require.Equal(t, resp, c.Check)
})
Expand Down
4 changes: 2 additions & 2 deletions codersdk/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func (c *Client) ListOrganizationRoles(ctx context.Context, org uuid.UUID) ([]Ro
return roles, json.NewDecoder(res.Body).Decode(&roles)
}

func (c *Client) CheckPermissions(ctx context.Context, checks UserPermissionCheckRequest) (UserPermissionCheckResponse, error) {
func (c *Client) CheckPermissions(ctx context.Context, checks UserAuthorizationRequest) (UserAuthorizationResponse, error) {
res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/authorization", uuidOrMe(Me)), checks)
if err != nil {
return nil, err
Expand All @@ -53,6 +53,6 @@ func (c *Client) CheckPermissions(ctx context.Context, checks UserPermissionChec
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var roles UserPermissionCheckResponse
var roles UserAuthorizationResponse
return roles, json.NewDecoder(res.Body).Decode(&roles)
}
16 changes: 8 additions & 8 deletions codersdk/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,23 +76,23 @@ type UserRoles struct {
OrganizationRoles map[uuid.UUID][]string `json:"organization_roles"`
}

type UserPermissionCheckResponse map[string]bool
type UserAuthorizationResponse map[string]bool

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

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

type UserPermissionCheckObject struct {
type UserAuthorizationObject struct {
// ResourceType is the name of the resource.
// './coderd/rbac/object.go' has the list of valid resource types.
ResourceType string `json:"resource_type"`
Expand Down
13 changes: 2 additions & 11 deletions site/src/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,20 @@ import axios from "axios"
import { getApiKey, login, logout } from "./api"
import * as TypesGen from "./typesGenerated"

// Mock the axios module so that no real network requests are made, but rather
// we swap in a resolved or rejected value
//
// See: https://jestjs.io/docs/mock-functions#mocking-modules
jest.mock("axios")

describe("api.ts", () => {
describe("login", () => {
it("should return LoginResponse", async () => {
// given
const loginResponse: TypesGen.LoginWithPasswordResponse = {
session_token: "abc_123_test",
}
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
return Promise.resolve({ data: loginResponse })
})
axios.post = axiosMockPost
jest.spyOn(axios, "post").mockResolvedValueOnce({ data: loginResponse })

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

// then
expect(axiosMockPost).toHaveBeenCalled()
expect(axios.post).toHaveBeenCalled()
expect(result).toStrictEqual(loginResponse)
})

Expand Down
8 changes: 8 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ export const getAuthMethods = async (): Promise<TypesGen.AuthMethods> => {
return response.data
}

export const checkUserPermissions = async (
userId: string,
params: TypesGen.UserAuthorizationRequest,
): Promise<TypesGen.UserAuthorizationResponse> => {
const response = await axios.post<TypesGen.UserAuthorizationResponse>(`/api/v2/users/${userId}/authorization`, params)
return response.data
}

export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => {
const response = await axios.post<TypesGen.GenerateAPIKeyResponse>("/api/v2/users/me/keys")
return response.data
Expand Down
12 changes: 6 additions & 6 deletions site/src/api/typesGenerated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,26 +316,26 @@ export interface User {
}

// From codersdk/users.go:95:6
export interface UserPermissionCheck {
readonly object: UserPermissionCheckObject
export interface UserAuthorization {
readonly object: UserAuthorizationObject
readonly action: string
}

// From codersdk/users.go:111:6
export interface UserPermissionCheckObject {
export interface UserAuthorizationObject {
readonly resource_type: string
readonly owner_id?: string
readonly organization_id?: string
readonly resource_id?: string
}

// From codersdk/users.go:84:6
export interface UserPermissionCheckRequest {
readonly checks: Record<string, UserPermissionCheck>
export interface UserAuthorizationRequest {
readonly checks: Record<string, UserAuthorization>
}

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

// From codersdk/users.go:74:6
export interface UserRoles {
Expand Down
40 changes: 40 additions & 0 deletions site/src/components/Navbar/Navbar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { screen, waitFor } from "@testing-library/react"
import React from "react"
import * as API from "../../api/api"
import { renderWithAuth } from "../../testHelpers/renderHelpers"
import { checks } from "../../xServices/auth/authXService"
import { Language as AdminDropdownLanguage } from "../AdminDropdown/AdminDropdown"
import { Navbar } from "./Navbar"

beforeEach(() => {
jest.resetAllMocks()
})

describe("Navbar", () => {
describe("when user has permission to read all users", () => {
it("displays the admin menu", async () => {
const checkUserPermissionsSpy = jest.spyOn(API, "checkUserPermissions").mockResolvedValueOnce({
[checks.readAllUsers]: true,
})

renderWithAuth(<Navbar />)

// Wait for the request is done
await waitFor(() => expect(checkUserPermissionsSpy).toBeCalledTimes(1))
await screen.findByRole("button", { name: AdminDropdownLanguage.menuTitle })
})
})

describe("when user has NO permission to read all users", () => {
it("does not display the admin menu", async () => {
const checkUserPermissionsSpy = jest.spyOn(API, "checkUserPermissions").mockResolvedValueOnce({
[checks.readAllUsers]: false,
})
renderWithAuth(<Navbar />)

// Wait for the request is done
await waitFor(() => expect(checkUserPermissionsSpy).toBeCalledTimes(1))
expect(screen.queryByRole("button", { name: AdminDropdownLanguage.menuTitle })).not.toBeInTheDocument()
})
})
})
9 changes: 7 additions & 2 deletions site/src/components/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { useActor } from "@xstate/react"
import { useActor, useSelector } from "@xstate/react"
import React, { useContext } from "react"
import { selectPermissions } from "../../xServices/auth/authSelectors"
import { XServiceContext } from "../../xServices/StateContext"
import { NavbarView } from "../NavbarView/NavbarView"

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

return <NavbarView user={me} onSignOut={onSignOut} />
return <NavbarView user={me} onSignOut={onSignOut} displayAdminDropdown={displayAdminDropdown} />
}
22 changes: 20 additions & 2 deletions site/src/components/NavbarView/NavbarView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,33 @@ const Template: Story<NavbarViewProps> = (args: NavbarViewProps) => <NavbarView

export const ForAdmin = Template.bind({})
ForAdmin.args = {
user: { id: "1", username: "Administrator", email: "[email protected]", created_at: "dawn" },
user: {
id: "1",
username: "Administrator",
email: "[email protected]",
created_at: "dawn",
status: "active",
organization_ids: [],
roles: [],
},
displayAdminDropdown: true,
onSignOut: () => {
return Promise.resolve()
},
}

export const ForMember = Template.bind({})
ForMember.args = {
user: { id: "1", username: "CathyCoder", email: "[email protected]", created_at: "dawn" },
user: {
id: "1",
username: "CathyCoder",
email: "[email protected]",
created_at: "dawn",
status: "active",
organization_ids: [],
roles: [],
},
displayAdminDropdown: false,
onSignOut: () => {
return Promise.resolve()
},
Expand Down
4 changes: 2 additions & 2 deletions site/src/components/NavbarView/NavbarView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe("NavbarView", () => {
}
it("renders content", async () => {
// When
render(<NavbarView user={MockUser} onSignOut={noop} />)
render(<NavbarView user={MockUser} onSignOut={noop} displayAdminDropdown />)

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

// When
render(<NavbarView user={mockUser} onSignOut={noop} />)
render(<NavbarView user={mockUser} onSignOut={noop} displayAdminDropdown />)

// Then
// There should be a 'B' avatar!
Expand Down
5 changes: 3 additions & 2 deletions site/src/components/NavbarView/NavbarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import { UserDropdown } from "../UserDropdown/UsersDropdown"
export interface NavbarViewProps {
user?: TypesGen.User
onSignOut: () => void
displayAdminDropdown: boolean
}

export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut }) => {
export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut, displayAdminDropdown }) => {
const styles = useStyles()
return (
<nav className={styles.root}>
Expand All @@ -31,7 +32,7 @@ export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut }) => {
</ListItem>
</List>
<div className={styles.fullWidth} />
{user && user.email === "[email protected]" && <AdminDropdown />}
{displayAdminDropdown && <AdminDropdown />}
<div className={styles.fixed}>{user && <UserDropdown user={user} onSignOut={onSignOut} />}</div>
</nav>
)
Expand Down
12 changes: 12 additions & 0 deletions site/src/testHelpers/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { rest } from "msw"
import { permissionsToCheck } from "../xServices/auth/authXService"
import * as M from "./entities"

export const handlers = [
Expand Down Expand Up @@ -54,6 +55,17 @@ export const handlers = [
rest.get("/api/v2/users/roles", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockSiteRoles))
}),
rest.post("/api/v2/users/:userId/authorization", async (req, res, ctx) => {
const permissions = Object.keys(permissionsToCheck)
const response = permissions.reduce((obj, permission) => {
return {
...obj,
[permission]: true,
}
}, {})

return res(ctx.status(200), ctx.json(response))
}),

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