Make the DAM's scope-based access control optional - #6153
Conversation
a10753f to
81aa089
Compare
81aa089 to
4e59af0
Compare
## Problem `FilesService` and the DAM files controller injected `ContentScopeService` only to call its `scopesAreEqual` method — a stateless deep comparison. That ties the DAM to the user-permissions layer for a check that needs no state. `FoldersService` ran the same comparison inline via `lodash.isequal`, so the rule existed twice. ## Solution The comparison lives in `contentScopesAreEqual` now, next to the `ContentScope` interface in `user-permissions/`: - `ContentScopeService.scopesAreEqual` delegates to it, so its callers are unaffected. - The DAM services and the files controller call it directly and no longer inject `ContentScopeService`. No changeset because `contentScopesAreEqual` is internal and `ContentScopeService.scopesAreEqual` keeps its signature, so the public API is unchanged. ## Verification - New unit tests in `content-scopes-are-equal.spec.ts` cover a class instance against a plain object plus differing values, differing keys and `undefined` scopes ## Outlook First of three stacked pull requests that let `DamFilesModule` be registered without `UserPermissionsModule`: #6151 splits `DamModule` into sub-modules, #6153 makes the DAM's scope-based access control optional. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
8e05bbe to
f6631f2
Compare
b3506b4 to
b19802b
Compare
| if (this.disableScopeAccessControl) { | ||
| new Logger(DamFilesModule.name).warn( | ||
| "Scope-based access control is disabled (disableScopeAccessControl = true). The DAM will not check scopes on its REST endpoints, and its GraphQL resolvers are only as protected as the guards you register yourself.", | ||
| ); | ||
| } |
There was a problem hiding this comment.
IMO we don't need this warning. We don't log warnings for similar scenarios (e.g., the public option in the FileUploadsModule). If a dev uses the option, they (hopefully) know what they're doing.
| private isAllowed(user: CurrentUser, scope: DamScopeInterface | undefined): boolean { | ||
| return isAllowedToAccessScope({ | ||
| accessControlService: this.accessControlService, | ||
| disableScopeAccessControl: this.disableScopeAccessControl, | ||
| user, | ||
| scope, | ||
| }); | ||
| } |
There was a problem hiding this comment.
This method is identical in the three controllers. Maybe we should move isAllowedToAccessScope to a service?
| private readonly cacheService: ScaledImagesCacheService, | ||
| @Inject(forwardRef(() => BlobStorageBackendService)) private readonly blobStorageBackendService: BlobStorageBackendService, | ||
| @Inject(ACCESS_CONTROL_SERVICE) private accessControlService: AccessControlServiceInterface, | ||
| @Optional() @Inject(ACCESS_CONTROL_SERVICE) private accessControlService: AccessControlServiceInterface | undefined, |
There was a problem hiding this comment.
Have you considered to just make the service optional and not add an additional option? Or did you decide to make the decision to not have ACL explicit by setting the option?
|
Needs human review — touches auth/access-control logic. This PR makes the DAM's Generated by Claude Code |
## Problem
`DamModule` registered file handling, image scaling, the block
transformers and the dependents resolver as a single unit. A project
that only needs DAM file upload and storage therefore had to bring
`ImgproxyModule` and `DependenciesModule` along with it.
Computing the dominant color of an uploaded image was the one place
where `FilesService` used imgproxy, which is what made that dependency
reach into file handling at all.
## Solution
`DamDominantColorService` now holds the dominant color computation, and
`DamModule` is a facade composing four sub-modules.
`DamModule.register()` keeps its signature and still composes all of
them.
| Module | Responsibility | Depends on |
| --- | --- | --- |
| `DamFilesModule` | file and folder CRUD, upload, storage, serving,
dam-items, media alternatives, licenses, warnings | `BlobStorageModule`,
`UserPermissionsModule`, MikroORM |
| `DamImagesModule` | image scaling and serving, dominant color, image
validators, `FileImagesResolver` | `ImgproxyModule`,
`BlobStorageModule`, `UserPermissionsModule`, MikroORM, `DamFilesModule`
|
| `DamBlocksModule` | the four DAM block transformer services |
`DamFilesModule`, `DamImagesModule` |
| `DamDependentsModule` | the `dependents` field on `DamFile` |
`DependenciesModule` |
`FilesService` receives the dominant color calculator through the
optional `DAM_DOMINANT_COLOR_CALCULATOR` token, which `DamImagesModule`
provides. Without that module, uploads skip the color and
`FilesService.calculateDominantColor` returns `undefined`. The contract
is a standalone interface (`DominantColorCalculatorInterface`), so the
files code has no runtime link to the imgproxy-backed service — the
emitted `files.service.js` contains no `require` of anything under
`dam/images`.
`DamModule` and each sub-module throw when they are registered more than
once in the same process. A second registration would mount the DAM
routes twice and add the `dependents` field to the file type again, so
it never worked as intended.
`FileImagesResolver` moved from `dam/files/` to `dam/images/`, so its
folder matches the module that registers it.
## Decisions
- **`DamFilesModule` is the only sub-module that is exported.** It is
the one a project can register on its own. `DamImagesModule`,
`DamBlocksModule` and `DamDependentsModule` each need providers that
`DamFilesModule` registers, so exporting them would offer combinations
that fail at DI time. `DamDominantColorService` stays internal for the
same reason — it is an implementation detail of `DamImagesModule`,
reachable through `FilesService.calculateDominantColor`.
## Verification
- Demo API boots with `DamFilesModule`, `DamImagesModule`,
`DamBlocksModule` and `DamDependentsModule` all reporting initialized
dependencies, and registers the `cms.dam.calculateDominantImageColor`
command
- `schema.gql` and `block-meta.json` regenerate unchanged, so the split
does not affect the GraphQL schema
## Outlook
The goal is that `DamFilesModule` can be registered on its own with as
few dependencies as possible, for a service that only stores and serves
DAM files:
```ts
DamFilesModule.register({ damConfig, Scope: DamScope, File: DamFile, Folder: DamFolder });
```
What `DamFilesModule` still needs to resolve:
| Dependency | Injected by | Provided by | Status |
| --- | --- | --- | --- |
| `BlobStorageBackendService` | `FilesService`, `FoldersService`,
`FilesController` | `BlobStorageModule` | stays — the DAM has to store
files somewhere |
| `EntityManager` / `MikroORM` | most providers |
`MikroOrmModule.forRoot` | stays |
| `ACCESS_CONTROL_SERVICE` | `FilesController`, `FoldersController` |
`UserPermissionsModule` | optional in #6153 |
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The DAM controllers required an AccessControlService, so DamFilesModule could not be registered without UserPermissionsModule. This was the last dependency keeping a files-only DAM setup from working. Inject the service with @optional() and route every check through isAllowedToAccessScope, which fails closed when no service is present. Pass disableScopeAccessControl to DamFilesModule.register to run the DAM behind your own authentication guard; a warning is logged at module init when the checks are disabled. The option lives on DamFilesModule rather than in DamConfig so that it is unreachable from DamModule, which always runs with UserPermissionsModule, and so that an authorization switch does not sit in the config object projects fill from environment variables. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Making the AccessControlService optional protected the REST endpoints, which deny every scoped request when no service is registered. The GraphQL resolvers have no such fallback: their scope checks come from UserPermissionsGuard, which the same module provides, so a DAM without UserPermissionsModule would serve them unguarded. Check at module init instead: without an AccessControlService and without disableScopeAccessControl, DamFilesModule aborts startup with an error naming both options. A half-protected DAM can no longer run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b19802b to
f028375
Compare
The packages were renamed from `@comet/*` to `@dextinity/*` on main, so the changeset no longer matched a workspace package.
depends on #6151
Problem
The DAM's REST controllers check whether the current user may access a file's scope, and they ask an
AccessControlServiceto decide. That service comes fromUserPermissionsModuleand is injected unconditionally, so registeringDamFilesModulewithout the user permissions setup fails at startup with an unresolved dependency.That is the last thing keeping the DAM from running as a files-only service, which is what this stack (#6150, #6151) works toward.
Solution
The access control service is optional now, and every scope check goes through one helper,
isAllowedToAccessScope.A DAM that has no access control service still refuses to start, but on its own terms:
DamFilesModulechecks at module init and throws an error naming the two ways forward, instead of Nest reporting an unresolved dependency. A project that runs the DAM behind its own authentication guard, and therefore wants no scope checks at all, takes the second one:Setting it logs a warning at startup. Nothing changes for applications that use
DamModuletogether withUserPermissionsModule: they keep using the registeredAccessControlService.One limitation — the option switches off the REST controllers' scope checks only. The DAM's GraphQL resolvers rely on
UserPermissionsGuard, which comes from the same module, so a DAM withoutUserPermissionsModulehas no permission checks on its GraphQL API and has to bring its own.Decisions
403per request. Denying every scoped request keeps the REST endpoints closed, but the GraphQL resolvers have no such fallback: their scope checks come fromUserPermissionsGuard, which the same module provides, so they would serve requests unguarded. A half-protected DAM cannot run this way.isAllowedToAccessScopestill denies access when the service is missing or no user is authenticated, so the endpoints stay closed even if something ever gets past the startup check.DamFilesModule, not inDamConfig. Projects fillDamConfigfromcomet-config.jsonand environment variables, which is no place for an authorization switch. It also keeps the option out ofDamModule.register(), so a normal Comet app cannot turn scope checks off.Dropped alternative: no option at all, and standalone setups register an allow-everything
AccessControlServiceinstead. That service answers every permission check in an application, not just the DAM's, so it would be an easy way to switch off authorization everywhere.Verification
scope-access-control.spec.tscovers both helpers: the startup check with a service, without one, and with the option set; and the per-request check for a service that allows, a service that denies, a missing service, a missing user, and the option overriding all of themBig picture
After this,
DamFilesModuleresolves against two things only — blob storage and MikroORM:BlobStorageBackendServiceFilesService,FoldersService,FilesControllerBlobStorageModuleEntityManager/MikroORMMikroOrmModule.forRootContentScopeServiceFilesService,FilesControllerUserPermissionsModuleImgproxyServiceFilesServiceImgproxyModuleDependenciesServiceDependenciesModuleACCESS_CONTROL_SERVICEFilesController,FoldersController,DamFilesModuleUserPermissionsModule