DependencyTrack / DependencyTrack/dependency-track
HTTP 500 when converting a collection project back to a regular project, if the request body carries parent
- Dominant language
- Java
- Stars
- 4.2k
- Forks
- 811
- Avg merge
- 8h 39m
- Merged PRs (30d)
- 237
Description
### Current Behavior
On 5.1.0 (PostgreSQL 18), `POST /api/v1/project` returns `500 Uncaught internal server error` when a
request clears `collectionLogic`, sets a `classifier`, and carries a `parent`. The same request
without `parent` returns `200` — but the project then loses its parent, `POST` replacing the whole
resource. So a collection project cannot be turned back into a regular project while keeping its
parent.
```
ERROR [logger] Update of object with id "org.dependencytrack.model.Project:4734" using statement
"UPDATE "PROJECT" SET "CLASSIFIER"=? WHERE "ID"=?" failed :
org.postgresql.util.PSQLException: ERROR: new row for relation "PROJECT"
violates check constraint "PROJECT_COLLECTION_CLASSIFIER_check"
```
That constraint — `CHECK (COLLECTION_LOGIC IS NULL OR CLASSIFIER IS NULL)` — is intentional ([`V202605022031__init.sql#L886`](https://github.com/DependencyTrack/dependency-track/blob/5.1.0/migration/src/main/resources/org/dependencytrack/migration/V202605022031__init.sql#L886)).
The problem is that `ProjectQueryManager.updateProject` lets the row transit through a state it
forbids:
1. [L262](https://github.com/DependencyTrack/dependency-track/blob/5.1.0/apiserver/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java#L262) sets the classifier on the persistent object, marking the field dirty.
2. [L291-L292](https://github.com/DependencyTrack/dependency-track/blob/5.1.0/apiserver/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java#L291-L292) resolves the parent with a query. DataNucleus flushes dirty state before any query, emitting
`UPDATE "PROJECT" SET "CLASSIFIER"=?` while `COLLECTION_LOGIC` still holds `AGGREGATE_*` — both
columns non-null, constraint violated.
3. [L330](https://github.com/DependencyTrack/dependency-track/blob/5.1.0/apiserver/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java#L330), which clears `COLLECTION_LOGIC`, is never reached.
With no `parent` in the body, step 2 is replaced by `setParent(null)`, which runs no query, so
nothing is flushed early and the single flush in `persist(project)` writes a consistent row.
The same hazard is already documented and guarded a few lines below for `collectionTag` ([L315-L318](https://github.com/DependencyTrack/dependency-track/blob/5.1.0/apiserver/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java#L315-L318)); the parent lookup is not.
Clients cannot avoid this: omitting `collectionLogic`, sending it as `null`, and sending
`classifier: null` all fail identically, because the resource layer forces a non-null classifier
whenever `collectionLogic` is null ([`ProjectResource.java#L792-L796`](https://github.com/DependencyTrack/dependency-track/blob/5.1.0/apiserver/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java#L792-L796)).
### Steps to Reproduce
1. Create a project `parent-demo`.
2. Create a project `child-demo` version `1.0.0`, with `parent-demo` as its parent.
3. Edit `child-demo`: tick **Collection project**, choose **Aggregate direct children**, save.
This succeeds — the UI clears the classifier by itself, as the constraint requires.
4. Edit `child-demo` again: untick **Collection project**, pick any classifier, save.
→ **500**, nothing is saved.
Step 4 fails because the form posts `classifier`, `collectionLogic: null` and `parent` in the same
payload ([`ProjectDetailsModal.vue`](https://github.com/DependencyTrack/frontend/blob/5.1.0/src/views/portfolio/projects/ProjectDetailsModal.vue#L822-L845)).
It fails whether or not a classifier is picked, since the API substitutes `APPLICATION` for a null
one.
The same happens on a direct `POST /api/v1/project` with that payload. Dropping `parent` from it is
what isolates the cause — the request then returns `200`, but the project loses its parent:
```jsonc
// POST /api/v1/project → 500
{"uuid":"","name":"child-demo","version":"1.0.0","active":true,
"classifier":"LIBRARY","parent":{"uuid":""}}
// same, without "parent" → 200, and then: collectionLogic=null, classifier=LIBRARY, parent=null
{"uuid":"","name":"child-demo","version":"1.0.0","active":true,
"classifier":"LIBRARY"}
```
### Expected Behavior
Step 4 should succeed, leaving `child-demo` with `collectionLogic = null`, the chosen classifier,
and its parent unchanged.
`collectionLogic` should be applied before any query that can flush dirty state. Moving the parent
resolution block ([L286-L304](https://github.com/DependencyTrack/dependency-track/blob/5.1.0/apiserver/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java#L286-L304)) above the `setClassifier` call, or setting `collectionLogic` before the classifier, should suffice —
the same shape as the existing `collectionTag` guard. If the combination is meant to be rejected,
a `400`/`409` with a message would be preferable to an uncaught `500`.
### Dependency-Track Version
5.x
### Browser
N/A
### Checklist
- [x] I have read and understand the [contributing guidelines](https://github.com/DependencyTrack/dependency-track/blob/main/CONTRIBUTING.md#filing-issues)
- [x] I have checked the [existing issues](https://github.com/DependencyTrack/dependency-track/issues) for whether this defect was already reported
Contributor guide
Research direction
Start in apiserver/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java, focusing on updateProject and the parent-resolution block around lines 262-330. Reproduce the POST with collectionLogic cleared, a classifier, and a parent, then verify the update avoids the PostgreSQL constraint violation before persisting. Done means the request returns successfully and preserves the parent while leaving collectionLogic null and the classifier set.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java, postgresql
- Domain
- api, backend, database
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100