Skip to content

Make the DAM's scope-based access control optional - #6153

Open
VPS-thodax wants to merge 4 commits into
mainfrom
refactor/optional-dam-scope-access-control
Open

Make the DAM's scope-based access control optional#6153
VPS-thodax wants to merge 4 commits into
mainfrom
refactor/optional-dam-scope-access-control

Conversation

@VPS-thodax

@VPS-thodax VPS-thodax commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

depends on #6151

Problem

The DAM's REST controllers check whether the current user may access a file's scope, and they ask an AccessControlService to decide. That service comes from UserPermissionsModule and is injected unconditionally, so registering DamFilesModule without 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: DamFilesModule checks 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:

DamFilesModule.register({
    damConfig,
    Scope: DamScope,
    File: DamFile,
    Folder: DamFolder,
    disableScopeAccessControl: true,
});

Setting it logs a warning at startup. Nothing changes for applications that use DamModule together with UserPermissionsModule: they keep using the registered AccessControlService.

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 without UserPermissionsModule has no permission checks on its GraphQL API and has to bring its own.

Decisions

  • A missing access control service stops the application at startup, rather than being answered with 403 per request. Denying every scoped request keeps the REST endpoints closed, but the GraphQL resolvers have no such fallback: their scope checks come from UserPermissionsGuard, which the same module provides, so they would serve requests unguarded. A half-protected DAM cannot run this way. isAllowedToAccessScope still 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.
  • The option lives on DamFilesModule, not in DamConfig. Projects fill DamConfig from comet-config.json and environment variables, which is no place for an authorization switch. It also keeps the option out of DamModule.register(), so a normal Comet app cannot turn scope checks off.
  • One shared function instead of the same check in three controllers. The rule cannot drift apart between them, and it can be unit-tested.

Dropped alternative: no option at all, and standalone setups register an allow-everything AccessControlService instead. 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.ts covers 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 them
  • The Demo API boots, which the startup check only allows while the access control service is injected, and DAM thumbnails and a file preview still load in the Demo admin

Big picture

After this, DamFilesModule resolves against two things only — blob storage and MikroORM:

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
ContentScopeService FilesService, FilesController UserPermissionsModule removed in #6150
ImgproxyService FilesService ImgproxyModule removed in #6151
DependenciesService the dependents resolver DependenciesModule removed in #6151
ACCESS_CONTROL_SERVICE FilesController, FoldersController, DamFilesModule UserPermissionsModule optional in this PR

@VPS-thodax VPS-thodax self-assigned this Aug 9, 2026
@VPS-thodax
VPS-thodax force-pushed the refactor/optional-dam-scope-access-control branch from a10753f to 81aa089 Compare August 9, 2026 21:38
@VPS-thodax
VPS-thodax force-pushed the refactor/optional-dam-scope-access-control branch from 81aa089 to 4e59af0 Compare August 9, 2026 21:50
VPS-Obi added a commit that referenced this pull request Aug 10, 2026
## 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>
@VPS-Obi
VPS-Obi force-pushed the refactor/optional-dam-scope-access-control branch from 8e05bbe to f6631f2 Compare August 10, 2026 08:30
@VPS-thodax
VPS-thodax force-pushed the refactor/optional-dam-scope-access-control branch 2 times, most recently from b3506b4 to b19802b Compare August 10, 2026 20:26
@VPS-thodax
VPS-thodax marked this pull request as ready for review August 11, 2026 06:33
@VPS-thodax
VPS-thodax requested a review from kaufmo August 11, 2026 06:33
@github-actions
github-actions Bot requested a review from VPS-Obi August 11, 2026 06:33
Comment on lines +62 to +66
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.",
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +76 to +83
private isAllowed(user: CurrentUser, scope: DamScopeInterface | undefined): boolean {
return isAllowedToAccessScope({
accessControlService: this.accessControlService,
disableScopeAccessControl: this.disableScopeAccessControl,
user,
scope,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@VPS-Obi

VPS-Obi commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Needs human review — touches auth/access-control logic.

This PR makes the DAM's AccessControlService optional and reroutes every scope check in FilesController, FoldersController, and ImagesController through a new isAllowedToAccessScope helper that fails closed when the service is missing. Authorization-path changes like this warrant a careful human look even with good unit coverage (scope-access-control.spec.ts) — worth double-checking the fail-closed guarantee holds for the GraphQL resolvers too, per the PR's own noted limitation. Already has 3 inline review comments and a requested reviewer (kaufmo), so review is in progress.


Generated by Claude Code

Base automatically changed from refactor/split-dam-module to main August 13, 2026 07:24
VPS-thodax added a commit that referenced this pull request Aug 13, 2026
## 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>
VPS-thodax and others added 2 commits August 13, 2026 09:24
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>
@VPS-thodax
VPS-thodax force-pushed the refactor/optional-dam-scope-access-control branch from b19802b to f028375 Compare August 13, 2026 07:24
The packages were renamed from `@comet/*` to `@dextinity/*` on main, so the
changeset no longer matched a workspace package.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants