Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/user-permissions-wildcard-content-scope.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions demo/api/src/auth/access-control.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -74,7 +74,7 @@ describe("AccessControlService", () => {

const contentScopes = service.getContentScopesForUser(unknownUser);

expect(contentScopes).toEqual([{ domain: "main", language: "en" }]);
expect(contentScopes).toEqual([{ domain: "main", language: "*" }]);
});
});
});
3 changes: 2 additions & 1 deletion demo/api/src/auth/access-control.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "*" }];
}
}
}
9 changes: 9 additions & 0 deletions docs/docs/2-core-concepts/5-user-permissions/1-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
)),
);
}),
};
Expand Down
4 changes: 2 additions & 2 deletions packages/api/cms-api/src/auth/resolver/auth.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -58,7 +58,7 @@ export function createAuthResolver(config?: AuthResolverConfig): Type<unknown> {
async allowedContentScopes(@Parent() user: CurrentUser): Promise<ContentScopeWithLabel[]> {
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)),
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(
Expand Down
49 changes: 31 additions & 18 deletions packages/api/cms-api/src/user-permissions/access-control.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[dimension];
const containingValue = (containingScope as Record<string, unknown>)[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<string, unknown>)[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);
Expand All @@ -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<string, unknown>)[dimension];
const targetValue = (contentScope as Record<string, unknown>)[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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,17 +188,29 @@ export class UserPermissionsService {
user,
availableContentScopes,
includeContentScopesManual,
representAllContentScopesAsWildcard = false,
}: {
user: User;
availableContentScopes: ContentScope[];
includeContentScopesManual: boolean;
representAllContentScopesAsWildcard?: boolean;
}): Promise<ContentScope[]> {
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);
}
Expand All @@ -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),
)
Expand Down Expand Up @@ -249,7 +261,13 @@ export class UserPermissionsService {
}

async getPermissionsAndContentScopes(user: User): Promise<CurrentUserPermission[]> {
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) => {
Expand Down
4 changes: 2 additions & 2 deletions packages/api/cms-api/src/warnings/warning.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
}
}
Expand Down
Loading