fix(schema): enforce approvalRequired on PUT /{name} + PATCH /{name}/schema

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.
This commit is contained in:
Andrei Zimin
2026-05-14 15:05:09 +03:00
parent 83caf9c3c8
commit 018ebff207
2 changed files with 39 additions and 0 deletions
@@ -126,6 +126,30 @@ public class DictionaryDefinitionService {
var existing = findByName(name); var existing = findByName(name);
var prevSchema = existing.getSchemaJson(); 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.setDisplayName(req.displayName());
existing.setDescription(req.description()); existing.setDescription(req.description());
existing.setSchemaJson(req.schemaJson()); existing.setSchemaJson(req.schemaJson());
@@ -89,6 +89,21 @@ public class SchemaPatchService {
throw OrdinisException.notFound( throw OrdinisException.notFound(
"dictionary_not_found", "Dictionary not found: " + dictName); "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())) { if (!d.getSchemaVersion().equals(req.expectedHeadVersion())) {
throw OrdinisException.conflict( throw OrdinisException.conflict(
"version_mismatch", "version_mismatch",