Skip to content

Commit 35091e6

Browse files
committed
Add AffectedScope-decorator
1 parent 89db54c commit 35091e6

File tree

6 files changed

+200
-1
lines changed

6 files changed

+200
-1
lines changed

.changeset/eleven-zoos-rule.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@comet/cms-api": minor
3+
---
4+
5+
Add `@AffectedScope` decorator
6+
7+
See https://docs.comet-dxp.com/docs/core-concepts/content-scope/evaluate-content-scopes#operations-that-require-a-content-scope-independently-of-a-specific-entity

docs/docs/2-core-concepts/1-content-scope/3-evaluate-content-scopes.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,24 @@ It's also possible to pass a function which returns the content scope to the `@S
7575
@ScopedEntity(PageTreeNodeDocumentEntityScopeService)
7676
export class PredefinedPage extends BaseEntity implements DocumentInterface {
7777
```
78+
79+
### Operations that require a content scope independently of a specific entity
80+
81+
**@AffectedScope**
82+
83+
Use this decorator in following cases:
84+
85+
- You don't have an entity to check with `@AffectedEntity`
86+
- You want to check for a combination of content scope values.
87+
88+
Example:
89+
90+
```ts
91+
@Query([Product])
92+
@AffectedScope((args) => ({ country: args.country, department: args.department }))
93+
async products(@Args("country", { type: () => string }) id: string, @Args("department", { type: () => string }): Promise<Product[]> {
94+
// Note: you can trust "country" and "department" being in a valid scope in the sense that the user must have a content scope which is a combination of these dimensions:
95+
// [{ country: "AT", department: "main" }]; // ok
96+
// [{ country: "AT" }, { department: "main" }]; // not sufficient
97+
}
98+
```

packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.spec.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DISABLE_COMET_GUARDS_METADATA_KEY } from "../../auth/decorators/disable
77
import { AbstractAccessControlService } from "../access-control.service";
88
import { ContentScopeService } from "../content-scope.service";
99
import { AFFECTED_ENTITY_METADATA_KEY, AffectedEntityMeta } from "../decorators/affected-entity.decorator";
10+
import { AFFECTED_SCOPE_METADATA_KEY, AffectedScopeMeta } from "../decorators/affected-scope.decorator";
1011
import { REQUIRED_PERMISSION_METADATA_KEY, RequiredPermissionMetadata } from "../decorators/required-permission.decorator";
1112
import { SCOPED_ENTITY_METADATA_KEY, ScopedEntityMeta } from "../decorators/scoped-entity.decorator";
1213
import { CurrentUser } from "../dto/current-user";
@@ -41,12 +42,14 @@ describe("UserPermissionsGuard", () => {
4142
affectedEntities?: AffectedEntityMeta[];
4243
scopedEntity?: ScopedEntityMeta<TestEntity>;
4344
disableCometGuards?: boolean;
45+
affectedScope?: AffectedScopeMeta;
4446
}) => {
4547
reflector.getAllAndOverride = jest.fn().mockImplementation((decorator: string) => {
4648
if (decorator === REQUIRED_PERMISSION_METADATA_KEY) return annotations.requiredPermission;
4749
if (decorator === AFFECTED_ENTITY_METADATA_KEY) return annotations.affectedEntities;
4850
if (decorator === SCOPED_ENTITY_METADATA_KEY) return annotations.scopedEntity;
4951
if (decorator === DISABLE_COMET_GUARDS_METADATA_KEY) return annotations.disableCometGuards;
52+
if (decorator === AFFECTED_SCOPE_METADATA_KEY) return annotations.affectedScope;
5053
return false;
5154
});
5255
};
@@ -527,4 +530,148 @@ describe("UserPermissionsGuard", () => {
527530
),
528531
).rejects.toThrowError("Could not get content scope");
529532
});
533+
534+
it("allows user by AffectedScope", async () => {
535+
mockAnnotations({
536+
requiredPermission: {
537+
requiredPermission: [permissions.p1],
538+
options: undefined,
539+
},
540+
affectedScope: { argsToScope: (args) => ({ a: args.a }) },
541+
});
542+
expect(
543+
await guard.canActivate(
544+
mockContext({
545+
userPermissions: [
546+
{
547+
permission: permissions.p1,
548+
contentScopes: [{ a: 1 }],
549+
},
550+
],
551+
args: { a: 1 },
552+
}),
553+
),
554+
).toBe(true);
555+
expect(
556+
await guard.canActivate(
557+
mockContext({
558+
userPermissions: [
559+
{
560+
permission: permissions.p1,
561+
contentScopes: [{ a: 1 }],
562+
},
563+
],
564+
args: { a: 1, b: 2 },
565+
}),
566+
),
567+
).toBe(true);
568+
});
569+
570+
it("allows user by multidimensional AffectedScope", async () => {
571+
mockAnnotations({
572+
requiredPermission: {
573+
requiredPermission: [permissions.p1],
574+
options: undefined,
575+
},
576+
affectedScope: { argsToScope: (args) => ({ a: args.a, b: args.submittedB }) },
577+
});
578+
expect(
579+
await guard.canActivate(
580+
mockContext({
581+
userPermissions: [
582+
{
583+
permission: permissions.p1,
584+
contentScopes: [{ a: 1, b: "2" }],
585+
},
586+
],
587+
args: { a: 1, submittedB: "2" },
588+
}),
589+
),
590+
).toBe(true);
591+
});
592+
593+
it("denies by wrong AffectedScope", async () => {
594+
mockAnnotations({
595+
requiredPermission: {
596+
requiredPermission: [permissions.p1],
597+
options: undefined,
598+
},
599+
affectedScope: { argsToScope: (args) => ({ a: args.a }) },
600+
});
601+
expect(
602+
await guard.canActivate(
603+
mockContext({
604+
userPermissions: [
605+
{
606+
permission: permissions.p1,
607+
contentScopes: [{ a: 1 }],
608+
},
609+
],
610+
args: { a: 2 },
611+
}),
612+
),
613+
).toBe(false);
614+
expect(
615+
await guard.canActivate(
616+
mockContext({
617+
userPermissions: [
618+
{
619+
permission: permissions.p1,
620+
contentScopes: [{ a: 1 }],
621+
},
622+
],
623+
args: { a: "1" },
624+
}),
625+
),
626+
).toBe(false);
627+
});
628+
629+
it("denies by wrong multidimensional AffectedScope", async () => {
630+
mockAnnotations({
631+
requiredPermission: {
632+
requiredPermission: [permissions.p1],
633+
options: undefined,
634+
},
635+
affectedScope: { argsToScope: (args) => ({ a: args.a, b: args.b }) },
636+
});
637+
expect(
638+
await guard.canActivate(
639+
mockContext({
640+
userPermissions: [
641+
{
642+
permission: permissions.p1,
643+
contentScopes: [{ a: 1, b: "2" }],
644+
},
645+
],
646+
args: { a: 1 },
647+
}),
648+
),
649+
).toBe(false);
650+
expect(
651+
await guard.canActivate(
652+
mockContext({
653+
userPermissions: [
654+
{
655+
permission: permissions.p1,
656+
contentScopes: [{ a: 1 }, { b: "2" }], // User must have combination of a and b
657+
},
658+
],
659+
args: { a: 1, b: "2" },
660+
}),
661+
),
662+
).toBe(false);
663+
expect(
664+
await guard.canActivate(
665+
mockContext({
666+
userPermissions: [
667+
{
668+
permission: permissions.p1,
669+
contentScopes: [{ a: 1, b: "2" }],
670+
},
671+
],
672+
args: { a: 1, b: 2 },
673+
}),
674+
),
675+
).toBe(false);
676+
});
530677
});

packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export class UserPermissionsGuard implements CanActivate {
6666
} else {
6767
if (requiredContentScopes.length === 0)
6868
throw new Error(
69-
`Could not get content scope. Either pass a scope-argument or add an @AffectedEntity()-decorator or enable skipScopeCheck in the @RequiredPermission()-decorator of ${location}`,
69+
`Could not get content scope. Either pass a scope-argument or add an @AffectedEntity()/@AffectedScope()-decorator or enable skipScopeCheck in the @RequiredPermission()-decorator of ${location}`,
7070
);
7171

7272
// requiredContentScopes is an two level array of scopes

packages/api/cms-api/src/user-permissions/content-scope.service.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { PageTreeService } from "../page-tree/page-tree.service";
99
import { SCOPED_ENTITY_METADATA_KEY, ScopedEntityMeta } from "../user-permissions/decorators/scoped-entity.decorator";
1010
import { ContentScope } from "../user-permissions/interfaces/content-scope.interface";
1111
import { AFFECTED_ENTITY_METADATA_KEY, AffectedEntityMeta } from "./decorators/affected-entity.decorator";
12+
import { AFFECTED_SCOPE_METADATA_KEY, AffectedScopeMeta } from "./decorators/affected-scope.decorator";
1213

1314
// TODO Remove service and move into UserPermissionsGuard once ChangesCheckerInterceptor is removed
1415
@Injectable()
@@ -33,13 +34,23 @@ export class ContentScopeService {
3334
const args = await this.getArgs(context);
3435
const location = `${context.getClass().name}::${context.getHandler().name}()`;
3536

37+
// AffectedEntities
3638
const affectedEntities = this.reflector.getAllAndOverride<AffectedEntityMeta[]>(AFFECTED_ENTITY_METADATA_KEY, [context.getHandler()]) || [];
3739
for (const affectedEntity of affectedEntities) {
3840
contentScopes.push(...(await this.getContentScopesFromEntity(affectedEntity, args, location)));
3941
}
42+
43+
// AffectedScope
44+
const affectedScope = this.reflector.getAllAndOverride<AffectedScopeMeta>(AFFECTED_SCOPE_METADATA_KEY, [context.getHandler()]) || undefined;
45+
if (affectedScope) {
46+
contentScopes.push([affectedScope.argsToScope(args) as ContentScope]);
47+
}
48+
49+
// Scope arg
4050
if (args.scope) {
4151
contentScopes.push([args.scope as ContentScope]);
4252
}
53+
4354
return contentScopes;
4455
}
4556

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { SetMetadata } from "@nestjs/common";
2+
3+
import { type ContentScope } from "../../user-permissions/interfaces/content-scope.interface";
4+
5+
export type AffectedScopeMeta = {
6+
argsToScope: (args: Record<string, unknown>) => ContentScope;
7+
};
8+
9+
export const AFFECTED_SCOPE_METADATA_KEY = "affectedScope";
10+
11+
export const AffectedScope = (argsToScope: AffectedScopeMeta["argsToScope"]): MethodDecorator => {
12+
return SetMetadata<string, AffectedScopeMeta>(AFFECTED_SCOPE_METADATA_KEY, { argsToScope });
13+
};

0 commit comments

Comments
 (0)