From 097f739492a1bac2d83c0981bb52b9bcb57c6e5e Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 5 Aug 2024 17:55:35 -0800 Subject: [PATCH] feat: add organization-scoped permission checks to deployment settings (#14063) * s/readAllUsers/viewAllUsers Other frontend variables use the `view` syntax. Arguably we should use `read` to match the backend, but `view` does seem more UI-like. * Check license for organizations All the checks now require both the experiment and license. I also renamed the variable canViewOrganizations everywhere for consistency. * Allow any auditor to view the audit log * Use fine-grained permissions on settings page Since in addition to deployment settings this page now also includes users, audit logs, groups, and orgs. Since you might not be able to fetch deployment values, move all the loaders to the individual pages instead of in the wrapping layout. * Add stories for organization members page Needed to break it out into a separate view to do this. * Add stories for multi-org sidebar * Remove multi-org check from management settings layout We only use this layout when multi-org is enabled, so no need to run the check a second time. * Add more stories for deployment dropdown --- site/src/api/queries/organizations.ts | 61 +++ site/src/contexts/auth/permissions.tsx | 54 ++- site/src/modules/dashboard/Navbar/Navbar.tsx | 5 +- .../dashboard/Navbar/NavbarView.stories.tsx | 44 ++- site/src/pages/AuditPage/AuditPage.tsx | 13 +- .../DeploySettingsLayout.tsx | 31 +- .../ExternalAuthSettingsPage.tsx | 7 +- .../GeneralSettingsPage.tsx | 21 +- .../NetworkSettingsPage.tsx | 7 +- .../ObservabilitySettingsPage.tsx | 13 +- .../SecuritySettingsPage.tsx | 17 +- .../UserAuthSettingsPage.tsx | 7 +- site/src/pages/GroupsPage/GroupsPage.tsx | 5 +- .../GroupsPage/GroupsPage.tsx | 75 ++-- .../ManagementSettingsLayout.tsx | 54 ++- .../OrganizationMembersPage.test.tsx | 27 +- .../OrganizationMembersPage.tsx | 258 +++--------- .../OrganizationMembersPageView.stories.tsx | 58 +++ .../OrganizationMembersPageView.tsx | 216 ++++++++++ .../OrganizationSettingsPage.tsx | 48 ++- .../OrganizationSettingsPageView.stories.tsx | 7 + .../OrganizationSettingsPageView.tsx | 11 +- .../pages/ManagementSettingsPage/Sidebar.tsx | 322 ++------------- .../SidebarView.stories.tsx | 117 ++++++ .../ManagementSettingsPage/SidebarView.tsx | 368 ++++++++++++++++++ site/src/pages/UsersPage/UsersLayout.tsx | 13 +- site/src/pages/UsersPage/UsersPage.tsx | 12 +- site/src/pages/UsersPage/UsersPageView.tsx | 6 +- site/src/testHelpers/entities.ts | 22 +- 29 files changed, 1252 insertions(+), 647 deletions(-) create mode 100644 site/src/pages/ManagementSettingsPage/OrganizationMembersPageView.stories.tsx create mode 100644 site/src/pages/ManagementSettingsPage/OrganizationMembersPageView.tsx create mode 100644 site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx create mode 100644 site/src/pages/ManagementSettingsPage/SidebarView.tsx 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 && (