Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion calm-hub/PERMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,12 @@ canWrite(username, namespace):
| `write` | Domain `D` | Read + write content in `D` | Flat — no hierarchy |
| `admin` | Namespace `N` | Read + write content in `N` and descendants; list/grant/revoke entitlements in `N` and descendants; create child namespaces of `N` | **OR any ancestor** |
| `admin` | Domain `D` | Read + write content in `D`; list/grant/revoke entitlements for `D` | Flat — no hierarchy |
| `admin` | `GLOBAL` | Create/delete any namespace or domain; read + write all content; manage all entitlements (including further `GLOBAL admin` grants) | Bypasses all checks via `hasGlobalAdmin()` — only `admin` is valid; `read`/`write` grants on `GLOBAL` are rejected with 400 |
| `admin` | `GLOBAL` | Create/delete any namespace or domain; delete any architecture, pattern, flow, standard, interface, timeline, ADR, decorator, control requirement, or control configuration; read + write all content; manage all entitlements (including further `GLOBAL admin` grants) | Bypasses all checks via `hasGlobalAdmin()` — only `admin` is valid; `read`/`write` grants on `GLOBAL` are rejected with 400 |

Content-resource deletion is deliberately `GLOBAL admin`-only — a namespace- or
domain-scoped `admin` grant does not permit it, even for content the grant
otherwise gives full read/write access to. A control requirement refuses to
delete (`409`) while it still has configurations; delete those first.

---

Expand Down
7 changes: 7 additions & 0 deletions calm-hub/decisions/0001-versioned-artefact-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ the old shape too. Both are excluded by
revisiting with the implementation experience it was waiting for. Tracked in
[#2884](https://github.com/finos/architecture-as-code/issues/2884).

The "nothing is ever deleted" premise below described the old shape and no
longer holds: every type this ADR covers, plus Control and Decorator, now
has a `GLOBAL admin`-gated `DELETE` endpoint that removes a resource and all
of its versions outright. See `PERMISSIONS.md` and
`store/util/MongoVersionDocumentStore#deleteResource` /
`NitriteVersionDocumentStore#deleteResource`.

## Context

Every Mongo store in `calm-hub` (`store/mongo/`) uses a **one document per
Expand Down
6 changes: 4 additions & 2 deletions calm-hub/decisions/0003-shared-version-store-helper.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,10 @@ the primitive operations every store needs against its
that fails, removing the header again. The split shape makes that
compensation necessary — the old shape wrote the resource and its first
version in one document write, so a failure left nothing behind — and
there is no delete endpoint for any of these types, so a header stranded
with `versionCount: 0` stays visible in listings and search permanently.
without it a header stranded with `versionCount: 0` stays visible in
listings and search until an admin notices and deletes it by hand via the
`deleteResource` endpoint added later; `deleteHeader` exists so that never
has to happen in the ordinary case.

That is why it belongs here rather than in each store. A per-store copy
is a correctness routine duplicated once per type per backend, fourteen
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,35 @@ void end_to_end_limit_slices_the_summary_list() {
.statusCode(200)
.body("values", hasSize(2));
}

@Test
@Order(9)
void end_to_end_delete_an_architecture() {
given()
.when().delete("/api/calm/namespaces/finos/architectures/1")
.then()
.statusCode(204);

// Deleting removes the whole resource, all versions included — not just the latest.
given()
.when().get("/api/calm/namespaces/finos/architectures/1/versions/1.0.0")
.then()
.statusCode(404);

given()
.when().get("/api/calm/namespaces/finos/architectures")
.then()
.statusCode(200)
.body("values", hasSize(1))
.body("values[0].id", equalTo(2));
}

@Test
@Order(10)
void end_to_end_delete_a_missing_architecture_returns_404() {
given()
.when().delete("/api/calm/namespaces/finos/architectures/999")
.then()
.statusCode(404);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -213,11 +213,15 @@ void end_to_end_get_configurations_returns_404_for_invalid_control() {

@Test
@Order(15)
void end_to_end_get_configuration_returns_404_for_nonexistent_config() {
void end_to_end_get_configuration_by_id_alone_returns_405() {
// There is no GET at this exact path — only .../configurations/{id}/versions[...] —
// so it always fell through to a 404 "no matching route". Since the DELETE endpoint
// now claims this exact path, JAX-RS correctly reports 405 (path matched, method
// didn't) instead of 404 (nothing matched).
given()
.when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/configurations/999")
.then()
.statusCode(404);
.statusCode(405);
}

@Test
Expand Down Expand Up @@ -552,4 +556,98 @@ void end_to_end_create_requirement_version_stores_only_inner_json_and_updates_wr
.body("values.find { it.id == 1 }.name", equalTo("Final Access Control"))
.body("values.find { it.id == 1 }.description", equalTo("Final"));
}

// --- Delete: requirement + configuration ---
//
// Uses a freshly created control (rather than control 1, already exercised above) so this
// scenario is self-contained and doesn't depend on the ordering or accumulated state of the
// tests above.

@Test
@Order(50)
void end_to_end_delete_control_refuses_while_configurations_exist_then_succeeds() throws JsonProcessingException {
CreateControlRequirement requirementRequest = new CreateControlRequirement(
"Delete Test Control", "Control created to exercise delete", "{\"type\": \"requirement\"}");

String location = given()
.body(objectMapper.writeValueAsString(requirementRequest))
.header("Content-Type", "application/json")
.when().post("/api/calm/domains/" + VALID_DOMAIN + "/controls")
.then()
.statusCode(201)
.extract().header("Location");
int controlId = Integer.parseInt(location.substring(location.lastIndexOf('/') + 1));

CreateControlConfiguration configRequest = new CreateControlConfiguration("{\"setting\": \"enabled\"}");
String configLocation = given()
.body(objectMapper.writeValueAsString(configRequest))
.header("Content-Type", "application/json")
.when().post("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/configurations")
.then()
.statusCode(201)
.extract().header("Location");
int configId = Integer.parseInt(configLocation.substring(configLocation.lastIndexOf('/') + 1));

// Refuses while the configuration still exists — does not cascade.
given()
.when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId)
.then()
.statusCode(409)
.body(containsString("configuration"));

// Requirement is untouched by the refused delete.
given()
.when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/requirement/versions/1.0.0")
.then()
.statusCode(200);

// Delete the configuration first...
given()
.when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/configurations/" + configId)
.then()
.statusCode(204);

given()
.when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/configurations/" + configId + "/versions")
.then()
.statusCode(404);

// ...then the requirement can be deleted.
given()
.when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId)
.then()
.statusCode(204);

given()
.when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/requirement/versions/1.0.0")
.then()
.statusCode(404);
}

@Test
@Order(51)
void end_to_end_delete_control_returns_404_for_missing_control() {
given()
.when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/99999")
.then()
.statusCode(404);
}

@Test
@Order(52)
void end_to_end_delete_control_returns_404_for_invalid_domain() {
given()
.when().delete("/api/calm/domains/" + INVALID_DOMAIN + "/controls/1")
.then()
.statusCode(404);
}

@Test
@Order(53)
void end_to_end_delete_configuration_returns_404_for_missing_configuration() {
given()
.when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/configurations/99999")
.then()
.statusCode(404);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,34 @@ void end_to_end_reject_malformed_json_on_versioned_put() {
.statusCode(400)
.body(containsString("could not be parsed"));
}

@Test
@Order(7)
void end_to_end_delete_an_architecture() {
given()
.when().delete("/api/calm/namespaces/finos/architectures/1")
.then()
.statusCode(204);

// Deleting removes the whole resource, all versions included — not just the latest.
given()
.when().get("/api/calm/namespaces/finos/architectures/1/versions/1.0.0")
.then()
.statusCode(404);

given()
.when().get("/api/calm/namespaces/finos/architectures")
.then()
.statusCode(200)
.body("values", empty());
}

@Test
@Order(8)
void end_to_end_delete_a_missing_architecture_returns_404() {
given()
.when().delete("/api/calm/namespaces/finos/architectures/999")
.then()
.statusCode(404);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,15 @@ void end_to_end_get_configurations_returns_404_for_invalid_control() {

@Test
@Order(15)
void end_to_end_get_configuration_returns_404_for_nonexistent_config() {
void end_to_end_get_configuration_by_id_alone_returns_405() {
// There is no GET at this exact path — only .../configurations/{id}/versions[...] —
// so it always fell through to a 404 "no matching route". Since the DELETE endpoint
// now claims this exact path, JAX-RS correctly reports 405 (path matched, method
// didn't) instead of 404 (nothing matched).
given()
.when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/configurations/999")
.then()
.statusCode(404);
.statusCode(405);
}

@Test
Expand Down Expand Up @@ -514,4 +518,98 @@ void end_to_end_create_requirement_version_stores_only_inner_json_and_updates_wr
.body("values.find { it.id == 1 }.name", equalTo("Final Access Control"))
.body("values.find { it.id == 1 }.description", equalTo("Final"));
}

// --- Delete: requirement + configuration ---
//
// Uses a freshly created control (rather than control 1, already exercised above) so this
// scenario is self-contained and doesn't depend on the ordering or accumulated state of the
// tests above.

@Test
@Order(50)
void end_to_end_delete_control_refuses_while_configurations_exist_then_succeeds() throws JsonProcessingException {
CreateControlRequirement requirementRequest = new CreateControlRequirement(
"Delete Test Control", "Control created to exercise delete", "{\"type\": \"requirement\"}");

String location = given()
.body(objectMapper.writeValueAsString(requirementRequest))
.header("Content-Type", "application/json")
.when().post("/api/calm/domains/" + VALID_DOMAIN + "/controls")
.then()
.statusCode(201)
.extract().header("Location");
int controlId = Integer.parseInt(location.substring(location.lastIndexOf('/') + 1));

CreateControlConfiguration configRequest = new CreateControlConfiguration("{\"setting\": \"enabled\"}");
String configLocation = given()
.body(objectMapper.writeValueAsString(configRequest))
.header("Content-Type", "application/json")
.when().post("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/configurations")
.then()
.statusCode(201)
.extract().header("Location");
int configId = Integer.parseInt(configLocation.substring(configLocation.lastIndexOf('/') + 1));

// Refuses while the configuration still exists — does not cascade.
given()
.when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId)
.then()
.statusCode(409)
.body(containsString("configuration"));

// Requirement is untouched by the refused delete.
given()
.when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/requirement/versions/1.0.0")
.then()
.statusCode(200);

// Delete the configuration first...
given()
.when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/configurations/" + configId)
.then()
.statusCode(204);

given()
.when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/configurations/" + configId + "/versions")
.then()
.statusCode(404);

// ...then the requirement can be deleted.
given()
.when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId)
.then()
.statusCode(204);

given()
.when().get("/api/calm/domains/" + VALID_DOMAIN + "/controls/" + controlId + "/requirement/versions/1.0.0")
.then()
.statusCode(404);
}

@Test
@Order(51)
void end_to_end_delete_control_returns_404_for_missing_control() {
given()
.when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/99999")
.then()
.statusCode(404);
}

@Test
@Order(52)
void end_to_end_delete_control_returns_404_for_invalid_domain() {
given()
.when().delete("/api/calm/domains/" + INVALID_DOMAIN + "/controls/1")
.then()
.statusCode(404);
}

@Test
@Order(53)
void end_to_end_delete_configuration_returns_404_for_missing_configuration() {
given()
.when().delete("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/configurations/99999")
.then()
.statusCode(404);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.finos.calm.domain.exception;

/**
* Thrown when a control requirement cannot be deleted because it still has
* configurations associated with it. Carries the raw control id and configuration
* count as fields rather than a formatted message — the resource layer (see
* {@code ControlResource#controlHasConfigurationsResponse}) is solely responsible
* for composing the user-facing message, so it isn't duplicated here.
*/
public class ControlHasConfigurationsException extends Exception {
private final int controlId;
private final int configurationCount;

/**
* @param controlId the control that could not be deleted because it still has configurations
* @param configurationCount how many configurations exist under the control
*/
public ControlHasConfigurationsException(int controlId, int configurationCount) {
super("Control not empty: " + controlId);
this.controlId = controlId;
this.configurationCount = configurationCount;
}

public int getControlId() {
return controlId;
}

public int getConfigurationCount() {
return configurationCount;
}
}
Loading
Loading