diff --git a/.changeset/sixty-cobras-brake.md b/.changeset/sixty-cobras-brake.md index bb9ee035ea..cebcfff8b3 100644 --- a/.changeset/sixty-cobras-brake.md +++ b/.changeset/sixty-cobras-brake.md @@ -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 diff --git a/demo/admin/src/common/ContentScopeProvider.tsx b/demo/admin/src/common/ContentScopeProvider.tsx index 6a6c0bb27e..98793dd476 100644 --- a/demo/admin/src/common/ContentScopeProvider.tsx +++ b/demo/admin/src/common/ContentScopeProvider.tsx @@ -54,7 +54,7 @@ const ContentScopeProvider: React.FC const sitesConfig = useSitesConfig(); 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)), diff --git a/demo/admin/src/common/MasterMenu.tsx b/demo/admin/src/common/MasterMenu.tsx index d580c1bf04..5c0a392cf0 100644 --- a/demo/admin/src/common/MasterMenu.tsx +++ b/demo/admin/src/common/MasterMenu.tsx @@ -149,6 +149,7 @@ export const masterMenuData: MasterMenuData = [ requiredPermission: "pageTree", }, ], + requiredPermission: "pageTree", }, { primary: , diff --git a/demo/admin/src/dashboard/Dashboard.tsx b/demo/admin/src/dashboard/Dashboard.tsx index 340171810f..01dbed5051 100644 --- a/demo/admin/src/dashboard/Dashboard.tsx +++ b/demo/admin/src/dashboard/Dashboard.tsx @@ -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"; @@ -11,7 +11,7 @@ import { LatestContentUpdates } from "./LatestContentUpdates"; const Dashboard: React.FC = () => { const intl = useIntl(); - + const isAllowed = useUserPermissionCheck(); return ( { - + {isAllowed("pageTree") && } {process.env.NODE_ENV !== "development" && } diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 91523991fc..6709484995 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -146,6 +146,7 @@ type FilenameResponse { type CurrentUserPermission { permission: String! + contentScopes: [JSONObject!]! } type CurrentUser { @@ -153,7 +154,6 @@ type CurrentUser { name: String! email: String! language: String! - contentScopes: [JSONObject!]! permissions: [CurrentUserPermission!]! } @@ -173,6 +173,8 @@ type UserPermission { reason: String requestedBy: String approvedBy: String + overrideContentScopes: Boolean! + contentScopes: [JSONObject!]! } enum UserPermissionSource { @@ -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! @@ -970,6 +973,12 @@ input UserPermissionInput { approvedBy: String } +input UserPermissionOverrideContentScopesInput { + permissionId: ID! + overrideContentScopes: Boolean! + contentScopes: [JSONObject!]! = [] +} + input UserContentScopesInput { contentScopes: [JSONObject!]! = [] } diff --git a/demo/api/src/auth/access-control.service.ts b/demo/api/src/auth/access-control.service.ts index 8ffffe8e7a..67c8e47d69 100644 --- a/demo/api/src/auth/access-control.service.ts +++ b/demo/api/src/auth/access-control.service.ts @@ -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 { diff --git a/packages/admin/cms-admin/src/common/MasterMenu.tsx b/packages/admin/cms-admin/src/common/MasterMenu.tsx index 4525e0c6ab..6dee7605b5 100644 --- a/packages/admin/cms-admin/src/common/MasterMenu.tsx +++ b/packages/admin/cms-admin/src/common/MasterMenu.tsx @@ -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 & { requiredPermission?: string; @@ -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)) { diff --git a/packages/admin/cms-admin/src/common/MasterMenuRoutes.tsx b/packages/admin/cms-admin/src/common/MasterMenuRoutes.tsx index b5b6e1f249..792cc61c4e 100644 --- a/packages/admin/cms-admin/src/common/MasterMenuRoutes.tsx +++ b/packages/admin/cms-admin/src/common/MasterMenuRoutes.tsx @@ -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)) { diff --git a/packages/admin/cms-admin/src/index.ts b/packages/admin/cms-admin/src/index.ts index 3fb4d01bb2..4fe3a22358 100644 --- a/packages/admin/cms-admin/src/index.ts +++ b/packages/admin/cms-admin/src/index.ts @@ -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"; diff --git a/packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx b/packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx index 5899480eef..eb0df6fdf2 100644 --- a/packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx +++ b/packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx @@ -1,12 +1,16 @@ 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(undefined); export interface CurrentUserInterface { @@ -14,7 +18,7 @@ export interface CurrentUserInterface { email?: string; language?: string; permissions: GQLCurrentUserPermission[]; - contentScopes: ContentScopeInterface[]; + allowedContentScopes: ContentScopeInterface[]; } export const CurrentUserProvider: React.FC<{ @@ -26,9 +30,9 @@ export const CurrentUserProvider: React.FC<{ id name email - contentScopes permissions { permission + contentScopes } } } @@ -39,12 +43,17 @@ export const CurrentUserProvider: React.FC<{ if (!data) return ; 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))), + ); }), }; @@ -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); +} diff --git a/packages/admin/cms-admin/src/userPermissions/user/permissions/OverrideContentScopesDialog.tsx b/packages/admin/cms-admin/src/userPermissions/user/permissions/OverrideContentScopesDialog.tsx new file mode 100644 index 0000000000..7f60799f60 --- /dev/null +++ b/packages/admin/cms-admin/src/userPermissions/user/permissions/OverrideContentScopesDialog.tsx @@ -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 = ({ permissionId, userId, handleDialogClose }) => { + const client = useApolloClient(); + + const submit = async (data: FormSubmitData) => { + await client.mutate({ + 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( + 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 ; + } + + 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 ( + + + mode="edit" + onSubmit={submit} + initialValues={initialValues} + render={({ values }) => ( + <> + + + + + + } + component={FinalFormSwitch} + type="checkbox" + disabled={disabled} + /> + {values.overrideContentScopes && + data.availableContentScopes.map((contentScope: ContentScope) => ( + ( + <> + + :{" "} + +
+ + ))} + /> + ))} +
+ + handleDialogClose()}> + + + {!disabled && } + + + )} + /> +
+ ); +}; diff --git a/packages/admin/cms-admin/src/userPermissions/user/permissions/PermissionGrid.tsx b/packages/admin/cms-admin/src/userPermissions/user/permissions/PermissionGrid.tsx index 5c35f93d49..799c2d2ba4 100644 --- a/packages/admin/cms-admin/src/userPermissions/user/permissions/PermissionGrid.tsx +++ b/packages/admin/cms-admin/src/userPermissions/user/permissions/PermissionGrid.tsx @@ -9,6 +9,7 @@ import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { camelCaseToHumanReadable } from "../../utils/camelCaseToHumanReadable"; +import { OverrideContentScopesDialog } from "./OverrideContentScopesDialog"; import { PermissionDialog } from "./PermissionDialog"; import { GQLPermissionForGridFragment, GQLPermissionsQuery, GQLPermissionsQueryVariables, namedOperations } from "./PermissionGrid.generated"; @@ -17,6 +18,7 @@ export const PermissionGrid: React.FC<{ }> = ({ userId }) => { const intl = useIntl(); const [permissionId, setPermissionId] = React.useState(null); + const [overrideContentScopesId, setOverrideContentScopesId] = React.useState(null); const { data, loading, error } = useQuery( gql` @@ -34,6 +36,7 @@ export const PermissionGrid: React.FC<{ reason requestedBy approvedBy + overrideContentScopes } `, { @@ -100,6 +103,20 @@ export const PermissionGrid: React.FC<{ ), }, + { + field: "overrideContentScopes", + headerName: "", + width: 175, + sortable: false, + pinnable: false, + filterable: false, + renderCell: ({ row }) => + (row.source === "MANUAL" || row.overrideContentScopes) && ( + + ), + }, { field: "edit", width: 60, @@ -176,6 +193,13 @@ export const PermissionGrid: React.FC<{ ), }} /> + {overrideContentScopesId && ( + setOverrideContentScopesId(null)} + /> + )} {permissionId && setPermissionId(null)} />} ); diff --git a/packages/api/cms-api/generate-schema.ts b/packages/api/cms-api/generate-schema.ts index d741421ed3..638039767d 100644 --- a/packages/api/cms-api/generate-schema.ts +++ b/packages/api/cms-api/generate-schema.ts @@ -3,11 +3,9 @@ import { NestFactory } from "@nestjs/core"; import { Field, GraphQLSchemaBuilderModule, GraphQLSchemaFactory, ObjectType } from "@nestjs/graphql"; import { writeFile } from "fs/promises"; import { printSchema } from "graphql"; -import { GraphQLJSONObject } from "graphql-type-json"; import { BuildsResolver, - ContentScope, createAuthResolver, createPageTreeResolver, createRedirectsResolver, @@ -60,8 +58,6 @@ class CurrentUser implements CurrentUserInterface { email: string; @Field() language: string; - @Field(() => [GraphQLJSONObject]) - contentScopes: ContentScope[]; @Field(() => [CurrentUserPermission]) permissions: CurrentUserPermission[]; } diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql index 5b866d46ff..29cc6098ac 100644 --- a/packages/api/cms-api/schema.gql +++ b/packages/api/cms-api/schema.gql @@ -142,6 +142,7 @@ type FilenameResponse { type CurrentUserPermission { permission: String! + contentScopes: [JSONObject!]! } type User { @@ -160,6 +161,8 @@ type UserPermission { reason: String requestedBy: String approvedBy: String + overrideContentScopes: Boolean! + contentScopes: [JSONObject!]! } enum UserPermissionSource { @@ -221,7 +224,6 @@ type CurrentUser { name: String! email: String! language: String! - contentScopes: [JSONObject!]! permissions: [CurrentUserPermission!]! } @@ -522,7 +524,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! } input CreateBuildsInput { @@ -622,6 +625,12 @@ input UserPermissionInput { approvedBy: String } +input UserPermissionOverrideContentScopesInput { + permissionId: ID! + overrideContentScopes: Boolean! + contentScopes: [JSONObject!]! = [] +} + input UserContentScopesInput { contentScopes: [JSONObject!]! = [] } diff --git a/packages/api/cms-api/src/auth/current-user/current-user.ts b/packages/api/cms-api/src/auth/current-user/current-user.ts index f48d13e44c..64db83a0a7 100644 --- a/packages/api/cms-api/src/auth/current-user/current-user.ts +++ b/packages/api/cms-api/src/auth/current-user/current-user.ts @@ -7,6 +7,6 @@ export interface CurrentUserInterface { language: string; permissions?: { permission: string; + contentScopes: ContentScope[]; }[]; - contentScopes?: ContentScope[]; } diff --git a/packages/api/cms-api/src/builds/build-templates.service.ts b/packages/api/cms-api/src/builds/build-templates.service.ts index 28e23be747..153e0a657e 100644 --- a/packages/api/cms-api/src/builds/build-templates.service.ts +++ b/packages/api/cms-api/src/builds/build-templates.service.ts @@ -17,7 +17,7 @@ export class BuildTemplatesService { async getAllowedBuilderCronJobs(user: CurrentUserInterface): Promise { return (await this.getAllBuilderCronJobs()).filter((cronJob) => { - return this.accessControlService.isAllowedContentScope(user, this.kubernetesService.getContentScope(cronJob) ?? {}); + return this.accessControlService.isAllowed(user, "builds", this.kubernetesService.getContentScope(cronJob) ?? {}); }); } diff --git a/packages/api/cms-api/src/builds/builds.resolver.ts b/packages/api/cms-api/src/builds/builds.resolver.ts index 87512cdc9f..04431d8eed 100644 --- a/packages/api/cms-api/src/builds/builds.resolver.ts +++ b/packages/api/cms-api/src/builds/builds.resolver.ts @@ -37,7 +37,7 @@ export class BuildsResolver { throw new Error("Triggering build from different instance is not allowed"); } - if (!this.accessControlService.isAllowedContentScope(user, this.kubernetesService.getContentScope(cronJob) ?? {})) { + if (!this.accessControlService.isAllowed(user, "builds", this.kubernetesService.getContentScope(cronJob) ?? {})) { throw new Error("Triggering build not allowed"); } diff --git a/packages/api/cms-api/src/builds/builds.service.ts b/packages/api/cms-api/src/builds/builds.service.ts index c225df3c5e..127d7e0fb4 100644 --- a/packages/api/cms-api/src/builds/builds.service.ts +++ b/packages/api/cms-api/src/builds/builds.service.ts @@ -32,7 +32,7 @@ export class BuildsService { private async getAllowedBuildJobs(user: CurrentUserInterface): Promise { const allJobs = await this.kubernetesService.getAllJobs(`${BUILDER_LABEL} = true, ${INSTANCE_LABEL} = ${this.kubernetesService.helmRelease}`); return allJobs.filter((job) => { - return this.accessControlService.isAllowedContentScope(user, this.kubernetesService.getContentScope(job) ?? {}); + return this.accessControlService.isAllowed(user, "builds", this.kubernetesService.getContentScope(job) ?? {}); }); } diff --git a/packages/api/cms-api/src/cron-jobs/cron-jobs.resolver.ts b/packages/api/cms-api/src/cron-jobs/cron-jobs.resolver.ts index d4dcf53f76..14ba4a80cf 100644 --- a/packages/api/cms-api/src/cron-jobs/cron-jobs.resolver.ts +++ b/packages/api/cms-api/src/cron-jobs/cron-jobs.resolver.ts @@ -40,7 +40,7 @@ export class CronJobsResolver { .filter((cronJob) => { const contentScope = this.kubernetesService.getContentScope(cronJob); if (contentScope) { - return this.accessControlService.isAllowedContentScope(user, contentScope); + return this.accessControlService.isAllowed(user, "builds", contentScope); } return true; @@ -56,7 +56,7 @@ export class CronJobsResolver { const cronJob = await this.kubernetesService.getCronJob(name); const contentScope = this.kubernetesService.getContentScope(cronJob); - if (contentScope && !this.accessControlService.isAllowedContentScope(user, contentScope)) { + if (contentScope && !this.accessControlService.isAllowed(user, "builds", contentScope)) { throw new Error("Access denied"); } @@ -68,7 +68,7 @@ export class CronJobsResolver { async triggerKubernetesCronJob(@Args("name") name: string, @GetCurrentUser() user: CurrentUserInterface): Promise { const cronJob = await this.kubernetesService.getCronJob(name); const contentScope = this.kubernetesService.getContentScope(cronJob); - if (contentScope && !this.accessControlService.isAllowedContentScope(user, contentScope)) { + if (contentScope && !this.accessControlService.isAllowed(user, "builds", contentScope)) { throw new Error("Access denied"); } diff --git a/packages/api/cms-api/src/cron-jobs/jobs.resolver.ts b/packages/api/cms-api/src/cron-jobs/jobs.resolver.ts index 486a4995ef..b11362b397 100644 --- a/packages/api/cms-api/src/cron-jobs/jobs.resolver.ts +++ b/packages/api/cms-api/src/cron-jobs/jobs.resolver.ts @@ -27,7 +27,7 @@ export class JobsResolver { const cronJob = await this.kubernetesService.getCronJob(cronJobName); const contentScope = this.kubernetesService.getContentScope(cronJob); - if (contentScope && !this.accessControlService.isAllowedContentScope(user, contentScope)) { + if (contentScope && !this.accessControlService.isAllowed(user, "cronJobs", contentScope)) { throw new Error("Access denied"); } diff --git a/packages/api/cms-api/src/dam/files/files.controller.ts b/packages/api/cms-api/src/dam/files/files.controller.ts index db0127d8eb..12e87db69c 100644 --- a/packages/api/cms-api/src/dam/files/files.controller.ts +++ b/packages/api/cms-api/src/dam/files/files.controller.ts @@ -75,7 +75,7 @@ export function createFilesController({ Scope: PassedScope }: { Scope?: Type { - return this.accessControlService.isAllowedContentScope(user, scope); + return this.accessControlService.isAllowed(user, "dam", scope); }); if (!canAccessFileScopes) { throw new Error(`User can't access the scope of one or more files`); diff --git a/packages/api/cms-api/src/dam/images/images.controller.ts b/packages/api/cms-api/src/dam/images/images.controller.ts index 0640bffee1..0e52a67f32 100644 --- a/packages/api/cms-api/src/dam/images/images.controller.ts +++ b/packages/api/cms-api/src/dam/images/images.controller.ts @@ -71,7 +71,7 @@ export class ImagesController { throw new NotFoundException(); } - if (file.scope !== undefined && !this.accessControlService.isAllowedContentScope(user, file.scope)) { + if (file.scope !== undefined && !this.accessControlService.isAllowed(user, "dam", file.scope)) { throw new ForbiddenException(); } @@ -97,7 +97,7 @@ export class ImagesController { throw new NotFoundException(); } - if (file.scope !== undefined && !this.accessControlService.isAllowedContentScope(user, file.scope)) { + if (file.scope !== undefined && !this.accessControlService.isAllowed(user, "dam", file.scope)) { throw new ForbiddenException(); } diff --git a/packages/api/cms-api/src/mikro-orm/migrations/Migration20231218092313.ts b/packages/api/cms-api/src/mikro-orm/migrations/Migration20231218092313.ts new file mode 100644 index 0000000000..1b54d31161 --- /dev/null +++ b/packages/api/cms-api/src/mikro-orm/migrations/Migration20231218092313.ts @@ -0,0 +1,16 @@ +import { Migration } from "@mikro-orm/migrations"; + +export class Migration20231218092313 extends Migration { + async up(): Promise { + this.addSql( + 'alter table "CometUserPermission" add column "overrideContentScopes" boolean not null default false, add column "contentScopes" jsonb not null default \'[]\'::jsonb;', + ); + this.addSql('alter table "CometUserPermission" alter column "overrideContentScopes" drop default'); + this.addSql('alter table "CometUserPermission" alter column "contentScopes" drop default'); + } + + async down(): Promise { + this.addSql('alter table "CometUserPermission" drop column "overrideContentScopes";'); + this.addSql('alter table "CometUserPermission" drop column "contentScopes";'); + } +} diff --git a/packages/api/cms-api/src/mikro-orm/mikro-orm.module.ts b/packages/api/cms-api/src/mikro-orm/mikro-orm.module.ts index 3e49aecbdc..2d77176624 100644 --- a/packages/api/cms-api/src/mikro-orm/mikro-orm.module.ts +++ b/packages/api/cms-api/src/mikro-orm/mikro-orm.module.ts @@ -21,6 +21,7 @@ import { Migration20230821090303 } from "./migrations/Migration20230821090303"; import { Migration20231204140305 } from "./migrations/Migration20231204140305"; import { Migration20231206123505 } from "./migrations/Migration20231206123505"; import { Migration20231215103630 } from "./migrations/Migration20231215103630"; +import { Migration20231218092313 } from "./migrations/Migration20231218092313"; import { Migration20231222090009 } from "./migrations/Migration20231222090009"; export const PG_UNIQUE_CONSTRAINT_VIOLATION = "23505"; @@ -81,6 +82,7 @@ export function createOrmConfig({ migrations, ...defaults }: MikroOrmNestjsOptio { name: "Migration20231215103630", class: Migration20231215103630 }, { name: "Migration20231222090009", class: Migration20231222090009 }, { name: "Migration20231204140305", class: Migration20231204140305 }, + { name: "Migration20231218092313", class: Migration20231218092313 }, ...(migrations?.migrationsList || []), ].sort((migrationA, migrationB) => { if (migrationA.name < migrationB.name) { diff --git a/packages/api/cms-api/src/user-permissions/access-control.service.ts b/packages/api/cms-api/src/user-permissions/access-control.service.ts index f1372af2da..5c6abf61e5 100644 --- a/packages/api/cms-api/src/user-permissions/access-control.service.ts +++ b/packages/api/cms-api/src/user-permissions/access-control.service.ts @@ -7,13 +7,11 @@ import { AccessControlServiceInterface } from "./user-permissions.types"; @Injectable() export abstract class AbstractAccessControlService implements AccessControlServiceInterface { - isAllowedContentScope(user: CurrentUserInterface, contentScope: ContentScope): boolean { - if (!user.contentScopes) return false; - return user.contentScopes.some((cs) => Object.entries(contentScope).every(([scope, value]) => cs[scope] === value)); + private checkContentScope(userContentScopes: ContentScope[], contentScope: ContentScope): boolean { + return userContentScopes.some((cs) => Object.entries(contentScope).every(([scope, value]) => cs[scope] === value)); } - - isAllowedPermission(user: CurrentUserInterface, permission: keyof Permission): boolean { + isAllowed(user: CurrentUserInterface, permission: keyof Permission, contentScope?: ContentScope): boolean { if (!user.permissions) return false; - return user.permissions.some((p) => p.permission === permission); + return user.permissions.some((p) => p.permission === permission && (!contentScope || this.checkContentScope(p.contentScopes, contentScope))); } } diff --git a/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts b/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts index a4d7a15fe6..7bd402cf3b 100644 --- a/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts +++ b/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts @@ -5,6 +5,7 @@ import { GqlContextType, GqlExecutionContext } from "@nestjs/graphql"; import { CurrentUserInterface } from "../../auth/current-user/current-user"; import { ContentScopeService } from "../content-scope.service"; import { RequiredPermission } from "../decorators/required-permission.decorator"; +import { ContentScope } from "../interfaces/content-scope.interface"; import { ACCESS_CONTROL_SERVICE } from "../user-permissions.constants"; import { AccessControlServiceInterface } from "../user-permissions.types"; @@ -38,8 +39,9 @@ export class UserPermissionsGuard implements CanActivate { throw new Error(`RequiredPermission decorator is missing in ${context.getClass().name}::${context.getHandler().name}()`); } + let contentScope: ContentScope | undefined; if (!this.isResolvingGraphQLField(context) && !requiredPermission.options?.skipScopeCheck) { - const contentScope = await this.contentScopeService.inferScopeFromExecutionContext(context); + contentScope = await this.contentScopeService.inferScopeFromExecutionContext(context); if (!contentScope) { throw new Error( `Could not get ContentScope. Either pass a scope-argument or add @AffectedEntity()-decorator or enable skipScopeCheck in @RequiredPermission() (${ @@ -47,15 +49,15 @@ export class UserPermissionsGuard implements CanActivate { }::${context.getHandler().name}())`, ); } - if (!this.accessControlService.isAllowedContentScope(user, contentScope)) { - return false; - } } const requiredPermissions = Array.isArray(requiredPermission.requiredPermission) ? requiredPermission.requiredPermission : [requiredPermission.requiredPermission]; - return requiredPermissions.some((permission) => this.accessControlService.isAllowedPermission(user, permission)); + if (requiredPermissions.length === 0) { + throw new Error(`RequiredPermission decorator has empty permissions in ${context.getClass().name}::${context.getHandler().name}()`); + } + return requiredPermissions.some((permission) => this.accessControlService.isAllowed(user, permission, contentScope)); } // See https://docs.nestjs.com/graphql/other-features#execute-enhancers-at-the-field-resolver-level diff --git a/packages/api/cms-api/src/user-permissions/dto/current-user.ts b/packages/api/cms-api/src/user-permissions/dto/current-user.ts index f1537b27eb..7c5285bd53 100644 --- a/packages/api/cms-api/src/user-permissions/dto/current-user.ts +++ b/packages/api/cms-api/src/user-permissions/dto/current-user.ts @@ -8,6 +8,8 @@ import { ContentScope } from "../interfaces/content-scope.interface"; export class CurrentUserPermission { @Field() permission: string; + @Field(() => [GraphQLJSONObject]) + contentScopes: ContentScope[]; } @ObjectType() @@ -20,8 +22,6 @@ export class CurrentUser implements CurrentUserInterface { email: string; @Field() language: string; - @Field(() => [GraphQLJSONObject]) - contentScopes: ContentScope[]; @Field(() => [CurrentUserPermission]) permissions: CurrentUserPermission[]; } diff --git a/packages/api/cms-api/src/user-permissions/dto/user-content-scopes.input.ts b/packages/api/cms-api/src/user-permissions/dto/user-content-scopes.input.ts index 59ae5eec28..d02ec06922 100644 --- a/packages/api/cms-api/src/user-permissions/dto/user-content-scopes.input.ts +++ b/packages/api/cms-api/src/user-permissions/dto/user-content-scopes.input.ts @@ -1,5 +1,5 @@ import { Field, InputType } from "@nestjs/graphql"; -import { IsArray } from "class-validator"; +import { IsArray, IsObject } from "class-validator"; import { GraphQLJSONObject } from "graphql-type-json"; import { ContentScope } from "../interfaces/content-scope.interface"; @@ -8,5 +8,6 @@ import { ContentScope } from "../interfaces/content-scope.interface"; export class UserContentScopesInput { @Field(() => [GraphQLJSONObject], { defaultValue: [] }) @IsArray() + @IsObject({ each: true }) contentScopes: ContentScope[] = []; } diff --git a/packages/api/cms-api/src/user-permissions/dto/user-permission.input.ts b/packages/api/cms-api/src/user-permissions/dto/user-permission.input.ts index 43e25f2e5c..279f158ea1 100644 --- a/packages/api/cms-api/src/user-permissions/dto/user-permission.input.ts +++ b/packages/api/cms-api/src/user-permissions/dto/user-permission.input.ts @@ -1,5 +1,24 @@ -import { Field, InputType } from "@nestjs/graphql"; -import { IsDate, IsOptional, IsString } from "class-validator"; +import { Field, ID, InputType } from "@nestjs/graphql"; +import { IsArray, IsBoolean, IsDate, IsObject, IsOptional, IsString, IsUUID } from "class-validator"; +import { GraphQLJSONObject } from "graphql-type-json"; + +import { ContentScope } from "../interfaces/content-scope.interface"; + +@InputType() +export class UserPermissionOverrideContentScopesInput { + @Field(() => ID) + @IsUUID() + permissionId: string; + + @Field(() => Boolean) + @IsBoolean() + overrideContentScopes: boolean; + + @Field(() => [GraphQLJSONObject], { defaultValue: [] }) + @IsArray() + @IsObject({ each: true }) + contentScopes: ContentScope[] = []; +} @InputType() export class UserPermissionInput { diff --git a/packages/api/cms-api/src/user-permissions/entities/user-permission.entity.ts b/packages/api/cms-api/src/user-permissions/entities/user-permission.entity.ts index 71079c10cf..67057aeb4b 100644 --- a/packages/api/cms-api/src/user-permissions/entities/user-permission.entity.ts +++ b/packages/api/cms-api/src/user-permissions/entities/user-permission.entity.ts @@ -1,7 +1,10 @@ import { BaseEntity, Entity, PrimaryKey, Property } from "@mikro-orm/core"; import { Field, ID, ObjectType, registerEnumType } from "@nestjs/graphql"; +import { GraphQLJSONObject } from "graphql-type-json"; import { v4 } from "uuid"; +import { ContentScope } from "../interfaces/content-scope.interface"; + export enum UserPermissionSource { MANUAL = "MANUAL", BY_RULE = "BY_RULE", @@ -46,4 +49,12 @@ export class UserPermission extends BaseEntity { @Field({ nullable: true }) @Property({ columnType: "text", nullable: true }) approvedBy?: string; + + @Field() + @Property() + overrideContentScopes: boolean = false; + + @Field(() => [GraphQLJSONObject]) + @Property({ type: "json" }) + contentScopes: ContentScope[] = []; } diff --git a/packages/api/cms-api/src/user-permissions/user-content-scopes.resolver.ts b/packages/api/cms-api/src/user-permissions/user-content-scopes.resolver.ts index 1a494ed039..85afd60ace 100644 --- a/packages/api/cms-api/src/user-permissions/user-content-scopes.resolver.ts +++ b/packages/api/cms-api/src/user-permissions/user-content-scopes.resolver.ts @@ -18,13 +18,13 @@ export class UserContentScopesResolver { private readonly userService: UserPermissionsService, ) {} - @Mutation(() => [GraphQLJSONObject]) + @Mutation(() => Boolean) @SkipBuild() async userPermissionsUpdateContentScopes( @Args("userId", { type: () => String }) userId: string, @Args("input", { type: () => UserContentScopesInput }) { contentScopes }: UserContentScopesInput, - ): Promise { - this.userService.checkContentScopes(contentScopes); + ): Promise { + await this.userService.checkContentScopes(contentScopes); let entity = await this.repository.findOne({ userId }); if (entity) { entity = this.repository.assign(entity, { userId, contentScopes }); @@ -32,7 +32,7 @@ export class UserContentScopesResolver { entity = this.repository.create({ userId, contentScopes }); } await this.repository.persistAndFlush(entity); - return this.userService.getContentScopes(userId); + return true; } @Query(() => [GraphQLJSONObject]) @@ -40,7 +40,10 @@ export class UserContentScopesResolver { @Args("userId", { type: () => String }) userId: string, @Args("skipManual", { type: () => Boolean, nullable: true }) skipManual = false, ): Promise { - return this.userService.getContentScopes(userId, skipManual); + return this.userService.normalizeContentScopes( + await this.userService.getContentScopes(userId, !skipManual), + await this.userService.getAvailableContentScopes(), + ); } @Query(() => [GraphQLJSONObject]) diff --git a/packages/api/cms-api/src/user-permissions/user-permission.resolver.ts b/packages/api/cms-api/src/user-permissions/user-permission.resolver.ts index d764e14277..1c97e6c9a2 100644 --- a/packages/api/cms-api/src/user-permissions/user-permission.resolver.ts +++ b/packages/api/cms-api/src/user-permissions/user-permission.resolver.ts @@ -5,8 +5,8 @@ import { IsString } from "class-validator"; import { SkipBuild } from "../builds/skip-build.decorator"; import { RequiredPermission } from "./decorators/required-permission.decorator"; -import { UserPermissionInput } from "./dto/user-permission.input"; -import { UserPermission } from "./entities/user-permission.entity"; +import { UserPermissionInput, UserPermissionOverrideContentScopesInput } from "./dto/user-permission.input"; +import { UserPermission, UserPermissionSource } from "./entities/user-permission.entity"; import { UserPermissionsService } from "./user-permissions.service"; @ArgsType() @@ -20,13 +20,13 @@ export class UserPermissionListArgs { @RequiredPermission(["userPermissions"], { skipScopeCheck: true }) export class UserPermissionResolver { constructor( - private readonly userService: UserPermissionsService, + private readonly service: UserPermissionsService, @InjectRepository(UserPermission) private readonly permissionRepository: EntityRepository, ) {} @Query(() => [UserPermission]) async userPermissionsPermissionList(@Args() args: UserPermissionListArgs): Promise { - return this.userService.getPermissions(args.userId); + return this.service.getPermissions(args.userId); } @Query(() => UserPermission) @@ -44,7 +44,7 @@ export class UserPermissionResolver { @Args("input", { type: () => UserPermissionInput }) input: UserPermissionInput, ): Promise { const permission = new UserPermission(); - this.userService.getUser(userId); //validate user exists + this.service.getUser(userId); //validate user exists permission.userId = userId; permission.assign(input); await this.permissionRepository.persistAndFlush(permission); @@ -53,7 +53,7 @@ export class UserPermissionResolver { @Query(() => [String]) async userPermissionsAvailablePermissions(): Promise { - return this.userService.getAvailablePermissions(); + return this.service.getAvailablePermissions(); } @Mutation(() => UserPermission) @@ -75,13 +75,28 @@ export class UserPermissionResolver { return true; } + @Mutation(() => UserPermission) + async userPermissionsUpdateOverrideContentScopes( + @Args("input", { type: () => UserPermissionOverrideContentScopesInput }) input: UserPermissionOverrideContentScopesInput, + ): Promise { + const permission = await this.getPermission(input.permissionId); + await this.service.checkContentScopes(input.contentScopes); + permission.overrideContentScopes = input.overrideContentScopes; + permission.contentScopes = input.contentScopes; + await this.permissionRepository.persistAndFlush(permission); + return permission; + } + async getPermission(id: string, userId?: string): Promise { const permission = await this.permissionRepository.findOne(id); - if (permission) return permission; + if (permission) { + permission.source = UserPermissionSource.MANUAL; + return permission; + } if (!userId) { throw new Error(`Permission not found: ${id}`); } - for (const p of await this.userService.getPermissions(userId)) { + for (const p of await this.service.getPermissions(userId)) { if (p.id === id) return p; } throw new Error("Permission not found"); diff --git a/packages/api/cms-api/src/user-permissions/user-permissions.service.ts b/packages/api/cms-api/src/user-permissions/user-permissions.service.ts index 8fb94e5cb1..ed954b8168 100644 --- a/packages/api/cms-api/src/user-permissions/user-permissions.service.ts +++ b/packages/api/cms-api/src/user-permissions/user-permissions.service.ts @@ -1,7 +1,7 @@ import { EntityRepository } from "@mikro-orm/core"; import { InjectRepository } from "@mikro-orm/nestjs"; import { Inject, Injectable } from "@nestjs/common"; -import { differenceInDays } from "date-fns"; +import { isFuture, isPast } from "date-fns"; import isEqual from "lodash.isequal"; import getUuid from "uuid-by-string"; @@ -79,67 +79,83 @@ export class UserPermissionsService { permission.id = getUuid(JSON.stringify(p)); permission.source = UserPermissionSource.BY_RULE; permission.userId = userId; - permission.assign({ - permission: p.permission, - validFrom: p.validFrom, - validTo: p.validTo, - reason: p.reason, - requestedBy: p.requestedBy, - approvedBy: p.approvedBy, - }); + permission.overrideContentScopes = !!p.contentScopes; + permission.assign(p); permissions.push(permission); } } } - return permissions.sort( - (a, b) => availablePermissions.indexOf(a.permission as keyof Permission) - availablePermissions.indexOf(b.permission as keyof Permission), - ); + return permissions + .filter((value) => availablePermissions.some((p) => p === value.permission)) // Filter out permissions that are not defined in availablePermissions (e.g. outdated database entries) + .sort( + (a, b) => + availablePermissions.indexOf(a.permission as keyof Permission) - availablePermissions.indexOf(b.permission as keyof Permission), + ); } - async getContentScopes(userId: string, skipManual = false): Promise { - const availableContentScopes = await this.getAvailableContentScopes(); + async getContentScopes(userId: string, includeContentScopesManual = true): Promise { const contentScopes: ContentScope[] = []; + if (this.accessControlService.getContentScopesForUser) { const user = await this.getUser(userId); if (user) { const userContentScopes = await this.accessControlService.getContentScopesForUser(user); if (userContentScopes === UserPermissions.allContentScopes) { - contentScopes.push(...availableContentScopes); + contentScopes.push(...(await this.getAvailableContentScopes())); } else { contentScopes.push(...userContentScopes); } } } - if (!skipManual) { + + if (includeContentScopesManual) { const entity = await this.contentScopeRepository.findOne({ userId }); if (entity) { contentScopes.push(...entity.contentScopes); } } - return [...new Set(contentScopes)] // Make values unique + + return contentScopes; + } + + normalizeContentScopes(contentScopes: ContentScope[], availableContentScopes: ContentScope[]): ContentScope[] { + return [...new Set(contentScopes.map((cs) => JSON.stringify(cs)))] // Make values unique + .map((cs) => JSON.parse(cs)) .filter((value) => availableContentScopes.some((cs) => isEqual(cs, value))) // Allow only values that are defined in availableContentScopes .sort((a, b) => availableContentScopes.indexOf(a) - availableContentScopes.indexOf(b)); // Order by availableContentScopes } async createCurrentUser(user: User): Promise { + const availableContentScopes = await this.getAvailableContentScopes(); + const userContentScopes = await this.getContentScopes(user.id); + const permissions = (await this.getPermissions(user.id)) + .filter((p) => (!p.validFrom || isPast(p.validFrom)) && (!p.validTo || isFuture(p.validTo))) + .reduce((acc: CurrentUser["permissions"], userPermission) => { + const contentScopes = userPermission.overrideContentScopes ? userPermission.contentScopes : userContentScopes; + const existingPermission = acc.find((p) => p.permission === userPermission.permission); + if (existingPermission) { + existingPermission.contentScopes = [...existingPermission.contentScopes, ...contentScopes]; + } else { + acc.push({ + permission: userPermission.permission, + contentScopes, + }); + } + return acc; + }, []) + .map((p) => { + p.contentScopes = this.normalizeContentScopes(p.contentScopes, availableContentScopes); + return p; + }); + const currentUser = new CurrentUser(); - Object.assign(currentUser, { + return Object.assign(currentUser, { id: user.id, name: user.name, email: user.email ?? "", language: user.language, - contentScopes: await this.getContentScopes(user.id), - permissions: (await this.getPermissions(user.id)) - .filter( - (p) => - (!p.validFrom || differenceInDays(new Date(), p.validFrom) >= 0) && - (!p.validTo || differenceInDays(p.validTo, new Date()) >= 0), - ) - .map((p) => ({ - permission: p.permission, - })), + permissions, }); - return currentUser; } } diff --git a/packages/api/cms-api/src/user-permissions/user-permissions.types.ts b/packages/api/cms-api/src/user-permissions/user-permissions.types.ts index 258330fdab..0d66e8ff61 100644 --- a/packages/api/cms-api/src/user-permissions/user-permissions.types.ts +++ b/packages/api/cms-api/src/user-permissions/user-permissions.types.ts @@ -14,15 +14,16 @@ export enum UserPermissions { export type Users = [User[], number]; -export type PermissionsForUser = - | Pick[] - | UserPermissions.allPermissions; +type PermissionForUser = { + permission: keyof Permission; + contentScopes?: ContentScope[]; +} & Pick; +export type PermissionsForUser = PermissionForUser[] | UserPermissions.allPermissions; export type ContentScopesForUser = ContentScope[] | UserPermissions.allContentScopes; export interface AccessControlServiceInterface { - isAllowedPermission(user: CurrentUserInterface, permission: keyof Permission): boolean; - isAllowedContentScope(user: CurrentUserInterface, contentScope: ContentScope): boolean; + isAllowed(user: CurrentUserInterface, permission: keyof Permission, contentScope?: ContentScope): boolean; getPermissionsForUser?: (user: User) => Promise | PermissionsForUser; getContentScopesForUser?: (user: User) => Promise | ContentScopesForUser; }