From 018ebff2072952828f6b5e7dbd6b19f204e0432a Mon Sep 17 00:00:00 2001 From: Andrei Zimin Date: Thu, 14 May 2026 15:05:09 +0300 Subject: [PATCH] fix(schema): enforce approvalRequired on PUT /{name} + PATCH /{name}/schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported 2026-05-14: editing schema on an approvalRequired dictionary applied inline without going through the schema-draft maker-checker workflow. Two endpoints both lacked the gate that DictionaryRecordService already had for record CRUD: 1. PUT /api/v1/admin/dictionaries/{name} (frontend SchemaDrivenForm "edit schema" save button hits this). DictionaryDefinitionService.updateSchema overwrote schemaJson directly. Bonus backdoor: same request could simultaneously flip approvalRequired:true→false and rewrite the schema in one transaction — review-bypass via toggle. 2. PATCH /api/v1/admin/dictionaries/{name}/schema (inline non-breaking patch — description / title / minimum / maximum / enum-add). Frontend doesn't currently call this, but backend exposed it without a gate. Both now reject with `422 approval_required` carrying the schema-drafts URL in the message. Frontend's existing serverErrorMessage in DictionaryEditorDialog already surfaces backend `message` to the user, so no UI changes needed for correctness. UX polish (hide "Save" / show "Create draft" CTA when approvalRequired=true) is a follow-up. The PUT gate is narrow on purpose: schemaChanged OR approvalRequired toggle-off. Pure metadata edits (display name, description, locales, projection flag) on an approvalRequired dict still go through inline — those don't affect record validation, no review needed. --- .../service/DictionaryDefinitionService.java | 24 +++++++++++++++++++ .../restapi/service/SchemaPatchService.java | 15 ++++++++++++ 2 files changed, 39 insertions(+) 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 81d18f0..3c5e35d 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 @@ -126,6 +126,30 @@ 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()); + boolean tryingToDisableApproval = + req.approvalRequired() != null && !req.approvalRequired(); + if (existing.isApprovalRequired() && (schemaChanged || tryingToDisableApproval)) { + throw OrdinisException.unprocessableEntity( + "approval_required", + "Schema changes require the draft workflow on this dictionary. " + + "Use POST /api/v1/admin/dictionaries/" + name + + "/schema-drafts. Toggling approvalRequired off is also " + + "blocked here — needs an explicit admin endpoint."); + } + existing.setDisplayName(req.displayName()); existing.setDescription(req.description()); existing.setSchemaJson(req.schemaJson()); 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 676fdee..b232e8c 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,6 +89,21 @@ 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."); + } if (!d.getSchemaVersion().equals(req.expectedHeadVersion())) { throw OrdinisException.conflict( "version_mismatch",