diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 8dcef31bf676e..1061c15e22b57 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -600,6 +600,24 @@ class ApiMethods { return response.data; }; + patchOrganizationRole = async ( + organizationId: string, + role: TypesGen.Role, + ): Promise => { + const response = await this.axios.patch( + `/api/v2/organizations/${organizationId}/members/roles`, + role, + ); + + return response.data; + }; + + deleteOrganizationRole = async (organizationId: string, roleName: string) => { + await this.axios.delete( + `/api/v2/organizations/${organizationId}/members/roles/${roleName}`, + ); + }; + /** * @param organization Can be the organization's ID or name */ diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 65cb843c08e2e..9befb55e7da28 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -177,6 +177,13 @@ export const organizationPermissions = (organizationId: string | undefined) => { }, action: "read", }, + assignOrgRole: { + object: { + resource_type: "assign_org_role", + organization_id: organizationId, + }, + action: "create", + }, }, }), }; diff --git a/site/src/api/queries/roles.ts b/site/src/api/queries/roles.ts index e51805e72c527..944a4fd4f85f4 100644 --- a/site/src/api/queries/roles.ts +++ b/site/src/api/queries/roles.ts @@ -1,4 +1,13 @@ +import type { QueryClient } from "react-query"; import { API } from "api/api"; +import type { Role } from "api/typesGenerated"; + +const getRoleQueryKey = (organizationId: string, roleName: string) => [ + "organization", + organizationId, + "role", + roleName, +]; export const roles = () => { return { @@ -13,3 +22,30 @@ export const organizationRoles = (organizationId: string) => { queryFn: () => API.getOrganizationRoles(organizationId), }; }; + +export const patchOrganizationRole = ( + queryClient: QueryClient, + organizationId: string, +) => { + return { + mutationFn: (request: Role) => + API.patchOrganizationRole(organizationId, request), + onSuccess: async (updatedRole: Role) => + await queryClient.invalidateQueries( + getRoleQueryKey(organizationId, updatedRole.name), + ), + }; +}; + +export const deleteRole = ( + queryClient: QueryClient, + organizationId: string, +) => { + return { + mutationFn: API.deleteOrganizationRole, + onSuccess: async (_: void, roleName: string) => + await queryClient.invalidateQueries( + getRoleQueryKey(organizationId, roleName), + ), + }; +}; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx new file mode 100644 index 0000000000000..6bbe37c58a813 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -0,0 +1,69 @@ +import type { FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useNavigate, useParams } from "react-router-dom"; +import { getErrorMessage } from "api/errors"; +import { organizationPermissions } from "api/queries/organizations"; +import { patchOrganizationRole, organizationRoles } from "api/queries/roles"; +import type { PatchRoleRequest } from "api/typesGenerated"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { Loader } from "components/Loader/Loader"; +import { pageTitle } from "utils/page"; +import { useOrganizationSettings } from "../ManagementSettingsLayout"; +import CreateEditRolePageView from "./CreateEditRolePageView"; + +export const CreateEditRolePage: FC = () => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { organization: organizationName, roleName } = useParams() as { + organization: string; + roleName: string; + }; + const { organizations } = useOrganizationSettings(); + const organization = organizations?.find((o) => o.name === organizationName); + const permissionsQuery = useQuery(organizationPermissions(organization?.id)); + const patchOrganizationRoleMutation = useMutation( + patchOrganizationRole(queryClient, organizationName), + ); + const { data: roleData, isLoading } = useQuery( + organizationRoles(organizationName), + ); + const role = roleData?.find((role) => role.name === roleName); + const permissions = permissionsQuery.data; + + if (isLoading || !permissions) { + return ; + } + + return ( + <> + + + {pageTitle( + role !== undefined ? "Edit Custom Role" : "Create Custom Role", + )} + + + + { + try { + await patchOrganizationRoleMutation.mutateAsync(data); + navigate(`/organizations/${organizationName}/roles`); + } catch (error) { + displayError( + getErrorMessage(error, "Failed to update custom role"), + ); + } + }} + error={patchOrganizationRoleMutation.error} + isLoading={patchOrganizationRoleMutation.isLoading} + organizationName={organizationName} + canAssignOrgRole={permissions.assignOrgRole} + /> + + ); +}; + +export default CreateEditRolePage; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.stories.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.stories.tsx new file mode 100644 index 0000000000000..4afd5969e5750 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + mockApiError, + MockRoleWithOrgPermissions, + assignableRole, +} from "testHelpers/entities"; +import { CreateEditRolePageView } from "./CreateEditRolePageView"; + +const meta: Meta = { + title: "pages/OrganizationCreateEditRolePage", + component: CreateEditRolePageView, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + role: assignableRole(MockRoleWithOrgPermissions, true), + onSubmit: () => null, + error: undefined, + isLoading: false, + organizationName: "my-org", + canAssignOrgRole: true, + }, +}; + +export const WithError: Story = { + args: { + role: assignableRole(MockRoleWithOrgPermissions, true), + onSubmit: () => null, + error: mockApiError({ + message: "A role named new-role already exists.", + validations: [{ field: "name", detail: "Role names must be unique" }], + }), + isLoading: false, + organizationName: "my-org", + canAssignOrgRole: true, + }, +}; + +export const CannotEdit: Story = { + args: { + role: assignableRole(MockRoleWithOrgPermissions, true), + onSubmit: () => null, + error: undefined, + isLoading: false, + organizationName: "my-org", + canAssignOrgRole: false, + }, +}; + +export const ShowAllResources: Story = { + args: { + role: assignableRole(MockRoleWithOrgPermissions, true), + onSubmit: () => null, + error: undefined, + isLoading: false, + organizationName: "my-org", + canAssignOrgRole: true, + allResources: true, + }, +}; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx new file mode 100644 index 0000000000000..8e58f19ac5954 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx @@ -0,0 +1,332 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import Button from "@mui/material/Button"; +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableFooter from "@mui/material/TableFooter"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import TextField from "@mui/material/TextField"; +import { useFormik } from "formik"; +import { type ChangeEvent, useState, type FC } from "react"; +import { useNavigate } from "react-router-dom"; +import * as Yup from "yup"; +import { isApiValidationError } from "api/errors"; +import { RBACResourceActions } from "api/rbacresources_gen"; +import type { + Role, + PatchRoleRequest, + Permission, + AssignableRoles, + RBACResource, + RBACAction, +} from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { FormFields, FormFooter, VerticalForm } from "components/Form/Form"; +import { + PageHeader, + PageHeaderSubtitle, + PageHeaderTitle, +} from "components/PageHeader/PageHeader"; +import { getFormHelpers, nameValidator } from "utils/formUtils"; + +const validationSchema = Yup.object({ + name: nameValidator("Name"), +}); + +export type CreateEditRolePageViewProps = { + role: AssignableRoles | undefined; + onSubmit: (data: PatchRoleRequest) => void; + error?: unknown; + isLoading: boolean; + organizationName: string; + canAssignOrgRole: boolean; + allResources?: boolean; +}; + +export const CreateEditRolePageView: FC = ({ + role, + onSubmit, + error, + isLoading, + organizationName, + canAssignOrgRole, + allResources = false, +}) => { + const navigate = useNavigate(); + const onCancel = () => navigate(-1); + + const form = useFormik({ + initialValues: { + name: role?.name || "", + display_name: role?.display_name || "", + site_permissions: role?.site_permissions || [], + organization_permissions: role?.organization_permissions || [], + user_permissions: role?.user_permissions || [], + }, + validationSchema, + onSubmit, + }); + + const getFieldHelpers = getFormHelpers(form, error); + + return ( + <> + + + + + ) + } + > + + {role ? "Edit" : "Create"} custom role + + + {"Set a name and permissions for this role."} + + + + + {Boolean(error) && !isApiValidationError(error) && ( + + )} + + + + + + {canAssignOrgRole && ( + + )} + + + ); +}; + +interface ActionCheckboxesProps { + permissions: readonly Permission[] | undefined; + form: ReturnType> & { values: Role }; + allResources: boolean; +} + +const ResourceActionComparator = ( + p: Permission, + resource: string, + action: string, +) => + p.resource_type === resource && + (p.action.toString() === "*" || p.action === action); + +const DEFAULT_RESOURCES = [ + "audit_log", + "group", + "template", + "organization_member", + "provisioner_daemon", + "workspace", +]; + +const resources = new Set(DEFAULT_RESOURCES); + +const filteredRBACResourceActions = Object.fromEntries( + Object.entries(RBACResourceActions).filter(([resource]) => + resources.has(resource), + ), +); + +const ActionCheckboxes: FC = ({ + permissions, + form, + allResources, +}) => { + const [checkedActions, setCheckActions] = useState(permissions); + const [showAllResources, setShowAllResources] = useState(allResources); + + const handleActionCheckChange = async ( + e: ChangeEvent, + form: ReturnType> & { values: Role }, + ) => { + const { name, checked } = e.currentTarget; + const [resource_type, action] = name.split(":"); + + const newPermissions = checked + ? [ + ...(checkedActions ?? []), + { + negate: false, + resource_type: resource_type as RBACResource, + action: action as RBACAction, + }, + ] + : checkedActions?.filter( + (p) => p.resource_type !== resource_type || p.action !== action, + ); + + setCheckActions(newPermissions); + await form.setFieldValue("organization_permissions", newPermissions); + }; + + const resourceActions = showAllResources + ? RBACResourceActions + : filteredRBACResourceActions; + + return ( + + + + + Permission + + + + + + + {Object.entries(resourceActions).map(([resourceKey, value]) => { + return ( + + +
  • + {resourceKey} +
      + {Object.entries(value).map(([actionKey, value]) => ( +
    • + + + ResourceActionComparator( + p, + resourceKey, + actionKey, + ), + )} + onChange={(e) => handleActionCheckChange(e, form)} + /> + {actionKey} + {" "} + –{" "} + {value} +
    • + ))} +
    +
  • +
    +
    + ); + })} +
    + + + + + + + +
    +
    + ); +}; + +interface ShowAllResourcesCheckboxProps { + showAllResources: boolean; + setShowAllResources: React.Dispatch>; +} + +const ShowAllResourcesCheckbox: FC = ({ + showAllResources, + setShowAllResources, +}) => { + return ( + setShowAllResources(e.currentTarget.checked)} + checkedIcon={} + icon={} + /> + } + label={Show all permissions} + /> + ); +}; + +const styles = { + checkBoxes: { + margin: 0, + listStyleType: "none", + }, + actionText: (theme) => ({ + color: theme.palette.text.primary, + }), + actionDescription: (theme) => ({ + color: theme.palette.text.secondary, + }), +} satisfies Record>; + +export default CreateEditRolePageView; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx new file mode 100644 index 0000000000000..e1529815796c0 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -0,0 +1,80 @@ +import AddIcon from "@mui/icons-material/AddOutlined"; +import Button from "@mui/material/Button"; +import { type FC, useEffect } from "react"; +import { Helmet } from "react-helmet-async"; +import { useQuery } from "react-query"; +import { Link as RouterLink, useParams } from "react-router-dom"; +import { getErrorMessage } from "api/errors"; +import { organizationPermissions } from "api/queries/organizations"; +import { organizationRoles } from "api/queries/roles"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { Loader } from "components/Loader/Loader"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; +import { pageTitle } from "utils/page"; +import { useOrganizationSettings } from "../ManagementSettingsLayout"; +import CustomRolesPageView from "./CustomRolesPageView"; + +export const CustomRolesPage: FC = () => { + const { custom_roles: isCustomRolesEnabled } = useFeatureVisibility(); + const { organization: organizationName } = useParams() as { + organization: string; + }; + const { organizations } = useOrganizationSettings(); + const organization = organizations?.find((o) => o.name === organizationName); + const permissionsQuery = useQuery(organizationPermissions(organization?.id)); + const organizationRolesQuery = useQuery(organizationRoles(organizationName)); + const filteredRoleData = organizationRolesQuery.data?.filter( + (role) => role.built_in === false, + ); + const permissions = permissionsQuery.data; + + useEffect(() => { + if (organizationRolesQuery.error) { + displayError( + getErrorMessage( + organizationRolesQuery.error, + "Error loading custom roles.", + ), + ); + } + }, [organizationRolesQuery.error]); + + if (!permissions) { + return ; + } + + return ( + <> + + {pageTitle("Custom Roles")} + + + + {permissions.assignOrgRole && isCustomRolesEnabled && ( + + )} + + } + > + Custom Roles + + + + + ); +}; + +export default CustomRolesPage; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx new file mode 100644 index 0000000000000..52b2045fbba17 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { MockRoleWithOrgPermissions } from "testHelpers/entities"; +import { CustomRolesPageView } from "./CustomRolesPageView"; + +const meta: Meta = { + title: "pages/OrganizationCustomRolesPage", + component: CustomRolesPageView, +}; + +export default meta; +type Story = StoryObj; + +export const NotEnabled: Story = { + args: { + roles: [MockRoleWithOrgPermissions], + canAssignOrgRole: true, + isCustomRolesEnabled: false, + }, +}; + +export const Enabled: Story = { + args: { + roles: [MockRoleWithOrgPermissions], + canAssignOrgRole: true, + isCustomRolesEnabled: true, + }, +}; + +export const EmptyDisplayName: Story = { + args: { + roles: [ + { + ...MockRoleWithOrgPermissions, + name: "my-custom-role", + display_name: "", + }, + ], + canAssignOrgRole: true, + isCustomRolesEnabled: true, + }, +}; + +export const EmptyRoleWithoutPermission: Story = { + args: { + roles: [], + canAssignOrgRole: false, + isCustomRolesEnabled: true, + }, +}; + +export const EmptyRoleWithPermission: Story = { + args: { + roles: [], + canAssignOrgRole: true, + isCustomRolesEnabled: true, + }, +}; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx new file mode 100644 index 0000000000000..12404e91dab2d --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -0,0 +1,166 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import AddOutlined from "@mui/icons-material/AddOutlined"; +import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight"; +import Button from "@mui/material/Button"; +import Skeleton from "@mui/material/Skeleton"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import type { FC } from "react"; +import { Link as RouterLink, useNavigate } from "react-router-dom"; +import type { Role } from "api/typesGenerated"; +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { Paywall } from "components/Paywall/Paywall"; +import { + TableLoaderSkeleton, + TableRowSkeleton, +} from "components/TableLoader/TableLoader"; +import { useClickableTableRow } from "hooks"; +import { docs } from "utils/docs"; + +export type CustomRolesPageViewProps = { + roles: Role[] | undefined; + canAssignOrgRole: boolean; + isCustomRolesEnabled: boolean; +}; + +export const CustomRolesPageView: FC = ({ + roles, + canAssignOrgRole, + isCustomRolesEnabled, +}) => { + const isLoading = roles === undefined; + const isEmpty = Boolean(roles && roles.length === 0); + + return ( + <> + + + + + + + + + + Name + Permissions + + + + + + + + + + + + + } + variant="contained" + > + Create custom role + + ) + } + /> + + + + + + {roles?.map((role) => ( + + ))} + + + +
    +
    +
    +
    + + ); +}; + +interface RoleRowProps { + role: Role; +} + +const RoleRow: FC = ({ role }) => { + const navigate = useNavigate(); + const rowProps = useClickableTableRow({ + onClick: () => navigate(role.name), + }); + + return ( + + {role.display_name || role.name} + + + {role.organization_permissions.length} + + + +
    + +
    +
    +
    + ); +}; + +const TableLoader = () => { + return ( + + + + + + + + + + + + + + ); +}; + +const styles = { + arrowRight: (theme) => ({ + color: theme.palette.text.secondary, + width: 20, + height: 20, + }), + arrowCell: { + display: "flex", + }, + secondary: (theme) => ({ + color: theme.palette.text.secondary, + }), +} satisfies Record>; + +export default CustomRolesPageView; diff --git a/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx b/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx index 5490b4df68b49..c0d9ea18e9325 100644 --- a/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx +++ b/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx @@ -4,11 +4,13 @@ import { MockOrganization2, MockPermissions, } from "testHelpers/entities"; +import { withDashboardProvider } from "testHelpers/storybook"; import { SidebarView } from "./SidebarView"; const meta: Meta = { title: "components/MultiOrgSidebarView", component: SidebarView, + decorators: [withDashboardProvider], args: { activeOrganization: undefined, activeOrgPermissions: undefined, @@ -88,6 +90,7 @@ export const SelectedOrgAdmin: Story = { viewMembers: true, viewGroups: true, auditOrganization: true, + assignOrgRole: true, }, }, }; diff --git a/site/src/pages/ManagementSettingsPage/SidebarView.tsx b/site/src/pages/ManagementSettingsPage/SidebarView.tsx index d9738563aa9de..87a11e43329c6 100644 --- a/site/src/pages/ManagementSettingsPage/SidebarView.tsx +++ b/site/src/pages/ManagementSettingsPage/SidebarView.tsx @@ -10,6 +10,7 @@ import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar"; import { Stack } from "components/Stack/Stack"; import { UserAvatar } from "components/UserAvatar/UserAvatar"; import { type ClassName, useClassName } from "hooks/useClassName"; +import { useDashboard } from "modules/dashboard/useDashboard"; import { linkToAuditing, linkToUsers, withFilter } from "modules/navigation"; interface SidebarProps { @@ -184,6 +185,8 @@ interface OrganizationSettingsNavigationProps { const OrganizationSettingsNavigation: FC< OrganizationSettingsNavigationProps > = (props) => { + const { experiments } = useDashboard(); + return ( <> )} + {props.permissions.assignOrgRole && + experiments.includes("custom-roles") && ( + + Roles + + )} {/* For now redirect to the site-wide audit page with the organization pre-filled into the filter. Based on user feedback we might want to serve a copy of the audit page or even delete this link. */} diff --git a/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx b/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx index a3c9286fe8362..a1a0b14514390 100644 --- a/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx +++ b/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx @@ -138,7 +138,7 @@ export const EditRolesButton: FC = ({ onChange={handleChange} isChecked={selectedRoleNames.has(role.name)} value={role.name} - name={role.display_name} + name={role.display_name || role.name} description={roleDescriptions[role.name] ?? ""} /> ))} diff --git a/site/src/router.tsx b/site/src/router.tsx index 615d12969e184..b6d1c43f103d6 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -242,6 +242,14 @@ const OrganizationGroupSettingsPage = lazy( const OrganizationMembersPage = lazy( () => import("./pages/ManagementSettingsPage/OrganizationMembersPage"), ); +const OrganizationCustomRolesPage = lazy( + () => + import("./pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage"), +); +const CreateEditRolePage = lazy( + () => + import("./pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage"), +); const TemplateEmbedPage = lazy( () => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"), ); @@ -376,6 +384,11 @@ export const router = createBrowserRouter( } /> } /> {groupsRouter()} + + } /> + } /> + } /> + } /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 1c12784a3c84f..f2ad7362111b8 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -326,6 +326,71 @@ export const MockOrganizationAuditorRole: TypesGen.Role = { organization_id: MockOrganization.id, }; +export const MockRoleWithOrgPermissions: TypesGen.Role = { + name: "my-role-1", + display_name: "My Role 1", + organization_id: MockOrganization.id, + site_permissions: [], + organization_permissions: [ + { + negate: false, + resource_type: "organization_member", + action: "create", + }, + { + negate: false, + resource_type: "organization_member", + action: "delete", + }, + { + negate: false, + resource_type: "organization_member", + action: "read", + }, + { + negate: false, + resource_type: "organization_member", + action: "update", + }, + { + negate: false, + resource_type: "template", + action: "create", + }, + { + negate: false, + resource_type: "template", + action: "delete", + }, + { + negate: false, + resource_type: "template", + action: "read", + }, + { + negate: false, + resource_type: "template", + action: "update", + }, + { + negate: false, + resource_type: "template", + action: "view_insights", + }, + { + negate: false, + resource_type: "audit_log", + action: "create", + }, + { + negate: false, + resource_type: "audit_log", + action: "read", + }, + ], + user_permissions: [], +}; + // assignableRole takes a role and a boolean. The boolean implies if the // actor can assign (add/remove) the role from other users. export function assignableRole(