Repository navigation
fix: enforce dataset permissions on v3 publisheddata register - #2884
Conversation
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- Routing the dataset update through
DatasetsV4Controllerfrom withinPublishedDataControllerintroduces a controller-to-controller dependency; consider extracting a permission-enforcing service method instead soPublishedDataControllerdepends only on services rather than another controller. - Using
cloneDeepon the full ExpressRequestobject can be expensive and may not behave as expected with circular references; it may be safer to construct a minimal request-like object containing only the fields needed byDatasetsV4Controller.findByIdAndUpdate(e.g., headers and user info).
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Routing the dataset update through `DatasetsV4Controller` from within `PublishedDataController` introduces a controller-to-controller dependency; consider extracting a permission-enforcing service method instead so `PublishedDataController` depends only on services rather than another controller.
- Using `cloneDeep` on the full Express `Request` object can be expensive and may not behave as expected with circular references; it may be safer to construct a minimal request-like object containing only the fields needed by `DatasetsV4Controller.findByIdAndUpdate` (e.g., headers and user info).
## Individual Comments
### Comment 1
<location path="src/published-data/published-data.controller.ts" line_range="390-395" />
<code_context>
const xml = formRegistrationXML(publishedDataObsolete);
+ const mergePatchRequest = cloneDeep(request);
+ mergePatchRequest.headers["content-type"] =
+ "application/merge-patch+json";
await Promise.all(
publishedDataObsolete.pidArray.map(async (pid) => {
- await this.datasetsService.findByIdAndUpdate(pid, {
- isPublished: true,
- datasetlifecycle: { publishedOn: data.registeredTime },
- });
+ await this.datasetsController.findByIdAndUpdate(
+ mergePatchRequest,
+ pid,
</code_context>
<issue_to_address>
**suggestion (performance):** Cloning the entire Express request with `cloneDeep` is heavy and potentially problematic; consider a lighter approach.
Deep-cloning an Express `Request` is expensive and can fail on circular or non-serializable properties. Since you only need to adjust headers for the downstream controller, consider either:
- A shallow copy: `{ ...request, headers: { ...request.headers, 'content-type': 'application/merge-patch+json' } }`, or
- A minimal object with just the fields `findByIdAndUpdate` uses (e.g. `user`, `headers`).
This avoids unnecessary cloning work and reduces the risk of runtime issues.
Suggested implementation:
```typescript
const mergePatchRequest = {
...request,
headers: {
...request.headers,
"content-type": "application/merge-patch+json",
},
};
```
1. Remove the `cloneDeep` import from this file if it exists (e.g. `import { cloneDeep } from "lodash";`), as it is no longer used.
2. If `findByIdAndUpdate` only relies on a subset of request properties (e.g. `user`, `headers`), you may further refine `mergePatchRequest` to only include those fields to make the object even lighter.
</issue_to_address>
### Comment 2
<location path="src/published-data/published-data.controller.ts" line_range="83" />
<code_context>
private readonly attachmentsService: AttachmentsService,
private readonly configService: ConfigService,
private readonly datasetsService: DatasetsService,
+ private readonly datasetsController: DatasetsV4Controller,
private readonly httpService: HttpService,
private readonly proposalsService: ProposalsService,
</code_context>
<issue_to_address>
**issue (complexity):** Consider moving the shared dataset update and CASL logic into a dedicated service so `PublishedDataController.register` no longer depends on `DatasetsV4Controller` or mutates HTTP requests.
Injecting `DatasetsV4Controller` and cloning/mutating the `Request` to reuse `findByIdAndUpdate` is adding coupling and HTTP‑layer complexity into `register`. You can keep the CASL behavior and v4 update semantics while simplifying the flow by extracting the shared update logic into a dedicated service that both controllers use.
Example refactor:
```ts
// datasets-update.service.ts
@Injectable()
export class DatasetsUpdateService {
constructor(
private readonly datasetsService: DatasetsService,
private readonly caslAbilityFactory: CaslAbilityFactory,
) {}
async mergePatchDataset(
user: JWTUser,
id: string,
patch: Partial<DatasetClass>,
) {
const ability = this.caslAbilityFactory.createForUser(user);
// reuse the same CASL check as DatasetsV4Controller
if (!ability.can(Action.Update, DatasetClass)) {
throw new ForbiddenException();
}
// reuse the v4 merge-patch behavior here (no Request needed)
return this.datasetsService.findByIdAndUpdate(id, patch, {
// any v4-specific QueryOptions / merge-patch handling
new: true,
});
}
}
```
`DatasetsV4Controller` then calls the service instead of inlining the logic:
```ts
// datasets.v4.controller.ts
@Post(':id')
async findByIdAndUpdate(
@Req() req: Request,
@Param('id') id: string,
@Body() body: UpdateDatasetV4Dto,
) {
const user = req.user as JWTUser;
return this.datasetsUpdateService.mergePatchDataset(user, id, body);
}
```
And `PublishedDataController.register` can reuse the same behavior without injecting another controller or cloning/mutating the `Request`:
```ts
// published-data.controller.ts
@Post('/:id/register')
async register(
@Req() request: Request,
@Param('id') id: string,
): Promise<IRegister | null> {
// ...existing code up to pidArray...
const user = request.user as JWTUser;
await Promise.all(
publishedDataObsolete.pidArray.map((pid) =>
this.datasetsUpdateService.mergePatchDataset(user, pid, {
isPublished: true,
datasetlifecycle: { publishedOn: data.registeredTime },
}),
),
);
// ...rest of register implementation...
}
```
This keeps all current functionality (including CASL checks and v4 update behavior), but removes:
- controller‑to‑controller dependency,
- manual `Request` cloning and header manipulation,
- HTTP concerns from the domain‑level registration logic.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| private readonly attachmentsService: AttachmentsService, | ||
| private readonly configService: ConfigService, | ||
| private readonly datasetsService: DatasetsService, | ||
| private readonly datasetsController: DatasetsV4Controller, |
There was a problem hiding this comment.
issue (complexity): Consider moving the shared dataset update and CASL logic into a dedicated service so PublishedDataController.register no longer depends on DatasetsV4Controller or mutates HTTP requests.
Injecting DatasetsV4Controller and cloning/mutating the Request to reuse findByIdAndUpdate is adding coupling and HTTP‑layer complexity into register. You can keep the CASL behavior and v4 update semantics while simplifying the flow by extracting the shared update logic into a dedicated service that both controllers use.
Example refactor:
// datasets-update.service.ts
@Injectable()
export class DatasetsUpdateService {
constructor(
private readonly datasetsService: DatasetsService,
private readonly caslAbilityFactory: CaslAbilityFactory,
) {}
async mergePatchDataset(
user: JWTUser,
id: string,
patch: Partial<DatasetClass>,
) {
const ability = this.caslAbilityFactory.createForUser(user);
// reuse the same CASL check as DatasetsV4Controller
if (!ability.can(Action.Update, DatasetClass)) {
throw new ForbiddenException();
}
// reuse the v4 merge-patch behavior here (no Request needed)
return this.datasetsService.findByIdAndUpdate(id, patch, {
// any v4-specific QueryOptions / merge-patch handling
new: true,
});
}
}DatasetsV4Controller then calls the service instead of inlining the logic:
// datasets.v4.controller.ts
@Post(':id')
async findByIdAndUpdate(
@Req() req: Request,
@Param('id') id: string,
@Body() body: UpdateDatasetV4Dto,
) {
const user = req.user as JWTUser;
return this.datasetsUpdateService.mergePatchDataset(user, id, body);
}And PublishedDataController.register can reuse the same behavior without injecting another controller or cloning/mutating the Request:
// published-data.controller.ts
@Post('/:id/register')
async register(
@Req() request: Request,
@Param('id') id: string,
): Promise<IRegister | null> {
// ...existing code up to pidArray...
const user = request.user as JWTUser;
await Promise.all(
publishedDataObsolete.pidArray.map((pid) =>
this.datasetsUpdateService.mergePatchDataset(user, pid, {
isPublished: true,
datasetlifecycle: { publishedOn: data.registeredTime },
}),
),
);
// ...rest of register implementation...
}This keeps all current functionality (including CASL checks and v4 update behavior), but removes:
- controller‑to‑controller dependency,
- manual
Requestcloning and header manipulation, - HTTP concerns from the domain‑level registration logic.
Published data now goes through DatasetsV4Controller, so DatasetsController is only a route holder again and does not need to be provided or exported.
The v3 publisheddata register endpoint updated datasets through the service without checking dataset level permissions, so any authenticated user could flip isPublished and lifecycle fields on arbitrary datasets by supplying their pids. This routes the update through the dataset controller so the dataset update permission is enforced.
Summary by Sourcery
Bug Fixes: