diff --git a/.changeset/user-permissions-wildcard-content-scope.md b/.changeset/user-permissions-wildcard-content-scope.md new file mode 100644 index 00000000000..2dc83a194d2 --- /dev/null +++ b/.changeset/user-permissions-wildcard-content-scope.md @@ -0,0 +1,19 @@ +--- +"@dextinity/cms-api": minor +"@dextinity/cms-admin": minor +--- + +Support wildcard values for content scope dimensions in `getContentScopesForUser` + +`getContentScopesForUser` can now use the wildcard value `"*"` as the value of a content scope dimension to grant access to any value for that dimension. The wildcard is matched during the content scope check, so it does not need to be part of `availableContentScopes`. + +**Example** + +```ts +getContentScopesForUser(user: User): ContentScopesForUser { + // Grant access to every language within the "main" domain + return [{ domain: "main", language: "*" }]; +} +``` + +For users with access to all content scopes, `currentUser.permissions[].contentScopes` now returns a single wildcard scope (e.g. `[{ domain: "*", language: "*" }]`) instead of the enumerated `availableContentScopes`. The default `isAllowed` and `currentUser.allowedContentScopes` handle the wildcard; a custom `isAllowed` must treat `"*"` as matching any value of a dimension. diff --git a/demo/api/src/auth/access-control.service.spec.ts b/demo/api/src/auth/access-control.service.spec.ts index d15d90adecf..bb8e0b90b45 100644 --- a/demo/api/src/auth/access-control.service.spec.ts +++ b/demo/api/src/auth/access-control.service.spec.ts @@ -61,7 +61,7 @@ describe("AccessControlService", () => { const contentScopes = service.getContentScopesForUser(nonAdminUser); - expect(contentScopes).toEqual([{ domain: "main", language: "en" }]); + expect(contentScopes).toEqual([{ domain: "main", language: "*" }]); }); it("should return limited content scopes for unknown non-admin user", () => { @@ -74,7 +74,7 @@ describe("AccessControlService", () => { const contentScopes = service.getContentScopesForUser(unknownUser); - expect(contentScopes).toEqual([{ domain: "main", language: "en" }]); + expect(contentScopes).toEqual([{ domain: "main", language: "*" }]); }); }); }); diff --git a/demo/api/src/auth/access-control.service.ts b/demo/api/src/auth/access-control.service.ts index 9f929b7eadf..40a3f7a1536 100644 --- a/demo/api/src/auth/access-control.service.ts +++ b/demo/api/src/auth/access-control.service.ts @@ -15,7 +15,8 @@ export class AccessControlService extends AbstractAccessControlService { if (user.isAdmin) { return UserPermissions.allContentScopes; } else { - return [{ domain: "main", language: "en" }]; + // Grant access to every language within the "main" domain using a wildcard dimension + return [{ domain: "main", language: "*" }]; } } } diff --git a/docs/docs/2-core-concepts/5-user-permissions/1-setup.md b/docs/docs/2-core-concepts/5-user-permissions/1-setup.md index b003e32493d..24c19b6af5f 100644 --- a/docs/docs/2-core-concepts/5-user-permissions/1-setup.md +++ b/docs/docs/2-core-concepts/5-user-permissions/1-setup.md @@ -68,6 +68,15 @@ It's also possible to add additional properties and meta information to permissi `getContentScopesForUser` returns the general scopes for the user but can be overridden for each permission in `getPermissionsForUser`. Please refer to the types that the IDE offers. ::: +`getContentScopesForUser` may use the wildcard value `"*"` for a single content scope dimension to allow any value for it. The wildcard is matched during the content scope check and does not need to be part of `availableContentScopes`. + +```ts +getContentScopesForUser(user: User): ContentScopesForUser { + // Grant access to every language within the "main" domain + return [{ domain: "main", language: "*" }]; +} +``` + ## Admin Add the `UserPermissionsPage` component. Currently, it's not possible to customize the admin panel. diff --git a/packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx b/packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx index 6980aa12aa4..3c66bd923c9 100644 --- a/packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx +++ b/packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx @@ -97,7 +97,14 @@ export const CurrentUserProvider = ({ isAllowed, children }: PropsWithChildren<{ return user.permissions.some( (p) => p.permission === permission && - (!contentScope || p.contentScopes.some((cs) => Object.entries(contentScope).every(([scope, value]) => cs[scope] === value))), + (!contentScope || + p.contentScopes.some((cs) => + // A wildcard ("*") dimension in the user's content scopes allows any value for that dimension; + // null and undefined are treated the same, matching the server-side check. + Object.entries(contentScope).every( + ([scope, value]) => cs[scope] === "*" || cs[scope] === value || (cs[scope] == null && value == null), + ), + )), ); }), }; 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 5ab7c6f9de3..3f45f0bf74f 100644 --- a/packages/api/cms-api/src/auth/resolver/auth.resolver.ts +++ b/packages/api/cms-api/src/auth/resolver/auth.resolver.ts @@ -2,9 +2,9 @@ import { Inject, Type } from "@nestjs/common"; import { Args, Context, Mutation, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; import { GraphQLJSONObject } from "graphql-scalars"; import { IncomingMessage } from "http"; -import isEqual from "lodash.isequal"; import { SkipBuild } from "../../builds/skip-build.decorator"; +import { isScopeWithin } from "../../user-permissions/access-control.service"; import { DisablePermissionCheck, RequiredPermission } from "../../user-permissions/decorators/required-permission.decorator"; import { ContentScopeWithLabel } from "../../user-permissions/dto/content-scope"; import { CurrentUser } from "../../user-permissions/dto/current-user"; @@ -58,7 +58,7 @@ export function createAuthResolver(config?: AuthResolverConfig): Type { async allowedContentScopes(@Parent() user: CurrentUser): Promise { const allowedContentScopes = user.permissions.flatMap((p) => p.contentScopes); return (await this.service.getAvailableContentScopes()).filter((contentScopeWithLabel) => - allowedContentScopes.some((allowedContentScope) => isEqual(contentScopeWithLabel.scope, allowedContentScope)), + allowedContentScopes.some((allowedContentScope) => isScopeWithin(contentScopeWithLabel.scope, allowedContentScope)), ); } } diff --git a/packages/api/cms-api/src/user-permissions/access-control.service.test.ts b/packages/api/cms-api/src/user-permissions/access-control.service.test.ts index 344d7950952..6080c94fcb6 100644 --- a/packages/api/cms-api/src/user-permissions/access-control.service.test.ts +++ b/packages/api/cms-api/src/user-permissions/access-control.service.test.ts @@ -37,6 +37,22 @@ describe("AbstractAccessControlService", () => { expect(service.isAllowed(user, "pageTree", { domain: "main", language: undefined })).toBe(true); }); + + it("should allow any value for a wildcard scope dimension", () => { + const user: CurrentUser = { + id: "b26d86a7-32bb-4c84-ab9d-d167dddd40ff", + name: "User", + email: "user@example.com", + permissions: [{ permission: "pageTree", contentScopes: [{ domain: "main", language: "*" }] }], + }; + + expect(service.isAllowed(user, "pageTree", { domain: "main", language: "en" })).toBe(true); + expect(service.isAllowed(user, "pageTree", { domain: "main", language: "de" })).toBe(true); + expect(service.isAllowed(user, "pageTree", { domain: "main" })).toBe(true); + + // The wildcard only applies to its dimension, other dimensions must still match + expect(service.isAllowed(user, "pageTree", { domain: "secondary", language: "en" })).toBe(false); + }); }); describe("isEqualOrMorePermissions", () => { @@ -126,6 +142,66 @@ describe("AbstractAccessControlService", () => { ).toBe(true); }); + it("should treat a wildcard scope dimension as covering any concrete value", () => { + // A user with a wildcard scope can impersonate a user with a concrete value for that dimension + expect( + AbstractAccessControlService.isEqualOrMorePermissions( + [{ permission: permissions.p1, contentScopes: [{ domain: "main", language: "*" }] }], + [{ permission: permissions.p1, contentScopes: [{ domain: "main", language: "en" }] }], + ), + ).toBe(true); + expect( + AbstractAccessControlService.isEqualOrMorePermissions( + [{ permission: permissions.p1, contentScopes: [{ domain: "*", language: "*" }] }], + [ + { + permission: permissions.p1, + contentScopes: [ + { domain: "main", language: "en" }, + { domain: "secondary", language: "de" }, + ], + }, + ], + ), + ).toBe(true); + + // A concrete value does not cover a wildcard, which grants broader access + expect( + AbstractAccessControlService.isEqualOrMorePermissions( + [{ permission: permissions.p1, contentScopes: [{ domain: "main", language: "en" }] }], + [{ permission: permissions.p1, contentScopes: [{ domain: "main", language: "*" }] }], + ), + ).toBe(false); + + // The wildcard only applies to its dimension, other dimensions must still match + expect( + AbstractAccessControlService.isEqualOrMorePermissions( + [{ permission: permissions.p1, contentScopes: [{ domain: "main", language: "*" }] }], + [{ permission: permissions.p1, contentScopes: [{ domain: "secondary", language: "en" }] }], + ), + ).toBe(false); + }); + + it("should let a user with wildcards for all dimensions cover any scope", () => { + // A user with access to all content scopes is represented with a wildcard per dimension (see + // getPermissionsAndContentScopes) and can therefore impersonate any other user. + expect( + AbstractAccessControlService.isEqualOrMorePermissions( + [{ permission: permissions.p1, contentScopes: [{ domain: "*", language: "*" }] }], + [ + { + permission: permissions.p1, + contentScopes: [ + { domain: "main", language: "en" }, + { domain: "main", language: "*" }, + { domain: "secondary", language: "de" }, + ], + }, + ], + ), + ).toBe(true); + }); + it("should be true on more permissions", () => { expect(AbstractAccessControlService.isEqualOrMorePermissions([{ permission: permissions.p1, contentScopes: [] }], [])).toBe(true); expect( diff --git a/packages/api/cms-api/src/user-permissions/access-control.service.ts b/packages/api/cms-api/src/user-permissions/access-control.service.ts index 74578ee0e7c..a0df252aa68 100644 --- a/packages/api/cms-api/src/user-permissions/access-control.service.ts +++ b/packages/api/cms-api/src/user-permissions/access-control.service.ts @@ -1,32 +1,33 @@ import { Injectable, Logger } from "@nestjs/common"; -import isEqual from "lodash.isequal"; import { CurrentUser, CurrentUserPermission } from "./dto/current-user"; import { ContentScope } from "./interfaces/content-scope.interface"; import { AccessControlServiceInterface, Permission } from "./user-permissions.types"; +// Whether `scope` is within `containingScope`: for every dimension of `scope`, `containingScope` holds the same value +// or the wildcard "*" (which matches any value); null and undefined are treated the same. `containingScope` may be +// broader than `scope` (e.g. via wildcards), but not narrower. Dimensions it constrains beyond `scope` are ignored. +export function isScopeWithin(scope: ContentScope, containingScope: ContentScope): boolean { + return Object.keys(scope).every((dimension) => { + const value = (scope as Record)[dimension]; + const containingValue = (containingScope as Record)[dimension]; + return containingValue === "*" || containingValue === value || (containingValue == null && value == null); + }); +} + @Injectable() export abstract class AbstractAccessControlService implements AccessControlServiceInterface { private static readonly logger = new Logger(AbstractAccessControlService.name); - private checkContentScope(userContentScopes: ContentScope[], targetContentScope: ContentScope): boolean { - return userContentScopes.some((userContentScope) => - Object.entries(targetContentScope).every(([dimension, targetContentScopeValue]) => { - const userContentScopeValue = (userContentScope as Record)[dimension]; - - // Treat null and undefined the same - if (userContentScopeValue == null && targetContentScopeValue == null) { - return true; - } - - return userContentScopeValue === targetContentScopeValue; - }), - ); - } isAllowed(user: CurrentUser, permission: Permission, contentScope?: ContentScope): boolean { - if (!user.permissions) return false; - return user.permissions.some((p) => p.permission === permission && (!contentScope || this.checkContentScope(p.contentScopes, contentScope))); + if (!user.permissions) { + return false; + } + return user.permissions.some( + (p) => p.permission === permission && (!contentScope || p.contentScopes.some((cs) => isScopeWithin(contentScope, cs))), + ); } + static isEqualOrMorePermissions(permissions: CurrentUserPermission[], targetPermissions: CurrentUserPermission[]): boolean { for (const permission of targetPermissions) { const currentUserPermission = permissions.find((p) => p.permission === permission.permission); @@ -35,7 +36,19 @@ export abstract class AbstractAccessControlService implements AccessControlServi return false; } for (const contentScope of permission.contentScopes) { - if (!currentUserPermission.contentScopes.find((cs) => isEqual(cs, contentScope))) { + // The current user must have at least as much access as the target for this content scope. Unlike + // isScopeWithin, the current user's scope must not be narrower on any dimension it constrains + // beyond the target either, so the dimensions of both scopes are checked (a wildcard "*" matches any + // value; null and undefined are treated the same). + const hasCoveringContentScope = currentUserPermission.contentScopes.some((cs) => { + const dimensions = new Set([...Object.keys(cs), ...Object.keys(contentScope)]); + return [...dimensions].every((dimension) => { + const value = (cs as Record)[dimension]; + const targetValue = (contentScope as Record)[dimension]; + return value === "*" || value === targetValue || (value == null && targetValue == null); + }); + }); + if (!hasCoveringContentScope) { this.logger.debug(`Missing content scope ${JSON.stringify(contentScope)} for permission "${permission.permission}".`); return false; } 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 4e6c85494cf..095f6772fdb 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 @@ -188,17 +188,29 @@ export class UserPermissionsService { user, availableContentScopes, includeContentScopesManual, + representAllContentScopesAsWildcard = false, }: { user: User; availableContentScopes: ContentScope[]; includeContentScopesManual: boolean; + representAllContentScopesAsWildcard?: boolean; }): Promise { const contentScopes: ContentScope[] = []; if (this.accessControlService.getContentScopesForUser) { const userContentScopes = await this.accessControlService.getContentScopesForUser(user); if (userContentScopes === UserPermissions.allContentScopes) { - contentScopes.push(...availableContentScopes); + if (representAllContentScopesAsWildcard) { + // For the current user, represent access to all content scopes as a single scope that grants any + // value ("*") of every available dimension, so that the wildcard is preserved for content scope + // checks and permission comparison (impersonation) instead of being expanded to concrete scopes. + const dimensions = new Set(availableContentScopes.flatMap((contentScope) => Object.keys(contentScope))); + contentScopes.push(Object.fromEntries([...dimensions].map((dimension) => [dimension, "*"]))); + } else { + // For other uses (e.g. the content scopes list in the user permissions panel), expand access to all + // content scopes to the concrete available scopes. + contentScopes.push(...availableContentScopes); + } } else { contentScopes.push(...userContentScopes); } @@ -221,7 +233,7 @@ export class UserPermissionsService { try { const user = await this.getUser(request?.cookies["comet-impersonate-user-id"]); if ( - await AbstractAccessControlService.isEqualOrMorePermissions( + AbstractAccessControlService.isEqualOrMorePermissions( await this.getPermissionsAndContentScopes(authenticatedUser), await this.getPermissionsAndContentScopes(user), ) @@ -249,7 +261,13 @@ export class UserPermissionsService { } async getPermissionsAndContentScopes(user: User): Promise { - const userContentScopes = await this.getContentScopes(user); + const availableContentScopes = (await this.getAvailableContentScopes()).map((cs) => cs.scope); + const userContentScopes = await this.filterContentScopesForUser({ + user, + availableContentScopes, + includeContentScopesManual: true, + representAllContentScopesAsWildcard: true, + }); return (await this.getPermissions(user)) .filter((p) => (!p.validFrom || isPast(p.validFrom)) && (!p.validTo || isFuture(p.validTo))) .reduce((acc: CurrentUser["permissions"], userPermission) => { diff --git a/packages/api/cms-api/src/warnings/warning.resolver.ts b/packages/api/cms-api/src/warnings/warning.resolver.ts index 58dbc41c67d..695539970b4 100644 --- a/packages/api/cms-api/src/warnings/warning.resolver.ts +++ b/packages/api/cms-api/src/warnings/warning.resolver.ts @@ -2,12 +2,12 @@ import { InjectRepository } from "@mikro-orm/nestjs"; import { EntityManager, EntityRepository, FindOptions } from "@mikro-orm/postgresql"; import { UnauthorizedException } from "@nestjs/common"; import { Args, ID, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; -import isEqual from "lodash.isequal"; import { GetCurrentUser } from "../auth/decorators/get-current-user.decorator"; import { EntityInfoObject } from "../common/entityInfo/entity-info.object"; import { EntityInfoService } from "../common/entityInfo/entity-info.service"; import { gqlArgsToMikroOrmQuery } from "../common/filter/mikro-orm"; +import { isScopeWithin } from "../user-permissions/access-control.service"; import { AffectedEntity } from "../user-permissions/decorators/affected-entity.decorator"; import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator"; import { CurrentUser } from "../user-permissions/dto/current-user"; @@ -41,7 +41,7 @@ export class WarningResolver { const allowedScopesForUser = user.permissions.find(({ permission }) => permission === "warnings")?.contentScopes; for (const scope of scopes) { - if (!allowedScopesForUser?.find((allowedScope) => isEqual(allowedScope, scope))) { + if (!allowedScopesForUser?.some((allowedScope) => isScopeWithin(scope, allowedScope))) { throw new UnauthorizedException("Scopes were passed that the user does not have permission to"); } }