Gate the REST render endpoint behind an approval flag

POST /v1/edit-projects/{projectId}:render was unauthenticated -- anyone could
trigger a render. It now requires an approved.flag in the project directory and
returns 409 otherwise (config video-clipping.editing.require-render-approval,
default true). This closes the "no check at all" hole; it is a basic presence gate,
not yet authenticated/digest-bound authorization (a remaining hardening item).
New SpringBootTest asserts 409 without approval; the delegation unit test disables
the gate. mvn verify 271/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPuJXQyAeWpFcTtcnxo1UN
This commit is contained in:
JSLMPR 2026-07-24 12:38:12 +02:00
parent 31efe33081
commit 430d71fae8
5 changed files with 64 additions and 4 deletions

View File

@ -84,8 +84,9 @@ Local models used (each with a provenance sidecar under `models/`):
- **Licensing:** MusicGen (CC-BY-NC), AudioLDM2 (CC-BY-NC-SA) and YOLOv8 (AGPL) are **non-commercial/copyleft**.
Commercial use requires swapping in commercially-licensed models/assets.
- **Not production-hardened:** no Spring Security/authN, no container/K8s/deployment manifests, REST persistence
defaults to in-memory, `POST /v1/edit-projects/{projectId}:render` has no approval gate, and no-egress
operation is not yet certified.
defaults to in-memory, and no-egress operation is not yet certified. `POST /v1/edit-projects/{projectId}:render`
now requires an `approved.flag` in the project directory, but that is a basic presence gate — not yet an
authenticated, digest-bound authorization.
- **VLM quality:** on distant/small subjects the small local VLM is only weakly discriminative; a stronger
model or closer framing improves Tier-2 selection.
- A director can only cut what was filmed — it cannot show a moment the camera never captured.

View File

@ -3,8 +3,10 @@ package org.example.videoclips.api;
import jakarta.validation.Valid;
import org.example.videoclips.api.dto.CreateEditProjectRequest;
import org.example.videoclips.api.dto.SaveEditPlanRequest;
import org.example.videoclips.config.VideoClippingProperties;
import org.example.videoclips.editing.EditPlanService;
import org.example.videoclips.editing.EditProjectService;
import org.example.videoclips.editing.EditProjectStore;
import org.example.videoclips.editing.EditRenderer;
import org.example.videoclips.editing.StoryboardPromptGenerator;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@ -17,25 +19,37 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.nio.file.Files;
import java.nio.file.Path;
@RestController
@RequestMapping("/v1/edit-projects")
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class EditProjectController {
static final String APPROVAL_FILE_NAME = "approved.flag";
private final EditProjectService editProjectService;
private final StoryboardPromptGenerator storyboardPromptGenerator;
private final EditPlanService editPlanService;
private final EditRenderer editRenderer;
private final EditProjectStore editProjectStore;
private final VideoClippingProperties.Editing properties;
public EditProjectController(EditProjectService editProjectService,
StoryboardPromptGenerator storyboardPromptGenerator,
EditPlanService editPlanService,
EditRenderer editRenderer) {
EditRenderer editRenderer,
EditProjectStore editProjectStore,
VideoClippingProperties properties) {
this.editProjectService = editProjectService;
this.storyboardPromptGenerator = storyboardPromptGenerator;
this.editPlanService = editPlanService;
this.editRenderer = editRenderer;
this.editProjectStore = editProjectStore;
this.properties = properties.getEditing();
}
@PostMapping
@ -61,7 +75,26 @@ public class EditProjectController {
@PostMapping("/{projectId}:render")
public Object render(@PathVariable String projectId) {
requireRenderApproval(projectId);
editRenderer.render(projectId);
return editProjectService.getProject(projectId);
}
/**
* Basic approval gate: refuse to render unless an {@code approved.flag} artifact exists in the project
* directory. This closes the previously unauthenticated render trigger; it is NOT yet an authenticated,
* digest-bound authorization (that remains a production-hardening item). Disable via
* {@code video-clipping.editing.require-render-approval=false}.
*/
private void requireRenderApproval(String projectId) {
if (!properties.isRequireRenderApproval()) {
return;
}
Path approval = editProjectStore.projectDirectory(projectId).resolve(APPROVAL_FILE_NAME);
if (!Files.isRegularFile(approval)) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Render requires approval: place an '" + APPROVAL_FILE_NAME
+ "' file in the project directory before rendering");
}
}
}

View File

@ -484,6 +484,13 @@ public class VideoClippingProperties {
public static class Editing {
private boolean enabled = true;
/**
* Require an approval artifact ({@code approved.flag}) in the project directory before the REST
* render endpoint will render. Defaults on. NOTE: this is a basic presence gate, not yet an
* authenticated, digest-bound authorization that remains a production-hardening item.
*/
private boolean requireRenderApproval = true;
private String projectDirectory = "./output/edit-projects";
private String highlightProjectDirectory = "./output/highlight-projects";
@ -569,6 +576,14 @@ public class VideoClippingProperties {
this.enabled = enabled;
}
public boolean isRequireRenderApproval() {
return requireRenderApproval;
}
public void setRequireRenderApproval(boolean requireRenderApproval) {
this.requireRenderApproval = requireRenderApproval;
}
public String getProjectDirectory() {
return projectDirectory;
}

View File

@ -180,4 +180,11 @@ class EditProjectControllerTest {
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/not-found"));
}
@Test
void rejectsRenderWithoutApproval() throws Exception {
// No approved.flag exists for this project -> the render endpoint must refuse (409), not render.
mockMvc.perform(post("/v1/edit-projects/unapproved-" + UUID.randomUUID() + ":render"))
.andExpect(status().isConflict());
}
}

View File

@ -1,9 +1,11 @@
package org.example.videoclips.api;
import org.example.videoclips.api.dto.EditProjectResponse;
import org.example.videoclips.config.VideoClippingProperties;
import org.example.videoclips.editing.EditPlanService;
import org.example.videoclips.editing.EditProjectService;
import org.example.videoclips.editing.EditProjectStatus;
import org.example.videoclips.editing.EditProjectStore;
import org.example.videoclips.editing.EditRenderer;
import org.example.videoclips.editing.StoryboardPromptGenerator;
import org.junit.jupiter.api.Test;
@ -25,8 +27,10 @@ class EditProjectRenderControllerTest {
"input", "output", 60, "cinematic-porsche-promo", true, true, true,
Instant.EPOCH, Instant.EPOCH, null);
when(projects.getProject("project")).thenReturn(rendered);
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setRequireRenderApproval(false); // this test verifies delegation, not the gate
EditProjectController controller = new EditProjectController(projects, mock(StoryboardPromptGenerator.class),
mock(EditPlanService.class), renderer);
mock(EditPlanService.class), renderer, mock(EditProjectStore.class), properties);
Object response = controller.render("project");