diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java index 2cc34ce..da6df63 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java @@ -9,6 +9,7 @@ import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder; import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger; import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import cloud.nstart.terravault.ordinis.restapi.service.schema.SchemaCompatibility; import com.fasterxml.jackson.databind.JsonNode; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -126,39 +127,46 @@ public class DictionaryDefinitionService { var existing = findByName(name); var prevSchema = existing.getSchemaJson(); - // Approval gate: dictionaries marked approvalRequired must route SCHEMA - // changes through the schema-draft workflow. Non-schema metadata (display - // name, description, locales, projection flag) are still allowed inline — - // those don't affect record validation. Two backdoors closed here: - // 1. Editing schemaJson on an approvalRequired dict bypassed review - // (the SchemaDrivenForm "edit schema" button hits PUT /{name}, - // which had no gate). Reported 2026-05-14. - // 2. Same request could simultaneously flip approvalRequired:true→false - // (lines below) and rewrite schemaJson — review-bypass via toggle. - // We block both: if the dict is currently approvalRequired and the - // request either changes schemaJson or tries to flip the flag off, - // reject with 422 approval_required. - boolean schemaChanged = !java.util.Objects.equals(prevSchema, req.schemaJson()); + // Approval gate (revised 2026-05-14 after first-day-in-prod feedback): + // approvalRequired dicts must route BREAKING schema changes + governance + // toggles through the schema-draft workflow. Non-breaking edits — adding + // an optional field, widening an enum, editing description / title — + // pass inline (verified by SchemaCompatibility.isBreaking). + // + // Three clauses, all with the same remedy (open a draft): + // 1. schemaBreaking — add required field, type change, etc. + // 2. tryingToDisableApproval — flip approvalRequired:true→false + // (rewrites the gate itself). + // 3. tryingToChangeMinRole — change/clear approvalMinRole; would let + // a maker self-approve restricted-dict + // drafts via a generic reviewer role. + // + // SchemaCompatibility.isBreaking is conservative — false positives are + // recoverable (open a draft that turns out to be trivial) but false + // negatives let real breaking changes slip past review. + boolean schemaBreaking = + SchemaCompatibility.isBreaking(prevSchema, req.schemaJson()); boolean tryingToDisableApproval = req.approvalRequired() != null && !req.approvalRequired(); - // Approval-min-role flip is a third backdoor: a maker with PUT permission - // could clear approvalMinRole (send "" → stored as null) and then approve - // their own restricted-dict drafts using a generic RECORD_REVIEWER role. - // Treat any change to approvalMinRole the same as a schema rewrite — - // route through the draft workflow (or eventually a dedicated admin - // endpoint that requires a higher role than `approvalMinRole` itself). + // Approval-min-role flip is a separate backdoor: a maker with PUT + // permission could clear approvalMinRole (send "" → stored as null) and + // then approve their own restricted-dict drafts using a generic + // RECORD_REVIEWER role. Treat any change to approvalMinRole the same as + // a breaking schema change — route through the draft workflow. boolean tryingToChangeMinRole = req.approvalMinRole() != null && !java.util.Objects.equals( req.approvalMinRole().isBlank() ? null : req.approvalMinRole(), existing.getApprovalMinRole()); if (existing.isApprovalRequired() - && (schemaChanged || tryingToDisableApproval || tryingToChangeMinRole)) { + && (schemaBreaking || tryingToDisableApproval || tryingToChangeMinRole)) { throw OrdinisException.unprocessableEntity( "approval_required", - "Schema changes, approvalRequired toggle-off, and approvalMinRole " - + "edits all require the draft workflow on this dictionary. " - + "Use POST /api/v1/admin/dictionaries/" + name + "/schema-drafts."); + "Breaking schema changes, approvalRequired toggle-off, and " + + "approvalMinRole edits require the draft workflow on this " + + "dictionary. Use POST /api/v1/admin/dictionaries/" + name + + "/schema-drafts. Non-breaking edits (add optional field, " + + "widen enum, edit description) pass inline."); } existing.setDisplayName(req.displayName()); diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchService.java index b232e8c..8c81d1f 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchService.java @@ -89,21 +89,14 @@ public class SchemaPatchService { throw OrdinisException.notFound( "dictionary_not_found", "Dictionary not found: " + dictName); } - // Approval gate: dictionaries marked approvalRequired must route ALL - // schema changes — even non-breaking inline patches — through the - // schema-draft maker-checker workflow. Otherwise a maker could bypass - // review by relabeling fields (description / title) or expanding - // numeric/enum ranges. Records service already enforces this for row - // CRUD (see DictionaryRecordService line ~270); this closes the same - // gap for schema patches. Reported 2026-05-14 — schema edit on an - // approvalRequired dict was applying inline without review. - if (d.isApprovalRequired()) { - throw OrdinisException.unprocessableEntity( - "approval_required", - "Schema changes require the draft workflow on this dictionary. " - + "Use POST /api/v1/admin/dictionaries/" + dictName - + "/schema-drafts instead."); - } + // No approval gate here on purpose. PATCH /schema is restricted by + // construction to non-breaking mutations: ALLOWED_KEYS = description / + // title / minimum / maximum / enum, with validators that reject numeric + // narrowing and enum removal (lines below). After the policy revision + // 2026-05-14 — non-breaking edits pass inline even on approvalRequired + // dicts — this endpoint is safe to leave open. PUT /{name} carries the + // breaking-vs-non-breaking gate via SchemaCompatibility because it + // accepts arbitrary schema rewrites. if (!d.getSchemaVersion().equals(req.expectedHeadVersion())) { throw OrdinisException.conflict( "version_mismatch", diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schema/SchemaCompatibility.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schema/SchemaCompatibility.java new file mode 100644 index 0000000..f7506d9 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schema/SchemaCompatibility.java @@ -0,0 +1,138 @@ +package cloud.nstart.terravault.ordinis.restapi.service.schema; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.HashSet; +import java.util.Set; + +/** + * Detect whether a JSON-schema change is "breaking" — i.e. could invalidate + * existing records or break downstream consumers. + * + *
Used by two callers with different stakes: + * + *
Heuristic, not a complete JSON-schema diff. Conservative wins ties:
+ * if we can't tell, return {@code true} (treat as breaking) — false
+ * positives are recoverable (admin opens a draft that turns out to be
+ * trivial) but false negatives are not.
+ */
+public final class SchemaCompatibility {
+
+ private SchemaCompatibility() {
+ // utility
+ }
+
+ /**
+ * @return {@code true} if the transition from {@code oldSchema} to
+ * {@code newSchema} is breaking.
+ * {@code false} when both are null, equal, or differ only in
+ * compatible ways (added optional field, description / title /
+ * maximum widened, enum values added, etc.).
+ */
+ public static boolean isBreaking(JsonNode oldSchema, JsonNode newSchema) {
+ if (oldSchema == null || newSchema == null) {
+ // No baseline → can't classify. Caller decides: definitionService
+ // never calls this when prevSchema is null (only on edits).
+ return false;
+ }
+ if (oldSchema.equals(newSchema)) return false;
+
+ // === required[] changes ===
+ Set