Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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