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:
parent
31efe33081
commit
430d71fae8
|
|
@ -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**.
|
- **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.
|
Commercial use requires swapping in commercially-licensed models/assets.
|
||||||
- **Not production-hardened:** no Spring Security/authN, no container/K8s/deployment manifests, REST persistence
|
- **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
|
defaults to in-memory, and no-egress operation is not yet certified. `POST /v1/edit-projects/{projectId}:render`
|
||||||
operation is not yet certified.
|
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
|
- **VLM quality:** on distant/small subjects the small local VLM is only weakly discriminative; a stronger
|
||||||
model or closer framing improves Tier-2 selection.
|
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.
|
- A director can only cut what was filmed — it cannot show a moment the camera never captured.
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ package org.example.videoclips.api;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.example.videoclips.api.dto.CreateEditProjectRequest;
|
import org.example.videoclips.api.dto.CreateEditProjectRequest;
|
||||||
import org.example.videoclips.api.dto.SaveEditPlanRequest;
|
import org.example.videoclips.api.dto.SaveEditPlanRequest;
|
||||||
|
import org.example.videoclips.config.VideoClippingProperties;
|
||||||
import org.example.videoclips.editing.EditPlanService;
|
import org.example.videoclips.editing.EditPlanService;
|
||||||
import org.example.videoclips.editing.EditProjectService;
|
import org.example.videoclips.editing.EditProjectService;
|
||||||
|
import org.example.videoclips.editing.EditProjectStore;
|
||||||
import org.example.videoclips.editing.EditRenderer;
|
import org.example.videoclips.editing.EditRenderer;
|
||||||
import org.example.videoclips.editing.StoryboardPromptGenerator;
|
import org.example.videoclips.editing.StoryboardPromptGenerator;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
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.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/v1/edit-projects")
|
@RequestMapping("/v1/edit-projects")
|
||||||
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
|
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
public class EditProjectController {
|
public class EditProjectController {
|
||||||
|
|
||||||
|
static final String APPROVAL_FILE_NAME = "approved.flag";
|
||||||
|
|
||||||
private final EditProjectService editProjectService;
|
private final EditProjectService editProjectService;
|
||||||
private final StoryboardPromptGenerator storyboardPromptGenerator;
|
private final StoryboardPromptGenerator storyboardPromptGenerator;
|
||||||
private final EditPlanService editPlanService;
|
private final EditPlanService editPlanService;
|
||||||
private final EditRenderer editRenderer;
|
private final EditRenderer editRenderer;
|
||||||
|
private final EditProjectStore editProjectStore;
|
||||||
|
private final VideoClippingProperties.Editing properties;
|
||||||
|
|
||||||
public EditProjectController(EditProjectService editProjectService,
|
public EditProjectController(EditProjectService editProjectService,
|
||||||
StoryboardPromptGenerator storyboardPromptGenerator,
|
StoryboardPromptGenerator storyboardPromptGenerator,
|
||||||
EditPlanService editPlanService,
|
EditPlanService editPlanService,
|
||||||
EditRenderer editRenderer) {
|
EditRenderer editRenderer,
|
||||||
|
EditProjectStore editProjectStore,
|
||||||
|
VideoClippingProperties properties) {
|
||||||
this.editProjectService = editProjectService;
|
this.editProjectService = editProjectService;
|
||||||
this.storyboardPromptGenerator = storyboardPromptGenerator;
|
this.storyboardPromptGenerator = storyboardPromptGenerator;
|
||||||
this.editPlanService = editPlanService;
|
this.editPlanService = editPlanService;
|
||||||
this.editRenderer = editRenderer;
|
this.editRenderer = editRenderer;
|
||||||
|
this.editProjectStore = editProjectStore;
|
||||||
|
this.properties = properties.getEditing();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
|
@ -61,7 +75,26 @@ public class EditProjectController {
|
||||||
|
|
||||||
@PostMapping("/{projectId}:render")
|
@PostMapping("/{projectId}:render")
|
||||||
public Object render(@PathVariable String projectId) {
|
public Object render(@PathVariable String projectId) {
|
||||||
|
requireRenderApproval(projectId);
|
||||||
editRenderer.render(projectId);
|
editRenderer.render(projectId);
|
||||||
return editProjectService.getProject(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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -484,6 +484,13 @@ public class VideoClippingProperties {
|
||||||
public static class Editing {
|
public static class Editing {
|
||||||
private boolean enabled = true;
|
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 projectDirectory = "./output/edit-projects";
|
||||||
|
|
||||||
private String highlightProjectDirectory = "./output/highlight-projects";
|
private String highlightProjectDirectory = "./output/highlight-projects";
|
||||||
|
|
@ -569,6 +576,14 @@ public class VideoClippingProperties {
|
||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isRequireRenderApproval() {
|
||||||
|
return requireRenderApproval;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequireRenderApproval(boolean requireRenderApproval) {
|
||||||
|
this.requireRenderApproval = requireRenderApproval;
|
||||||
|
}
|
||||||
|
|
||||||
public String getProjectDirectory() {
|
public String getProjectDirectory() {
|
||||||
return projectDirectory;
|
return projectDirectory;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,4 +180,11 @@ class EditProjectControllerTest {
|
||||||
.andExpect(status().isNotFound())
|
.andExpect(status().isNotFound())
|
||||||
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/not-found"));
|
.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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package org.example.videoclips.api;
|
package org.example.videoclips.api;
|
||||||
|
|
||||||
import org.example.videoclips.api.dto.EditProjectResponse;
|
import org.example.videoclips.api.dto.EditProjectResponse;
|
||||||
|
import org.example.videoclips.config.VideoClippingProperties;
|
||||||
import org.example.videoclips.editing.EditPlanService;
|
import org.example.videoclips.editing.EditPlanService;
|
||||||
import org.example.videoclips.editing.EditProjectService;
|
import org.example.videoclips.editing.EditProjectService;
|
||||||
import org.example.videoclips.editing.EditProjectStatus;
|
import org.example.videoclips.editing.EditProjectStatus;
|
||||||
|
import org.example.videoclips.editing.EditProjectStore;
|
||||||
import org.example.videoclips.editing.EditRenderer;
|
import org.example.videoclips.editing.EditRenderer;
|
||||||
import org.example.videoclips.editing.StoryboardPromptGenerator;
|
import org.example.videoclips.editing.StoryboardPromptGenerator;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
@ -25,8 +27,10 @@ class EditProjectRenderControllerTest {
|
||||||
"input", "output", 60, "cinematic-porsche-promo", true, true, true,
|
"input", "output", 60, "cinematic-porsche-promo", true, true, true,
|
||||||
Instant.EPOCH, Instant.EPOCH, null);
|
Instant.EPOCH, Instant.EPOCH, null);
|
||||||
when(projects.getProject("project")).thenReturn(rendered);
|
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),
|
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");
|
Object response = controller.render("project");
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue