Skip to content

Commit

Permalink
Allow setting Content Scopes for each permission (#1524)
Browse files Browse the repository at this point in the history
The contentScopes now have to be checked for every permission
individually.
  • Loading branch information
fraxachun authored Jan 26, 2024
1 parent 8e86516 commit 298da50
Show file tree
Hide file tree
Showing 36 changed files with 390 additions and 117 deletions.
3 changes: 2 additions & 1 deletion .changeset/sixty-cobras-brake.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ Replace ContentScopeModule with UserPermissionsModule
Breaking changes:

- ContentScope-Module has been removed
- canAccessScope has been moved to AccessControlService and renamed to isAllowedContentScope
- canAccessScope has been moved to AccessControlService and refactored into isAllowed
- contentScopes- and permissions-fields have been added to CurrentUser-Object
- role- and rights-fields has been removed from CurrentUser-Object
- AllowForRole-decorator has been removed
- Rename decorator SubjectEntity to AffectedEntity
Expand Down
2 changes: 1 addition & 1 deletion demo/admin/src/common/ContentScopeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const ContentScopeProvider: React.FC<Pick<ContentScopeProviderProps, "children">
const sitesConfig = useSitesConfig<SitesConfig>();
const user = useCurrentUser();

const allowedUserDomains = user.contentScopes.map((scope) => scope.domain);
const allowedUserDomains = user.allowedContentScopes.map((scope) => scope.domain);

const allowedSiteConfigs = Object.fromEntries(
Object.entries(sitesConfig.configs).filter(([siteKey, siteConfig]) => allowedUserDomains.includes(siteKey)),
Expand Down
1 change: 1 addition & 0 deletions demo/admin/src/common/MasterMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export const masterMenuData: MasterMenuData = [
requiredPermission: "pageTree",
},
],
requiredPermission: "pageTree",
},
{
primary: <FormattedMessage id="menu.componentDemo" defaultMessage="Component demo" />,
Expand Down
6 changes: 3 additions & 3 deletions demo/admin/src/dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MainContent, Stack } from "@comet/admin";
import { DashboardHeader, LatestBuildsDashboardWidget } from "@comet/cms-admin";
import { DashboardHeader, LatestBuildsDashboardWidget, useUserPermissionCheck } from "@comet/cms-admin";
import { Grid } from "@mui/material";
import { ContentScopeIndicator } from "@src/common/ContentScopeIndicator";
import * as React from "react";
Expand All @@ -11,7 +11,7 @@ import { LatestContentUpdates } from "./LatestContentUpdates";

const Dashboard: React.FC = () => {
const intl = useIntl();

const isAllowed = useUserPermissionCheck();
return (
<Stack topLevelTitle={intl.formatMessage({ id: "dashboard", defaultMessage: "Dashboard" })}>
<DashboardHeader
Expand All @@ -23,7 +23,7 @@ const Dashboard: React.FC = () => {
<MainContent>
<ContentScopeIndicator global />
<Grid container direction="row" spacing={4}>
<LatestContentUpdates />
{isAllowed("pageTree") && <LatestContentUpdates />}
{process.env.NODE_ENV !== "development" && <LatestBuildsDashboardWidget />}
</Grid>
</MainContent>
Expand Down
13 changes: 11 additions & 2 deletions demo/api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,14 @@ type FilenameResponse {

type CurrentUserPermission {
permission: String!
contentScopes: [JSONObject!]!
}

type CurrentUser {
id: String!
name: String!
email: String!
language: String!
contentScopes: [JSONObject!]!
permissions: [CurrentUserPermission!]!
}

Expand All @@ -173,6 +173,8 @@ type UserPermission {
reason: String
requestedBy: String
approvedBy: String
overrideContentScopes: Boolean!
contentScopes: [JSONObject!]!
}

enum UserPermissionSource {
Expand Down Expand Up @@ -909,7 +911,8 @@ type Mutation {
userPermissionsCreatePermission(userId: String!, input: UserPermissionInput!): UserPermission!
userPermissionsUpdatePermission(id: String!, input: UserPermissionInput!): UserPermission!
userPermissionsDeletePermission(id: ID!): Boolean!
userPermissionsUpdateContentScopes(userId: String!, input: UserContentScopesInput!): [JSONObject!]!
userPermissionsUpdateOverrideContentScopes(input: UserPermissionOverrideContentScopesInput!): UserPermission!
userPermissionsUpdateContentScopes(userId: String!, input: UserContentScopesInput!): Boolean!
createBuilds(input: CreateBuildsInput!): Boolean!
saveLink(linkId: ID!, input: LinkInput!, attachedPageTreeNodeId: ID!, lastUpdatedAt: DateTime): Link!
savePage(pageId: ID!, input: PageInput!, attachedPageTreeNodeId: ID!, lastUpdatedAt: DateTime): Page!
Expand Down Expand Up @@ -970,6 +973,12 @@ input UserPermissionInput {
approvedBy: String
}

input UserPermissionOverrideContentScopesInput {
permissionId: ID!
overrideContentScopes: Boolean!
contentScopes: [JSONObject!]! = []
}

input UserContentScopesInput {
contentScopes: [JSONObject!]! = []
}
Expand Down
2 changes: 1 addition & 1 deletion demo/api/src/auth/access-control.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class AccessControlService extends AbstractAccessControlService {
if (user.email.endsWith("@comet-dxp.com")) {
return UserPermissions.allPermissions;
} else {
return [{ permission: "news" }];
return [{ permission: "products" }, { permission: "news", contentScopes: [{ domain: "secondary", language: "en" }] }];
}
}
getContentScopesForUser(user: User): ContentScopesForUser {
Expand Down
11 changes: 3 additions & 8 deletions packages/admin/cms-admin/src/common/MasterMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import * as React from "react";
import { RouteProps, useRouteMatch } from "react-router-dom";

import { CurrentUserContext } from "../userPermissions/hooks/currentUser";
import { useUserPermissionCheck } from "../userPermissions/hooks/currentUser";

type MasterMenuItemRoute = Omit<MenuItemRouterLinkProps, "to"> & {
requiredPermission?: string;
Expand All @@ -33,13 +33,8 @@ export function isMasterMenuItemAnchor(item: MasterMenuItem): item is MasterMenu
}

export function useMenuFromMasterMenuData(items: MasterMenuData): MenuItem[] {
const context = React.useContext(CurrentUserContext);
const checkPermission = (item: MasterMenuItem): boolean => {
if (!item.requiredPermission) return true;
if (context === undefined)
throw new Error("MasterMenu: requiredPermission is set but CurrentUserContext not found. Make sure CurrentUserProvider exists.");
return context.isAllowed(context.currentUser, item.requiredPermission);
};
const isAllowed = useUserPermissionCheck();
const checkPermission = (item: MasterMenuItem): boolean => !item.requiredPermission || isAllowed(item.requiredPermission);

const mapFn = (item: MasterMenuItem): MenuItem => {
if (isMasterMenuItemAnchor(item)) {
Expand Down
11 changes: 3 additions & 8 deletions packages/admin/cms-admin/src/common/MasterMenuRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,12 @@ import { RouteWithErrorBoundary } from "@comet/admin";
import * as React from "react";
import { Redirect, RouteProps, Switch, useRouteMatch } from "react-router-dom";

import { CurrentUserContext } from "../userPermissions/hooks/currentUser";
import { useUserPermissionCheck } from "../userPermissions/hooks/currentUser";
import { isMasterMenuItemAnchor, MasterMenuData, MasterMenuItem } from "./MasterMenu";

export function useRoutePropsFromMasterMenuData(items: MasterMenuData): RouteProps[] {
const context = React.useContext(CurrentUserContext);
const checkPermission = (item: MasterMenuItem): boolean => {
if (!item.requiredPermission) return true;
if (context === undefined)
throw new Error("MasterMenuRoutes: requiredPermission is set but CurrentUserContext not found. Make sure CurrentUserProvider exists.");
return context.isAllowed(context.currentUser, item.requiredPermission);
};
const isAllowed = useUserPermissionCheck();
const checkPermission = (item: MasterMenuItem): boolean => !item.requiredPermission || isAllowed(item.requiredPermission);

const flat = (routes: RouteProps[], item: MasterMenuItem): RouteProps[] => {
if (isMasterMenuItemAnchor(item)) {
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/cms-admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export type { SiteConfig } from "./sitesConfig/SitesConfigContext";
export { SitesConfigProvider } from "./sitesConfig/SitesConfigProvider";
export { useSiteConfig } from "./sitesConfig/useSiteConfig";
export { useSitesConfig } from "./sitesConfig/useSitesConfig";
export { CurrentUserInterface, CurrentUserProvider, useCurrentUser } from "./userPermissions/hooks/currentUser";
export { CurrentUserInterface, CurrentUserProvider, useCurrentUser, useUserPermissionCheck } from "./userPermissions/hooks/currentUser";
export { UserPermissionsPage } from "./userPermissions/UserPermissionsPage";
// eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports
import emotionStyled from "@emotion/styled";
Expand Down
30 changes: 23 additions & 7 deletions packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { gql, useQuery } from "@apollo/client";
import { Loading } from "@comet/admin";
import isEqual from "lodash.isequal";
import React from "react";

import { ContentScopeInterface } from "../../contentScope/Provider";
import { ContentScopeInterface, useContentScope } from "../../contentScope/Provider";
import { GQLCurrentUserPermission } from "../../graphql.generated";
import { GQLCurrentUserQuery } from "./currentUser.generated";

type CurrentUserContext = { currentUser: CurrentUserInterface; isAllowed: (user: CurrentUserInterface, permission: string) => boolean };
type CurrentUserContext = {
currentUser: CurrentUserInterface;
isAllowed: (user: CurrentUserInterface, permission: string, contentScope?: ContentScopeInterface) => boolean;
};
export const CurrentUserContext = React.createContext<CurrentUserContext | undefined>(undefined);

export interface CurrentUserInterface {
name?: string;
email?: string;
language?: string;
permissions: GQLCurrentUserPermission[];
contentScopes: ContentScopeInterface[];
allowedContentScopes: ContentScopeInterface[];
}

export const CurrentUserProvider: React.FC<{
Expand All @@ -26,9 +30,9 @@ export const CurrentUserProvider: React.FC<{
id
name
email
contentScopes
permissions {
permission
contentScopes
}
}
}
Expand All @@ -39,12 +43,17 @@ export const CurrentUserProvider: React.FC<{
if (!data) return <Loading behavior="fillPageHeight" />;

const context: CurrentUserContext = {
currentUser: data.currentUser,
currentUser: {
...data.currentUser,
allowedContentScopes: data.currentUser.permissions.flatMap((p) => p.contentScopes),
},
isAllowed:
isAllowed ??
((user: CurrentUserInterface, permission: string) => {
((user: CurrentUserInterface, permission: string, contentScope?: ContentScopeInterface) => {
if (user.email === undefined) return false;
return user.permissions.some((p) => p.permission === permission);
return user.permissions.some(
(p) => p.permission === permission && (!contentScope || p.contentScopes.some((cs) => isEqual(cs, contentScope))),
);
}),
};

Expand All @@ -56,3 +65,10 @@ export function useCurrentUser(): CurrentUserInterface {
if (!ret || !ret.currentUser) throw new Error("CurrentUser not found. Make sure CurrentUserContext exists.");
return ret.currentUser;
}

export function useUserPermissionCheck(): (permission: string) => boolean {
const context = React.useContext(CurrentUserContext);
if (!context) throw new Error("CurrentUser not found. Make sure CurrentUserContext exists.");
const contentScope = useContentScope();
return (permission: string) => context.isAllowed(context.currentUser, permission, contentScope.scope);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { gql, useApolloClient, useQuery } from "@apollo/client";
import { CancelButton, Field, FinalForm, FinalFormCheckbox, FinalFormSwitch, SaveButton } from "@comet/admin";
import { CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import React from "react";
import { FormattedMessage } from "react-intl";

import { camelCaseToHumanReadable } from "../../utils/camelCaseToHumanReadable";
import {
GQLOverrideContentScopesMutation,
GQLOverrideContentScopesMutationVariables,
GQLPermissionContentScopesQuery,
GQLPermissionContentScopesQueryVariables,
namedOperations,
} from "./OverrideContentScopesDialog.generated";

interface FormSubmitData {
overrideContentScopes: boolean;
contentScopes: string[];
}
interface FormProps {
permissionId: string;
userId: string;
handleDialogClose: () => void;
}
type ContentScope = {
[key: string]: string;
};

export const OverrideContentScopesDialog: React.FC<FormProps> = ({ permissionId, userId, handleDialogClose }) => {
const client = useApolloClient();

const submit = async (data: FormSubmitData) => {
await client.mutate<GQLOverrideContentScopesMutation, GQLOverrideContentScopesMutationVariables>({
mutation: gql`
mutation OverrideContentScopes($input: UserPermissionOverrideContentScopesInput!) {
userPermissionsUpdateOverrideContentScopes(input: $input) {
id
}
}
`,
variables: {
input: {
permissionId,
overrideContentScopes: data.overrideContentScopes,
contentScopes: data.contentScopes.map((contentScope) => JSON.parse(contentScope)),
},
},
refetchQueries: [namedOperations.Query.PermissionContentScopes, "Permissions"],
});
handleDialogClose();
};

const { data, error } = useQuery<GQLPermissionContentScopesQuery, GQLPermissionContentScopesQueryVariables>(
gql`
query PermissionContentScopes($permissionId: ID!, $userId: String) {
availableContentScopes: userPermissionsAvailableContentScopes
permission: userPermissionsPermission(id: $permissionId, userId: $userId) {
source
overrideContentScopes
contentScopes
}
}
`,
{
variables: { permissionId, userId },
},
);

if (error) {
throw new Error(error.message);
}

if (!data) {
return <CircularProgress />;
}

const initialValues: FormSubmitData = {
overrideContentScopes: data.permission.overrideContentScopes,
contentScopes: data.permission.contentScopes.map((v) => JSON.stringify(v)),
};
const disabled = data && data.permission.source === "BY_RULE";

return (
<Dialog maxWidth="sm" open={true}>
<FinalForm<FormSubmitData>
mode="edit"
onSubmit={submit}
initialValues={initialValues}
render={({ values }) => (
<>
<DialogTitle>
<FormattedMessage id="comet.userPermissions.scopes" defaultMessage="Scopes" />
</DialogTitle>
<DialogContent>
<Field
name="overrideContentScopes"
label={
<FormattedMessage id="comet.userPermissions.overrideScopes" defaultMessage="Permission-specific Content-Scopes" />
}
component={FinalFormSwitch}
type="checkbox"
disabled={disabled}
/>
{values.overrideContentScopes &&
data.availableContentScopes.map((contentScope: ContentScope) => (
<Field
disabled={disabled}
key={JSON.stringify(contentScope)}
name="contentScopes"
fullWidth
variant="horizontal"
type="checkbox"
component={FinalFormCheckbox}
value={JSON.stringify(contentScope)}
label={Object.entries(contentScope).map(([scope, value]) => (
<>
<FormattedMessage
id={`contentScope.scope.${scope}`}
defaultMessage={camelCaseToHumanReadable(scope)}
/>
:{" "}
<FormattedMessage
id={`contentScope.values.${value}`}
defaultMessage={camelCaseToHumanReadable(value)}
/>
<br />
</>
))}
/>
))}
</DialogContent>
<DialogActions>
<CancelButton onClick={() => handleDialogClose()}>
<FormattedMessage id="comet.userPermissions.close" defaultMessage="Close" />
</CancelButton>
{!disabled && <SaveButton type="submit" />}
</DialogActions>
</>
)}
/>
</Dialog>
);
};
Loading

0 comments on commit 298da50

Please sign in to comment.