From bdd7794e859b13c3ca84390c5b502397e1ac782a Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Fri, 14 Mar 2025 21:50:16 +0500 Subject: [PATCH 1/5] fix: remove provisioners from deployment sidebar (cherry-pick #16717) (#16927) Cherry-picked fix: remove provisioners from deployment sidebar (#16717) Provisioners should be only under orgs. This is a left over from a previous provisioner refactoring. closes #16921 Co-authored-by: Bruno Quaresma --- site/src/modules/management/DeploymentSidebarView.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/site/src/modules/management/DeploymentSidebarView.tsx b/site/src/modules/management/DeploymentSidebarView.tsx index 21ff6f84b4a48..4783133a872bb 100644 --- a/site/src/modules/management/DeploymentSidebarView.tsx +++ b/site/src/modules/management/DeploymentSidebarView.tsx @@ -94,11 +94,6 @@ export const DeploymentSidebarView: FC = ({ IdP Organization Sync )} - {permissions.viewDeploymentValues && ( - - Provisioners - - )} {!hasPremiumLicense && ( Premium )} From b615a35d433b06fcfe857af0efa773f77a19aeed Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Fri, 14 Mar 2025 20:18:13 -0500 Subject: [PATCH 2/5] chore: use org-scoped roles for organization user e2e tests (cherry-pick #16691) (#16793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked chore: use org-scoped roles for organization groups and members e2e tests (#16691) Co-authored-by: ケイラ --- site/e2e/api.ts | 32 ++++++++++++++++++++-- site/e2e/constants.ts | 7 +++++ site/e2e/helpers.ts | 29 +++++++++++++++++++- site/e2e/tests/organizationGroups.spec.ts | 15 ++++++++-- site/e2e/tests/organizationMembers.spec.ts | 20 ++++++-------- 5 files changed, 85 insertions(+), 18 deletions(-) diff --git a/site/e2e/api.ts b/site/e2e/api.ts index 902485b7b15b6..0dc9e46831708 100644 --- a/site/e2e/api.ts +++ b/site/e2e/api.ts @@ -3,8 +3,8 @@ import { expect } from "@playwright/test"; import { API, type DeploymentConfig } from "api/api"; import type { SerpentOption } from "api/typesGenerated"; import { formatDuration, intervalToDuration } from "date-fns"; -import { coderPort } from "./constants"; -import { findSessionToken, randomName } from "./helpers"; +import { coderPort, defaultPassword } from "./constants"; +import { type LoginOptions, findSessionToken, randomName } from "./helpers"; let currentOrgId: string; @@ -29,14 +29,40 @@ export const createUser = async (...orgIds: string[]) => { email: `${name}@coder.com`, username: name, name: name, - password: "s3cure&password!", + password: defaultPassword, login_type: "password", organization_ids: orgIds, user_status: null, }); + return user; }; +export const createOrganizationMember = async ( + orgRoles: Record, +): Promise => { + const name = randomName(); + const user = await API.createUser({ + email: `${name}@coder.com`, + username: name, + name: name, + password: defaultPassword, + login_type: "password", + organization_ids: Object.keys(orgRoles), + user_status: null, + }); + + for (const [org, roles] of Object.entries(orgRoles)) { + API.updateOrganizationMemberRoles(org, user.id, roles); + } + + return { + username: user.username, + email: user.email, + password: defaultPassword, + }; +}; + export const createGroup = async (orgId: string) => { const name = randomName(); const group = await API.createGroup(orgId, { diff --git a/site/e2e/constants.ts b/site/e2e/constants.ts index 4fcada0e6d15b..4d2d9099692d5 100644 --- a/site/e2e/constants.ts +++ b/site/e2e/constants.ts @@ -15,6 +15,7 @@ export const coderdPProfPort = 6062; // The name of the organization that should be used by default when needed. export const defaultOrganizationName = "coder"; +export const defaultOrganizationId = "00000000-0000-0000-0000-000000000000"; export const defaultPassword = "SomeSecurePassword!"; // Credentials for users @@ -30,6 +31,12 @@ export const users = { email: "templateadmin@coder.com", roles: ["Template Admin"], }, + userAdmin: { + username: "user-admin", + password: defaultPassword, + email: "useradmin@coder.com", + roles: ["User Admin"], + }, auditor: { username: "auditor", password: defaultPassword, diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 5692909355fca..24b46d47a151b 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -61,7 +61,7 @@ export function requireTerraformProvisioner() { test.skip(!requireTerraformTests); } -type LoginOptions = { +export type LoginOptions = { username: string; email: string; password: string; @@ -1127,3 +1127,30 @@ export async function createOrganization(page: Page): Promise<{ return { name, displayName, description }; } + +/** + * @param organization organization name + * @param user user email or username + */ +export async function addUserToOrganization( + page: Page, + organization: string, + user: string, + roles: string[] = [], +): Promise { + await page.goto(`/organizations/${organization}`, { + waitUntil: "domcontentloaded", + }); + + await page.getByPlaceholder("User email or username").fill(user); + await page.getByRole("option", { name: user }).click(); + await page.getByRole("button", { name: "Add user" }).click(); + const addedRow = page.locator("tr", { hasText: user }); + await expect(addedRow).toBeVisible(); + + await addedRow.getByLabel("Edit user roles").click(); + for (const role of roles) { + await page.getByText(role).click(); + } + await page.mouse.click(10, 10); // close the popover by clicking outside of it +} diff --git a/site/e2e/tests/organizationGroups.spec.ts b/site/e2e/tests/organizationGroups.spec.ts index dff12ab91c453..6e8aa74a4bf8b 100644 --- a/site/e2e/tests/organizationGroups.spec.ts +++ b/site/e2e/tests/organizationGroups.spec.ts @@ -2,10 +2,11 @@ import { expect, test } from "@playwright/test"; import { createGroup, createOrganization, + createOrganizationMember, createUser, setupApiCalls, } from "../api"; -import { defaultOrganizationName } from "../constants"; +import { defaultOrganizationId, defaultOrganizationName } from "../constants"; import { expectUrl } from "../expectUrl"; import { login, randomName, requiresLicense } from "../helpers"; import { beforeCoderTest } from "../hooks"; @@ -32,6 +33,11 @@ test("create group", async ({ page }) => { // Create a new organization const org = await createOrganization(); + const orgUserAdmin = await createOrganizationMember({ + [org.id]: ["organization-user-admin"], + }); + + await login(page, orgUserAdmin); await page.goto(`/organizations/${org.name}`); // Navigate to groups page @@ -64,8 +70,7 @@ test("create group", async ({ page }) => { await expect(addedRow).toBeVisible(); // Ensure we can't add a user who isn't in the org - const otherOrg = await createOrganization(); - const personToReject = await createUser(otherOrg.id); + const personToReject = await createUser(defaultOrganizationId); await page .getByPlaceholder("User email or username") .fill(personToReject.email); @@ -93,8 +98,12 @@ test("change quota settings", async ({ page }) => { // Create a new organization and group const org = await createOrganization(); const group = await createGroup(org.id); + const orgUserAdmin = await createOrganizationMember({ + [org.id]: ["organization-user-admin"], + }); // Go to settings + await login(page, orgUserAdmin); await page.goto(`/organizations/${org.name}/groups/${group.name}`); await page.getByRole("button", { name: "Settings", exact: true }).click(); expectUrl(page).toHavePathName( diff --git a/site/e2e/tests/organizationMembers.spec.ts b/site/e2e/tests/organizationMembers.spec.ts index 9edb2eb922ab8..51c3491ae3d62 100644 --- a/site/e2e/tests/organizationMembers.spec.ts +++ b/site/e2e/tests/organizationMembers.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { setupApiCalls } from "../api"; import { + addUserToOrganization, createOrganization, createUser, login, @@ -18,7 +19,7 @@ test("add and remove organization member", async ({ page }) => { requiresLicense(); // Create a new organization - const { displayName } = await createOrganization(page); + const { name: orgName, displayName } = await createOrganization(page); // Navigate to members page await page.getByRole("link", { name: "Members" }).click(); @@ -26,17 +27,14 @@ test("add and remove organization member", async ({ page }) => { // Add a user to the org const personToAdd = await createUser(page); - await page.getByPlaceholder("User email or username").fill(personToAdd.email); - await page.getByRole("option", { name: personToAdd.email }).click(); - await page.getByRole("button", { name: "Add user" }).click(); - const addedRow = page.locator("tr", { hasText: personToAdd.email }); - await expect(addedRow).toBeVisible(); + // This must be done as an admin, because you can't assign a role that has more + // permissions than you, even if you have the ability to assign roles. + await addUserToOrganization(page, orgName, personToAdd.email, [ + "Organization User Admin", + "Organization Template Admin", + ]); - // Give them a role - await addedRow.getByLabel("Edit user roles").click(); - await page.getByText("Organization User Admin").click(); - await page.getByText("Organization Template Admin").click(); - await page.mouse.click(10, 10); // close the popover by clicking outside of it + const addedRow = page.locator("tr", { hasText: personToAdd.email }); await expect(addedRow.getByText("Organization User Admin")).toBeVisible(); await expect(addedRow.getByText("+1 more")).toBeVisible(); From dba881fc7da62ac9c648ecbc1de61a00ec6c6a42 Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Fri, 14 Mar 2025 20:37:38 -0500 Subject: [PATCH 3/5] fix: fix audit log search (cherry-pick #16944) (#16945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked fix: fix audit log search (#16944) Co-authored-by: ケイラ --- site/src/pages/AuditPage/AuditPage.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/site/src/pages/AuditPage/AuditPage.tsx b/site/src/pages/AuditPage/AuditPage.tsx index efcf2068f19ad..3de18d4cb7edb 100644 --- a/site/src/pages/AuditPage/AuditPage.tsx +++ b/site/src/pages/AuditPage/AuditPage.tsx @@ -68,14 +68,6 @@ const AuditPage: FC = () => { }), }); - if (auditsQuery.error) { - return ( -
- -
- ); - } - return ( <> From 3569e56cf912aac565d7357b4efeb7ecd37a643f Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 11:13:40 -0500 Subject: [PATCH 4/5] fix: hide deleted users from org members query (cherry-pick #16830) (#16832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked fix: hide deleted users from org members query (#16830) Co-authored-by: ケイラ --- coderd/database/queries.sql.go | 2 +- coderd/database/queries/organizationmembers.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2f28b563e98b8..37c79537c622f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4967,7 +4967,7 @@ SELECT FROM organization_members INNER JOIN - users ON organization_members.user_id = users.id + users ON organization_members.user_id = users.id AND users.deleted = false WHERE -- Filter by organization id CASE diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index 71304c8883602..8685e71129ac9 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -9,7 +9,7 @@ SELECT FROM organization_members INNER JOIN - users ON organization_members.user_id = users.id + users ON organization_members.user_id = users.id AND users.deleted = false WHERE -- Filter by organization id CASE From 8a3ebaca0112703297af94d9f65dedaa832e2b4e Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:00:39 -0500 Subject: [PATCH 5/5] fix: fix deployment settings navigation issues (cherry-pick #16780) (#16790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked fix: fix deployment settings navigation issues (#16780) Co-authored-by: ケイラ --- site/e2e/tests/roles.spec.ts | 157 +++++++++++++++++ site/src/api/queries/organizations.ts | 17 -- site/src/contexts/auth/AuthProvider.tsx | 6 +- site/src/contexts/auth/permissions.tsx | 159 ++++++++++++------ .../modules/dashboard/DashboardProvider.tsx | 21 +-- .../dashboard/Navbar/DeploymentDropdown.tsx | 2 +- .../modules/dashboard/Navbar/MobileMenu.tsx | 2 +- site/src/modules/dashboard/Navbar/Navbar.tsx | 11 +- .../dashboard/Navbar/NavbarView.test.tsx | 2 +- .../dashboard/Navbar/ProxyMenu.stories.tsx | 4 +- .../management/DeploymentSettingsLayout.tsx | 26 ++- .../management/DeploymentSettingsProvider.tsx | 25 +-- .../management/organizationPermissions.tsx | 62 ------- .../TerminalPage/TerminalPage.stories.tsx | 6 +- site/src/router.tsx | 5 +- site/src/testHelpers/entities.ts | 58 ++++--- site/src/testHelpers/handlers.ts | 4 +- site/src/testHelpers/storybook.tsx | 4 +- 18 files changed, 350 insertions(+), 221 deletions(-) create mode 100644 site/e2e/tests/roles.spec.ts diff --git a/site/e2e/tests/roles.spec.ts b/site/e2e/tests/roles.spec.ts new file mode 100644 index 0000000000000..482436c9c9b2d --- /dev/null +++ b/site/e2e/tests/roles.spec.ts @@ -0,0 +1,157 @@ +import { type Page, expect, test } from "@playwright/test"; +import { + createOrganization, + createOrganizationMember, + setupApiCalls, +} from "../api"; +import { license, users } from "../constants"; +import { login, requiresLicense } from "../helpers"; +import { beforeCoderTest } from "../hooks"; + +test.beforeEach(async ({ page }) => { + beforeCoderTest(page); +}); + +type AdminSetting = (typeof adminSettings)[number]; + +const adminSettings = [ + "Deployment", + "Organizations", + "Healthcheck", + "Audit Logs", +] as const; + +async function hasAccessToAdminSettings(page: Page, settings: AdminSetting[]) { + // Organizations and Audit Logs both require a license to be visible + const visibleSettings = license + ? settings + : settings.filter((it) => it !== "Organizations" && it !== "Audit Logs"); + const adminSettingsButton = page.getByRole("button", { + name: "Admin settings", + }); + if (visibleSettings.length < 1) { + await expect(adminSettingsButton).not.toBeVisible(); + return; + } + + await adminSettingsButton.click(); + + for (const name of visibleSettings) { + await expect(page.getByText(name, { exact: true })).toBeVisible(); + } + + const hiddenSettings = adminSettings.filter( + (it) => !visibleSettings.includes(it), + ); + for (const name of hiddenSettings) { + await expect(page.getByText(name, { exact: true })).not.toBeVisible(); + } +} + +test.describe("roles admin settings access", () => { + test("member cannot see admin settings", async ({ page }) => { + await login(page, users.member); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + // None, "Admin settings" button should not be visible + await hasAccessToAdminSettings(page, []); + }); + + test("template admin can see admin settings", async ({ page }) => { + await login(page, users.templateAdmin); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await hasAccessToAdminSettings(page, ["Deployment", "Organizations"]); + }); + + test("user admin can see admin settings", async ({ page }) => { + await login(page, users.userAdmin); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await hasAccessToAdminSettings(page, ["Deployment", "Organizations"]); + }); + + test("auditor can see admin settings", async ({ page }) => { + await login(page, users.auditor); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await hasAccessToAdminSettings(page, [ + "Deployment", + "Organizations", + "Audit Logs", + ]); + }); + + test("admin can see admin settings", async ({ page }) => { + await login(page, users.admin); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await hasAccessToAdminSettings(page, [ + "Deployment", + "Organizations", + "Healthcheck", + "Audit Logs", + ]); + }); +}); + +test.describe("org-scoped roles admin settings access", () => { + requiresLicense(); + + test.beforeEach(async ({ page }) => { + await login(page); + await setupApiCalls(page); + }); + + test("org template admin can see admin settings", async ({ page }) => { + const org = await createOrganization(); + const orgTemplateAdmin = await createOrganizationMember({ + [org.id]: ["organization-template-admin"], + }); + + await login(page, orgTemplateAdmin); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await hasAccessToAdminSettings(page, ["Organizations"]); + }); + + test("org user admin can see admin settings", async ({ page }) => { + const org = await createOrganization(); + const orgUserAdmin = await createOrganizationMember({ + [org.id]: ["organization-user-admin"], + }); + + await login(page, orgUserAdmin); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await hasAccessToAdminSettings(page, ["Deployment", "Organizations"]); + }); + + test("org auditor can see admin settings", async ({ page }) => { + const org = await createOrganization(); + const orgAuditor = await createOrganizationMember({ + [org.id]: ["organization-auditor"], + }); + + await login(page, orgAuditor); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await hasAccessToAdminSettings(page, ["Organizations", "Audit Logs"]); + }); + + test("org admin can see admin settings", async ({ page }) => { + const org = await createOrganization(); + const orgAdmin = await createOrganizationMember({ + [org.id]: ["organization-admin"], + }); + + await login(page, orgAdmin); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await hasAccessToAdminSettings(page, [ + "Deployment", + "Organizations", + "Audit Logs", + ]); + }); +}); diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index a27514a03c161..374f9e7eacf4e 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -6,10 +6,8 @@ import type { UpdateOrganizationRequest, } from "api/typesGenerated"; import { - type AnyOrganizationPermissions, type OrganizationPermissionName, type OrganizationPermissions, - anyOrganizationPermissionChecks, organizationPermissionChecks, } from "modules/management/organizationPermissions"; import type { QueryClient } from "react-query"; @@ -266,21 +264,6 @@ export const organizationsPermissions = ( }; }; -export const anyOrganizationPermissionsKey = [ - "authorization", - "anyOrganization", -]; - -export const anyOrganizationPermissions = () => { - return { - queryKey: anyOrganizationPermissionsKey, - queryFn: () => - API.checkAuthorization({ - checks: anyOrganizationPermissionChecks, - }) as Promise, - }; -}; - export const getOrganizationIdpSyncClaimFieldValuesKey = ( organization: string, field: string, diff --git a/site/src/contexts/auth/AuthProvider.tsx b/site/src/contexts/auth/AuthProvider.tsx index ad475bddcbfb7..7418691a291e5 100644 --- a/site/src/contexts/auth/AuthProvider.tsx +++ b/site/src/contexts/auth/AuthProvider.tsx @@ -18,7 +18,7 @@ import { useContext, } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; -import { type Permissions, permissionsToCheck } from "./permissions"; +import { type Permissions, permissionChecks } from "./permissions"; export type AuthContextValue = { isLoading: boolean; @@ -50,13 +50,13 @@ export const AuthProvider: FC = ({ children }) => { const hasFirstUserQuery = useQuery(hasFirstUser(userMetadataState)); const permissionsQuery = useQuery({ - ...checkAuthorization({ checks: permissionsToCheck }), + ...checkAuthorization({ checks: permissionChecks }), enabled: userQuery.data !== undefined, }); const queryClient = useQueryClient(); const loginMutation = useMutation( - login({ checks: permissionsToCheck }, queryClient), + login({ checks: permissionChecks }, queryClient), ); const logoutMutation = useMutation(logout(queryClient)); diff --git a/site/src/contexts/auth/permissions.tsx b/site/src/contexts/auth/permissions.tsx index 1043862942edb..0d8957627c36d 100644 --- a/site/src/contexts/auth/permissions.tsx +++ b/site/src/contexts/auth/permissions.tsx @@ -1,156 +1,205 @@ import type { AuthorizationCheck } from "api/typesGenerated"; -export const checks = { - viewAllUsers: "viewAllUsers", - updateUsers: "updateUsers", - createUser: "createUser", - createTemplates: "createTemplates", - updateTemplates: "updateTemplates", - deleteTemplates: "deleteTemplates", - viewAnyAuditLog: "viewAnyAuditLog", - viewDeploymentValues: "viewDeploymentValues", - editDeploymentValues: "editDeploymentValues", - viewUpdateCheck: "viewUpdateCheck", - viewExternalAuthConfig: "viewExternalAuthConfig", - viewDeploymentStats: "viewDeploymentStats", - readWorkspaceProxies: "readWorkspaceProxies", - editWorkspaceProxies: "editWorkspaceProxies", - createOrganization: "createOrganization", - viewAnyGroup: "viewAnyGroup", - createGroup: "createGroup", - viewAllLicenses: "viewAllLicenses", - viewNotificationTemplate: "viewNotificationTemplate", - viewOrganizationIDPSyncSettings: "viewOrganizationIDPSyncSettings", -} as const satisfies Record; +export type Permissions = { + [k in PermissionName]: boolean; +}; -// Type expression seems a little redundant (`keyof typeof checks` has the same -// result), just because each key-value pair is currently symmetrical; this may -// change down the line -type PermissionValue = (typeof checks)[keyof typeof checks]; +export type PermissionName = keyof typeof permissionChecks; -export const permissionsToCheck = { - [checks.viewAllUsers]: { +export const permissionChecks = { + viewAllUsers: { object: { resource_type: "user", }, action: "read", }, - [checks.updateUsers]: { + updateUsers: { object: { resource_type: "user", }, action: "update", }, - [checks.createUser]: { + createUser: { object: { resource_type: "user", }, action: "create", }, - [checks.createTemplates]: { + createTemplates: { object: { resource_type: "template", any_org: true, }, action: "update", }, - [checks.updateTemplates]: { + updateTemplates: { object: { resource_type: "template", }, action: "update", }, - [checks.deleteTemplates]: { + deleteTemplates: { object: { resource_type: "template", }, action: "delete", }, - [checks.viewAnyAuditLog]: { - object: { - resource_type: "audit_log", - any_org: true, - }, - action: "read", - }, - [checks.viewDeploymentValues]: { + viewDeploymentValues: { object: { resource_type: "deployment_config", }, action: "read", }, - [checks.editDeploymentValues]: { + editDeploymentValues: { object: { resource_type: "deployment_config", }, action: "update", }, - [checks.viewUpdateCheck]: { + viewUpdateCheck: { object: { resource_type: "deployment_config", }, action: "read", }, - [checks.viewExternalAuthConfig]: { + viewExternalAuthConfig: { object: { resource_type: "deployment_config", }, action: "read", }, - [checks.viewDeploymentStats]: { + viewDeploymentStats: { object: { resource_type: "deployment_stats", }, action: "read", }, - [checks.readWorkspaceProxies]: { + readWorkspaceProxies: { object: { resource_type: "workspace_proxy", }, action: "read", }, - [checks.editWorkspaceProxies]: { + editWorkspaceProxies: { object: { resource_type: "workspace_proxy", }, action: "create", }, - [checks.createOrganization]: { + createOrganization: { object: { resource_type: "organization", }, action: "create", }, - [checks.viewAnyGroup]: { + viewAnyGroup: { object: { resource_type: "group", }, action: "read", }, - [checks.createGroup]: { + createGroup: { object: { resource_type: "group", }, action: "create", }, - [checks.viewAllLicenses]: { + viewAllLicenses: { object: { resource_type: "license", }, action: "read", }, - [checks.viewNotificationTemplate]: { + viewNotificationTemplate: { object: { resource_type: "notification_template", }, action: "read", }, - [checks.viewOrganizationIDPSyncSettings]: { + viewOrganizationIDPSyncSettings: { object: { resource_type: "idpsync_settings", }, action: "read", }, -} as const satisfies Record; -export type Permissions = Record; + viewAnyMembers: { + object: { + resource_type: "organization_member", + any_org: true, + }, + action: "read", + }, + editAnyGroups: { + object: { + resource_type: "group", + any_org: true, + }, + action: "update", + }, + assignAnyRoles: { + object: { + resource_type: "assign_org_role", + any_org: true, + }, + action: "assign", + }, + viewAnyIdpSyncSettings: { + object: { + resource_type: "idpsync_settings", + any_org: true, + }, + action: "read", + }, + editAnySettings: { + object: { + resource_type: "organization", + any_org: true, + }, + action: "update", + }, + viewAnyAuditLog: { + object: { + resource_type: "audit_log", + any_org: true, + }, + action: "read", + }, + viewDebugInfo: { + object: { + resource_type: "debug_info", + }, + action: "read", + }, +} as const satisfies Record; + +export const canViewDeploymentSettings = ( + permissions: Permissions | undefined, +): permissions is Permissions => { + return ( + permissions !== undefined && + (permissions.viewDeploymentValues || + permissions.viewAllLicenses || + permissions.viewAllUsers || + permissions.viewAnyGroup || + permissions.viewNotificationTemplate || + permissions.viewOrganizationIDPSyncSettings) + ); +}; + +/** + * Checks if the user can view or edit members or groups for the organization + * that produced the given OrganizationPermissions. + */ +export const canViewAnyOrganization = ( + permissions: Permissions | undefined, +): permissions is Permissions => { + return ( + permissions !== undefined && + (permissions.viewAnyMembers || + permissions.editAnyGroups || + permissions.assignAnyRoles || + permissions.viewAnyIdpSyncSettings || + permissions.editAnySettings) + ); +}; diff --git a/site/src/modules/dashboard/DashboardProvider.tsx b/site/src/modules/dashboard/DashboardProvider.tsx index bf8e307206aea..bb5987d6546be 100644 --- a/site/src/modules/dashboard/DashboardProvider.tsx +++ b/site/src/modules/dashboard/DashboardProvider.tsx @@ -1,10 +1,7 @@ import { appearance } from "api/queries/appearance"; import { entitlements } from "api/queries/entitlements"; import { experiments } from "api/queries/experiments"; -import { - anyOrganizationPermissions, - organizations, -} from "api/queries/organizations"; +import { organizations } from "api/queries/organizations"; import type { AppearanceConfig, Entitlements, @@ -13,8 +10,9 @@ import type { } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Loader } from "components/Loader/Loader"; +import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { canViewAnyOrganization } from "contexts/auth/permissions"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; -import { canViewAnyOrganization } from "modules/management/organizationPermissions"; import { type FC, type PropsWithChildren, createContext } from "react"; import { useQuery } from "react-query"; import { selectFeatureVisibility } from "./entitlements"; @@ -34,20 +32,17 @@ export const DashboardContext = createContext( export const DashboardProvider: FC = ({ children }) => { const { metadata } = useEmbeddedMetadata(); + const { permissions } = useAuthenticated(); const entitlementsQuery = useQuery(entitlements(metadata.entitlements)); const experimentsQuery = useQuery(experiments(metadata.experiments)); const appearanceQuery = useQuery(appearance(metadata.appearance)); const organizationsQuery = useQuery(organizations()); - const anyOrganizationPermissionsQuery = useQuery( - anyOrganizationPermissions(), - ); const error = entitlementsQuery.error || appearanceQuery.error || experimentsQuery.error || - organizationsQuery.error || - anyOrganizationPermissionsQuery.error; + organizationsQuery.error; if (error) { return ; @@ -57,8 +52,7 @@ export const DashboardProvider: FC = ({ children }) => { !entitlementsQuery.data || !appearanceQuery.data || !experimentsQuery.data || - !organizationsQuery.data || - !anyOrganizationPermissionsQuery.data; + !organizationsQuery.data; if (isLoading) { return ; @@ -79,8 +73,7 @@ export const DashboardProvider: FC = ({ children }) => { organizations: organizationsQuery.data, showOrganizations, canViewOrganizationSettings: - showOrganizations && - canViewAnyOrganization(anyOrganizationPermissionsQuery.data), + showOrganizations && canViewAnyOrganization(permissions), }} > {children} diff --git a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx index 746ddc8f89e78..876a3eb441cf1 100644 --- a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx @@ -82,7 +82,7 @@ const DeploymentDropdownContent: FC = ({ {canViewDeployment && ( diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.tsx index 20058335eb8e5..ae5f600ba68de 100644 --- a/site/src/modules/dashboard/Navbar/MobileMenu.tsx +++ b/site/src/modules/dashboard/Navbar/MobileMenu.tsx @@ -220,7 +220,7 @@ const AdminSettingsSub: FC = ({ asChild className={cn(itemStyles.default, itemStyles.sub)} > - Deployment + Deployment )} {canViewOrganizations && ( diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index f80887e1f1aec..7dc96c791e7ca 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -1,6 +1,7 @@ import { buildInfo } from "api/queries/buildInfo"; import { useProxy } from "contexts/ProxyContext"; import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { canViewDeploymentSettings } from "contexts/auth/permissions"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { useDashboard } from "modules/dashboard/useDashboard"; import type { FC } from "react"; @@ -11,16 +12,16 @@ import { NavbarView } from "./NavbarView"; export const Navbar: FC = () => { const { metadata } = useEmbeddedMetadata(); const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); - const { appearance, canViewOrganizationSettings } = useDashboard(); const { user: me, permissions, signOut } = useAuthenticated(); const featureVisibility = useFeatureVisibility(); + const proxyContextValue = useProxy(); + + const canViewDeployment = canViewDeploymentSettings(permissions); + const canViewOrganizations = canViewOrganizationSettings; + const canViewHealth = permissions.viewDebugInfo; const canViewAuditLog = featureVisibility.audit_log && permissions.viewAnyAuditLog; - const canViewDeployment = permissions.viewDeploymentValues; - const canViewOrganizations = canViewOrganizationSettings; - const proxyContextValue = useProxy(); - const canViewHealth = canViewDeployment; return ( { await userEvent.click(deploymentMenu); const deploymentSettingsLink = await screen.findByText(/deployment/i); - expect(deploymentSettingsLink.href).toContain("/deployment/general"); + expect(deploymentSettingsLink.href).toContain("/deployment"); }); }); diff --git a/site/src/modules/dashboard/Navbar/ProxyMenu.stories.tsx b/site/src/modules/dashboard/Navbar/ProxyMenu.stories.tsx index 883bbd0dd2f61..8e8cf7fcb8951 100644 --- a/site/src/modules/dashboard/Navbar/ProxyMenu.stories.tsx +++ b/site/src/modules/dashboard/Navbar/ProxyMenu.stories.tsx @@ -3,7 +3,7 @@ import { fn, userEvent, within } from "@storybook/test"; import { getAuthorizationKey } from "api/queries/authCheck"; import { getPreferredProxy } from "contexts/ProxyContext"; import { AuthProvider } from "contexts/auth/AuthProvider"; -import { permissionsToCheck } from "contexts/auth/permissions"; +import { permissionChecks } from "contexts/auth/permissions"; import { MockAuthMethodsAll, MockPermissions, @@ -45,7 +45,7 @@ const meta: Meta = { { key: ["authMethods"], data: MockAuthMethodsAll }, { key: ["hasFirstUser"], data: true }, { - key: getAuthorizationKey({ checks: permissionsToCheck }), + key: getAuthorizationKey({ checks: permissionChecks }), data: MockPermissions, }, ], diff --git a/site/src/modules/management/DeploymentSettingsLayout.tsx b/site/src/modules/management/DeploymentSettingsLayout.tsx index 676a24c936246..c40b6440a81c3 100644 --- a/site/src/modules/management/DeploymentSettingsLayout.tsx +++ b/site/src/modules/management/DeploymentSettingsLayout.tsx @@ -8,19 +8,31 @@ import { import { Loader } from "components/Loader/Loader"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { RequirePermission } from "contexts/auth/RequirePermission"; +import { canViewDeploymentSettings } from "contexts/auth/permissions"; import { type FC, Suspense } from "react"; -import { Outlet } from "react-router-dom"; +import { Navigate, Outlet, useLocation } from "react-router-dom"; import { DeploymentSidebar } from "./DeploymentSidebar"; const DeploymentSettingsLayout: FC = () => { const { permissions } = useAuthenticated(); + const location = useLocation(); - // The deployment settings page also contains users, audit logs, and groups - // so this page must be visible if you can see any of these. - const canViewDeploymentSettingsPage = - permissions.viewDeploymentValues || - permissions.viewAllUsers || - permissions.viewAnyAuditLog; + if (location.pathname === "/deployment") { + return ( + + ); + } + + // The deployment settings page also contains users and groups and more so + // this page must be visible if you can see any of these. + const canViewDeploymentSettingsPage = canViewDeploymentSettings(permissions); return ( diff --git a/site/src/modules/management/DeploymentSettingsProvider.tsx b/site/src/modules/management/DeploymentSettingsProvider.tsx index 633c67d67fe44..766d75aacd216 100644 --- a/site/src/modules/management/DeploymentSettingsProvider.tsx +++ b/site/src/modules/management/DeploymentSettingsProvider.tsx @@ -2,8 +2,6 @@ import type { DeploymentConfig } from "api/api"; import { deploymentConfig } from "api/queries/deployment"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Loader } from "components/Loader/Loader"; -import { useAuthenticated } from "contexts/auth/RequireAuth"; -import { RequirePermission } from "contexts/auth/RequirePermission"; import { type FC, createContext, useContext } from "react"; import { useQuery } from "react-query"; import { Outlet } from "react-router-dom"; @@ -28,19 +26,8 @@ export const useDeploymentSettings = (): DeploymentSettingsValue => { }; const DeploymentSettingsProvider: FC = () => { - const { permissions } = useAuthenticated(); const deploymentConfigQuery = useQuery(deploymentConfig()); - // The deployment settings page also contains users, audit logs, and groups - // so this page must be visible if you can see any of these. - const canViewDeploymentSettingsPage = - permissions.viewDeploymentValues || - permissions.viewAllUsers || - permissions.viewAnyAuditLog; - - // Not a huge problem to unload the content in the event of an error, - // because the sidebar rendering isn't tied to this. Even if the user hits - // a 403 error, they'll still have navigation options if (deploymentConfigQuery.error) { return ; } @@ -50,13 +37,11 @@ const DeploymentSettingsProvider: FC = () => { } return ( - - - - - + + + ); }; diff --git a/site/src/modules/management/organizationPermissions.tsx b/site/src/modules/management/organizationPermissions.tsx index 2059d8fd6f76f..1b79e11e68ca0 100644 --- a/site/src/modules/management/organizationPermissions.tsx +++ b/site/src/modules/management/organizationPermissions.tsx @@ -135,65 +135,3 @@ export const canEditOrganization = ( permissions.createOrgRoles) ); }; - -export type AnyOrganizationPermissions = { - [k in AnyOrganizationPermissionName]: boolean; -}; - -export type AnyOrganizationPermissionName = - keyof typeof anyOrganizationPermissionChecks; - -export const anyOrganizationPermissionChecks = { - viewAnyMembers: { - object: { - resource_type: "organization_member", - any_org: true, - }, - action: "read", - }, - editAnyGroups: { - object: { - resource_type: "group", - any_org: true, - }, - action: "update", - }, - assignAnyRoles: { - object: { - resource_type: "assign_org_role", - any_org: true, - }, - action: "assign", - }, - viewAnyIdpSyncSettings: { - object: { - resource_type: "idpsync_settings", - any_org: true, - }, - action: "read", - }, - editAnySettings: { - object: { - resource_type: "organization", - any_org: true, - }, - action: "update", - }, -} as const satisfies Record; - -/** - * Checks if the user can view or edit members or groups for the organization - * that produced the given OrganizationPermissions. - */ -export const canViewAnyOrganization = ( - permissions: AnyOrganizationPermissions | undefined, -): permissions is AnyOrganizationPermissions => { - return ( - permissions !== undefined && - (permissions.viewAnyMembers || - permissions.editAnyGroups || - permissions.assignAnyRoles || - permissions.viewAnyIdpSyncSettings || - permissions.editAnySettings) - ); -}; diff --git a/site/src/pages/TerminalPage/TerminalPage.stories.tsx b/site/src/pages/TerminalPage/TerminalPage.stories.tsx index b9dfeba1d811d..f50b75bac4a26 100644 --- a/site/src/pages/TerminalPage/TerminalPage.stories.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.stories.tsx @@ -1,11 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react"; import { getAuthorizationKey } from "api/queries/authCheck"; -import { anyOrganizationPermissionsKey } from "api/queries/organizations"; import { workspaceByOwnerAndNameKey } from "api/queries/workspaces"; import type { Workspace, WorkspaceAgentLifecycle } from "api/typesGenerated"; import { AuthProvider } from "contexts/auth/AuthProvider"; import { RequireAuth } from "contexts/auth/RequireAuth"; -import { permissionsToCheck } from "contexts/auth/permissions"; +import { permissionChecks } from "contexts/auth/permissions"; import { reactRouterOutlet, reactRouterParameters, @@ -74,10 +73,9 @@ const meta = { { key: ["appearance"], data: MockAppearanceConfig }, { key: ["organizations"], data: [MockDefaultOrganization] }, { - key: getAuthorizationKey({ checks: permissionsToCheck }), + key: getAuthorizationKey({ checks: permissionChecks }), data: { editWorkspaceProxies: true }, }, - { key: anyOrganizationPermissionsKey, data: {} }, ], chromatic: { delay: 300 }, }, diff --git a/site/src/router.tsx b/site/src/router.tsx index 66d37f92aeaf1..ebb9e6763d058 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -453,8 +453,6 @@ export const router = createBrowserRouter( path="notifications" element={} /> - } /> - } /> @@ -476,6 +474,9 @@ export const router = createBrowserRouter( } /> {groupsRouter()} + + } /> + } /> }> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 12654bc064fee..aa87ac7fbf6fc 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2856,6 +2856,41 @@ export const MockPermissions: Permissions = { viewAllLicenses: true, viewNotificationTemplate: true, viewOrganizationIDPSyncSettings: true, + viewDebugInfo: true, + assignAnyRoles: true, + editAnyGroups: true, + editAnySettings: true, + viewAnyIdpSyncSettings: true, + viewAnyMembers: true, +}; + +export const MockNoPermissions: Permissions = { + createTemplates: false, + createUser: false, + deleteTemplates: false, + updateTemplates: false, + viewAllUsers: false, + updateUsers: false, + viewAnyAuditLog: false, + viewDeploymentValues: false, + editDeploymentValues: false, + viewUpdateCheck: false, + viewDeploymentStats: false, + viewExternalAuthConfig: false, + readWorkspaceProxies: false, + editWorkspaceProxies: false, + createOrganization: false, + viewAnyGroup: false, + createGroup: false, + viewAllLicenses: false, + viewNotificationTemplate: false, + viewOrganizationIDPSyncSettings: false, + viewDebugInfo: false, + assignAnyRoles: false, + editAnyGroups: false, + editAnySettings: false, + viewAnyIdpSyncSettings: false, + viewAnyMembers: false, }; export const MockOrganizationPermissions: OrganizationPermissions = { @@ -2890,29 +2925,6 @@ export const MockNoOrganizationPermissions: OrganizationPermissions = { editIdpSyncSettings: false, }; -export const MockNoPermissions: Permissions = { - createTemplates: false, - createUser: false, - deleteTemplates: false, - updateTemplates: false, - viewAllUsers: false, - updateUsers: false, - viewAnyAuditLog: false, - viewDeploymentValues: false, - editDeploymentValues: false, - viewUpdateCheck: false, - viewDeploymentStats: false, - viewExternalAuthConfig: false, - readWorkspaceProxies: false, - editWorkspaceProxies: false, - createOrganization: false, - viewAnyGroup: false, - createGroup: false, - viewAllLicenses: false, - viewNotificationTemplate: false, - viewOrganizationIDPSyncSettings: false, -}; - export const MockDeploymentConfig: DeploymentConfig = { config: { enable_terraform_debug_mode: true, diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index b458956b17a1d..71e67697572e2 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { CreateWorkspaceBuildRequest } from "api/typesGenerated"; -import { permissionsToCheck } from "contexts/auth/permissions"; +import { permissionChecks } from "contexts/auth/permissions"; import { http, HttpResponse } from "msw"; import * as M from "./entities"; import { MockGroup, MockWorkspaceQuota } from "./entities"; @@ -173,7 +173,7 @@ export const handlers = [ }), http.post("/api/v2/authcheck", () => { const permissions = [ - ...Object.keys(permissionsToCheck), + ...Object.keys(permissionChecks), "canUpdateTemplate", "updateWorkspace", ]; diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index 2b81bf16cd40f..fdaeda69f15c1 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -6,7 +6,7 @@ import { hasFirstUserKey, meKey } from "api/queries/users"; import type { Entitlements } from "api/typesGenerated"; import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar"; import { AuthProvider } from "contexts/auth/AuthProvider"; -import { permissionsToCheck } from "contexts/auth/permissions"; +import { permissionChecks } from "contexts/auth/permissions"; import { DashboardContext } from "modules/dashboard/DashboardProvider"; import { DeploymentSettingsContext } from "modules/management/DeploymentSettingsProvider"; import { OrganizationSettingsContext } from "modules/management/OrganizationSettingsLayout"; @@ -114,7 +114,7 @@ export const withAuthProvider = (Story: FC, { parameters }: StoryContext) => { queryClient.setQueryData(meKey, parameters.user); queryClient.setQueryData(hasFirstUserKey, true); queryClient.setQueryData( - getAuthorizationKey({ checks: permissionsToCheck }), + getAuthorizationKey({ checks: permissionChecks }), parameters.permissions ?? {}, );