diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 11d97fedcf..65cb843c08 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -120,3 +120,64 @@ export const provisionerDaemons = (organization: string) => { queryFn: () => API.getProvisionerDaemonsByOrganization(organization), }; }; + +/** + * Fetch permissions for a single organization. + * + * If the ID is undefined, return a disabled query. + */ +export const organizationPermissions = (organizationId: string | undefined) => { + if (!organizationId) { + return { enabled: false }; + } + return { + queryKey: ["organization", organizationId, "permissions"], + queryFn: () => + API.checkAuthorization({ + checks: { + viewMembers: { + object: { + resource_type: "organization_member", + organization_id: organizationId, + }, + action: "read", + }, + editMembers: { + object: { + resource_type: "organization_member", + organization_id: organizationId, + }, + action: "update", + }, + createGroup: { + object: { + resource_type: "group", + organization_id: organizationId, + }, + action: "create", + }, + viewGroups: { + object: { + resource_type: "group", + organization_id: organizationId, + }, + action: "read", + }, + editOrganization: { + object: { + resource_type: "organization", + organization_id: organizationId, + }, + action: "update", + }, + auditOrganization: { + object: { + resource_type: "audit_log", + organization_id: organizationId, + }, + action: "read", + }, + }, + }), + }; +}; diff --git a/site/src/contexts/auth/permissions.tsx b/site/src/contexts/auth/permissions.tsx index 6e39286edb..c130e0a57e 100644 --- a/site/src/contexts/auth/permissions.tsx +++ b/site/src/contexts/auth/permissions.tsx @@ -1,21 +1,26 @@ export const checks = { - readAllUsers: "readAllUsers", + viewAllUsers: "viewAllUsers", updateUsers: "updateUsers", createUser: "createUser", createTemplates: "createTemplates", updateTemplates: "updateTemplates", deleteTemplates: "deleteTemplates", - viewAuditLog: "viewAuditLog", + viewAnyAuditLog: "viewAnyAuditLog", viewDeploymentValues: "viewDeploymentValues", - createGroup: "createGroup", + editDeploymentValues: "editDeploymentValues", viewUpdateCheck: "viewUpdateCheck", viewExternalAuthConfig: "viewExternalAuthConfig", viewDeploymentStats: "viewDeploymentStats", editWorkspaceProxies: "editWorkspaceProxies", + createOrganization: "createOrganization", + editAnyOrganization: "editAnyOrganization", + viewAnyGroup: "viewAnyGroup", + createGroup: "createGroup", + viewAllLicenses: "viewAllLicenses", } as const; export const permissionsToCheck = { - [checks.readAllUsers]: { + [checks.viewAllUsers]: { object: { resource_type: "user", }, @@ -51,9 +56,10 @@ export const permissionsToCheck = { }, action: "delete", }, - [checks.viewAuditLog]: { + [checks.viewAnyAuditLog]: { object: { resource_type: "audit_log", + any_org: true, }, action: "read", }, @@ -63,11 +69,11 @@ export const permissionsToCheck = { }, action: "read", }, - [checks.createGroup]: { + [checks.editDeploymentValues]: { object: { - resource_type: "group", + resource_type: "deployment_config", }, - action: "create", + action: "update", }, [checks.viewUpdateCheck]: { object: { @@ -93,6 +99,38 @@ export const permissionsToCheck = { }, action: "create", }, + [checks.createOrganization]: { + object: { + resource_type: "organization", + }, + action: "create", + }, + [checks.editAnyOrganization]: { + object: { + resource_type: "organization", + any_org: true, + }, + action: "update", + }, + [checks.viewAnyGroup]: { + object: { + resource_type: "group", + org_id: "any", + }, + action: "read", + }, + [checks.createGroup]: { + object: { + resource_type: "group", + }, + action: "create", + }, + [checks.viewAllLicenses]: { + object: { + resource_type: "license", + }, + action: "read", + }, } as const; export type Permissions = Record; diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index b480f6a208..d25e0733ee 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -16,12 +16,13 @@ export const Navbar: FC = () => { const { user: me, permissions, signOut } = useAuthenticated(); const featureVisibility = useFeatureVisibility(); const canViewAuditLog = - featureVisibility["audit_log"] && Boolean(permissions.viewAuditLog); + featureVisibility.audit_log && Boolean(permissions.viewAnyAuditLog); const canViewDeployment = Boolean(permissions.viewDeploymentValues); const canViewOrganizations = + Boolean(permissions.editAnyOrganization) && featureVisibility.multiple_organizations && experiments.includes("multi-organization"); - const canViewAllUsers = Boolean(permissions.readAllUsers); + const canViewAllUsers = Boolean(permissions.viewAllUsers); const proxyContextValue = useProxy(); const canViewHealth = canViewDeployment; diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index 146a66b937..684594b66c 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { within, userEvent } from "@storybook/test"; import { chromaticWithTablet } from "testHelpers/chromatic"; import { MockUser, MockUser2 } from "testHelpers/entities"; import { withDashboardProvider } from "testHelpers/storybook"; @@ -10,10 +11,11 @@ const meta: Meta = { component: NavbarView, args: { user: MockUser, + canViewAllUsers: true, canViewAuditLog: true, canViewDeployment: true, - canViewAllUsers: true, canViewHealth: true, + canViewOrganizations: true, }, decorators: [withDashboardProvider], }; @@ -21,15 +23,51 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const ForAdmin: Story = {}; +export const ForAdmin: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Deployment" })); + }, +}; + +export const ForAuditor: Story = { + args: { + user: MockUser2, + canViewAllUsers: false, + canViewAuditLog: true, + canViewDeployment: false, + canViewHealth: false, + canViewOrganizations: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Deployment" })); + }, +}; + +export const ForOrgAdmin: Story = { + args: { + user: MockUser2, + canViewAllUsers: false, + canViewAuditLog: true, + canViewDeployment: false, + canViewHealth: false, + canViewOrganizations: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Deployment" })); + }, +}; export const ForMember: Story = { args: { user: MockUser2, + canViewAllUsers: false, canViewAuditLog: false, canViewDeployment: false, - canViewAllUsers: false, canViewHealth: false, + canViewOrganizations: false, }, }; diff --git a/site/src/pages/AuditPage/AuditPage.tsx b/site/src/pages/AuditPage/AuditPage.tsx index ed81e36f19..34eff1cc89 100644 --- a/site/src/pages/AuditPage/AuditPage.tsx +++ b/site/src/pages/AuditPage/AuditPage.tsx @@ -17,10 +17,9 @@ import { import { AuditPageView } from "./AuditPageView"; const AuditPage: FC = () => { - const { audit_log: isAuditLogVisible } = useFeatureVisibility(); + const feats = useFeatureVisibility(); const { experiments } = useDashboard(); const location = useLocation(); - const isMultiOrg = experiments.includes("multi-organization"); /** * There is an implicit link between auditsQuery and filter via the @@ -75,7 +74,9 @@ const AuditPage: FC = () => { // TODO: Once multi-org is stable, we should place this redirect into the // router directly, if we still need to maintain it (for users who are // typing the old URL manually or have it bookmarked). - if (isMultiOrg && location.pathname !== "/deployment/audit") { + const canViewOrganizations = + feats.multiple_organizations && experiments.includes("multi-organization"); + if (canViewOrganizations && location.pathname !== "/deployment/audit") { return ; } @@ -88,10 +89,10 @@ const AuditPage: FC = () => { { user: userMenu, action: actionMenu, resourceType: resourceTypeMenu, - organization: isMultiOrg ? organizationsMenu : undefined, + organization: canViewOrganizations ? organizationsMenu : undefined, }, }} /> diff --git a/site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx b/site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx index bed83a9ee8..14b77ff550 100644 --- a/site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx +++ b/site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx @@ -9,11 +9,12 @@ import { Stack } from "components/Stack/Stack"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { RequirePermission } from "contexts/auth/RequirePermission"; import { useDashboard } from "modules/dashboard/useDashboard"; +import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { ManagementSettingsLayout } from "pages/ManagementSettingsPage/ManagementSettingsLayout"; import { Sidebar } from "./Sidebar"; type DeploySettingsContextValue = { - deploymentValues: DeploymentConfig; + deploymentValues: DeploymentConfig | undefined; }; export const DeploySettingsContext = createContext< @@ -33,9 +34,11 @@ export const useDeploySettings = (): DeploySettingsContextValue => { export const DeploySettingsLayout: FC = () => { const { experiments } = useDashboard(); - const multiOrgExperimentEnabled = experiments.includes("multi-organization"); + const feats = useFeatureVisibility(); + const canViewOrganizations = + feats.multiple_organizations && experiments.includes("multi-organization"); - return multiOrgExperimentEnabled ? ( + return canViewOrganizations ? ( ) : ( @@ -52,19 +55,15 @@ const DeploySettingsLayoutInner: FC = () => {
- {deploymentConfigQuery.data ? ( - - }> - - - - ) : ( - - )} + + }> + + +
diff --git a/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage.tsx b/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage.tsx index ada99d128c..7607072760 100644 --- a/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage.tsx @@ -1,5 +1,6 @@ import type { FC } from "react"; import { Helmet } from "react-helmet-async"; +import { Loader } from "components/Loader/Loader"; import { pageTitle } from "utils/page"; import { useDeploySettings } from "../DeploySettingsLayout"; import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView"; @@ -13,7 +14,11 @@ const ExternalAuthSettingsPage: FC = () => { {pageTitle("External Authentication Settings")} - + {deploymentValues ? ( + + ) : ( + + )} ); }; diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx index 9825de1d99..e1d9c4d873 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx @@ -4,6 +4,7 @@ import { useQuery } from "react-query"; import { deploymentDAUs } from "api/queries/deployment"; import { entitlements } from "api/queries/entitlements"; import { availableExperiments, experiments } from "api/queries/experiments"; +import { Loader } from "components/Loader/Loader"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { pageTitle } from "utils/page"; import { useDeploySettings } from "../DeploySettingsLayout"; @@ -29,14 +30,18 @@ const GeneralSettingsPage: FC = () => { {pageTitle("General Settings")} - + {deploymentValues ? ( + + ) : ( + + )} ); }; diff --git a/site/src/pages/DeploySettingsPage/NetworkSettingsPage/NetworkSettingsPage.tsx b/site/src/pages/DeploySettingsPage/NetworkSettingsPage/NetworkSettingsPage.tsx index 1c5832f129..64a110eccf 100644 --- a/site/src/pages/DeploySettingsPage/NetworkSettingsPage/NetworkSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/NetworkSettingsPage/NetworkSettingsPage.tsx @@ -1,5 +1,6 @@ import type { FC } from "react"; import { Helmet } from "react-helmet-async"; +import { Loader } from "components/Loader/Loader"; import { pageTitle } from "utils/page"; import { useDeploySettings } from "../DeploySettingsLayout"; import { NetworkSettingsPageView } from "./NetworkSettingsPageView"; @@ -13,7 +14,11 @@ const NetworkSettingsPage: FC = () => { {pageTitle("Network Settings")} - + {deploymentValues ? ( + + ) : ( + + )} ); }; diff --git a/site/src/pages/DeploySettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPage.tsx b/site/src/pages/DeploySettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPage.tsx index 668d878353..c07fc4ec5a 100644 --- a/site/src/pages/DeploySettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPage.tsx @@ -1,5 +1,6 @@ import type { FC } from "react"; import { Helmet } from "react-helmet-async"; +import { Loader } from "components/Loader/Loader"; import { useDashboard } from "modules/dashboard/useDashboard"; import { pageTitle } from "utils/page"; import { useDeploySettings } from "../DeploySettingsLayout"; @@ -15,10 +16,14 @@ const ObservabilitySettingsPage: FC = () => { {pageTitle("Observability Settings")} - + {deploymentValues ? ( + + ) : ( + + )} ); }; diff --git a/site/src/pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPage.tsx b/site/src/pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPage.tsx index 9968349dbf..3041af9ebd 100644 --- a/site/src/pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/SecuritySettingsPage/SecuritySettingsPage.tsx @@ -1,5 +1,6 @@ import type { FC } from "react"; import { Helmet } from "react-helmet-async"; +import { Loader } from "components/Loader/Loader"; import { useDashboard } from "modules/dashboard/useDashboard"; import { pageTitle } from "utils/page"; import { useDeploySettings } from "../DeploySettingsLayout"; @@ -15,12 +16,16 @@ const SecuritySettingsPage: FC = () => { {pageTitle("Security Settings")} - + {deploymentValues ? ( + + ) : ( + + )} ); }; diff --git a/site/src/pages/DeploySettingsPage/UserAuthSettingsPage/UserAuthSettingsPage.tsx b/site/src/pages/DeploySettingsPage/UserAuthSettingsPage/UserAuthSettingsPage.tsx index 2e8876abb5..a7af141b12 100644 --- a/site/src/pages/DeploySettingsPage/UserAuthSettingsPage/UserAuthSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/UserAuthSettingsPage/UserAuthSettingsPage.tsx @@ -1,5 +1,6 @@ import type { FC } from "react"; import { Helmet } from "react-helmet-async"; +import { Loader } from "components/Loader/Loader"; import { pageTitle } from "utils/page"; import { useDeploySettings } from "../DeploySettingsLayout"; import { UserAuthSettingsPageView } from "./UserAuthSettingsPageView"; @@ -13,7 +14,11 @@ const UserAuthSettingsPage: FC = () => { {pageTitle("User Authentication Settings")} - + {deploymentValues ? ( + + ) : ( + + )} ); }; diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index d15e41d271..213eaf7f86 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -11,14 +11,13 @@ import GroupsPageView from "./GroupsPageView"; export const GroupsPage: FC = () => { const { permissions } = useAuthenticated(); - const { createGroup: canCreateGroup } = permissions; const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility(); const groupsQuery = useQuery(groups("default")); useEffect(() => { if (groupsQuery.error) { displayError( - getErrorMessage(groupsQuery.error, "Error on loading groups."), + getErrorMessage(groupsQuery.error, "Unable to load groups."), ); } }, [groupsQuery.error]); @@ -31,7 +30,7 @@ export const GroupsPage: FC = () => { diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx index 9f3ddd507f..678a0f67e2 100644 --- a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx @@ -3,53 +3,68 @@ import Button from "@mui/material/Button"; import { type FC, useEffect } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; -import { - Navigate, - Link as RouterLink, - useLocation, - useParams, -} from "react-router-dom"; +import { Navigate, Link as RouterLink, useParams } from "react-router-dom"; import { getErrorMessage } from "api/errors"; import { groups } from "api/queries/groups"; +import { organizationPermissions } from "api/queries/organizations"; import type { Organization } from "api/typesGenerated"; +import { EmptyState } from "components/EmptyState/EmptyState"; import { displayError } from "components/GlobalSnackbar/utils"; +import { Loader } from "components/Loader/Loader"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; -import { useAuthenticated } from "contexts/auth/RequireAuth"; -import { useDashboard } from "modules/dashboard/useDashboard"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { pageTitle } from "utils/page"; import { useOrganizationSettings } from "../ManagementSettingsLayout"; import GroupsPageView from "./GroupsPageView"; export const GroupsPage: FC = () => { - const { permissions } = useAuthenticated(); - const { createGroup: canCreateGroup } = permissions; - const { - multiple_organizations: organizationsEnabled, - template_rbac: isTemplateRBACEnabled, - } = useFeatureVisibility(); - const { experiments } = useDashboard(); - const location = useLocation(); - const { organization = "default" } = useParams() as { organization?: string }; - const groupsQuery = useQuery(groups(organization)); + const feats = useFeatureVisibility(); + const { organization: organizationName } = useParams() as { + organization?: string; + }; + const groupsQuery = useQuery( + organizationName ? groups(organizationName) : { enabled: false }, + ); const { organizations } = useOrganizationSettings(); + const organization = organizations?.find((o) => o.name === organizationName); + const permissionsQuery = useQuery(organizationPermissions(organization?.id)); useEffect(() => { if (groupsQuery.error) { displayError( - getErrorMessage(groupsQuery.error, "Error on loading groups."), + getErrorMessage(groupsQuery.error, "Unable to load groups."), ); } }, [groupsQuery.error]); - if ( - organizationsEnabled && - experiments.includes("multi-organization") && - location.pathname === "/deployment/groups" - ) { - const defaultName = - getOrganizationNameByDefault(organizations) ?? "default"; - return ; + useEffect(() => { + if (permissionsQuery.error) { + displayError( + getErrorMessage(permissionsQuery.error, "Unable to load permissions."), + ); + } + }, [permissionsQuery.error]); + + if (!organizations) { + return ; + } + + if (!organizationName) { + const defaultName = getOrganizationNameByDefault(organizations); + if (defaultName) { + return ; + } + // We expect there to always be a default organization. + throw new Error("No default organization found"); + } + + if (!organization) { + return ; + } + + const permissions = permissionsQuery.data; + if (!permissions) { + return ; } return ( @@ -61,7 +76,7 @@ export const GroupsPage: FC = () => { - {canCreateGroup && isTemplateRBACEnabled && ( + {permissions.createGroup && feats.template_rbac && ( )} - {canCreateGroup && isTemplateRBACEnabled && ( + {permissions.createGroup && feats.template_rbac && (