diff --git a/.changeset/dirty-trees-burn.md b/.changeset/dirty-trees-burn.md new file mode 100644 index 0000000000..71ae41adf8 --- /dev/null +++ b/.changeset/dirty-trees-burn.md @@ -0,0 +1,5 @@ +--- +"@comet/cms-admin": minor +--- + +Add `MasterMenu` and `MasterMenuRoutes` components which both take a single data structure to define menu and routes. diff --git a/.changeset/sixty-cobras-brake.md b/.changeset/sixty-cobras-brake.md new file mode 100644 index 0000000000..bb9ee035ea --- /dev/null +++ b/.changeset/sixty-cobras-brake.md @@ -0,0 +1,16 @@ +--- +"@comet/cms-api": major +--- + +Replace ContentScopeModule with UserPermissionsModule + +Breaking changes: + +- ContentScope-Module has been removed +- canAccessScope has been moved to AccessControlService and renamed to isAllowedContentScope +- role- and rights-fields has been removed from CurrentUser-Object +- AllowForRole-decorator has been removed +- Rename decorator SubjectEntity to AffectedEntity +- Add RequiredPermission-decorator and make it mandatory when using UserPermissionsModule + +Upgrade-Guide: tbd diff --git a/demo/admin/src/App.tsx b/demo/admin/src/App.tsx index b9337384a3..057e1c87b8 100644 --- a/demo/admin/src/App.tsx +++ b/demo/admin/src/App.tsx @@ -6,52 +6,35 @@ import "material-design-icons/iconfont/material-icons.css"; import "typeface-open-sans"; import { ApolloProvider } from "@apollo/client"; -import { ErrorDialogHandler, MasterLayout, MuiThemeProvider, RouterBrowserRouter, RouteWithErrorBoundary, SnackbarProvider } from "@comet/admin"; +import { ErrorDialogHandler, MasterLayout, MuiThemeProvider, RouterBrowserRouter, SnackbarProvider } from "@comet/admin"; import { CmsBlockContextProvider, createHttpClient, - createRedirectsPage, - CronJobsPage, + CurrentUserProvider, DamConfigProvider, - DamPage, LocaleProvider, - PagesPage, - PublisherPage, + MasterMenuRoutes, SiteConfig, SitePreview, SitesConfigProvider, - UserPermissionsPage, } from "@comet/cms-admin"; import { css, Global } from "@emotion/react"; import { createApolloClient } from "@src/common/apollo/createApolloClient"; import ContentScopeProvider, { ContentScope } from "@src/common/ContentScopeProvider"; -import { additionalPageTreeNodeFieldsFragment, EditPageNode } from "@src/common/EditPageNode"; -import MasterHeader from "@src/common/MasterHeader"; -import MasterMenu from "@src/common/MasterMenu"; +import { additionalPageTreeNodeFieldsFragment } from "@src/common/EditPageNode"; import { createConfig } from "@src/config"; -import Dashboard from "@src/dashboard/Dashboard"; -import { pageTreeCategories, urlParamToCategory } from "@src/pageTree/pageTreeCategories"; -import { PredefinedPage } from "@src/predefinedPage/PredefinedPage"; +import { pageTreeCategories } from "@src/pageTree/pageTreeCategories"; import theme from "@src/theme"; import * as React from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import * as ReactDOM from "react-dom"; import { IntlProvider } from "react-intl"; -import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom"; +import { Route, Switch } from "react-router-dom"; -import { ComponentDemo } from "./common/ComponentDemo"; -import { ContentScopeIndicator } from "./common/ContentScopeIndicator"; +import MasterHeader from "./common/MasterHeader"; +import MasterMenu, { masterMenuData, pageTreeDocumentTypes } from "./common/MasterMenu"; import { getMessages } from "./lang"; -import { Link } from "./links/Link"; -import { NewsLinkBlock } from "./news/blocks/NewsLinkBlock"; -import { NewsPage } from "./news/generated/NewsPage"; -import MainMenu from "./pages/mainMenu/MainMenu"; -import { Page } from "./pages/Page"; -import ProductCategoriesPage from "./products/categories/ProductCategoriesPage"; -import { ProductsPage } from "./products/generated/ProductsPage"; -import ProductsHandmadePage from "./products/ProductsPage"; -import ProductTagsPage from "./products/tags/ProductTagsPage"; const GlobalStyle = () => ( , baseEl); @@ -83,162 +58,67 @@ class App extends React.Component { public render(): JSX.Element { return ( - , scope: ContentScope) => configs[scope.domain], - }} - > - - - scope.domain}> - - - - - - - - - {({ match }) => ( - - {/* @TODO: add preview to contentScope once site is capable of contentScope */} - } - /> - ( - - - - - ) => { - const category = urlParamToCategory(params.category); - - if (category === undefined) { - return ; - } - - return ( - ( - - )} - /> - ); - }} - /> - - ( - ( - - )} - /> - )} - /> - - - - - ( - - )} - /> - - - - - - - - - - - - - - )} - /> - - )} - - - - - - - - - - - - + + , scope: ContentScope) => configs[scope.domain], + }} + > + + + scope.domain}> + + + + + + + + + {({ match }) => ( + + {/* @TODO: add preview to contentScope once site is capable of contentScope */} + } + /> + ( + + + + )} + /> + + )} + + + + + + + + + + + + + ); } diff --git a/demo/admin/src/common/ContentScopeProvider.tsx b/demo/admin/src/common/ContentScopeProvider.tsx index b66c29b4ba..65e4a5bd89 100644 --- a/demo/admin/src/common/ContentScopeProvider.tsx +++ b/demo/admin/src/common/ContentScopeProvider.tsx @@ -1,5 +1,3 @@ -import { gql, useQuery } from "@apollo/client"; -import { Loading } from "@comet/admin"; import { Domain as DomainIcon } from "@comet/admin-icons"; import { ContentScopeConfigProps, @@ -11,12 +9,11 @@ import { useContentScope as useContentScopeLibrary, UseContentScopeApi, useContentScopeConfig as useContentScopeConfigLibrary, + useCurrentUser, useSitesConfig, } from "@comet/cms-admin"; import React from "react"; -import { GQLCurrentUserScopeQuery } from "./ContentScopeProvider.generated"; - type Domain = "main" | "secondary" | string; type Language = "en" | string; export interface ContentScope { @@ -52,22 +49,11 @@ export function useContentScopeConfig(p: ContentScopeConfigProps): void { return useContentScopeConfigLibrary(p); } -const currentUserQuery = gql` - query CurrentUserScope { - currentUser { - role - domains - } - } -`; - const ContentScopeProvider: React.FC> = ({ children }) => { const sitesConfig = useSitesConfig(); - const { loading, data } = useQuery(currentUserQuery); - - if (loading || !data) return ; + const user = useCurrentUser(); - const allowedUserDomains = data.currentUser.domains; + const allowedUserDomains = user.contentScopes.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 a588e2d180..d0578b60a2 100644 --- a/demo/admin/src/common/MasterMenu.tsx +++ b/demo/admin/src/common/MasterMenu.tsx @@ -1,95 +1,203 @@ -import { Menu, MenuCollapsibleItem, MenuContext, MenuItemRouterLink, useWindowSize } from "@comet/admin"; -import { Assets, Dashboard, Data, PageTree, Snips, Wrench } from "@comet/admin-icons"; +import { Assets, Dashboard as DashboardIcon, Data, PageTree, Snips, Wrench } from "@comet/admin-icons"; +import { + createRedirectsPage, + CronJobsPage, + DamPage, + MasterMenu as CometMasterMenu, + MasterMenuData, + PagesPage, + PublisherPage, + UserPermissionsPage, +} from "@comet/cms-admin"; +import Dashboard from "@src/dashboard/Dashboard"; +import { GQLPageTreeNodeCategory } from "@src/graphql.generated"; +import { Link } from "@src/links/Link"; +import { NewsLinkBlock } from "@src/news/blocks/NewsLinkBlock"; +import { NewsPage } from "@src/news/generated/NewsPage"; +import MainMenu from "@src/pages/mainMenu/MainMenu"; +import { Page } from "@src/pages/Page"; +import { categoryToUrlParam, pageTreeCategories, urlParamToCategory } from "@src/pageTree/pageTreeCategories"; +import { PredefinedPage } from "@src/predefinedPage/PredefinedPage"; +import ProductCategoriesPage from "@src/products/categories/ProductCategoriesPage"; +import { ProductsPage } from "@src/products/generated/ProductsPage"; +import ProductsHandmadePage from "@src/products/ProductsPage"; +import ProductTagsPage from "@src/products/tags/ProductTagsPage"; import * as React from "react"; -import { useIntl } from "react-intl"; -import { useRouteMatch } from "react-router"; +import { FormattedMessage } from "react-intl"; +import { Redirect, RouteComponentProps } from "react-router-dom"; -const permanentMenuMinWidth = 1024; +import { ComponentDemo } from "./ComponentDemo"; +import { ContentScopeIndicator } from "./ContentScopeIndicator"; +import { EditPageNode } from "./EditPageNode"; -const MasterMenu: React.FC = () => { - const { open, toggleOpen } = React.useContext(MenuContext); - const windowSize = useWindowSize(); - const intl = useIntl(); - const match = useRouteMatch(); +export const pageTreeDocumentTypes = { + Page, + Link, + PredefinedPage, +}; - const useTemporaryMenu: boolean = windowSize.width < permanentMenuMinWidth; +const RedirectsPage = createRedirectsPage({ customTargets: { news: NewsLinkBlock }, scopeParts: ["domain"] }); - // Open menu when changing to permanent variant and close when changing to temporary variant. - React.useEffect(() => { - if ((useTemporaryMenu && open) || (!useTemporaryMenu && !open)) { - toggleOpen(); - } - // useEffect dependencies must only include `location`, because the function should only be called once after changing the location. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [location]); +export const masterMenuData: MasterMenuData = [ + { + primary: , + icon: , + route: { + path: "/dashboard", + component: Dashboard, + }, + }, + { + primary: , + icon: , + submenu: pageTreeCategories.map((category) => ({ + primary: category.label, + to: `/pages/pagetree/${categoryToUrlParam(category.category as GQLPageTreeNodeCategory)}`, + })), + route: { + path: "/pages/pagetree/:category", + render: ({ match }: RouteComponentProps<{ category: string }>) => { + const category = urlParamToCategory(match.params.category); - return ( - - } - to={`${match.url}/dashboard`} - /> - }> - - - + if (category === undefined) { + return ; + } - }> - - - } - to={`${match.url}/assets`} - /> - }> - - - }> - - - - - } - /> - }> - } /> - } /> - } /> - } /> - - } - /> - - ); -}; + return ( + } + /> + ); + }, + }, + requiredPermission: "pageTree", + }, + { + primary: , + icon: , + submenu: [ + { + primary: , + route: { + path: "/structured-content/news", + component: NewsPage, + }, + }, + ], + requiredPermission: "news", + }, + { + primary: , + icon: , + route: { + path: "/assets", + render: () => } />, + }, + requiredPermission: "dam", + }, + { + primary: , + icon: , + submenu: [ + { + primary: , + route: { + path: "/project-snips/main-menu", + component: MainMenu, + }, + }, + ], + requiredPermission: "pageTree", + }, + { + primary: , + icon: , + submenu: [ + { + primary: , + route: { + path: "/system/publisher", + component: PublisherPage, + }, + requiredPermission: "builds", + }, + { + primary: , + route: { + path: "/system/cron-jobs", + component: CronJobsPage, + }, + requiredPermission: "cronJobs", + }, + { + primary: , + route: { + path: "/system/redirects", + render: () => , + }, + requiredPermission: "pageTree", + }, + ], + }, + { + primary: , + icon: , + route: { + path: "/component-demo", + component: ComponentDemo, + }, + requiredPermission: "pageTree", + }, + { + primary: , + icon: , + submenu: [ + { + primary: , + route: { + path: "/products", + component: ProductsPage, + }, + }, + { + primary: , + route: { + path: "/product-categories", + component: ProductCategoriesPage, + }, + }, + { + primary: , + route: { + path: "/product-tags", + component: ProductTagsPage, + }, + }, + { + primary: , + route: { + path: "/products-handmade", + component: ProductsHandmadePage, + }, + }, + ], + requiredPermission: "products", + }, + { + primary: , + icon: , + route: { + path: "/user-permissions", + component: UserPermissionsPage, + }, + requiredPermission: "userPermissions", + }, +]; +const MasterMenu = () => ; export default MasterMenu; diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 6bc4e7f58d..81e0ab4d65 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -131,6 +131,15 @@ type CurrentUserPermission { permission: String! } +type CurrentUser { + id: String! + name: String! + email: String! + language: String! + contentScopes: [JSONObject!]! + permissions: [CurrentUserPermission!]! +} + type User { id: String! name: String! @@ -260,14 +269,6 @@ type PredefinedPage implements DocumentInterface { type: String } -type CurrentUser { - name: String! - email: String! - language: String! - role: String! - domains: [String!]! -} - type DamScope { domain: String! } diff --git a/demo/api/src/app.module.ts b/demo/api/src/app.module.ts index 54c982a409..33a911e6fc 100644 --- a/demo/api/src/app.module.ts +++ b/demo/api/src/app.module.ts @@ -5,10 +5,7 @@ import { BlocksModule, BlocksTransformerMiddlewareFactory, BuildsModule, - ContentScope, - ContentScopeModule, CronJobsModule, - CurrentUserInterface, DamModule, DependenciesModule, FilesService, @@ -31,6 +28,7 @@ import { PagesModule } from "@src/pages/pages.module"; import { PredefinedPage } from "@src/predefined-page/entities/predefined-page.entity"; import { Request } from "express"; +import { AccessControlService } from "./auth/access-control.service"; import { AuthModule } from "./auth/auth.module"; import { UserService } from "./auth/user.service"; import { DamScope } from "./dam/dto/dam-scope"; @@ -78,14 +76,8 @@ export class AppModule { inject: [BLOCKS_MODULE_TRANSFORMER_DEPENDENCIES], }), AuthModule, - ContentScopeModule.forRoot({ - canAccessScope(requestScope: ContentScope, user: CurrentUserInterface) { - if (!user.domains) return true; //all domains - return user.domains.includes(requestScope.domain); - }, - }), UserPermissionsModule.forRootAsync({ - useFactory: (userService: UserService) => ({ + useFactory: (userService: UserService, accessControlService: AccessControlService) => ({ availablePermissions: ["news", "products"], availableContentScopes: [ { domain: "main", language: "de" }, @@ -93,8 +85,9 @@ export class AppModule { { domain: "secondary", language: "en" }, ], userService, + accessControlService, }), - inject: [UserService], + inject: [UserService, AccessControlService], imports: [AuthModule], }), BlocksModule.forRoot({ diff --git a/demo/api/src/auth/access-control.service.ts b/demo/api/src/auth/access-control.service.ts new file mode 100644 index 0000000000..8ffffe8e7a --- /dev/null +++ b/demo/api/src/auth/access-control.service.ts @@ -0,0 +1,20 @@ +import { AbstractAccessControlService, ContentScopesForUser, PermissionsForUser, User, UserPermissions } from "@comet/cms-api"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class AccessControlService extends AbstractAccessControlService { + getPermissionsForUser(user: User): PermissionsForUser { + if (user.email.endsWith("@comet-dxp.com")) { + return UserPermissions.allPermissions; + } else { + return [{ permission: "news" }]; + } + } + getContentScopesForUser(user: User): ContentScopesForUser { + if (user.email.endsWith("@comet-dxp.com")) { + return UserPermissions.allContentScopes; + } else { + return [{ domain: "main", language: "en" }]; + } + } +} diff --git a/demo/api/src/auth/auth.module.ts b/demo/api/src/auth/auth.module.ts index ed849aead8..03e8a30aab 100644 --- a/demo/api/src/auth/auth.module.ts +++ b/demo/api/src/auth/auth.module.ts @@ -1,21 +1,15 @@ -import { createAuthResolver, createCometAuthGuard, createStaticAuthedUserStrategy } from "@comet/cms-api"; +import { createAuthResolver, createCometAuthGuard, createStaticAuthedUserStrategy, CurrentUser } from "@comet/cms-api"; import { Module } from "@nestjs/common"; import { APP_GUARD } from "@nestjs/core"; -import { CurrentUser } from "./current-user"; +import { AccessControlService } from "./access-control.service"; +import { staticUsers } from "./static-users"; import { UserService } from "./user.service"; @Module({ providers: [ createStaticAuthedUserStrategy({ - staticAuthedUser: { - id: "1", - name: "Test Admin", - email: "demo@comet-dxp.com", - language: "en", - role: "admin", - domains: ["main", "secondary"], - }, + staticAuthedUser: staticUsers[0].id, }), createAuthResolver({ currentUser: CurrentUser, @@ -25,7 +19,8 @@ import { UserService } from "./user.service"; useClass: createCometAuthGuard(["static-authed-user"]), }, UserService, + AccessControlService, ], - exports: [UserService], + exports: [UserService, AccessControlService], }) export class AuthModule {} diff --git a/demo/api/src/auth/current-user.ts b/demo/api/src/auth/current-user.ts deleted file mode 100644 index 75bae1703b..0000000000 --- a/demo/api/src/auth/current-user.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { CurrentUserInterface } from "@comet/cms-api"; -import { Field, ObjectType } from "@nestjs/graphql"; - -declare module "@comet/cms-api" { - interface CurrentUserInterface { - domains: Array<"main" | "secondary">; - } -} - -@ObjectType() -export class CurrentUser implements CurrentUserInterface { - id: string; - @Field() - name: string; - - @Field() - email: string; - - @Field() - language: string; - - @Field() - role: string; - - @Field(() => [String]) - domains: Array<"main" | "secondary">; -} diff --git a/demo/api/src/auth/static-users.ts b/demo/api/src/auth/static-users.ts new file mode 100644 index 0000000000..8e998c8ce7 --- /dev/null +++ b/demo/api/src/auth/static-users.ts @@ -0,0 +1,16 @@ +import { User } from "@comet/cms-api"; + +export const staticUsers: User[] = [ + { + id: "1", + name: "Admin", + email: "demo@comet-dxp.com", + language: "en", + }, + { + id: "2", + name: "Non-Admin", + email: "test@test.com", + language: "en", + }, +]; diff --git a/demo/api/src/auth/user.service.ts b/demo/api/src/auth/user.service.ts index 86e6e4d9af..42197fb897 100644 --- a/demo/api/src/auth/user.service.ts +++ b/demo/api/src/auth/user.service.ts @@ -1,23 +1,10 @@ -import { ContentScopesForUser, FindUsersArgs, PermissionsForUser, User, UserPermissions, UserPermissionsUserService, Users } from "@comet/cms-api"; +import { FindUsersArgs, User, UserPermissionsUserServiceInterface, Users } from "@comet/cms-api"; import { Injectable } from "@nestjs/common"; -const staticUsers: User[] = [ - { - id: "1", - name: "Admin", - email: "demo@comet-dxp.com", - language: "en", - }, - { - id: "2", - name: "Non-Admin", - email: "test@test.com", - language: "en", - }, -]; +import { staticUsers } from "./static-users"; @Injectable() -export class UserService implements UserPermissionsUserService { +export class UserService implements UserPermissionsUserServiceInterface { getUser(id: string): User { const index = parseInt(id) - 1; if (staticUsers[index]) return staticUsers[index]; @@ -28,18 +15,4 @@ export class UserService implements UserPermissionsUserService { const users = staticUsers.filter((user) => !search || user.name.toLowerCase().includes(search) || user.email.toLowerCase().includes(search)); return [users, users.length]; } - getPermissionsForUser(user: User): PermissionsForUser { - if (user.email.endsWith("@comet-dxp.com")) { - return UserPermissions.allPermissions; - } else { - return [{ permission: "news" }]; - } - } - getContentScopesForUser(user: User): ContentScopesForUser { - if (user.email.endsWith("@comet-dxp.com")) { - return UserPermissions.allContentScopes; - } else { - return [{ domain: "main", language: "en" }]; - } - } } diff --git a/demo/api/src/footer/entities/footer.entity.ts b/demo/api/src/footer/entities/footer.entity.ts index 2d01becd0c..099e5eb16e 100644 --- a/demo/api/src/footer/entities/footer.entity.ts +++ b/demo/api/src/footer/entities/footer.entity.ts @@ -12,7 +12,7 @@ import { FooterContentScope } from "./footer-content-scope.entity"; implements: () => [DocumentInterface], }) @RootBlockEntity() -@CrudSingleGenerator({ targetDirectory: `${__dirname}/../generated/` }) +@CrudSingleGenerator({ targetDirectory: `${__dirname}/../generated/`, requiredPermission: ["pageTree"] }) export class Footer extends BaseEntity implements DocumentInterface { [OptionalProps]?: "createdAt" | "updatedAt"; diff --git a/demo/api/src/footer/generated/footer.resolver.ts b/demo/api/src/footer/generated/footer.resolver.ts index 3df39e0039..2d0d828c0b 100644 --- a/demo/api/src/footer/generated/footer.resolver.ts +++ b/demo/api/src/footer/generated/footer.resolver.ts @@ -1,6 +1,6 @@ // This file has been generated by comet api-generator. // You may choose to use this file as scaffold by moving this file out of generated folder and removing this comment. -import { validateNotModified } from "@comet/cms-api"; +import { RequiredPermission, validateNotModified } from "@comet/cms-api"; import { InjectRepository } from "@mikro-orm/nestjs"; import { EntityManager, EntityRepository } from "@mikro-orm/postgresql"; import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; @@ -11,6 +11,7 @@ import { FooterInput } from "./dto/footer.input"; import { FootersService } from "./footers.service"; @Resolver(() => Footer) +@RequiredPermission(["pageTree"]) export class FooterResolver { constructor( private readonly entityManager: EntityManager, diff --git a/demo/api/src/links/links.resolver.ts b/demo/api/src/links/links.resolver.ts index 3aab9c1895..377718c465 100644 --- a/demo/api/src/links/links.resolver.ts +++ b/demo/api/src/links/links.resolver.ts @@ -1,4 +1,4 @@ -import { PageTreeNodeVisibility, PageTreeService, SubjectEntity, validateNotModified } from "@comet/cms-api"; +import { AffectedEntity, PageTreeNodeVisibility, PageTreeService, validateNotModified } from "@comet/cms-api"; import { InjectRepository } from "@mikro-orm/nestjs"; import { EntityRepository } from "@mikro-orm/postgresql"; import { UnauthorizedException } from "@nestjs/common"; @@ -12,13 +12,13 @@ export class LinksResolver { constructor(@InjectRepository(Link) readonly repository: EntityRepository, private readonly pageTreeService: PageTreeService) {} @Query(() => Link, { nullable: true }) - @SubjectEntity(Link, { idArg: "linkId" }) + @AffectedEntity(Link, { idArg: "linkId" }) async link(@Args("linkId", { type: () => ID }) linkId: string): Promise { return this.repository.findOne(linkId); } @Mutation(() => Link) - @SubjectEntity(Link, { pageTreeNodeIdArg: "attachedPageTreeNodeId" }) + @AffectedEntity(Link, { pageTreeNodeIdArg: "attachedPageTreeNodeId" }) async saveLink( @Args("linkId", { type: () => ID }) linkId: string, @Args("input", { type: () => LinkInput }) input: LinkInput, diff --git a/demo/api/src/menus/main-menu-item.resolver.ts b/demo/api/src/menus/main-menu-item.resolver.ts index 2deb6d964f..3f3e0a4266 100644 --- a/demo/api/src/menus/main-menu-item.resolver.ts +++ b/demo/api/src/menus/main-menu-item.resolver.ts @@ -1,4 +1,12 @@ -import { PageTreeNodeVisibility, PageTreeService, RequestContext, RequestContextInterface, validateNotModified } from "@comet/cms-api"; +import { + AffectedEntity, + PageTreeNodeVisibility, + PageTreeService, + RequestContext, + RequestContextInterface, + RequiredPermission, + validateNotModified, +} from "@comet/cms-api"; import { InjectRepository } from "@mikro-orm/nestjs"; import { EntityRepository } from "@mikro-orm/postgresql"; import { Args, ID, Mutation, Query, Resolver } from "@nestjs/graphql"; @@ -8,6 +16,8 @@ import { MainMenuItemInput } from "./dto/main-menu-item.input"; import { MainMenuItem } from "./entities/main-menu-item.entity"; @Resolver(() => MainMenuItem) +@RequiredPermission(["pageTree"]) +@AffectedEntity(MainMenuItem, { pageTreeNodeIdArg: "pageTreeNodeId" }) export class MainMenuItemResolver { constructor( @InjectRepository(MainMenuItem) private readonly mainMenuItemRepository: EntityRepository, diff --git a/demo/api/src/menus/menus.resolver.ts b/demo/api/src/menus/menus.resolver.ts index a659056138..277c0dc68a 100644 --- a/demo/api/src/menus/menus.resolver.ts +++ b/demo/api/src/menus/menus.resolver.ts @@ -10,6 +10,7 @@ import { MainMenuObject } from "./dto/main-menu.object"; import { MainMenuItem } from "./entities/main-menu-item.entity"; @Resolver(() => MainMenuObject) +@PublicApi() export class MenusResolver { constructor( private readonly pageTreeReadApi: PageTreeReadApiService, @@ -17,7 +18,6 @@ export class MenusResolver { ) {} @Query(() => MainMenuObject) - @PublicApi() async mainMenu(@Args("scope", { type: () => PageTreeNodeScope }) scope: PageTreeNodeScope): Promise { await this.pageTreeReadApi.preloadNodes(scope); const rootNodes = await this.pageTreeReadApi.pageTreeRootNodeList({ diff --git a/demo/api/src/news/generated/news.resolver.ts b/demo/api/src/news/generated/news.resolver.ts index 0d3caee817..b2bf198aab 100644 --- a/demo/api/src/news/generated/news.resolver.ts +++ b/demo/api/src/news/generated/news.resolver.ts @@ -1,6 +1,6 @@ // This file has been generated by comet api-generator. // You may choose to use this file as scaffold by moving this file out of generated folder and removing this comment. -import { extractGraphqlFields, SubjectEntity, validateNotModified } from "@comet/cms-api"; +import { AffectedEntity, extractGraphqlFields, RequiredPermission, validateNotModified } from "@comet/cms-api"; import { FindOptions } from "@mikro-orm/core"; import { InjectRepository } from "@mikro-orm/nestjs"; import { EntityManager, EntityRepository } from "@mikro-orm/postgresql"; @@ -15,6 +15,7 @@ import { PaginatedNews } from "./dto/paginated-news"; import { NewsService } from "./news.service"; @Resolver(() => News) +@RequiredPermission(["news"]) export class NewsResolver { constructor( private readonly entityManager: EntityManager, @@ -23,7 +24,7 @@ export class NewsResolver { ) {} @Query(() => News) - @SubjectEntity(News) + @AffectedEntity(News) async news(@Args("id", { type: () => ID }) id: string): Promise { const news = await this.repository.findOneOrFail(id); return news; @@ -83,7 +84,7 @@ export class NewsResolver { } @Mutation(() => News) - @SubjectEntity(News) + @AffectedEntity(News) async updateNews( @Args("id", { type: () => ID }) id: string, @Args("input", { type: () => NewsUpdateInput }) input: NewsUpdateInput, @@ -112,7 +113,7 @@ export class NewsResolver { } @Mutation(() => Boolean) - @SubjectEntity(News) + @AffectedEntity(News) async deleteNews(@Args("id", { type: () => ID }) id: string): Promise { const news = await this.repository.findOneOrFail(id); await this.entityManager.remove(news); @@ -121,7 +122,7 @@ export class NewsResolver { } @Mutation(() => News) - @SubjectEntity(News) + @AffectedEntity(News) async updateNewsVisibility( @Args("id", { type: () => ID }) id: string, @Args("visible", { type: () => Boolean }) visible: boolean, diff --git a/demo/api/src/news/news-comment.resolver.ts b/demo/api/src/news/news-comment.resolver.ts index b273e78183..cb9f5093db 100644 --- a/demo/api/src/news/news-comment.resolver.ts +++ b/demo/api/src/news/news-comment.resolver.ts @@ -1,4 +1,4 @@ -import { SubjectEntity } from "@comet/cms-api"; +import { AffectedEntity } from "@comet/cms-api"; import { InjectRepository } from "@mikro-orm/nestjs"; import { EntityRepository } from "@mikro-orm/postgresql"; import { Args, ID, Mutation, Resolver } from "@nestjs/graphql"; @@ -15,7 +15,7 @@ export class NewsCommentResolver { ) {} @Mutation(() => NewsComment) - @SubjectEntity(News, { idArg: "newsId" }) + @AffectedEntity(News, { idArg: "newsId" }) async createNewsComment( @Args("newsId", { type: () => ID }) newsId: string, @Args("input", { type: () => NewsCommentInput }) input: NewsCommentInput, @@ -32,7 +32,7 @@ export class NewsCommentResolver { } @Mutation(() => NewsComment) - @SubjectEntity(NewsComment) + @AffectedEntity(NewsComment) async updateNewsComment( @Args("id", { type: () => ID }) id: string, @Args("input", { type: () => NewsCommentInput }) input: NewsCommentInput, @@ -53,7 +53,7 @@ export class NewsCommentResolver { } @Mutation(() => Boolean) - @SubjectEntity(NewsComment) + @AffectedEntity(NewsComment) async deleteNewsComment(@Args("id", { type: () => ID }) id: string): Promise { await this.newsCommentRepository.removeAndFlush({ id }); diff --git a/demo/api/src/pages/pages.resolver.ts b/demo/api/src/pages/pages.resolver.ts index 29c4ba7d84..0a26a57b04 100644 --- a/demo/api/src/pages/pages.resolver.ts +++ b/demo/api/src/pages/pages.resolver.ts @@ -1,10 +1,11 @@ import { + AffectedEntity, OffsetBasedPaginationArgs, PageTreeNodeInterface, PageTreeNodeVisibility, PageTreeService, + RequiredPermission, SortArgs, - SubjectEntity, validateNotModified, } from "@comet/cms-api"; import { FindOptions } from "@mikro-orm/core"; @@ -22,11 +23,13 @@ import { Page } from "./entities/page.entity"; export class PagesArgs extends IntersectionType(OffsetBasedPaginationArgs, SortArgs) {} @Resolver(() => Page) +@RequiredPermission(["pageTree"]) export class PagesResolver { constructor(@InjectRepository(Page) private readonly repository: EntityRepository, private readonly pageTreeService: PageTreeService) {} // TODO add scope argument (who uses this anyway? probably dashboard) @Query(() => PaginatedPages) + @RequiredPermission(["pageTree"], { skipScopeCheck: true }) async pages(@Args() { offset, limit, sortColumnName, sortDirection }: PagesArgs): Promise { const options: FindOptions = { offset, limit }; if (sortColumnName) { @@ -43,7 +46,7 @@ export class PagesResolver { } @Mutation(() => Page) - @SubjectEntity(Page, { pageTreeNodeIdArg: "attachedPageTreeNodeId" }) + @AffectedEntity(Page, { pageTreeNodeIdArg: "attachedPageTreeNodeId" }) async savePage( @Args("pageId", { type: () => ID }) pageId: string, @Args("input", { type: () => PageInput }) input: PageInput, diff --git a/demo/api/src/predefined-page/predefined-page.resolver.ts b/demo/api/src/predefined-page/predefined-page.resolver.ts index 9daf9c13fc..96943287d4 100644 --- a/demo/api/src/predefined-page/predefined-page.resolver.ts +++ b/demo/api/src/predefined-page/predefined-page.resolver.ts @@ -1,10 +1,10 @@ import { + AffectedEntity, PageTreeNodeInterface, PageTreeNodeVisibility, PageTreeService, RequestContext, RequestContextInterface, - SubjectEntity, validateNotModified, } from "@comet/cms-api"; import { InjectRepository } from "@mikro-orm/nestjs"; @@ -27,13 +27,13 @@ export class PredefinedPageResolver { ) {} @Query(() => PredefinedPage, { nullable: true }) - @SubjectEntity(PredefinedPage) + @AffectedEntity(PredefinedPage) async predefinedPage(@Args("id", { type: () => ID }) id: string): Promise { return this.repository.findOneOrFail(id); } @Mutation(() => PredefinedPage) - @SubjectEntity(PredefinedPage, { pageTreeNodeIdArg: "attachedPageTreeNodeId" }) + @AffectedEntity(PredefinedPage, { pageTreeNodeIdArg: "attachedPageTreeNodeId" }) async savePredefinedPage( @Args("id", { type: () => ID }) id: string, @Args("input", { type: () => PredefinedPageInput }) input: PredefinedPageInput, diff --git a/demo/api/src/products/entities/product-category.entity.ts b/demo/api/src/products/entities/product-category.entity.ts index 74367a41de..d0a9ed81ce 100644 --- a/demo/api/src/products/entities/product-category.entity.ts +++ b/demo/api/src/products/entities/product-category.entity.ts @@ -9,7 +9,7 @@ import { Product } from "./product.entity"; implements: () => [DocumentInterface], }) @Entity() -@CrudGenerator({ targetDirectory: `${__dirname}/../generated/` }) +@CrudGenerator({ targetDirectory: `${__dirname}/../generated/`, requiredPermission: ["products"] }) export class ProductCategory extends BaseEntity implements DocumentInterface { [OptionalProps]?: "createdAt" | "updatedAt"; diff --git a/demo/api/src/products/entities/product-tag.entity.ts b/demo/api/src/products/entities/product-tag.entity.ts index 28843565ce..03bf7365e9 100644 --- a/demo/api/src/products/entities/product-tag.entity.ts +++ b/demo/api/src/products/entities/product-tag.entity.ts @@ -9,7 +9,7 @@ import { Product } from "./product.entity"; implements: () => [DocumentInterface], }) @Entity() -@CrudGenerator({ targetDirectory: `${__dirname}/../generated/` }) +@CrudGenerator({ targetDirectory: `${__dirname}/../generated/`, requiredPermission: ["products"] }) export class ProductTag extends BaseEntity implements DocumentInterface { [OptionalProps]?: "createdAt" | "updatedAt"; diff --git a/demo/api/src/products/generated/product-category.resolver.ts b/demo/api/src/products/generated/product-category.resolver.ts index 0371ed08e9..ba7be4ffa2 100644 --- a/demo/api/src/products/generated/product-category.resolver.ts +++ b/demo/api/src/products/generated/product-category.resolver.ts @@ -1,6 +1,6 @@ // This file has been generated by comet api-generator. // You may choose to use this file as scaffold by moving this file out of generated folder and removing this comment. -import { extractGraphqlFields, SubjectEntity, validateNotModified } from "@comet/cms-api"; +import { AffectedEntity, extractGraphqlFields, RequiredPermission, validateNotModified } from "@comet/cms-api"; import { FindOptions, Reference } from "@mikro-orm/core"; import { InjectRepository } from "@mikro-orm/nestjs"; import { EntityManager, EntityRepository } from "@mikro-orm/postgresql"; @@ -15,6 +15,7 @@ import { ProductCategoryInput, ProductCategoryUpdateInput } from "./dto/product- import { ProductCategoriesService } from "./product-categories.service"; @Resolver(() => ProductCategory) +@RequiredPermission(["products"], { skipScopeCheck: true }) export class ProductCategoryResolver { constructor( private readonly entityManager: EntityManager, @@ -24,7 +25,7 @@ export class ProductCategoryResolver { ) {} @Query(() => ProductCategory) - @SubjectEntity(ProductCategory) + @AffectedEntity(ProductCategory) async productCategory(@Args("id", { type: () => ID }) id: string): Promise { const productCategory = await this.repository.findOneOrFail(id); return productCategory; @@ -85,7 +86,7 @@ export class ProductCategoryResolver { } @Mutation(() => ProductCategory) - @SubjectEntity(ProductCategory) + @AffectedEntity(ProductCategory) async updateProductCategory( @Args("id", { type: () => ID }) id: string, @Args("input", { type: () => ProductCategoryUpdateInput }) input: ProductCategoryUpdateInput, @@ -114,7 +115,7 @@ export class ProductCategoryResolver { } @Mutation(() => Boolean) - @SubjectEntity(ProductCategory) + @AffectedEntity(ProductCategory) async deleteProductCategory(@Args("id", { type: () => ID }) id: string): Promise { const productCategory = await this.repository.findOneOrFail(id); await this.entityManager.remove(productCategory); diff --git a/demo/api/src/products/generated/product-tag.resolver.ts b/demo/api/src/products/generated/product-tag.resolver.ts index 5387476610..b9bd1fe5ca 100644 --- a/demo/api/src/products/generated/product-tag.resolver.ts +++ b/demo/api/src/products/generated/product-tag.resolver.ts @@ -1,6 +1,6 @@ // This file has been generated by comet api-generator. // You may choose to use this file as scaffold by moving this file out of generated folder and removing this comment. -import { extractGraphqlFields, SubjectEntity, validateNotModified } from "@comet/cms-api"; +import { AffectedEntity, extractGraphqlFields, RequiredPermission, validateNotModified } from "@comet/cms-api"; import { FindOptions, Reference } from "@mikro-orm/core"; import { InjectRepository } from "@mikro-orm/nestjs"; import { EntityManager, EntityRepository } from "@mikro-orm/postgresql"; @@ -15,6 +15,7 @@ import { ProductTagsArgs } from "./dto/product-tags.args"; import { ProductTagsService } from "./product-tags.service"; @Resolver(() => ProductTag) +@RequiredPermission(["products"], { skipScopeCheck: true }) export class ProductTagResolver { constructor( private readonly entityManager: EntityManager, @@ -24,7 +25,7 @@ export class ProductTagResolver { ) {} @Query(() => ProductTag) - @SubjectEntity(ProductTag) + @AffectedEntity(ProductTag) async productTag(@Args("id", { type: () => ID }) id: string): Promise { const productTag = await this.repository.findOneOrFail(id); return productTag; @@ -78,7 +79,7 @@ export class ProductTagResolver { } @Mutation(() => ProductTag) - @SubjectEntity(ProductTag) + @AffectedEntity(ProductTag) async updateProductTag( @Args("id", { type: () => ID }) id: string, @Args("input", { type: () => ProductTagUpdateInput }) input: ProductTagUpdateInput, @@ -107,7 +108,7 @@ export class ProductTagResolver { } @Mutation(() => Boolean) - @SubjectEntity(ProductTag) + @AffectedEntity(ProductTag) async deleteProductTag(@Args("id", { type: () => ID }) id: string): Promise { const productTag = await this.repository.findOneOrFail(id); await this.entityManager.remove(productTag); diff --git a/demo/api/src/products/generated/product-variant.resolver.ts b/demo/api/src/products/generated/product-variant.resolver.ts index c2a2c9a31b..9aeef7a4d7 100644 --- a/demo/api/src/products/generated/product-variant.resolver.ts +++ b/demo/api/src/products/generated/product-variant.resolver.ts @@ -1,12 +1,14 @@ // This file has been generated by comet api-generator. // You may choose to use this file as scaffold by moving this file out of generated folder and removing this comment. +import { RequiredPermission } from "@comet/cms-api"; import { Parent, ResolveField, Resolver } from "@nestjs/graphql"; import { Product } from "../entities/product.entity"; import { ProductVariant } from "../entities/product-variant.entity"; @Resolver(() => ProductVariant) +@RequiredPermission(["products"], { skipScopeCheck: true }) export class ProductVariantResolver { @ResolveField(() => Product) async product(@Parent() productVariant: ProductVariant): Promise { diff --git a/demo/api/src/products/generated/product.resolver.ts b/demo/api/src/products/generated/product.resolver.ts index a86f21250a..689d0d98c8 100644 --- a/demo/api/src/products/generated/product.resolver.ts +++ b/demo/api/src/products/generated/product.resolver.ts @@ -1,6 +1,6 @@ // This file has been generated by comet api-generator. // You may choose to use this file as scaffold by moving this file out of generated folder and removing this comment. -import { extractGraphqlFields, SubjectEntity, validateNotModified } from "@comet/cms-api"; +import { AffectedEntity, extractGraphqlFields, RequiredPermission, validateNotModified } from "@comet/cms-api"; import { FindOptions, Reference } from "@mikro-orm/core"; import { InjectRepository } from "@mikro-orm/nestjs"; import { EntityManager, EntityRepository } from "@mikro-orm/postgresql"; @@ -18,6 +18,7 @@ import { ProductsArgs } from "./dto/products.args"; import { ProductsService } from "./products.service"; @Resolver(() => Product) +@RequiredPermission(["products"], { skipScopeCheck: true }) export class ProductResolver { constructor( private readonly entityManager: EntityManager, @@ -30,7 +31,7 @@ export class ProductResolver { ) {} @Query(() => Product) - @SubjectEntity(Product) + @AffectedEntity(Product) async product(@Args("id", { type: () => ID }) id: string): Promise { const product = await this.repository.findOneOrFail(id); return product; @@ -127,7 +128,7 @@ export class ProductResolver { } @Mutation(() => Product) - @SubjectEntity(Product) + @AffectedEntity(Product) async updateProduct( @Args("id", { type: () => ID }) id: string, @Args("input", { type: () => ProductUpdateInput }) input: ProductUpdateInput, @@ -189,7 +190,7 @@ export class ProductResolver { } @Mutation(() => Boolean) - @SubjectEntity(Product) + @AffectedEntity(Product) async deleteProduct(@Args("id", { type: () => ID }) id: string): Promise { const product = await this.repository.findOneOrFail(id); await this.entityManager.remove(product); @@ -198,7 +199,7 @@ export class ProductResolver { } @Mutation(() => Product) - @SubjectEntity(Product) + @AffectedEntity(Product) async updateProductVisibility( @Args("id", { type: () => ID }) id: string, @Args("visible", { type: () => Boolean }) visible: boolean, diff --git a/docs/docs/migration/migration-from-v4-to-v5.md b/docs/docs/migration/migration-from-v4-to-v5.md index 6fcf9c0eee..f82da2dbf4 100644 --- a/docs/docs/migration/migration-from-v4-to-v5.md +++ b/docs/docs/migration/migration-from-v4-to-v5.md @@ -84,7 +84,7 @@ indexData(): BlockIndexData { ### File and Folder Entities -`File` and `Folder` are no longer exported by `@comet/cms-api`. Instead, use the exported `FileInterface` and `FolderInterface` for typing. +`File` and `Folder` are no longer exported by `@comet/cms-api`. Instead, use the exported `FileInterface` and `FolderInterface` for typing. If you need classes (e.g. as return type of a GraphQL field), you can create them using the `createFileEntity()` and `createFolderEntity()` factories. You will then need to pass your classes to the `DamModule` during initialization: @@ -178,7 +178,10 @@ dependencies: (state) => { ```tsx replaceDependenciesInOutput: (output, replacements) => { const clonedOutput: PixelImageBlockInput = deepClone(output); - const replacement = replacements.find((replacement) => replacement.type === "DamFile" && replacement.originalId === output.damFileId); + const replacement = replacements.find( + (replacement) => + replacement.type === "DamFile" && replacement.originalId === output.damFileId, + ); if (replacement) { clonedOutput.damFileId = replacement.replaceWithId; diff --git a/packages/admin/cms-admin/src/common/MasterMenu.tsx b/packages/admin/cms-admin/src/common/MasterMenu.tsx new file mode 100644 index 0000000000..972ea385bf --- /dev/null +++ b/packages/admin/cms-admin/src/common/MasterMenu.tsx @@ -0,0 +1,81 @@ +import { Menu, MenuCollapsibleItem, MenuContext, MenuItemRouterLink, MenuItemRouterLinkProps, useWindowSize } from "@comet/admin"; +import * as React from "react"; +import { RouteProps, useRouteMatch } from "react-router-dom"; + +import { CurrentUserContext } from "../userPermissions/hooks/currentUser"; + +export type MasterMenuItem = Omit & { + requiredPermission?: string; + route?: RouteProps; + to?: string; + submenu?: MasterMenuItem[]; +}; + +export type MasterMenuData = MasterMenuItem[]; + +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 mapFn = (item: MasterMenuItem): MenuItem => { + const { route, submenu, to, ...menuItem } = item; + return { + menuItem: { + ...menuItem, + to: to ?? route?.path?.toString() ?? "", + }, + hasSubmenu: !!submenu, + submenu: submenu ? submenu.filter(checkPermission).map(mapFn) : [], + }; + }; + return items.filter(checkPermission).map(mapFn); +} + +type MenuItem = { + menuItem: MenuItemRouterLinkProps; + hasSubmenu: boolean; + submenu: MenuItem[]; +}; + +export interface MasterMenuProps { + permanentMenuMinWidth?: number; + menu: MasterMenuData; +} + +export const MasterMenu: React.FC = ({ menu, permanentMenuMinWidth = 1024 }) => { + const menuItems = useMenuFromMasterMenuData(menu); + const { open, toggleOpen } = React.useContext(MenuContext); + const windowSize = useWindowSize(); + const match = useRouteMatch(); + const useTemporaryMenu: boolean = windowSize.width < permanentMenuMinWidth; + + // Open menu when changing to permanent variant and close when changing to temporary variant. + React.useEffect(() => { + if ((useTemporaryMenu && open) || (!useTemporaryMenu && !open)) { + toggleOpen(); + } + // useEffect dependencies must only include `location`, because the function should only be called once after changing the location. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location]); + + return ( + + {menuItems.map((menuItem, index) => + menuItem.hasSubmenu ? ( + + {menuItem.submenu.map((submenu, index) => ( + + ))} + + ) : ( + + ), + )} + + ); +}; diff --git a/packages/admin/cms-admin/src/common/MasterMenuRoutes.tsx b/packages/admin/cms-admin/src/common/MasterMenuRoutes.tsx new file mode 100644 index 0000000000..84ae7f6281 --- /dev/null +++ b/packages/admin/cms-admin/src/common/MasterMenuRoutes.tsx @@ -0,0 +1,45 @@ +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 { 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 flat = (routes: RouteProps[], item: MasterMenuItem): RouteProps[] => { + if (item.route && checkPermission(item)) { + routes.push(item.route); + } + if (item.submenu) { + routes.concat(item.submenu.reduce(flat, routes)); + } + return routes; + }; + return items.reduce(flat, []); +} + +export interface MasterMenuRoutesProps { + menu: MasterMenuData; +} + +export const MasterMenuRoutes: React.FC = ({ menu }) => { + const routes = useRoutePropsFromMasterMenuData(menu); + const match = useRouteMatch(); + + return ( + + + {routes.map((route, index) => ( + + ))} + + ); +}; diff --git a/packages/admin/cms-admin/src/index.ts b/packages/admin/cms-admin/src/index.ts index dd929e344b..a8ada00053 100644 --- a/packages/admin/cms-admin/src/index.ts +++ b/packages/admin/cms-admin/src/index.ts @@ -26,6 +26,10 @@ export { Header } from "./common/header/Header"; export { UserHeaderItem } from "./common/header/UserHeaderItem"; export type { TextMatch } from "./common/MarkedMatches"; export { MarkedMatches } from "./common/MarkedMatches"; +export type { MasterMenuData, MasterMenuProps } from "./common/MasterMenu"; +export { MasterMenu, useMenuFromMasterMenuData } from "./common/MasterMenu"; +export type { MasterMenuRoutesProps } from "./common/MasterMenuRoutes"; +export { MasterMenuRoutes, useRoutePropsFromMasterMenuData } from "./common/MasterMenuRoutes"; export type { PageListItem } from "./common/PageList"; export { PageList } from "./common/PageList"; export { PageName } from "./common/PageName"; @@ -86,6 +90,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 { 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 new file mode 100644 index 0000000000..5899480eef --- /dev/null +++ b/packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx @@ -0,0 +1,58 @@ +import { gql, useQuery } from "@apollo/client"; +import { Loading } from "@comet/admin"; +import React from "react"; + +import { ContentScopeInterface } from "../../contentScope/Provider"; +import { GQLCurrentUserPermission } from "../../graphql.generated"; +import { GQLCurrentUserQuery } from "./currentUser.generated"; + +type CurrentUserContext = { currentUser: CurrentUserInterface; isAllowed: (user: CurrentUserInterface, permission: string) => boolean }; +export const CurrentUserContext = React.createContext(undefined); + +export interface CurrentUserInterface { + name?: string; + email?: string; + language?: string; + permissions: GQLCurrentUserPermission[]; + contentScopes: ContentScopeInterface[]; +} + +export const CurrentUserProvider: React.FC<{ + isAllowed?: CurrentUserContext["isAllowed"]; +}> = ({ isAllowed, children }) => { + const { data, error } = useQuery(gql` + query CurrentUser { + currentUser { + id + name + email + contentScopes + permissions { + permission + } + } + } + `); + + if (error) throw error.message; + + if (!data) return ; + + const context: CurrentUserContext = { + currentUser: data.currentUser, + isAllowed: + isAllowed ?? + ((user: CurrentUserInterface, permission: string) => { + if (user.email === undefined) return false; + return user.permissions.some((p) => p.permission === permission); + }), + }; + + return {children}; +}; + +export function useCurrentUser(): CurrentUserInterface { + const ret = React.useContext(CurrentUserContext); + if (!ret || !ret.currentUser) throw new Error("CurrentUser not found. Make sure CurrentUserContext exists."); + return ret.currentUser; +} diff --git a/packages/api/blocks-api/CHANGELOG.md b/packages/api/blocks-api/CHANGELOG.md index 5a57970bc9..cfbcf6ceb8 100644 --- a/packages/api/blocks-api/CHANGELOG.md +++ b/packages/api/blocks-api/CHANGELOG.md @@ -23,7 +23,6 @@ - Install `@comet/cli` as a dev dependency - Replace the scripts in the package.json of your admin: - ```json "generate-block-types": "comet generate-block-types --inputs", "generate-block-types:watch": "chokidar -s \"**/block-meta.json\" -c \"npm run generate-block-types\"" diff --git a/packages/api/cms-api/generate-schema.ts b/packages/api/cms-api/generate-schema.ts index 20ad58e30f..4c33a99e9d 100644 --- a/packages/api/cms-api/generate-schema.ts +++ b/packages/api/cms-api/generate-schema.ts @@ -12,7 +12,6 @@ import { createPageTreeResolver, createRedirectsResolver, CurrentUserInterface, - CurrentUserRightInterface, DependenciesResolverFactory, DependentsResolverFactory, DocumentInterface, @@ -51,17 +50,9 @@ class Page implements DocumentInterface { updatedAt: Date; } -@ObjectType() -class CurrentUserRight implements CurrentUserRightInterface { - @Field() - right: string; - - @Field(() => [String]) - values: string[]; -} - @ObjectType() class CurrentUser implements CurrentUserInterface { + @Field() id: string; @Field() name: string; @@ -69,10 +60,6 @@ class CurrentUser implements CurrentUserInterface { email: string; @Field() language: string; - @Field() - role: string; - @Field(() => [CurrentUserRight], { nullable: true }) - rights: CurrentUserRightInterface[]; @Field(() => [GraphQLJSONObject]) contentScopes: ContentScope[]; @Field(() => [CurrentUserPermission]) diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql index ff2c841a5a..07ff45b3c2 100644 --- a/packages/api/cms-api/schema.gql +++ b/packages/api/cms-api/schema.gql @@ -199,17 +199,11 @@ input DependentFilter { rootColumnName: String } -type CurrentUserRight { - right: String! - values: [String!]! -} - type CurrentUser { + id: String! name: String! email: String! language: String! - role: String! - rights: [CurrentUserRight!] contentScopes: [JSONObject!]! permissions: [CurrentUserPermission!]! } diff --git a/packages/api/cms-api/src/auth/current-user/current-user-loader.ts b/packages/api/cms-api/src/auth/current-user/current-user-loader.ts new file mode 100644 index 0000000000..b962ad6fb6 --- /dev/null +++ b/packages/api/cms-api/src/auth/current-user/current-user-loader.ts @@ -0,0 +1,7 @@ +import { CurrentUserInterface } from "./current-user"; + +export interface CurrentUserLoaderInterface { + load: (userId: string, data?: unknown) => Promise; +} + +export const CURRENT_USER_LOADER = "current-user-loader"; 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 030f895ce2..f48d13e44c 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 @@ -5,20 +5,8 @@ export interface CurrentUserInterface { name: string; email: string; language: string; - role?: string; - rights?: CurrentUserRightInterface[]; permissions?: { permission: string; }[]; contentScopes?: ContentScope[]; } - -export interface CurrentUserRightInterface { - right: string; - values: string[]; -} - -export interface CurrentUserLoaderInterface { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - load: (data: any) => Promise; -} diff --git a/packages/api/cms-api/src/auth/decorators/allow-for-role.decorator.ts b/packages/api/cms-api/src/auth/decorators/allow-for-role.decorator.ts deleted file mode 100644 index 7501e47746..0000000000 --- a/packages/api/cms-api/src/auth/decorators/allow-for-role.decorator.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { CustomDecorator, SetMetadata } from "@nestjs/common"; - -export const allowForRoleMetadataKey = "allowForRole"; - -export const AllowForRole = (...roles: string[]): CustomDecorator => SetMetadata(allowForRoleMetadataKey, roles); diff --git a/packages/api/cms-api/src/auth/guards/comet.guard.ts b/packages/api/cms-api/src/auth/guards/comet.guard.ts index c128040781..621fb735fd 100644 --- a/packages/api/cms-api/src/auth/guards/comet.guard.ts +++ b/packages/api/cms-api/src/auth/guards/comet.guard.ts @@ -5,9 +5,6 @@ import { AuthGuard, IAuthGuard, Type } from "@nestjs/passport"; import { Request } from "express"; import { isObservable, lastValueFrom } from "rxjs"; -import { CurrentUserInterface } from "../current-user/current-user"; -import { allowForRoleMetadataKey } from "../decorators/allow-for-role.decorator"; - export function createCometAuthGuard(type?: string | string[]): Type { @Injectable() class CometAuthGuard extends AuthGuard(type) implements CanActivate { @@ -45,18 +42,7 @@ export function createCometAuthGuard(type?: string | string[]): Type } const canActivate = await super.canActivate(context); - const isAllowed = isObservable(canActivate) ? await lastValueFrom(canActivate) : canActivate; - - const roles = this.reflector.getAllAndOverride(allowForRoleMetadataKey, [context.getHandler(), context.getClass()]) ?? []; - if (isAllowed && roles.length > 0) { - const userRole = ((this.getRequest(context).user as CurrentUserInterface) || undefined)?.role; - if (!userRole) return false; - - const userRoleIsAllowed = roles.some((role) => role.toLowerCase() === userRole.toLowerCase()); - return userRoleIsAllowed; - } - - return isAllowed; + return isObservable(canActivate) ? lastValueFrom(canActivate) : canActivate; } } return mixin(CometAuthGuard); diff --git a/packages/api/cms-api/src/auth/resolver/auth.resolver.ts b/packages/api/cms-api/src/auth/resolver/auth.resolver.ts index ad00762ffa..9e80af991a 100644 --- a/packages/api/cms-api/src/auth/resolver/auth.resolver.ts +++ b/packages/api/cms-api/src/auth/resolver/auth.resolver.ts @@ -5,6 +5,7 @@ import { IncomingMessage } from "http"; import { SkipBuild } from "../../builds/skip-build.decorator"; import { CurrentUserInterface } from "../current-user/current-user"; import { GetCurrentUser } from "../decorators/get-current-user.decorator"; +import { PublicApi } from "../decorators/public-api.decorator"; interface AuthResolverConfig { currentUser: Type; @@ -14,6 +15,7 @@ interface AuthResolverConfig { export function createAuthResolver(config: AuthResolverConfig): Type { @Resolver(() => config.currentUser) + @PublicApi() class AuthResolver { @Query(() => config.currentUser) async currentUser(@GetCurrentUser() user: typeof config.currentUser): Promise { diff --git a/packages/api/cms-api/src/auth/strategies/auth-proxy-jwt.strategy.ts b/packages/api/cms-api/src/auth/strategies/auth-proxy-jwt.strategy.ts index 2bda34e9e3..34842c7071 100644 --- a/packages/api/cms-api/src/auth/strategies/auth-proxy-jwt.strategy.ts +++ b/packages/api/cms-api/src/auth/strategies/auth-proxy-jwt.strategy.ts @@ -1,42 +1,28 @@ -import { Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { PassportStrategy, Type } from "@nestjs/passport"; +import { JwtPayload } from "jsonwebtoken"; import { passportJwtSecret } from "jwks-rsa"; import { ExtractJwt, Strategy, StrategyOptions } from "passport-jwt"; -import { CurrentUserInterface, CurrentUserLoaderInterface } from "../current-user/current-user"; +import { CurrentUserInterface } from "../current-user/current-user"; +import { CURRENT_USER_LOADER, CurrentUserLoaderInterface } from "../current-user/current-user-loader"; interface AuthProxyJwtStrategyConfig { jwksUri: string; - currentUserLoader?: CurrentUserLoaderInterface; strategyName?: string; audience?: string; strategyOptions?: Omit; } -class CurrentUserLoader implements CurrentUserLoaderInterface { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async load(data: any): Promise { - return { - id: data.sub, - name: data.name, - email: data.email, - language: data.language, - role: data.ext?.role, - rights: data.ext?.rights, - }; - } -} - export function createAuthProxyJwtStrategy({ jwksUri, audience, - currentUserLoader, strategyOptions, strategyName = "auth-proxy-jwt", }: AuthProxyJwtStrategyConfig): Type { @Injectable() class AuthProxyJwtStrategy extends PassportStrategy(Strategy, strategyName) { - constructor() { + constructor(@Optional() @Inject(CURRENT_USER_LOADER) private readonly currentUserLoader: CurrentUserLoaderInterface) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKeyProvider: passportJwtSecret({ @@ -47,10 +33,17 @@ export function createAuthProxyJwtStrategy({ }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async validate(data: any): Promise { - const userLoader = currentUserLoader ? currentUserLoader : new CurrentUserLoader(); - return userLoader.load(data); + async validate(data: JwtPayload): Promise { + if (!data.sub) throw new Error("JwtPayload does not contain sub."); + if (!this.currentUserLoader) { + return { + id: data.sub, + name: data.name, + email: data.email, + language: data.language, + }; + } + return this.currentUserLoader.load(data.sub, data); } } return AuthProxyJwtStrategy; diff --git a/packages/api/cms-api/src/auth/strategies/static-authed-user.strategy.ts b/packages/api/cms-api/src/auth/strategies/static-authed-user.strategy.ts index 710a1219e9..5b0a97a673 100644 --- a/packages/api/cms-api/src/auth/strategies/static-authed-user.strategy.ts +++ b/packages/api/cms-api/src/auth/strategies/static-authed-user.strategy.ts @@ -1,27 +1,28 @@ -import { Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { PassportStrategy, Type } from "@nestjs/passport"; -import jwt from "jsonwebtoken"; -import { ExtractJwt, Strategy } from "passport-jwt"; +import { Strategy } from "passport-custom"; import { CurrentUserInterface } from "../current-user/current-user"; +import { CURRENT_USER_LOADER, CurrentUserLoaderInterface } from "../current-user/current-user-loader"; interface StaticAuthedUserStrategyConfig { - staticAuthedUser: CurrentUserInterface; + staticAuthedUser: CurrentUserInterface | string; + userExtraData?: unknown; } export function createStaticAuthedUserStrategy(config: StaticAuthedUserStrategyConfig): Type { @Injectable() class StaticAuthedUserStrategy extends PassportStrategy(Strategy, "static-authed-user") { - constructor() { - const secretOrKey = "static"; - super({ - jwtFromRequest: ExtractJwt.fromExtractors([() => jwt.sign(config.staticAuthedUser, secretOrKey)]), - secretOrKey, - }); + constructor(@Optional() @Inject(CURRENT_USER_LOADER) private readonly currentUserLoader: CurrentUserLoaderInterface) { + super(); } - validate(data: CurrentUserInterface): CurrentUserInterface { - return data; + async validate(): Promise { + if (typeof config.staticAuthedUser === "string") { + if (!this.currentUserLoader) throw new Error("You have to provide CURRENT_USER_LOADER when setting staticAuthedUser as string"); + return this.currentUserLoader.load(config.staticAuthedUser, config.userExtraData); + } + return config.staticAuthedUser; } } return StaticAuthedUserStrategy; diff --git a/packages/api/cms-api/src/builds/build-templates.resolver.ts b/packages/api/cms-api/src/builds/build-templates.resolver.ts index f948b9f80f..a60694a9cf 100644 --- a/packages/api/cms-api/src/builds/build-templates.resolver.ts +++ b/packages/api/cms-api/src/builds/build-templates.resolver.ts @@ -3,11 +3,13 @@ import { Query, Resolver } from "@nestjs/graphql"; import { CurrentUserInterface } from "../auth/current-user/current-user"; import { GetCurrentUser } from "../auth/decorators/get-current-user.decorator"; import { KubernetesService } from "../kubernetes/kubernetes.service"; +import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator"; import { BuildTemplatesService } from "./build-templates.service"; import { LABEL_ANNOTATION } from "./builds.constants"; import { BuildTemplateObject } from "./dto/build-template.object"; @Resolver(() => BuildTemplateObject) +@RequiredPermission(["builds"], { skipScopeCheck: true }) // Scopes are checked in Code export class BuildTemplatesResolver { constructor(private readonly kubernetesService: KubernetesService, private readonly buildTemplatesService: BuildTemplatesService) {} 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 27b71f25f7..248329874d 100644 --- a/packages/api/cms-api/src/builds/build-templates.service.ts +++ b/packages/api/cms-api/src/builds/build-templates.service.ts @@ -1,19 +1,23 @@ import { V1CronJob } from "@kubernetes/client-node"; -import { Injectable } from "@nestjs/common"; +import { Inject, Injectable } from "@nestjs/common"; import { CurrentUserInterface } from "../auth/current-user/current-user"; -import { ContentScopeService } from "../content-scope/content-scope.service"; import { INSTANCE_LABEL } from "../kubernetes/kubernetes.constants"; import { KubernetesService } from "../kubernetes/kubernetes.service"; +import { ACCESS_CONTROL_SERVICE } from "../user-permissions/user-permissions.constants"; +import { AccessControlServiceInterface } from "../user-permissions/user-permissions.types"; import { BUILDER_LABEL } from "./builds.constants"; @Injectable() export class BuildTemplatesService { - constructor(private readonly kubernetesService: KubernetesService, private readonly contentScopeService: ContentScopeService) {} + constructor( + private readonly kubernetesService: KubernetesService, + @Inject(ACCESS_CONTROL_SERVICE) private accessControlService: AccessControlServiceInterface, + ) {} async getAllowedBuilderCronJobs(user: CurrentUserInterface): Promise { return (await this.getAllBuilderCronJobs()).filter((cronJob) => { - return this.contentScopeService.canAccessScope(this.kubernetesService.getContentScope(cronJob), user); + return this.accessControlService.isAllowedContentScope(user, 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 be342f8794..e5f8242fac 100644 --- a/packages/api/cms-api/src/builds/builds.resolver.ts +++ b/packages/api/cms-api/src/builds/builds.resolver.ts @@ -1,11 +1,14 @@ import { V1CronJob } from "@kubernetes/client-node"; +import { Inject } from "@nestjs/common"; import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; import { CurrentUserInterface } from "../auth/current-user/current-user"; import { GetCurrentUser } from "../auth/decorators/get-current-user.decorator"; -import { ContentScopeService } from "../content-scope/content-scope.service"; import { INSTANCE_LABEL } from "../kubernetes/kubernetes.constants"; import { KubernetesService } from "../kubernetes/kubernetes.service"; +import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator"; +import { ACCESS_CONTROL_SERVICE } from "../user-permissions/user-permissions.constants"; +import { AccessControlServiceInterface } from "../user-permissions/user-permissions.types"; import { BuildsService } from "./builds.service"; import { AutoBuildStatus } from "./dto/auto-build-status.object"; import { Build } from "./dto/build.object"; @@ -13,11 +16,12 @@ import { CreateBuildsInput } from "./dto/create-builds.input"; import { SkipBuild } from "./skip-build.decorator"; @Resolver(() => Build) +@RequiredPermission(["builds"], { skipScopeCheck: true }) // Scopes are checked in code export class BuildsResolver { constructor( private readonly kubernetesService: KubernetesService, private readonly buildsService: BuildsService, - private readonly contentScopeService: ContentScopeService, + @Inject(ACCESS_CONTROL_SERVICE) private accessControlService: AccessControlServiceInterface, ) {} @Mutation(() => Boolean) @@ -33,7 +37,7 @@ export class BuildsResolver { throw new Error("Triggering build from different instance is not allowed"); } - if (!this.contentScopeService.canAccessScope(this.kubernetesService.getContentScope(cronJob), user)) { + if (!this.accessControlService.isAllowedContentScope(user, 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 1f9e01c215..2fc1f447ce 100644 --- a/packages/api/cms-api/src/builds/builds.service.ts +++ b/packages/api/cms-api/src/builds/builds.service.ts @@ -1,16 +1,17 @@ import { V1CronJob, V1Job } from "@kubernetes/client-node"; import { InjectRepository } from "@mikro-orm/nestjs"; import { EntityRepository } from "@mikro-orm/postgresql"; -import { Injectable } from "@nestjs/common"; +import { Inject, Injectable } from "@nestjs/common"; import parser from "cron-parser"; import { format } from "date-fns"; import { CurrentUserInterface } from "../auth/current-user/current-user"; -import { ContentScope } from "../common/decorators/content-scope.interface"; -import { ContentScopeService } from "../content-scope/content-scope.service"; import { JobStatus } from "../kubernetes/job-status.enum"; import { INSTANCE_LABEL, PARENT_CRON_JOB_LABEL } from "../kubernetes/kubernetes.constants"; import { KubernetesService } from "../kubernetes/kubernetes.service"; +import { ContentScope } from "../user-permissions/interfaces/content-scope.interface"; +import { ACCESS_CONTROL_SERVICE } from "../user-permissions/user-permissions.constants"; +import { AccessControlServiceInterface } from "../user-permissions/user-permissions.types"; import { BuildTemplatesService } from "./build-templates.service"; import { BUILDER_LABEL, LABEL_ANNOTATION, TRIGGER_ANNOTATION } from "./builds.constants"; import { AutoBuildStatus } from "./dto/auto-build-status.object"; @@ -25,13 +26,13 @@ export class BuildsService { @InjectRepository(ChangesSinceLastBuild) private readonly changesRepository: EntityRepository, private readonly buildTemplatesService: BuildTemplatesService, private readonly kubernetesService: KubernetesService, - private readonly contentScopeService: ContentScopeService, + @Inject(ACCESS_CONTROL_SERVICE) private accessControlService: AccessControlServiceInterface, ) {} 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.contentScopeService.canAccessScope(this.kubernetesService.getContentScope(job), user); + return this.accessControlService.isAllowedContentScope(user, this.kubernetesService.getContentScope(job)); }); } diff --git a/packages/api/cms-api/src/builds/changes-checker.console.ts b/packages/api/cms-api/src/builds/changes-checker.console.ts index 1373dd0652..312c571734 100644 --- a/packages/api/cms-api/src/builds/changes-checker.console.ts +++ b/packages/api/cms-api/src/builds/changes-checker.console.ts @@ -1,9 +1,9 @@ import { MikroORM, UseRequestContext } from "@mikro-orm/core"; import { Injectable } from "@nestjs/common"; import { Command, Console } from "nestjs-console"; -import { ContentScope } from "src/common/decorators/content-scope.interface"; import { KubernetesService } from "../kubernetes/kubernetes.service"; +import { ContentScope } from "../user-permissions/interfaces/content-scope.interface"; import { BuildTemplatesService } from "./build-templates.service"; import { BuildsService } from "./builds.service"; diff --git a/packages/api/cms-api/src/builds/changes-checker.interceptor.ts b/packages/api/cms-api/src/builds/changes-checker.interceptor.ts index f053890cbb..43f18ee550 100644 --- a/packages/api/cms-api/src/builds/changes-checker.interceptor.ts +++ b/packages/api/cms-api/src/builds/changes-checker.interceptor.ts @@ -4,8 +4,8 @@ import { GqlExecutionContext } from "@nestjs/graphql"; import { GraphQLResolveInfo } from "graphql"; import { Observable } from "rxjs"; -import { ContentScope } from "../common/decorators/content-scope.interface"; -import { ContentScopeService } from "../content-scope/content-scope.service"; +import { ContentScopeService } from "../user-permissions/content-scope.service"; +import { ContentScope } from "../user-permissions/interfaces/content-scope.interface"; import { BuildsService } from "./builds.service"; import { SKIP_BUILD_METADATA_KEY } from "./skip-build.decorator"; diff --git a/packages/api/cms-api/src/builds/entities/changes-since-last-build.entity.ts b/packages/api/cms-api/src/builds/entities/changes-since-last-build.entity.ts index ce66806079..dd9aa2223f 100644 --- a/packages/api/cms-api/src/builds/entities/changes-since-last-build.entity.ts +++ b/packages/api/cms-api/src/builds/entities/changes-since-last-build.entity.ts @@ -1,7 +1,7 @@ import { BaseEntity, Entity, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core"; import { v4 as uuid } from "uuid"; -import { ContentScope } from "../../common/decorators/content-scope.interface"; +import { ContentScope } from "../../user-permissions/interfaces/content-scope.interface"; @Entity() export class ChangesSinceLastBuild extends BaseEntity { diff --git a/packages/api/cms-api/src/common/decorators/content-scope.interface.ts b/packages/api/cms-api/src/common/decorators/content-scope.interface.ts deleted file mode 100644 index 36a619c4cc..0000000000 --- a/packages/api/cms-api/src/common/decorators/content-scope.interface.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ContentScope {} diff --git a/packages/api/cms-api/src/content-scope/conent-scope.constants.ts b/packages/api/cms-api/src/content-scope/conent-scope.constants.ts deleted file mode 100644 index 8fec66c720..0000000000 --- a/packages/api/cms-api/src/content-scope/conent-scope.constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const CAN_ACCESS_SCOPE = "can-access-scope"; diff --git a/packages/api/cms-api/src/content-scope/content-scope.module.ts b/packages/api/cms-api/src/content-scope/content-scope.module.ts deleted file mode 100644 index 9bf95b6ca5..0000000000 --- a/packages/api/cms-api/src/content-scope/content-scope.module.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { DynamicModule, Global, Module } from "@nestjs/common"; -import { APP_GUARD } from "@nestjs/core"; -import { CurrentUserInterface } from "src/auth/current-user/current-user"; -import { ContentScope } from "src/common/decorators/content-scope.interface"; - -import { CAN_ACCESS_SCOPE } from "./conent-scope.constants"; -import { ContentScopeService } from "./content-scope.service"; -import { ScopeGuard } from "./scope.guard"; - -export type CanAccessScope = (requestScope: ContentScope, user: CurrentUserInterface) => boolean; - -interface ContentScopeModuleOptions { - canAccessScope: CanAccessScope; -} - -@Global() -@Module({}) -export class ContentScopeModule { - static forRoot(options: ContentScopeModuleOptions): DynamicModule { - const { canAccessScope } = options; - return { - module: ContentScopeModule, - imports: [], - providers: [{ provide: APP_GUARD, useClass: ScopeGuard }, { provide: CAN_ACCESS_SCOPE, useValue: canAccessScope }, ContentScopeService], - exports: [ContentScopeService], - }; - } -} diff --git a/packages/api/cms-api/src/content-scope/content-scope.service.ts b/packages/api/cms-api/src/content-scope/content-scope.service.ts deleted file mode 100644 index 995ff3ad48..0000000000 --- a/packages/api/cms-api/src/content-scope/content-scope.service.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { EntityClass, MikroORM } from "@mikro-orm/core"; -import { ExecutionContext, Inject, Injectable, Optional } from "@nestjs/common"; -import { Reflector } from "@nestjs/core"; -import { GqlExecutionContext } from "@nestjs/graphql"; -import isEqual from "lodash.isequal"; - -import { CurrentUserInterface } from "../auth/current-user/current-user"; -import { ContentScope } from "../common/decorators/content-scope.interface"; -import { ScopedEntityMeta } from "../common/decorators/scoped-entity.decorator"; -import { SubjectEntityMeta } from "../common/decorators/subject-entity.decorator"; -import { PageTreeService } from "../page-tree/page-tree.service"; -import { CAN_ACCESS_SCOPE } from "./conent-scope.constants"; -import { CanAccessScope } from "./content-scope.module"; - -@Injectable() -export class ContentScopeService { - constructor( - @Inject(CAN_ACCESS_SCOPE) private canAccessScopeConfig: CanAccessScope, - private reflector: Reflector, - private readonly orm: MikroORM, - @Optional() private readonly pageTreeService?: PageTreeService, - ) {} - - scopesAreEqual(scope1: ContentScope | undefined, scope2: ContentScope | undefined): boolean { - // The scopes are cloned because they could be - // - an instance of a class (e.g. DamScope) - // - or a plain object (from a GraphQL input) - // Then they are not deeply equal, although they represent the same scope - return isEqual({ ...scope1 }, { ...scope2 }); - } - - canAccessScope(requestScope: ContentScope, user: CurrentUserInterface): boolean { - return this.canAccessScopeConfig(requestScope, user); - } - - async inferScopeFromExecutionContext(context: ExecutionContext): Promise { - if (context.getType().toString() === "graphql") { - const gqlContext = GqlExecutionContext.create(context); - const args = gqlContext.getArgs(); - - const subjectEntity = this.reflector.getAllAndOverride("subjectEntity", [context.getHandler(), context.getClass()]); - if (subjectEntity) { - let subjectScope: ContentScope | undefined; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const repo = this.orm.em.getRepository(subjectEntity.entity); - if (subjectEntity.options.idArg) { - if (!args[subjectEntity.options.idArg]) { - throw new Error(`${subjectEntity.options.idArg} arg not found`); - } - const row = await repo.findOneOrFail(args[subjectEntity.options.idArg]); - if (row.scope) { - subjectScope = row.scope; - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const scoped = this.reflector.getAllAndOverride("scopedEntity", [subjectEntity.entity as EntityClass]); - if (!scoped) { - return undefined; - } - subjectScope = await scoped.fn(row); - } - } else if (subjectEntity.options.pageTreeNodeIdArg && args[subjectEntity.options.pageTreeNodeIdArg]) { - if (!args[subjectEntity.options.pageTreeNodeIdArg]) { - throw new Error(`${subjectEntity.options.pageTreeNodeIdArg} arg not found`); - } - if (this.pageTreeService === undefined) { - throw new Error("pageTreeNodeIdArg was given but no PageTreeModule is registered"); - } - const node = await this.pageTreeService - .createReadApi({ visibility: "all" }) - .getNode(args[subjectEntity.options.pageTreeNodeIdArg]); - if (!node) throw new Error("Can't find pageTreeNode"); - subjectScope = node.scope; - } else { - // TODO implement something more flexible that supports something like that: @SubjectEntity(Product, ProductEntityLoader) - throw new Error("idArg or pageTreeNodeIdArg is required"); - } - if (subjectScope === undefined) throw new Error("Scope not found"); - if (args.scope) { - // args.scope also exists, check if they match - if (!isEqual(args.scope, subjectScope)) { - throw new Error("Content Scope from arg doesn't match subjectEntity scope, usually you only need one of them"); - } - } - return subjectScope; - } - if (args.scope) { - return args.scope; - } - } - return undefined; - } -} diff --git a/packages/api/cms-api/src/content-scope/decorators/scope-guard-active.decorator.ts b/packages/api/cms-api/src/content-scope/decorators/scope-guard-active.decorator.ts deleted file mode 100644 index 0226a4ad6d..0000000000 --- a/packages/api/cms-api/src/content-scope/decorators/scope-guard-active.decorator.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { CustomDecorator, SetMetadata } from "@nestjs/common"; - -const SCOPE_GUARD_ACTIVE_METADATA_KEY = "scopeGuardActive"; - -type ScopeGuardActiveMetadataValue = boolean; - -const ScopeGuardActive = (active: boolean): CustomDecorator => { - return SetMetadata(SCOPE_GUARD_ACTIVE_METADATA_KEY, active); -}; - -export { SCOPE_GUARD_ACTIVE_METADATA_KEY, ScopeGuardActive }; -export type { ScopeGuardActiveMetadataValue }; diff --git a/packages/api/cms-api/src/content-scope/scope.guard.ts b/packages/api/cms-api/src/content-scope/scope.guard.ts deleted file mode 100644 index 96f9b08e90..0000000000 --- a/packages/api/cms-api/src/content-scope/scope.guard.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; -import { Reflector } from "@nestjs/core"; -import { GqlExecutionContext } from "@nestjs/graphql"; - -import { CurrentUserInterface } from "../auth/current-user/current-user"; -import { ContentScopeService } from "./content-scope.service"; -import { SCOPE_GUARD_ACTIVE_METADATA_KEY, ScopeGuardActiveMetadataValue } from "./decorators/scope-guard-active.decorator"; - -@Injectable() -export class ScopeGuard implements CanActivate { - constructor(private reflector: Reflector, private readonly contentScopeService: ContentScopeService) {} - - async canActivate(context: ExecutionContext): Promise { - const isPublicApi = this.reflector.getAllAndOverride("publicApi", [context.getHandler(), context.getClass()]); - if (isPublicApi) { - return true; - } - - const scopeGuardActive = this.reflector.getAllAndOverride(SCOPE_GUARD_ACTIVE_METADATA_KEY, [ - context.getHandler(), - context.getClass(), - ]); - - if (scopeGuardActive === false) { - return true; - } - - const request = - context.getType().toString() === "graphql" ? GqlExecutionContext.create(context).getContext().req : context.switchToHttp().getRequest(); - const user = request.user as CurrentUserInterface | undefined; - if (!user) return true; - - const requestScope = await this.contentScopeService.inferScopeFromExecutionContext(context); - if (requestScope) { - return this.contentScopeService.canAccessScope(requestScope, user); - } else { - //not a scoped request, open to anyone - } - - return true; - } -} 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 b55fee4332..f1cf187d89 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 @@ -3,9 +3,11 @@ import { Query, Resolver } from "@nestjs/graphql"; import { BUILDER_LABEL } from "../builds/builds.constants"; import { INSTANCE_LABEL } from "../kubernetes/kubernetes.constants"; import { KubernetesService } from "../kubernetes/kubernetes.service"; +import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator"; import { CronJob } from "./dto/cron-job.object"; @Resolver(() => CronJob) +@RequiredPermission(["cronJobs"], { skipScopeCheck: true }) export class CronJobsResolver { constructor(private readonly kubernetesService: KubernetesService) {} diff --git a/packages/api/cms-api/src/dam/files/dam-items.resolver.ts b/packages/api/cms-api/src/dam/files/dam-items.resolver.ts index 47bb28b5c1..07796f403b 100644 --- a/packages/api/cms-api/src/dam/files/dam-items.resolver.ts +++ b/packages/api/cms-api/src/dam/files/dam-items.resolver.ts @@ -1,7 +1,7 @@ import { Type } from "@nestjs/common"; import { Args, createUnionType, Field, Int, ObjectType, Query, Resolver } from "@nestjs/graphql"; -import { ScopeGuardActive } from "../../content-scope/decorators/scope-guard-active.decorator"; +import { RequiredPermission } from "../../user-permissions/decorators/required-permission.decorator"; import { DamScopeInterface } from "../types"; import { DamItemsService } from "./dam-items.service"; import { createDamItemArgs, createDamItemPositionArgs, DamItemPositionArgsInterface, DamItemsArgsInterface } from "./dto/dam-items.args"; @@ -53,7 +53,7 @@ export function createDamItemsResolver({ } } - @ScopeGuardActive(hasNonEmptyScope) + @RequiredPermission(["dam"], { skipScopeCheck: !hasNonEmptyScope }) @Resolver(() => DamItem) class DamItemsResolver { constructor(private readonly damItemsService: DamItemsService) {} diff --git a/packages/api/cms-api/src/dam/files/file-image.resolver.ts b/packages/api/cms-api/src/dam/files/file-image.resolver.ts index fb57ebf76c..51be89a11f 100644 --- a/packages/api/cms-api/src/dam/files/file-image.resolver.ts +++ b/packages/api/cms-api/src/dam/files/file-image.resolver.ts @@ -1,10 +1,12 @@ import { Args, Int, Parent, ResolveField, Resolver } from "@nestjs/graphql"; +import { RequiredPermission } from "../../user-permissions/decorators/required-permission.decorator"; import { ImagesService } from "../images/images.service"; import { DamFileImage } from "./entities/file-image.entity"; import { FilesService } from "./files.service"; @Resolver(() => DamFileImage) +@RequiredPermission(["dam"]) export class FileImagesResolver { constructor(private readonly imagesService: ImagesService, private readonly filesService: FilesService) {} 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 7a7248de15..6639b5799c 100644 --- a/packages/api/cms-api/src/dam/files/files.controller.ts +++ b/packages/api/cms-api/src/dam/files/files.controller.ts @@ -23,7 +23,9 @@ import { GetCurrentUser } from "../../auth/decorators/get-current-user.decorator import { DisableGlobalGuard } from "../../auth/decorators/global-guard-disable.decorator"; import { BlobStorageBackendService } from "../../blob-storage/backends/blob-storage-backend.service"; import { CometValidationException } from "../../common/errors/validation.exception"; -import { ContentScopeService } from "../../content-scope/content-scope.service"; +import { RequiredPermission } from "../../user-permissions/decorators/required-permission.decorator"; +import { ACCESS_CONTROL_SERVICE } from "../../user-permissions/user-permissions.constants"; +import { AccessControlServiceInterface } from "../../user-permissions/user-permissions.types"; import { CDN_ORIGIN_CHECK_HEADER, DamConfig } from "../dam.config"; import { DAM_CONFIG } from "../dam.constants"; import { DamScopeInterface } from "../types"; @@ -49,12 +51,13 @@ export function createFilesController({ Scope: PassedScope }: { Scope?: Type File) class FilesResolver { constructor( private readonly filesService: FilesService, @InjectRepository("DamFile") private readonly filesRepository: EntityRepository, @InjectRepository("DamFolder") private readonly foldersRepository: EntityRepository, - private readonly contentScopeService: ContentScopeService, + @Inject(ACCESS_CONTROL_SERVICE) private accessControlService: AccessControlServiceInterface, @Inject(DAM_FILE_VALIDATION_SERVICE) private readonly fileValidationService: FileValidationService, ) {} @@ -63,7 +64,7 @@ export function createFilesResolver({ File, Scope: PassedScope }: { File: Type File) - @SubjectEntity(File) + @AffectedEntity(File) async damFile(@Args("id", { type: () => ID }) id: string): Promise { const file = await this.filesService.findOneById(id); if (!file) { @@ -73,7 +74,7 @@ export function createFilesResolver({ File, Scope: PassedScope }: { File: Type [File]) - //@ SubjectEntity is not required here + //@ AffectedEntity is not required here async findCopiesOfFileInScope( @Args({ type: () => FindCopiesOfFileInScopeArgs }) { id, scope, imageCropArea }: FindCopiesOfFileInScopeArgsInterface, ): Promise { @@ -81,7 +82,7 @@ export function createFilesResolver({ File, Scope: PassedScope }: { File: Type File) - @SubjectEntity(File) + @AffectedEntity(File) async updateDamFile( @Args("id", { type: () => ID }) id: string, @Args("input", { type: () => UpdateFileInput }) input: UpdateFileInput, @@ -121,7 +122,7 @@ export function createFilesResolver({ File, Scope: PassedScope }: { File: Type File) - @SubjectEntity(File) + @AffectedEntity(File) @SkipBuild() async archiveDamFile(@Args("id", { type: () => ID }) id: string): Promise { const entity = await this.filesRepository.findOneOrFail(id); @@ -179,7 +180,7 @@ export function createFilesResolver({ File, Scope: PassedScope }: { File: Type File) - @SubjectEntity(File) + @AffectedEntity(File) @SkipBuild() async restoreDamFile(@Args("id", { type: () => ID }) id: string): Promise { const entity = await this.filesRepository.findOneOrFail(id); @@ -203,7 +204,7 @@ export function createFilesResolver({ File, Scope: PassedScope }: { File: Type Boolean) - @SubjectEntity(File) + @AffectedEntity(File) @SkipBuild() async deleteDamFile(@Args("id", { type: () => ID }) id: string): Promise { return this.filesService.delete(id); diff --git a/packages/api/cms-api/src/dam/files/files.service.ts b/packages/api/cms-api/src/dam/files/files.service.ts index 05857e1e04..c227c09d55 100644 --- a/packages/api/cms-api/src/dam/files/files.service.ts +++ b/packages/api/cms-api/src/dam/files/files.service.ts @@ -7,7 +7,6 @@ import exifr from "exifr"; import { createReadStream } from "fs"; import getColors from "get-image-colors"; import * as hasha from "hasha"; -import isEqual from "lodash.isequal"; import fetch from "node-fetch"; import { basename, extname, parse } from "path"; import probe from "probe-image-size"; @@ -17,7 +16,9 @@ import { CurrentUserInterface } from "../../auth/current-user/current-user"; import { BlobStorageBackendService } from "../../blob-storage/backends/blob-storage-backend.service"; import { CometEntityNotFoundException } from "../../common/errors/entity-not-found.exception"; import { SortDirection } from "../../common/sorting/sort-direction.enum"; -import { ContentScopeService } from "../../content-scope/content-scope.service"; +import { ContentScopeService } from "../../user-permissions/content-scope.service"; +import { ACCESS_CONTROL_SERVICE } from "../../user-permissions/user-permissions.constants"; +import { AccessControlServiceInterface } from "../../user-permissions/user-permissions.types"; import { FocalPoint } from "../common/enums/focal-point.enum"; import { CometImageResolutionException } from "../common/errors/image-resolution.exception"; import { DamConfig } from "../dam.config"; @@ -115,6 +116,7 @@ export class FilesService { private readonly imgproxyService: ImgproxyService, private readonly orm: MikroORM, private readonly contentScopeService: ContentScopeService, + @Inject(ACCESS_CONTROL_SERVICE) private accessControlService: AccessControlServiceInterface, ) {} private selectQueryBuilder(): QueryBuilder { @@ -259,7 +261,7 @@ export class FilesService { for (const file of files) { // Convert to JS object because deep-comparing classes and objects doesn't work - if (targetFolder?.scope !== undefined && !isEqual({ ...file.scope }, { ...targetFolder.scope })) { + if (targetFolder?.scope !== undefined && !this.contentScopeService.scopesAreEqual(file.scope, targetFolder.scope)) { throw new Error("Target folder scope doesn't match file scope"); } @@ -452,7 +454,7 @@ export class FilesService { if (!inboxFolder) { throw new Error("Specified inbox folder doesn't exist."); } - if (inboxFolder.scope && !this.contentScopeService.canAccessScope(inboxFolder.scope, user)) { + if (inboxFolder.scope && !this.accessControlService.isAllowedContentScope(user, inboxFolder.scope)) { throw new Error("User can't access the target scope"); } @@ -478,7 +480,7 @@ export class FilesService { const fileScopes = getUniqueFileScopes(files); const canAccessFileScopes = fileScopes.every((scope) => { - return this.contentScopeService.canAccessScope(scope, user); + return this.accessControlService.isAllowedContentScope(user, 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/files/folders.resolver.ts b/packages/api/cms-api/src/dam/files/folders.resolver.ts index 713dddcb8f..1f8be7fcff 100644 --- a/packages/api/cms-api/src/dam/files/folders.resolver.ts +++ b/packages/api/cms-api/src/dam/files/folders.resolver.ts @@ -2,9 +2,9 @@ import { NotFoundException, Type } from "@nestjs/common"; import { Args, ID, Mutation, ObjectType, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; import { SkipBuild } from "../../builds/skip-build.decorator"; -import { SubjectEntity } from "../../common/decorators/subject-entity.decorator"; import { PaginatedResponseFactory } from "../../common/pagination/paginated-response.factory"; -import { ScopeGuardActive } from "../../content-scope/decorators/scope-guard-active.decorator"; +import { AffectedEntity } from "../../user-permissions/decorators/affected-entity.decorator"; +import { RequiredPermission } from "../../user-permissions/decorators/required-permission.decorator"; import { DamScopeInterface } from "../types"; import { EmptyDamScope } from "./dto/empty-dam-scope"; import { createFolderArgs, createFolderByNameAndParentIdArgs, FolderArgsInterface, FolderByNameAndParentIdArgsInterface } from "./dto/folder.args"; @@ -36,7 +36,7 @@ export function createFoldersResolver({ @ObjectType() class PaginatedDamFolders extends PaginatedResponseFactory.create(Folder) {} - @ScopeGuardActive(hasNonEmptyScope) + @RequiredPermission(["dam"], { skipScopeCheck: !hasNonEmptyScope }) @Resolver(() => Folder) class FoldersResolver { constructor(private readonly foldersService: FoldersService) {} @@ -55,7 +55,7 @@ export function createFoldersResolver({ } @Query(() => Folder) - @SubjectEntity(Folder) + @AffectedEntity(Folder) async damFolder(@Args("id", { type: () => ID }) id: string): Promise { const folder = await this.foldersService.findOneById(id); if (!folder) { @@ -81,7 +81,7 @@ export function createFoldersResolver({ } @Mutation(() => Folder) - @SubjectEntity(Folder) + @AffectedEntity(Folder) @SkipBuild() async updateDamFolder( @Args("id", { type: () => ID }) id: string, @@ -101,7 +101,7 @@ export function createFoldersResolver({ } @Mutation(() => Boolean) - @SubjectEntity(Folder) + @AffectedEntity(Folder) @SkipBuild() async deleteDamFolder(@Args("id", { type: () => ID }) id: string): Promise { return this.foldersService.delete(id); 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 006daba54a..0640bffee1 100644 --- a/packages/api/cms-api/src/dam/images/images.controller.ts +++ b/packages/api/cms-api/src/dam/images/images.controller.ts @@ -10,7 +10,9 @@ import { CurrentUserInterface } from "../../auth/current-user/current-user"; import { GetCurrentUser } from "../../auth/decorators/get-current-user.decorator"; import { DisableGlobalGuard } from "../../auth/decorators/global-guard-disable.decorator"; import { BlobStorageBackendService } from "../../blob-storage/backends/blob-storage-backend.service"; -import { ContentScopeService } from "../../content-scope/content-scope.service"; +import { RequiredPermission } from "../../user-permissions/decorators/required-permission.decorator"; +import { ACCESS_CONTROL_SERVICE } from "../../user-permissions/user-permissions.constants"; +import { AccessControlServiceInterface } from "../../user-permissions/user-permissions.types"; import { ScaledImagesCacheService } from "../cache/scaled-images-cache.service"; import { FocalPoint } from "../common/enums/focal-point.enum"; import { CDN_ORIGIN_CHECK_HEADER, DamConfig } from "../dam.config"; @@ -40,6 +42,7 @@ const smartImageUrl = `:fileId/crop::focalPoint([A-Z]{5,9})/resize::resizeWidth: const focusImageUrl = `:fileId/crop::cropWidth::cropHeight::focalPoint::cropX::cropY/resize::resizeWidth::resizeHeight/:filename`; @Controller("dam/images") +@RequiredPermission(["dam"], { skipScopeCheck: true }) // Scopes are checked in Code export class ImagesController { constructor( @Inject(DAM_CONFIG) private readonly config: DamConfig, @@ -48,7 +51,7 @@ export class ImagesController { private readonly imagesService: ImagesService, private readonly cacheService: ScaledImagesCacheService, @Inject(forwardRef(() => BlobStorageBackendService)) private readonly blobStorageBackendService: BlobStorageBackendService, - private readonly contentScopeService: ContentScopeService, + @Inject(ACCESS_CONTROL_SERVICE) private accessControlService: AccessControlServiceInterface, ) {} @Get(`/preview/${smartImageUrl}`) @@ -68,7 +71,7 @@ export class ImagesController { throw new NotFoundException(); } - if (file.scope !== undefined && !this.contentScopeService.canAccessScope(file.scope, user)) { + if (file.scope !== undefined && !this.accessControlService.isAllowedContentScope(user, file.scope)) { throw new ForbiddenException(); } @@ -94,7 +97,7 @@ export class ImagesController { throw new NotFoundException(); } - if (file.scope !== undefined && !this.contentScopeService.canAccessScope(file.scope, user)) { + if (file.scope !== undefined && !this.accessControlService.isAllowedContentScope(user, file.scope)) { throw new ForbiddenException(); } diff --git a/packages/api/cms-api/src/generator/crud-generator.decorator.ts b/packages/api/cms-api/src/generator/crud-generator.decorator.ts index cd520a9e3d..7bed2944a3 100644 --- a/packages/api/cms-api/src/generator/crud-generator.decorator.ts +++ b/packages/api/cms-api/src/generator/crud-generator.decorator.ts @@ -1,5 +1,6 @@ export interface CrudGeneratorOptions { targetDirectory: string; + requiredPermission?: string[] | string; create?: boolean; update?: boolean; delete?: boolean; @@ -7,18 +8,20 @@ export interface CrudGeneratorOptions { export function CrudGenerator({ targetDirectory, + requiredPermission, create = true, update = true, delete: deleteMutation = true, }: CrudGeneratorOptions): ClassDecorator { // eslint-disable-next-line @typescript-eslint/ban-types return function (target: Function) { - Reflect.defineMetadata(`data:crudGeneratorOptions`, { targetDirectory, create, update, delete: deleteMutation }, target); + Reflect.defineMetadata(`data:crudGeneratorOptions`, { targetDirectory, requiredPermission, create, update, delete: deleteMutation }, target); }; } export interface CrudSingleGeneratorOptions { targetDirectory: string; + requiredPermission?: string[] | string; } export function CrudSingleGenerator(options: CrudSingleGeneratorOptions): ClassDecorator { diff --git a/packages/api/cms-api/src/generator/generate-crud-single.ts b/packages/api/cms-api/src/generator/generate-crud-single.ts index 0e7994b9a6..8efbb181ae 100644 --- a/packages/api/cms-api/src/generator/generate-crud-single.ts +++ b/packages/api/cms-api/src/generator/generate-crud-single.ts @@ -13,6 +13,7 @@ export async function generateCrudSingle(generatorOptions: CrudSingleGeneratorOp const instanceNamePlural = classNamePlural[0].toLocaleLowerCase() + classNamePlural.slice(1); const fileNameSingular = instanceNameSingular.replace(/[A-Z]/g, (i) => `-${i.toLocaleLowerCase()}`); const fileNamePlural = instanceNamePlural.replace(/[A-Z]/g, (i) => `-${i.toLocaleLowerCase()}`); + if (!generatorOptions.requiredPermission) generatorOptions.requiredPermission = [instanceNamePlural]; async function generateCrudResolver(): Promise { const generatedFiles: GeneratedFile[] = []; @@ -41,7 +42,7 @@ export async function generateCrudSingle(generatorOptions: CrudSingleGeneratorOp import { EntityRepository, EntityManager } from "@mikro-orm/postgresql"; import { FindOptions } from "@mikro-orm/core"; import { Args, ID, Mutation, Query, Resolver } from "@nestjs/graphql"; - import { SortDirection, validateNotModified } from "@comet/cms-api"; + import { RequiredPermission, SortDirection, validateNotModified } from "@comet/cms-api"; import { ${metadata.className} } from "${path.relative(generatorOptions.targetDirectory, metadata.path).replace(/\.ts$/, "")}"; ${ @@ -56,6 +57,7 @@ export async function generateCrudSingle(generatorOptions: CrudSingleGeneratorOp import { Paginated${classNamePlural} } from "./dto/paginated-${fileNamePlural}"; @Resolver(() => ${metadata.className}) + @RequiredPermission(${JSON.stringify(generatorOptions.requiredPermission)}${!scopeProp ? `, { skipScopeCheck: true }` : ""}) export class ${classNameSingular}Resolver { constructor( private readonly entityManager: EntityManager, diff --git a/packages/api/cms-api/src/generator/generate-crud.ts b/packages/api/cms-api/src/generator/generate-crud.ts index 82a646f29b..398ed926f2 100644 --- a/packages/api/cms-api/src/generator/generate-crud.ts +++ b/packages/api/cms-api/src/generator/generate-crud.ts @@ -551,6 +551,7 @@ ${ function generateNestedEntityResolver({ generatorOptions, metadata }: { generatorOptions: CrudGeneratorOptions; metadata: EntityMetadata }) { const { classNameSingular } = buildNameVariants(metadata); + const { scopeProp } = buildOptions(metadata); const imports: Imports = []; @@ -561,10 +562,12 @@ function generateNestedEntityResolver({ generatorOptions, metadata }: { generato imports.push(generateEntityImport(metadata, generatorOptions.targetDirectory)); return ` + import { RequiredPermission } from "@comet/cms-api"; import { Args, ID, Info, Mutation, Query, Resolver, ResolveField, Parent } from "@nestjs/graphql"; ${generateImportsCode(imports)} @Resolver(() => ${metadata.className}) + @RequiredPermission(${JSON.stringify(generatorOptions.requiredPermission)}${!scopeProp ? `, { skipScopeCheck: true }` : ""}) export class ${classNameSingular}Resolver { ${code} } @@ -707,7 +710,7 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr import { EntityRepository, EntityManager } from "@mikro-orm/postgresql"; import { FindOptions, Reference } from "@mikro-orm/core"; import { Args, ID, Info, Mutation, Query, Resolver, ResolveField, Parent } from "@nestjs/graphql"; - import { extractGraphqlFields, SortDirection, SubjectEntity, validateNotModified } from "@comet/cms-api"; + import { extractGraphqlFields, SortDirection, RequiredPermission, AffectedEntity, validateNotModified } from "@comet/cms-api"; import { GraphQLResolveInfo } from "graphql"; import { ${classNamePlural}Service } from "./${fileNamePlural}.service"; @@ -717,6 +720,7 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr ${generateImportsCode(imports)} @Resolver(() => ${metadata.className}) + @RequiredPermission(${JSON.stringify(generatorOptions.requiredPermission)}${!scopeProp ? `, { skipScopeCheck: true }` : ""}) export class ${classNameSingular}Resolver { constructor( private readonly entityManager: EntityManager, @@ -728,7 +732,7 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr ) {} @Query(() => ${metadata.className}) - @SubjectEntity(${metadata.className}) + @AffectedEntity(${metadata.className}) async ${instanceNameSingular}(${ integerTypes.includes(metadata.properties.id.type) ? `@Args("id", { type: () => ID }, { transform: (value) => parseInt(value) }) id: number` @@ -828,7 +832,7 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr generatorOptions.update ? ` @Mutation(() => ${metadata.className}) - @SubjectEntity(${metadata.className}) + @AffectedEntity(${metadata.className}) async update${classNameSingular}( ${ integerTypes.includes(metadata.properties.id.type) @@ -860,7 +864,7 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr generatorOptions.delete ? ` @Mutation(() => Boolean) - @SubjectEntity(${metadata.className}) + @AffectedEntity(${metadata.className}) async delete${metadata.className}(${ integerTypes.includes(metadata.properties.id.type) ? `@Args("id", { type: () => ID }, { transform: (value) => parseInt(value) }) id: number` @@ -879,7 +883,7 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr hasVisibleProp && generatorOptions.update ? ` @Mutation(() => ${metadata.className}) - @SubjectEntity(${metadata.className}) + @AffectedEntity(${metadata.className}) async update${classNameSingular}Visibility( @Args("id", { type: () => ID }) id: string, @Args("visible", { type: () => Boolean }) visible: boolean, @@ -912,8 +916,9 @@ export async function generateCrud(generatorOptions: CrudGeneratorOptions, metad const generatedFiles: GeneratedFile[] = []; - const { fileNameSingular, fileNamePlural } = buildNameVariants(metadata); + const { fileNameSingular, fileNamePlural, instanceNamePlural } = buildNameVariants(metadata); const { hasFilterArg, hasSortArg, argsFileName } = buildOptions(metadata); + if (!generatorOptions.requiredPermission) generatorOptions.requiredPermission = [instanceNamePlural]; async function generateCrudResolver(): Promise { if (hasFilterArg) { diff --git a/packages/api/cms-api/src/index.ts b/packages/api/cms-api/src/index.ts index 29fba18b16..ae29e8df05 100644 --- a/packages/api/cms-api/src/index.ts +++ b/packages/api/cms-api/src/index.ts @@ -1,8 +1,8 @@ import "reflect-metadata"; export { AccessLogModule } from "./access-log/access-log.module"; -export { CurrentUserInterface, CurrentUserLoaderInterface, CurrentUserRightInterface } from "./auth/current-user/current-user"; -export { AllowForRole } from "./auth/decorators/allow-for-role.decorator"; +export { CurrentUserInterface } from "./auth/current-user/current-user"; +export { CURRENT_USER_LOADER, CurrentUserLoaderInterface } from "./auth/current-user/current-user-loader"; export { GetCurrentUser } from "./auth/decorators/get-current-user.decorator"; export { DisableGlobalGuard } from "./auth/decorators/global-guard-disable.decorator"; export { PublicApi } from "./auth/decorators/public-api.decorator"; @@ -40,8 +40,6 @@ export { AutoBuildStatus } from "./builds/dto/auto-build-status.object"; export { ChangesSinceLastBuild } from "./builds/entities/changes-since-last-build.entity"; export { SKIP_BUILD_METADATA_KEY, SkipBuild } from "./builds/skip-build.decorator"; export { getRequestContextHeadersFromRequest, RequestContext, RequestContextInterface } from "./common/decorators/request-context.decorator"; -export { ScopedEntity, ScopedEntityMeta } from "./common/decorators/scoped-entity.decorator"; -export { SubjectEntity, SubjectEntityMeta, SubjectEntityOptions } from "./common/decorators/subject-entity.decorator"; export { getRequestFromExecutionContext } from "./common/decorators/utils"; export { CometException } from "./common/errors/comet.exception"; export { CometEntityNotFoundException } from "./common/errors/entity-not-found.exception"; @@ -64,7 +62,6 @@ export { SortDirection } from "./common/sorting/sort-direction.enum"; export { IsNullable } from "./common/validators/is-nullable"; export { IsSlug } from "./common/validators/is-slug"; export { IsUndefinable } from "./common/validators/is-undefinable"; -export { ContentScopeModule } from "./content-scope/content-scope.module"; export { CronJobsModule } from "./cron-jobs/cron-jobs.module"; export { DamImageBlock } from "./dam/blocks/dam-image.block"; export { ScaledImagesCacheService } from "./dam/cache/scaled-images-cache.service"; @@ -143,19 +140,21 @@ export { RedirectsModule } from "./redirects/redirects.module"; export { createRedirectsResolver } from "./redirects/redirects.resolver"; export { RedirectsService } from "./redirects/redirects.service"; export { IsValidRedirectSource, IsValidRedirectSourceConstraint } from "./redirects/validators/isValidRedirectSource"; +export { AbstractAccessControlService } from "./user-permissions/access-control.service"; +export { AffectedEntity, AffectedEntityMeta, AffectedEntityOptions } from "./user-permissions/decorators/affected-entity.decorator"; +export { RequiredPermission } from "./user-permissions/decorators/required-permission.decorator"; +export { ScopedEntity, ScopedEntityMeta } from "./user-permissions/decorators/scoped-entity.decorator"; export { CurrentUser } from "./user-permissions/dto/current-user"; export { FindUsersArgs } from "./user-permissions/dto/paginated-user-list"; export { User } from "./user-permissions/dto/user"; export { ContentScope } from "./user-permissions/interfaces/content-scope.interface"; export { Permission } from "./user-permissions/interfaces/user-permission.interface"; export { UserPermissionsModule } from "./user-permissions/user-permissions.module"; -export { UserPermissionsService } from "./user-permissions/user-permissions.service"; export { + AccessControlServiceInterface, ContentScopesForUser, PermissionsForUser, UserPermissions, - UserPermissionsOptions, - UserPermissionsOptionsFactory, - UserPermissionsUserService, + UserPermissionsUserServiceInterface, Users, } from "./user-permissions/user-permissions.types"; diff --git a/packages/api/cms-api/src/kubernetes/kubernetes.service.ts b/packages/api/cms-api/src/kubernetes/kubernetes.service.ts index 298c54490a..2f03c1c73d 100644 --- a/packages/api/cms-api/src/kubernetes/kubernetes.service.ts +++ b/packages/api/cms-api/src/kubernetes/kubernetes.service.ts @@ -4,7 +4,7 @@ import { addMinutes, differenceInMinutes } from "date-fns"; import fs from "fs"; import { CONTENT_SCOPE_ANNOTATION } from "../builds/builds.constants"; -import { ContentScope } from "../common/decorators/content-scope.interface"; +import { ContentScope } from "../user-permissions/interfaces/content-scope.interface"; import { JobStatus } from "./job-status.enum"; import { KUBERNETES_CONFIG } from "./kubernetes.constants"; import { KubernetesConfig } from "./kubernetes.module"; diff --git a/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts b/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts index 07bc2ca17c..7165433b67 100644 --- a/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts +++ b/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts @@ -2,10 +2,11 @@ import { Inject, Type } from "@nestjs/common"; import { Args, ArgsType, createUnionType, ID, Info, Mutation, ObjectType, Parent, Query, ResolveField, Resolver, Union } from "@nestjs/graphql"; import { GraphQLError, GraphQLResolveInfo } from "graphql"; -import { SubjectEntity } from "../common/decorators/subject-entity.decorator"; import { PaginatedResponseFactory } from "../common/pagination/paginated-response.factory"; import { DynamicDtoValidationPipe } from "../common/validation/dynamic-dto-validation.pipe"; import { DocumentInterface } from "../document/dto/document-interface"; +import { AffectedEntity } from "../user-permissions/decorators/affected-entity.decorator"; +import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator"; import { AttachedDocumentLoaderService } from "./attached-document-loader.service"; import { EmptyPageTreeNodeScope } from "./dto/empty-page-tree-node-scope"; import { @@ -67,6 +68,7 @@ export function createPageTreeResolver({ }); @Resolver(() => PageTreeNode) + @RequiredPermission(["pageTree"], { skipScopeCheck: !hasNonEmptyScope }) class PageTreeResolver { constructor( protected readonly pageTreeService: PageTreeService, @@ -76,7 +78,7 @@ export function createPageTreeResolver({ ) {} @Query(() => PageTreeNode, { nullable: true }) - @SubjectEntity(PageTreeNode) + @AffectedEntity(PageTreeNode) async pageTreeNode(@Args("id", { type: () => ID }) id: string): Promise { return this.pageTreeReadApi.getNodeOrFail(id); } @@ -221,7 +223,7 @@ export function createPageTreeResolver({ } @Mutation(() => PageTreeNode) - @SubjectEntity(PageTreeNode) + @AffectedEntity(PageTreeNode) async updatePageTreeNode( @Args("id", { type: () => ID }) id: string, @Args("input", { type: () => PageTreeNodeUpdateInput }, new DynamicDtoValidationPipe(PageTreeNodeUpdateInput)) @@ -240,7 +242,7 @@ export function createPageTreeResolver({ } @Mutation(() => Boolean) - @SubjectEntity(PageTreeNode) + @AffectedEntity(PageTreeNode) async deletePageTreeNode(@Args("id", { type: () => ID }) id: string): Promise { const pageTreeReadApi = this.pageTreeService.createReadApi({ visibility: "all", @@ -251,7 +253,7 @@ export function createPageTreeResolver({ } @Mutation(() => PageTreeNode) - @SubjectEntity(PageTreeNode) + @AffectedEntity(PageTreeNode) async updatePageTreeNodeVisibility( @Args("id", { type: () => ID }) id: string, @Args("input", { type: () => PageTreeNodeUpdateVisibilityInput }) input: PageTreeNodeUpdateVisibilityInput, @@ -263,7 +265,7 @@ export function createPageTreeResolver({ } @Mutation(() => PageTreeNode) - @SubjectEntity(PageTreeNode) + @AffectedEntity(PageTreeNode) async updatePageTreeNodeSlug( @Args("id", { type: () => ID }) id: string, @Args("slug", { type: () => String }) slug: string, @@ -272,7 +274,7 @@ export function createPageTreeResolver({ } @Mutation(() => [PageTreeNode]) - @SubjectEntity(PageTreeNode, { idArg: "ids" }) + @AffectedEntity(PageTreeNode, { idArg: "ids" }) async movePageTreeNodesByPos( @Args("ids", { type: () => [ID] }) ids: string[], @Args("input", { type: () => MovePageTreeNodesByPosInput }) input: MovePageTreeNodesByPosInput, @@ -323,7 +325,7 @@ export function createPageTreeResolver({ } @Mutation(() => [PageTreeNode]) - @SubjectEntity(PageTreeNode, { idArg: "ids" }) + @AffectedEntity(PageTreeNode, { idArg: "ids" }) async movePageTreeNodesByNeighbour( @Args("ids", { type: () => [ID] }) ids: string[], @Args("input", { type: () => MovePageTreeNodesByNeighbourInput }) input: MovePageTreeNodesByNeighbourInput, @@ -360,7 +362,7 @@ export function createPageTreeResolver({ } @Mutation(() => PageTreeNode) - @SubjectEntity(PageTreeNode) + @AffectedEntity(PageTreeNode) async updatePageTreeNodeCategory( @Args("id", { type: () => ID }) id: string, @Args("category", { type: () => String }) category: PageTreeNodeCategory, diff --git a/packages/api/cms-api/src/redirects/redirects.resolver.ts b/packages/api/cms-api/src/redirects/redirects.resolver.ts index a77bd2ae34..94c952b99b 100644 --- a/packages/api/cms-api/src/redirects/redirects.resolver.ts +++ b/packages/api/cms-api/src/redirects/redirects.resolver.ts @@ -4,12 +4,12 @@ import { EntityRepository } from "@mikro-orm/postgresql"; import { Type } from "@nestjs/common"; import { Args, ArgsType, ID, Mutation, ObjectType, Query, Resolver } from "@nestjs/graphql"; -import { SubjectEntity } from "../common/decorators/subject-entity.decorator"; import { CometValidationException } from "../common/errors/validation.exception"; import { PaginatedResponseFactory } from "../common/pagination/paginated-response.factory"; import { DynamicDtoValidationPipe } from "../common/validation/dynamic-dto-validation.pipe"; -import { ScopeGuardActive } from "../content-scope/decorators/scope-guard-active.decorator"; import { validateNotModified } from "../document/validateNotModified"; +import { AffectedEntity } from "../user-permissions/decorators/affected-entity.decorator"; +import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator"; import { EmptyRedirectScope } from "./dto/empty-redirect-scope"; import { PaginatedRedirectsArgsFactory } from "./dto/paginated-redirects-args.factory"; import { RedirectInputInterface } from "./dto/redirect-input.factory"; @@ -50,7 +50,7 @@ export function createRedirectsResolver({ class PaginatedRedirectsArgs extends PaginatedRedirectsArgsFactory.create({ Scope }) {} @Resolver(() => Redirect) - @ScopeGuardActive(hasNonEmptyScope) + @RequiredPermission(["pageTree"], { skipScopeCheck: !hasNonEmptyScope }) class RedirectsResolver { constructor( private readonly redirectService: RedirectsService, @@ -96,7 +96,7 @@ export function createRedirectsResolver({ } @Query(() => Redirect) - @SubjectEntity(Redirect) + @AffectedEntity(Redirect) async redirect(@Args("id", { type: () => ID }) id: string): Promise { const redirect = await this.repository.findOne(id); return redirect ?? null; @@ -130,7 +130,7 @@ export function createRedirectsResolver({ } @Mutation(() => Redirect) - @SubjectEntity(Redirect) + @AffectedEntity(Redirect) async updateRedirect( @Args("id", { type: () => ID }) id: string, @Args("input", { type: () => RedirectInput }, new DynamicDtoValidationPipe(RedirectInput)) input: RedirectInputInterface, @@ -151,7 +151,7 @@ export function createRedirectsResolver({ } @Mutation(() => Redirect) - @SubjectEntity(Redirect) + @AffectedEntity(Redirect) async updateRedirectActiveness( @Args("id", { type: () => ID }) id: string, @Args("input", { type: () => RedirectUpdateActivenessInput }) input: RedirectUpdateActivenessInput, @@ -165,7 +165,7 @@ export function createRedirectsResolver({ } @Mutation(() => Boolean) - @SubjectEntity(Redirect) + @AffectedEntity(Redirect) async deleteRedirect(@Args("id", { type: () => ID }) id: string): Promise { const entity = await this.repository.findOneOrFail(id); await this.repository.removeAndFlush(entity); 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 new file mode 100644 index 0000000000..f1372af2da --- /dev/null +++ b/packages/api/cms-api/src/user-permissions/access-control.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from "@nestjs/common"; + +import { CurrentUserInterface } from "../auth/current-user/current-user"; +import { ContentScope } from "./interfaces/content-scope.interface"; +import { Permission } from "./interfaces/user-permission.interface"; +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)); + } + + isAllowedPermission(user: CurrentUserInterface, permission: keyof Permission): boolean { + if (!user.permissions) return false; + return user.permissions.some((p) => p.permission === permission); + } +} diff --git a/packages/api/cms-api/src/user-permissions/auth/current-user-loader.ts b/packages/api/cms-api/src/user-permissions/auth/current-user-loader.ts new file mode 100644 index 0000000000..1b579a79b9 --- /dev/null +++ b/packages/api/cms-api/src/user-permissions/auth/current-user-loader.ts @@ -0,0 +1,15 @@ +import { Injectable } from "@nestjs/common"; +import { CurrentUserLoaderInterface } from "src/auth/current-user/current-user-loader"; + +import { UserPermissionsService } from "../user-permissions.service"; + +@Injectable() +export class UserPermissionsCurrentUserLoader implements CurrentUserLoaderInterface { + constructor(private readonly service: UserPermissionsService) {} + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async load(userId: string, data?: any) { + const user = await this.service.getUser(userId); + return { ...(await this.service.createCurrentUser(user)), ...data }; + } +} 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 new file mode 100644 index 0000000000..a4d7a15fe6 --- /dev/null +++ b/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts @@ -0,0 +1,71 @@ +import { CanActivate, ExecutionContext, Inject, Injectable } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +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 { ACCESS_CONTROL_SERVICE } from "../user-permissions.constants"; +import { AccessControlServiceInterface } from "../user-permissions.types"; + +@Injectable() +export class UserPermissionsGuard implements CanActivate { + constructor( + protected reflector: Reflector, + private readonly contentScopeService: ContentScopeService, + @Inject(ACCESS_CONTROL_SERVICE) private readonly accessControlService: AccessControlServiceInterface, + ) {} + + async canActivate(context: ExecutionContext): Promise { + if (this.reflector.getAllAndOverride("disableGlobalGuard", [context.getHandler(), context.getClass()])) { + return true; + } + + if (this.reflector.getAllAndOverride("publicApi", [context.getHandler(), context.getClass()])) { + return true; + } + + const request = + context.getType().toString() === "graphql" ? GqlExecutionContext.create(context).getContext().req : context.switchToHttp().getRequest(); + const user = request.user as CurrentUserInterface | undefined; + if (!user) return false; + + const requiredPermission = this.reflector.getAllAndOverride("requiredPermission", [ + context.getHandler(), + context.getClass(), + ]); + if (!requiredPermission) { + throw new Error(`RequiredPermission decorator is missing in ${context.getClass().name}::${context.getHandler().name}()`); + } + + if (!this.isResolvingGraphQLField(context) && !requiredPermission.options?.skipScopeCheck) { + const 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() (${ + context.getClass().name + }::${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)); + } + + // See https://docs.nestjs.com/graphql/other-features#execute-enhancers-at-the-field-resolver-level + isResolvingGraphQLField(context: ExecutionContext): boolean { + if (context.getType() === "graphql") { + const gqlContext = GqlExecutionContext.create(context); + const info = gqlContext.getInfo(); + const parentType = info.parentType.name; + return parentType !== "Query" && parentType !== "Mutation"; + } + return false; + } +} diff --git a/packages/api/cms-api/src/user-permissions/content-scope.service.ts b/packages/api/cms-api/src/user-permissions/content-scope.service.ts new file mode 100644 index 0000000000..a940d90e5f --- /dev/null +++ b/packages/api/cms-api/src/user-permissions/content-scope.service.ts @@ -0,0 +1,86 @@ +import { EntityClass, MikroORM } from "@mikro-orm/core"; +import { ExecutionContext, Injectable, Optional } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { GqlExecutionContext } from "@nestjs/graphql"; +import isEqual from "lodash.isequal"; + +import { PageTreeService } from "../page-tree/page-tree.service"; +import { ScopedEntityMeta } from "../user-permissions/decorators/scoped-entity.decorator"; +import { ContentScope } from "../user-permissions/interfaces/content-scope.interface"; +import { AffectedEntityMeta } from "./decorators/affected-entity.decorator"; + +@Injectable() +export class ContentScopeService { + constructor(private reflector: Reflector, private readonly orm: MikroORM, @Optional() private readonly pageTreeService?: PageTreeService) {} + + scopesAreEqual(scope1: ContentScope | undefined, scope2: ContentScope | undefined): boolean { + // The scopes are cloned because they could be + // - an instance of a class (e.g. DamScope) + // - or a plain object (from a GraphQL input) + // Then they are not deeply equal, although they represent the same scope + return isEqual({ ...scope1 }, { ...scope2 }); + } + + async inferScopeFromExecutionContext(context: ExecutionContext): Promise { + const args = await this.getArgs(context); + + const affectedEntity = this.reflector.getAllAndOverride("affectedEntity", [context.getHandler(), context.getClass()]); + if (affectedEntity) { + let contentScope: ContentScope | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const repo = this.orm.em.getRepository(affectedEntity.entity); + if (affectedEntity.options.idArg) { + if (!args[affectedEntity.options.idArg]) { + throw new Error(`${affectedEntity.options.idArg} arg not found`); + } + const row = await repo.findOneOrFail(args[affectedEntity.options.idArg]); + if (row.scope) { + contentScope = row.scope; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const scoped = this.reflector.getAllAndOverride("scopedEntity", [ + affectedEntity.entity as EntityClass, + ]); + if (!scoped) { + return undefined; + } + contentScope = await scoped.fn(row); + } + } else if (affectedEntity.options.pageTreeNodeIdArg && args[affectedEntity.options.pageTreeNodeIdArg]) { + if (!args[affectedEntity.options.pageTreeNodeIdArg]) { + throw new Error(`${affectedEntity.options.pageTreeNodeIdArg} arg not found`); + } + if (this.pageTreeService === undefined) { + throw new Error("pageTreeNodeIdArg was given but no PageTreeModule is registered"); + } + const node = await this.pageTreeService.createReadApi({ visibility: "all" }).getNode(args[affectedEntity.options.pageTreeNodeIdArg]); + if (!node) throw new Error("Can't find pageTreeNode"); + contentScope = node.scope; + } else { + // TODO implement something more flexible that supports something like that: @AffectedEntity(Product, ProductEntityLoader) + throw new Error("idArg or pageTreeNodeIdArg is required"); + } + if (contentScope === undefined) throw new Error("Scope not found"); + if (args.scope) { + // args.scope also exists, check if they match + if (!isEqual(args.scope, contentScope)) { + throw new Error("Content Scope from arg doesn't match affectedEntity scope, usually you only need one of them"); + } + } + return contentScope; + } + if (args.scope) { + return args.scope; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async getArgs(context: ExecutionContext): Promise> { + if (context.getType().toString() === "graphql") { + return GqlExecutionContext.create(context).getArgs(); + } else { + const request = context.switchToHttp().getRequest(); + return { ...request.params, ...request.query }; + } + } +} diff --git a/packages/api/cms-api/src/common/decorators/subject-entity.decorator.ts b/packages/api/cms-api/src/user-permissions/decorators/affected-entity.decorator.ts similarity index 56% rename from packages/api/cms-api/src/common/decorators/subject-entity.decorator.ts rename to packages/api/cms-api/src/user-permissions/decorators/affected-entity.decorator.ts index f93fac12f2..6ad3e86520 100644 --- a/packages/api/cms-api/src/common/decorators/subject-entity.decorator.ts +++ b/packages/api/cms-api/src/user-permissions/decorators/affected-entity.decorator.ts @@ -1,20 +1,20 @@ import { EntityName } from "@mikro-orm/core"; import { CustomDecorator, SetMetadata } from "@nestjs/common"; -export interface SubjectEntityOptions { +export interface AffectedEntityOptions { idArg?: string; pageTreeNodeIdArg?: string; } -export interface SubjectEntityMeta { +export interface AffectedEntityMeta { // eslint-disable-next-line @typescript-eslint/no-explicit-any entity: EntityName; //TODO - options: SubjectEntityOptions; + options: AffectedEntityOptions; } -export const SubjectEntity = ( +export const AffectedEntity = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any entity: EntityName, - { idArg, pageTreeNodeIdArg }: SubjectEntityOptions = { idArg: "id" }, + { idArg, pageTreeNodeIdArg }: AffectedEntityOptions = { idArg: "id" }, ): CustomDecorator => { - return SetMetadata("subjectEntity", { entity, options: { idArg, pageTreeNodeIdArg } }); + return SetMetadata("affectedEntity", { entity, options: { idArg, pageTreeNodeIdArg } }); }; diff --git a/packages/api/cms-api/src/user-permissions/decorators/required-permission.decorator.ts b/packages/api/cms-api/src/user-permissions/decorators/required-permission.decorator.ts new file mode 100644 index 0000000000..0e46e761c4 --- /dev/null +++ b/packages/api/cms-api/src/user-permissions/decorators/required-permission.decorator.ts @@ -0,0 +1,29 @@ +import { EntityClass, EntityManager } from "@mikro-orm/core"; +import { CustomDecorator, SetMetadata } from "@nestjs/common"; +import { Request } from "express"; + +import { CurrentUser } from "../dto/current-user"; +import { ContentScope } from "../interfaces/content-scope.interface"; +import { Permission } from "../interfaces/user-permission.interface"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RequiredPermissionArgs = { + args: ArgsType; + getScopeFromEntity: (entityClass: EntityClass, id: string) => Promise; + user: CurrentUser; + entityManager: EntityManager; + request: Request; +}; + +type RequiredPermissionOptions = { + skipScopeCheck?: boolean; +}; + +export type RequiredPermission = { + requiredPermission: (keyof Permission)[] | keyof Permission; + options: RequiredPermissionOptions | undefined; +}; + +export const RequiredPermission = (requiredPermission: (keyof Permission)[], options?: RequiredPermissionOptions): CustomDecorator => { + return SetMetadata("requiredPermission", { requiredPermission, options }); +}; diff --git a/packages/api/cms-api/src/common/decorators/scoped-entity.decorator.ts b/packages/api/cms-api/src/user-permissions/decorators/scoped-entity.decorator.ts similarity index 82% rename from packages/api/cms-api/src/common/decorators/scoped-entity.decorator.ts rename to packages/api/cms-api/src/user-permissions/decorators/scoped-entity.decorator.ts index 0f239b4cc5..70a084b0f7 100644 --- a/packages/api/cms-api/src/common/decorators/scoped-entity.decorator.ts +++ b/packages/api/cms-api/src/user-permissions/decorators/scoped-entity.decorator.ts @@ -1,6 +1,6 @@ import { CustomDecorator, SetMetadata } from "@nestjs/common"; -import { ContentScope } from "./content-scope.interface"; +import { ContentScope } from "../../user-permissions/interfaces/content-scope.interface"; export interface ScopedEntityMeta { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/api/cms-api/src/user-permissions/interfaces/user-permission.interface.ts b/packages/api/cms-api/src/user-permissions/interfaces/user-permission.interface.ts index b7ea2d323b..882cb53c08 100644 --- a/packages/api/cms-api/src/user-permissions/interfaces/user-permission.interface.ts +++ b/packages/api/cms-api/src/user-permissions/interfaces/user-permission.interface.ts @@ -2,5 +2,6 @@ export interface Permission { dam?: string; pageTree?: string; userPermissions?: string; - system?: string; + cronJobs?: string; + builds?: string; } 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 1f7e46827e..1a494ed039 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 @@ -4,12 +4,14 @@ import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; import { GraphQLJSONObject } from "graphql-type-json"; import { SkipBuild } from "../builds/skip-build.decorator"; +import { RequiredPermission } from "./decorators/required-permission.decorator"; import { UserContentScopesInput } from "./dto/user-content-scopes.input"; import { UserContentScopes } from "./entities/user-content-scopes.entity"; import { ContentScope } from "./interfaces/content-scope.interface"; import { UserPermissionsService } from "./user-permissions.service"; @Resolver() +@RequiredPermission(["userPermissions"], { skipScopeCheck: true }) export class UserContentScopesResolver { constructor( @InjectRepository(UserContentScopes) private readonly repository: EntityRepository, 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 4ec5e15cce..d764e14277 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 @@ -4,6 +4,7 @@ import { Args, ArgsType, Field, ID, Mutation, Query, Resolver } from "@nestjs/gr 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 { UserPermissionsService } from "./user-permissions.service"; @@ -16,6 +17,7 @@ export class UserPermissionListArgs { } @Resolver(() => UserPermission) +@RequiredPermission(["userPermissions"], { skipScopeCheck: true }) export class UserPermissionResolver { constructor( private readonly userService: UserPermissionsService, diff --git a/packages/api/cms-api/src/user-permissions/user-permissions.constants.ts b/packages/api/cms-api/src/user-permissions/user-permissions.constants.ts index 144389ed03..f3bee2d483 100644 --- a/packages/api/cms-api/src/user-permissions/user-permissions.constants.ts +++ b/packages/api/cms-api/src/user-permissions/user-permissions.constants.ts @@ -1 +1,3 @@ export const USER_PERMISSIONS_OPTIONS = "user-permissions-options"; +export const USER_PERMISSIONS_USER_SERVICE = "user-permissions-user-service"; +export const ACCESS_CONTROL_SERVICE = "access-control-service"; diff --git a/packages/api/cms-api/src/user-permissions/user-permissions.module.ts b/packages/api/cms-api/src/user-permissions/user-permissions.module.ts index 456e45f19b..c2660bd804 100644 --- a/packages/api/cms-api/src/user-permissions/user-permissions.module.ts +++ b/packages/api/cms-api/src/user-permissions/user-permissions.module.ts @@ -1,23 +1,47 @@ import { MikroOrmModule } from "@mikro-orm/nestjs"; import { DynamicModule, Global, Module, Provider } from "@nestjs/common"; +import { APP_GUARD } from "@nestjs/core"; +import { CURRENT_USER_LOADER } from "../auth/current-user/current-user-loader"; +import { UserPermissionsCurrentUserLoader } from "./auth/current-user-loader"; +import { UserPermissionsGuard } from "./auth/user-permissions.guard"; +import { ContentScopeService } from "./content-scope.service"; import { UserContentScopes } from "./entities/user-content-scopes.entity"; import { UserPermission } from "./entities/user-permission.entity"; import { UserResolver } from "./user.resolver"; import { UserContentScopesResolver } from "./user-content-scopes.resolver"; import { UserPermissionResolver } from "./user-permission.resolver"; -import { USER_PERMISSIONS_OPTIONS } from "./user-permissions.constants"; +import { ACCESS_CONTROL_SERVICE, USER_PERMISSIONS_OPTIONS, USER_PERMISSIONS_USER_SERVICE } from "./user-permissions.constants"; import { UserPermissionsService } from "./user-permissions.service"; -import { UserPermissionsAsyncOptions, UserPermissionsOptions, UserPermissionsOptionsFactory } from "./user-permissions.types"; +import { + UserPermissionsAsyncOptions, + UserPermissionsModuleAsyncOptions, + UserPermissionsModuleSyncOptions, + UserPermissionsOptionsFactory, +} from "./user-permissions.types"; @Global() @Module({ imports: [MikroOrmModule.forFeature([UserPermission, UserContentScopes])], - providers: [UserPermissionsService, UserResolver, UserPermissionResolver, UserContentScopesResolver, UserPermissionsService], - exports: [UserPermissionsService], + providers: [ + UserPermissionsService, + UserResolver, + UserPermissionResolver, + UserContentScopesResolver, + { + provide: CURRENT_USER_LOADER, + useClass: UserPermissionsCurrentUserLoader, + }, + ContentScopeService, + { + provide: APP_GUARD, + useClass: UserPermissionsGuard, + }, + ], + exports: [CURRENT_USER_LOADER, ContentScopeService, ACCESS_CONTROL_SERVICE], }) export class UserPermissionsModule { - static forRoot(options: UserPermissionsOptions): DynamicModule { + static forRoot(options: UserPermissionsModuleSyncOptions): DynamicModule { return { module: UserPermissionsModule, providers: [ @@ -25,19 +49,39 @@ export class UserPermissionsModule { provide: USER_PERMISSIONS_OPTIONS, useValue: options, }, + { + provide: USER_PERMISSIONS_USER_SERVICE, + useClass: options.UserService, + }, + { + provide: ACCESS_CONTROL_SERVICE, + useClass: options.AccessControlService, + }, ], }; } - static forRootAsync(asyncOptions: UserPermissionsAsyncOptions): DynamicModule { + static forRootAsync(options: UserPermissionsModuleAsyncOptions): DynamicModule { return { module: UserPermissionsModule, - imports: asyncOptions.imports, - providers: [this.createProvider(asyncOptions)], + imports: options.imports, + providers: [ + this.createProvider(options), + { + provide: USER_PERMISSIONS_USER_SERVICE, + useFactory: (options: UserPermissionsAsyncOptions) => options.userService, + inject: [USER_PERMISSIONS_OPTIONS], + }, + { + provide: ACCESS_CONTROL_SERVICE, + useFactory: (options: UserPermissionsAsyncOptions) => options.accessControlService, + inject: [USER_PERMISSIONS_OPTIONS], + }, + ], }; } - private static createProvider(options: UserPermissionsAsyncOptions): Provider { + private static createProvider(options: UserPermissionsModuleAsyncOptions): Provider { if (options.useFactory) { return { provide: USER_PERMISSIONS_OPTIONS, 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 e156090433..8fb94e5cb1 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 @@ -12,13 +12,20 @@ import { UserContentScopes } from "./entities/user-content-scopes.entity"; import { UserPermission, UserPermissionSource } from "./entities/user-permission.entity"; import { ContentScope } from "./interfaces/content-scope.interface"; import { Permission } from "./interfaces/user-permission.interface"; -import { USER_PERMISSIONS_OPTIONS } from "./user-permissions.constants"; -import { UserPermissions, UserPermissionsOptions } from "./user-permissions.types"; +import { ACCESS_CONTROL_SERVICE, USER_PERMISSIONS_OPTIONS, USER_PERMISSIONS_USER_SERVICE } from "./user-permissions.constants"; +import { + AccessControlServiceInterface, + UserPermissions, + UserPermissionsOptions, + UserPermissionsUserServiceInterface, +} from "./user-permissions.types"; @Injectable() export class UserPermissionsService { constructor( @Inject(USER_PERMISSIONS_OPTIONS) private readonly options: UserPermissionsOptions, + @Inject(USER_PERMISSIONS_USER_SERVICE) private readonly userService: UserPermissionsUserServiceInterface, + @Inject(ACCESS_CONTROL_SERVICE) private readonly accessControlService: AccessControlServiceInterface, @InjectRepository(UserPermission) private readonly permissionRepository: EntityRepository, @InjectRepository(UserContentScopes) private readonly contentScopeRepository: EntityRepository, ) {} @@ -28,15 +35,17 @@ export class UserPermissionsService { } async getAvailablePermissions(): Promise<(keyof Permission)[]> { - return [...new Set(["dam", "pageTree", "userPermissions", "system", ...(this.options.availablePermissions ?? [])])]; + return [ + ...new Set(["dam", "pageTree", "userPermissions", "cronJobs", "builds", ...(this.options.availablePermissions ?? [])]), + ]; } async getUser(id: string): Promise { - return this.options.userService.getUser(id); + return this.userService.getUser(id); } async findUsers(args: FindUsersArgs): Promise<[User[], number]> { - return this.options.userService.findUsers(args); + return this.userService.findUsers(args); } async checkContentScopes(contentScopes: ContentScope[]): Promise { @@ -58,10 +67,10 @@ export class UserPermissionsService { p.source = UserPermissionSource.MANUAL; return p; }); - if (this.options.userService.getPermissionsForUser) { + if (this.accessControlService.getPermissionsForUser) { const user = await this.getUser(userId); if (user) { - let permissionsByRule = await this.options.userService.getPermissionsForUser(user); + let permissionsByRule = await this.accessControlService.getPermissionsForUser(user); if (permissionsByRule === UserPermissions.allPermissions) { permissionsByRule = availablePermissions.map((permission) => ({ permission })); } @@ -91,10 +100,10 @@ export class UserPermissionsService { async getContentScopes(userId: string, skipManual = false): Promise { const availableContentScopes = await this.getAvailableContentScopes(); const contentScopes: ContentScope[] = []; - if (this.options.userService.getContentScopesForUser) { + if (this.accessControlService.getContentScopesForUser) { const user = await this.getUser(userId); if (user) { - const userContentScopes = await this.options.userService.getContentScopesForUser(user); + const userContentScopes = await this.accessControlService.getContentScopesForUser(user); if (userContentScopes === UserPermissions.allContentScopes) { contentScopes.push(...availableContentScopes); } else { 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 b76d48a914..258330fdab 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 @@ -1,4 +1,5 @@ import { ModuleMetadata, Type } from "@nestjs/common"; +import { CurrentUserInterface } from "src/auth/current-user/current-user"; import { FindUsersArgs } from "./dto/paginated-user-list"; import { User } from "./dto/user"; @@ -19,28 +20,41 @@ export type PermissionsForUser = export type ContentScopesForUser = ContentScope[] | UserPermissions.allContentScopes; +export interface AccessControlServiceInterface { + isAllowedPermission(user: CurrentUserInterface, permission: keyof Permission): boolean; + isAllowedContentScope(user: CurrentUserInterface, contentScope: ContentScope): boolean; + getPermissionsForUser?: (user: User) => Promise | PermissionsForUser; + getContentScopesForUser?: (user: User) => Promise | ContentScopesForUser; +} + +export interface UserPermissionsUserServiceInterface { + getUser: (id: string) => Promise | User; + findUsers: (args: FindUsersArgs) => Promise | Users; +} + export interface UserPermissionsOptions { availablePermissions?: (keyof Permission)[]; availableContentScopes?: ContentScope[]; - userService: UserPermissionsUserService; +} +export interface UserPermissionsModuleSyncOptions extends UserPermissionsOptions { + UserService: Type; + AccessControlService: Type; } -export interface UserPermissionsUserService { - getUser: (id: string) => Promise | User; - findUsers: (args: FindUsersArgs) => Promise | Users; - getPermissionsForUser?: (user: User) => Promise | PermissionsForUser; - getContentScopesForUser?: (user: User) => Promise | ContentScopesForUser; +export interface UserPermissionsAsyncOptions extends UserPermissionsOptions { + userService: UserPermissionsUserServiceInterface; + accessControlService: AccessControlServiceInterface; } export interface UserPermissionsOptionsFactory { - createUserPermissionsOptions(): Promise | UserPermissionsOptions; + createUserPermissionsOptions(): Promise | UserPermissionsAsyncOptions; } -export interface UserPermissionsAsyncOptions extends Pick { +export interface UserPermissionsModuleAsyncOptions extends Pick { // eslint-disable-next-line @typescript-eslint/no-explicit-any inject?: any[]; useExisting?: Type; useClass?: Type; // eslint-disable-next-line @typescript-eslint/no-explicit-any - useFactory?: (...args: any[]) => Promise | UserPermissionsOptions; + useFactory?: (...args: any[]) => Promise | UserPermissionsAsyncOptions; } diff --git a/packages/api/cms-api/src/user-permissions/user.resolver.ts b/packages/api/cms-api/src/user-permissions/user.resolver.ts index 2773bc0e9a..442160c703 100644 --- a/packages/api/cms-api/src/user-permissions/user.resolver.ts +++ b/packages/api/cms-api/src/user-permissions/user.resolver.ts @@ -1,6 +1,7 @@ import { Args, ObjectType, Query, Resolver } from "@nestjs/graphql"; import { PaginatedResponseFactory } from "../common/pagination/paginated-response.factory"; +import { RequiredPermission } from "./decorators/required-permission.decorator"; import { FindUsersArgs } from "./dto/paginated-user-list"; import { User } from "./dto/user"; import { UserPermissionsService } from "./user-permissions.service"; @@ -9,6 +10,7 @@ import { UserPermissionsService } from "./user-permissions.service"; class PaginatedUserList extends PaginatedResponseFactory.create(User) {} @Resolver(() => User) +@RequiredPermission(["userPermissions"], { skipScopeCheck: true }) export class UserResolver { constructor(private readonly userService: UserPermissionsService) {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31cbafea0c..092a6fd9c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2800,7 +2800,7 @@ importers: version: 8.32.0 jest: specifier: ^29.5.0 - version: 29.5.0 + version: 29.5.0(@types/node@18.15.3)(ts-node@10.9.1) jest-environment-jsdom: specifier: ^29.5.0 version: 29.5.0 @@ -2809,7 +2809,7 @@ importers: version: 15.0.0 next: specifier: ^12.0.0 - version: 12.3.4(react-dom@17.0.2)(react@17.0.2) + version: 12.3.4(@babel/core@7.20.12)(react-dom@17.0.2)(react@17.0.2) npm-run-all: specifier: ^4.1.5 version: 4.1.5 @@ -2830,7 +2830,7 @@ importers: version: 5.3.6(react-dom@17.0.2)(react-is@18.2.0)(react@17.0.2) ts-jest: specifier: ^29.0.5 - version: 29.0.5(jest@29.5.0)(typescript@4.9.4) + version: 29.0.5(@babel/core@7.20.12)(jest@29.5.0)(typescript@4.9.4) typescript: specifier: ^4.0.0 version: 4.9.4 @@ -4316,7 +4316,7 @@ packages: '@babel/traverse': 7.22.11 '@babel/types': 7.22.11 convert-source-map: 1.9.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@9.3.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -6756,7 +6756,7 @@ packages: '@babel/helper-split-export-declaration': 7.18.6 '@babel/parser': 7.20.13 '@babel/types': 7.20.7 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@9.3.1) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -6790,7 +6790,7 @@ packages: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.22.14 '@babel/types': 7.22.11 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@9.3.1) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -8145,7 +8145,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@9.3.1) espree: 9.5.2 globals: 13.19.0 ignore: 5.2.4 @@ -9503,7 +9503,7 @@ packages: engines: {node: '>=10.10.0'} dependencies: '@humanwhocodes/object-schema': 1.2.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@9.3.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -9557,48 +9557,6 @@ packages: slash: 3.0.0 dev: true - /@jest/core@29.5.0: - resolution: {integrity: sha512-28UzQc7ulUrOQw1IsN/kv1QES3q2kkbl/wGslyhAclqZ/8cMdB5M68BffkIdSJgKBUt50d3hbwJ92XESlE7LiQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@jest/console': 29.5.0 - '@jest/reporters': 29.5.0 - '@jest/test-result': 29.5.0 - '@jest/transform': 29.5.0 - '@jest/types': 29.5.0 - '@types/node': 18.15.3 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.7.1 - exit: 0.1.2 - graceful-fs: 4.2.10 - jest-changed-files: 29.5.0 - jest-config: 29.5.0(@types/node@18.15.3) - jest-haste-map: 29.5.0 - jest-message-util: 29.5.0 - jest-regex-util: 29.4.3 - jest-resolve: 29.5.0 - jest-resolve-dependencies: 29.5.0 - jest-runner: 29.5.0 - jest-runtime: 29.5.0 - jest-snapshot: 29.5.0 - jest-util: 29.5.0 - jest-validate: 29.5.0 - jest-watcher: 29.5.0 - micromatch: 4.0.5 - pretty-format: 29.5.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - supports-color - - ts-node - dev: true - /@jest/core@29.5.0(ts-node@10.9.1): resolution: {integrity: sha512-28UzQc7ulUrOQw1IsN/kv1QES3q2kkbl/wGslyhAclqZ/8cMdB5M68BffkIdSJgKBUt50d3hbwJ92XESlE7LiQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -15301,7 +15259,7 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} dependencies: - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@9.3.1) transitivePeerDependencies: - supports-color dev: true @@ -16310,7 +16268,7 @@ packages: '@babel/helper-module-imports': 7.18.6 babel-plugin-syntax-jsx: 6.18.0 lodash: 4.17.21 - styled-components: 5.3.6(react-dom@17.0.2)(react-is@18.2.0)(react@17.0.2) + styled-components: 5.3.6(react-dom@17.0.2)(react-is@17.0.2)(react@17.0.2) /babel-plugin-syntax-jsx@6.18.0: resolution: {integrity: sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==} @@ -19728,7 +19686,7 @@ packages: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@9.3.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.1.1 @@ -21888,7 +21846,7 @@ packages: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@9.3.1) transitivePeerDependencies: - supports-color dev: true @@ -21949,7 +21907,7 @@ packages: engines: {node: '>= 6'} dependencies: agent-base: 6.0.2 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@9.3.1) transitivePeerDependencies: - supports-color dev: true @@ -22867,7 +22825,7 @@ packages: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} dependencies: - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@9.3.1) istanbul-lib-coverage: 3.2.0 source-map: 0.6.1 transitivePeerDependencies: @@ -22946,34 +22904,6 @@ packages: - supports-color dev: true - /jest-cli@29.5.0: - resolution: {integrity: sha512-L1KcP1l4HtfwdxXNFCL5bmUbLQiKrakMUriBEcc1Vfz6gx31ORKdreuWvmQVBit+1ss9NNR3yxjwfwzZNdQXJw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@jest/core': 29.5.0 - '@jest/test-result': 29.5.0 - '@jest/types': 29.5.0 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.10 - import-local: 3.1.0 - jest-config: 29.5.0(@types/node@18.15.3) - jest-util: 29.5.0 - jest-validate: 29.5.0 - prompts: 2.4.2 - yargs: 17.6.2 - transitivePeerDependencies: - - '@types/node' - - supports-color - - ts-node - dev: true - /jest-cli@29.5.0(@types/node@18.15.3)(ts-node@10.9.1): resolution: {integrity: sha512-L1KcP1l4HtfwdxXNFCL5bmUbLQiKrakMUriBEcc1Vfz6gx31ORKdreuWvmQVBit+1ss9NNR3yxjwfwzZNdQXJw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -23002,45 +22932,6 @@ packages: - ts-node dev: true - /jest-config@29.5.0(@types/node@18.15.3): - resolution: {integrity: sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@types/node': '*' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - ts-node: - optional: true - dependencies: - '@babel/core': 7.22.11 - '@jest/test-sequencer': 29.5.0 - '@jest/types': 29.5.0 - '@types/node': 18.15.3 - babel-jest: 29.5.0(@babel/core@7.22.11) - chalk: 4.1.2 - ci-info: 3.7.1 - deepmerge: 4.2.2 - glob: 7.2.3 - graceful-fs: 4.2.10 - jest-circus: 29.5.0 - jest-environment-node: 29.5.0 - jest-get-type: 29.4.3 - jest-regex-util: 29.4.3 - jest-resolve: 29.5.0 - jest-runner: 29.5.0 - jest-util: 29.5.0 - jest-validate: 29.5.0 - micromatch: 4.0.5 - parse-json: 5.2.0 - pretty-format: 29.5.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - dev: true - /jest-config@29.5.0(@types/node@18.15.3)(ts-node@10.9.1): resolution: {integrity: sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -23380,7 +23271,7 @@ packages: '@babel/generator': 7.20.7 '@babel/plugin-syntax-jsx': 7.18.6(@babel/core@7.22.11) '@babel/plugin-syntax-typescript': 7.20.0(@babel/core@7.22.11) - '@babel/traverse': 7.20.13(supports-color@5.5.0) + '@babel/traverse': 7.20.13 '@babel/types': 7.20.7 '@jest/expect-utils': 29.5.0 '@jest/transform': 29.5.0 @@ -23478,26 +23369,6 @@ packages: merge-stream: 2.0.0 supports-color: 8.1.1 - /jest@29.5.0: - resolution: {integrity: sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@jest/core': 29.5.0 - '@jest/types': 29.5.0 - import-local: 3.1.0 - jest-cli: 29.5.0 - transitivePeerDependencies: - - '@types/node' - - supports-color - - ts-node - dev: true - /jest@29.5.0(@types/node@18.15.3)(ts-node@10.9.1): resolution: {integrity: sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -23842,7 +23713,7 @@ packages: dependencies: '@types/express': 4.17.16 '@types/jsonwebtoken': 9.0.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@9.3.1) jose: 4.11.2 limiter: 1.1.5 lru-memoizer: 2.1.4 @@ -25215,52 +25086,6 @@ packages: transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - dev: false - - /next@12.3.4(react-dom@17.0.2)(react@17.0.2): - resolution: {integrity: sha512-VcyMJUtLZBGzLKo3oMxrEF0stxh8HwuW976pAzlHhI3t8qJ4SROjCrSh1T24bhrbjw55wfZXAbXPGwPt5FLRfQ==} - engines: {node: '>=12.22.0'} - hasBin: true - peerDependencies: - fibers: '>= 3.1.0' - node-sass: ^6.0.0 || ^7.0.0 - react: ^17.0.2 || ^18.0.0-0 - react-dom: ^17.0.2 || ^18.0.0-0 - sass: ^1.3.0 - peerDependenciesMeta: - fibers: - optional: true - node-sass: - optional: true - sass: - optional: true - dependencies: - '@next/env': 12.3.4 - '@swc/helpers': 0.4.11 - caniuse-lite: 1.0.30001447 - postcss: 8.4.14 - react: 17.0.2 - react-dom: 17.0.2(react@17.0.2) - styled-jsx: 5.0.7(react@17.0.2) - use-sync-external-store: 1.2.0(react@17.0.2) - optionalDependencies: - '@next/swc-android-arm-eabi': 12.3.4 - '@next/swc-android-arm64': 12.3.4 - '@next/swc-darwin-arm64': 12.3.4 - '@next/swc-darwin-x64': 12.3.4 - '@next/swc-freebsd-x64': 12.3.4 - '@next/swc-linux-arm-gnueabihf': 12.3.4 - '@next/swc-linux-arm64-gnu': 12.3.4 - '@next/swc-linux-arm64-musl': 12.3.4 - '@next/swc-linux-x64-gnu': 12.3.4 - '@next/swc-linux-x64-musl': 12.3.4 - '@next/swc-win32-arm64-msvc': 12.3.4 - '@next/swc-win32-ia32-msvc': 12.3.4 - '@next/swc-win32-x64-msvc': 12.3.4 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - dev: true /nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -29672,7 +29497,6 @@ packages: react-is: 17.0.2 shallowequal: 1.1.0 supports-color: 5.5.0 - dev: false /styled-components@5.3.6(react-dom@17.0.2)(react-is@18.2.0)(react@17.0.2): resolution: {integrity: sha512-hGTZquGAaTqhGWldX7hhfzjnIYBZ0IXQXkCYdvF1Sq3DsUaLx6+NTHC5Jj1ooM2F68sBiVz3lvhfwQs/S3l6qg==} @@ -29696,6 +29520,7 @@ packages: react-is: 18.2.0 shallowequal: 1.1.0 supports-color: 5.5.0 + dev: true /styled-jsx@5.0.7(@babel/core@7.20.12)(react@17.0.2): resolution: {integrity: sha512-b3sUzamS086YLRuvnaDigdAewz1/EFYlHpYBP5mZovKEdQQOIIYq8lApylub3HHZ6xFjV051kkGU7cudJmrXEA==} @@ -29712,23 +29537,6 @@ packages: dependencies: '@babel/core': 7.20.12 react: 17.0.2 - dev: false - - /styled-jsx@5.0.7(react@17.0.2): - resolution: {integrity: sha512-b3sUzamS086YLRuvnaDigdAewz1/EFYlHpYBP5mZovKEdQQOIIYq8lApylub3HHZ6xFjV051kkGU7cudJmrXEA==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - dependencies: - react: 17.0.2 - dev: true /stylehacks@5.1.1(postcss@8.4.21): resolution: {integrity: sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==} @@ -30389,39 +30197,6 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.5(jest@29.5.0)(typescript@4.9.4): - resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - '@babel/core': '>=7.0.0-beta.0 <8' - '@jest/types': ^29.0.0 - babel-jest: ^29.0.0 - esbuild: '*' - jest: ^29.0.0 - typescript: '>=4.3' - peerDependenciesMeta: - '@babel/core': - optional: true - '@jest/types': - optional: true - babel-jest: - optional: true - esbuild: - optional: true - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - jest: 29.5.0 - jest-util: 29.5.0 - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.3.8 - typescript: 4.9.4 - yargs-parser: 21.1.1 - dev: true - /ts-loader@6.2.2(typescript@4.9.4): resolution: {integrity: sha512-HDo5kXZCBml3EUPcc7RlZOV/JGlLHwppTLEHb3SHnr5V7NXD4klMEkrhJe5wgRbaWsSXi+Y1SIBN/K9B6zWGWQ==} engines: {node: '>=8.6'}