feat(api,panel): reviewer context download for submissions; dockerfile field documented as audit-only (audit #45)
This commit is contained in:
9 files changed
+213
-9
No files matched your search
+51
-4
@@ -4407,10 +4407,24 @@ paths:
|
||||
type: object
|
||||
required: [image_ref, dockerfile, context_ref]
|
||||
properties:
|
||||
image_ref: { type: string }
|
||||
dockerfile: { type: string }
|
||||
context_ref: { type: string }
|
||||
base_image: { type: string }
|
||||
image_ref:
|
||||
type: string
|
||||
description: Push target under the internal registry (e.g. registry.felis.svc:5000/foo:1.0).
|
||||
dockerfile:
|
||||
type: string
|
||||
description: >-
|
||||
Audit archive of the recipe, recorded on the build row and shown in the
|
||||
panel — the executed Dockerfile is the file named `Dockerfile` at the
|
||||
root of the context tarball (Kaniko runs --dockerfile=Dockerfile), so
|
||||
this field is never executed.
|
||||
context_ref:
|
||||
type: string
|
||||
description: >-
|
||||
Location of the uploaded gzip build context; its root must contain the
|
||||
Dockerfile that gets executed.
|
||||
base_image:
|
||||
type: string
|
||||
description: Resolved FROM, recorded for audit only — not a build gate.
|
||||
responses:
|
||||
'202':
|
||||
description: Build accepted.
|
||||
@@ -4644,6 +4658,39 @@ paths:
|
||||
'503':
|
||||
$ref: '#/components/responses/ServiceUnavailable'
|
||||
|
||||
/api/v1/submissions/{id}/context:
|
||||
get:
|
||||
tags: [submissions]
|
||||
operationId: downloadSubmissionContext
|
||||
summary: Download a submission's uploaded build context (admin; user-directed lane over §16).
|
||||
description: >-
|
||||
The reviewer's read path to the artifact they are about to approve: the
|
||||
executed Dockerfile lives inside this tarball (Kaniko runs the context's
|
||||
root `Dockerfile`), so without it the human gate would be blind. Streams
|
||||
the stored context.tar.gz verbatim with an attachment disposition — the
|
||||
same bytes the build Pod fetches over the internal face. 404 when the
|
||||
submission is unknown or has no uploaded context; 503 when the
|
||||
deployment's context store has no implemented transport.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: admin
|
||||
security: [{ accessJWT: [] }]
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
description: The stored build context (gzip tarball), served as an attachment.
|
||||
content:
|
||||
application/gzip:
|
||||
schema: { type: string, format: binary }
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'503':
|
||||
$ref: '#/components/responses/ServiceUnavailable'
|
||||
|
||||
/api/v1/submissions/{id}/reject:
|
||||
post:
|
||||
tags: [submissions]
|
||||
|
||||
@@ -521,6 +521,9 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
{Method: "GET", Pattern: "/api/v1/submissions", Admin: true, h: a.handleListSubmissions},
|
||||
{Method: "POST", Pattern: "/api/v1/submissions/{id}/approve", Admin: true, h: a.handleApproveSubmission},
|
||||
{Method: "POST", Pattern: "/api/v1/submissions/{id}/reject", Admin: true, h: a.handleRejectSubmission},
|
||||
// The reviewer's read path to the uploaded blob: the executed Dockerfile
|
||||
// lives inside it, so approval would otherwise be blind.
|
||||
{Method: "GET", Pattern: "/api/v1/submissions/{id}/context", Admin: true, h: a.handleAdminSubmissionContext},
|
||||
// Auto-update maintenance window (spec §B; decision core internal/updates).
|
||||
// Admin-tier: it governs whether Felis may apply an update to itself, so setting
|
||||
// it requires the admin Zero-Trust path, not a mere session. API+persistence
|
||||
|
||||
@@ -286,15 +286,52 @@ func writeSubmitError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
// fetcher extracts it under a zip-slip guard, and Kaniko treats the result as
|
||||
// hostile regardless (spec §16).
|
||||
func (a *API) handleInternalSubmissionContext(w http.ResponseWriter, r *http.Request) {
|
||||
rc, ok := a.openSubmissionContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
streamSubmissionContext(w, rc)
|
||||
}
|
||||
|
||||
// handleAdminSubmissionContext streams a submission's stored build-context
|
||||
// tarball to a reviewing admin (admin-tier). Review is only a real gate if the
|
||||
// reviewer can inspect what they approve: the executed Dockerfile lives INSIDE
|
||||
// this tarball (build/jobspec.go pins --dockerfile=Dockerfile), so without this
|
||||
// route the human gate could not see the recipe at all. The bytes are the same
|
||||
// ones the build Pod fetches over the internal face; the attachment disposition
|
||||
// makes the browser download the attacker-supplied archive, never render it.
|
||||
func (a *API) handleAdminSubmissionContext(w http.ResponseWriter, r *http.Request) {
|
||||
rc, ok := a.openSubmissionContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="context.tar.gz"`)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
a.audit(r, principalFromContext(r.Context()).Email, "submission.context.download", r.PathValue("id"))
|
||||
streamSubmissionContext(w, rc)
|
||||
}
|
||||
|
||||
// openSubmissionContext resolves the build-context blob named in the request
|
||||
// path, mapping the submit-layer errors onto the shared submission statuses (a
|
||||
// missing blob is 404, an unwired transport 503). On failure the error response
|
||||
// is already written and the caller must return.
|
||||
func (a *API) openSubmissionContext(w http.ResponseWriter, r *http.Request) (io.ReadCloser, bool) {
|
||||
if a.Submissions == nil {
|
||||
writeError(w, r, errSubmissionsUnavailable)
|
||||
return
|
||||
return nil, false
|
||||
}
|
||||
rc, err := a.Submissions.OpenContext(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeSubmitError(w, r, err)
|
||||
return
|
||||
return nil, false
|
||||
}
|
||||
return rc, true
|
||||
}
|
||||
|
||||
// streamSubmissionContext copies the blob to w verbatim and closes it. The
|
||||
// caller must have set every header already: the copy commits the response, so
|
||||
// a failure mid-stream can only truncate it.
|
||||
func streamSubmissionContext(w http.ResponseWriter, rc io.ReadCloser) {
|
||||
defer rc.Close()
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
if _, err := io.Copy(w, rc); err != nil {
|
||||
|
||||
@@ -342,6 +342,7 @@ func TestSubmissionAdminRoutesAreAdminOnly(t *testing.T) {
|
||||
{"GET", "/api/v1/submissions", ""},
|
||||
{"POST", "/api/v1/submissions/sub-1/approve", ""},
|
||||
{"POST", "/api/v1/submissions/sub-1/reject", `{"reason":"no"}`},
|
||||
{"GET", "/api/v1/submissions/sub-1/context", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
api := adminSubAPI(&fakeSubmissions{})
|
||||
@@ -523,3 +524,46 @@ func TestInternalSubmissionContextRoute(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The admin context route is the reviewer's read path to the blob they are
|
||||
// approving: an admin streams the stored bytes with a download disposition,
|
||||
// and the error mapping matches the internal route (404 missing, 503 unwired).
|
||||
// The admin-only gate itself is pinned by TestSubmissionAdminRoutesAreAdminOnly.
|
||||
func TestAdminSubmissionContextRoute(t *testing.T) {
|
||||
t.Run("streams the blob with a download disposition", func(t *testing.T) {
|
||||
fs := &fakeSubmissions{openBody: "\x1f\x8b\x08\x00blob"}
|
||||
w := do(adminSubAPI(fs).ExternalHandler(), "GET", "/api/v1/submissions/sub-7/context", "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Body.String() != fs.openBody {
|
||||
t.Fatalf("body = %q, want the stored blob %q", w.Body.String(), fs.openBody)
|
||||
}
|
||||
if fs.openedID != "sub-7" {
|
||||
t.Fatalf("opened id = %q, want the path id", fs.openedID)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/gzip" {
|
||||
t.Fatalf("content-type = %q, want application/gzip", ct)
|
||||
}
|
||||
if cd := w.Header().Get("Content-Disposition"); cd != `attachment; filename="context.tar.gz"` {
|
||||
t.Fatalf("content-disposition = %q", cd)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing blob is 404", func(t *testing.T) {
|
||||
fs := &fakeSubmissions{openErr: fmt.Errorf("%w: gone", submit.ErrBlobNotFound)}
|
||||
w := do(adminSubAPI(fs).ExternalHandler(), "GET", "/api/v1/submissions/sub-7/context", "", nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unwired transport is 503", func(t *testing.T) {
|
||||
api := adminSubAPI(nil)
|
||||
api.Submissions = nil
|
||||
w := do(api.ExternalHandler(), "GET", "/api/v1/submissions/sub-7/context", "", nil)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d, want 503", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -18,8 +18,11 @@
|
||||
"trigger_build_btn": "Start Build",
|
||||
"dockerfile_label": "Dockerfile Content",
|
||||
"dockerfile_placeholder": "FROM library/postgres:15\nRUN echo 'setup'",
|
||||
"dockerfile_audit_hint": "Archived on the build row for audit. Kaniko executes the `Dockerfile` inside the context tarball — this text is never executed.",
|
||||
"context_ref_label": "Context Reference",
|
||||
"context_ref_placeholder": "e.g. minio/contexts/my-modpack.tar.gz",
|
||||
"download_context_btn": "Download context",
|
||||
"download_context_hint": "Downloads the uploaded context (.tar.gz) — it contains the Dockerfile that will actually be executed.",
|
||||
"base_image_label": "Base Image",
|
||||
"base_image_placeholder": "e.g. library/postgres:15",
|
||||
"view_logs_btn": "Logs",
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
"trigger_build_btn": "开始构建",
|
||||
"dockerfile_label": "Dockerfile 内容",
|
||||
"dockerfile_placeholder": "FROM library/postgres:15\nRUN echo 'setup'",
|
||||
"dockerfile_audit_hint": "仅存档到构建记录用于审计。实际执行的是构建上下文压缩包内的 `Dockerfile`——此处内容不会被执行。",
|
||||
"context_ref_label": "构建上下文引用",
|
||||
"context_ref_placeholder": "例如: minio/contexts/my-modpack.tar.gz",
|
||||
"download_context_btn": "下载上下文",
|
||||
"download_context_hint": "下载上传的构建上下文 (.tar.gz)——其中包含将被实际执行的 Dockerfile。",
|
||||
"base_image_label": "基础镜像",
|
||||
"base_image_placeholder": "例如: library/postgres:15",
|
||||
"view_logs_btn": "日志",
|
||||
|
||||
@@ -473,6 +473,40 @@ export const api = {
|
||||
rejectSubmission: (id: string, reason: string) =>
|
||||
request<Submission>("POST", `/submissions/${id}/reject`, { reason }),
|
||||
|
||||
// The reviewer's read path to the uploaded build context: the executed
|
||||
// Dockerfile lives inside the tarball, so approving without this would be
|
||||
// blind. The body is the attacker-supplied archive — download it, never
|
||||
// render it — which the API's attachment disposition enforces.
|
||||
downloadSubmissionContext: async (id: string): Promise<void> => {
|
||||
const { apiBase } = await loadConfig();
|
||||
const res = await fetch(`${apiBase}/submissions/${id}/context`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
let code = "error";
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const parsed = JSON.parse(await res.text()) as unknown;
|
||||
if (isApiError(parsed)) {
|
||||
code = parsed.error.code;
|
||||
message = parsed.error.message;
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON error body (e.g. an ingress page): keep the status line */
|
||||
}
|
||||
const err: ApiError = { status: res.status, code, message };
|
||||
throw err;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${id}-context.tar.gz`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
listMySubmissions: () =>
|
||||
request<{ submissions: Submission[] }>("GET", "/me/submissions").then((r) => r.submissions ?? []),
|
||||
|
||||
|
||||
@@ -405,6 +405,7 @@ export function ImageBuildPage() {
|
||||
rows={8}
|
||||
className="w-full rounded-md border border-input bg-zinc-950 px-3 py-2 text-xs font-mono text-zinc-200 shadow-sm placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/80 leading-relaxed">{t("dockerfile_audit_hint")}</p>
|
||||
</div>
|
||||
|
||||
{triggerError && <MessageLine kind="error" message={triggerError} compact />}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2 } from "lucide-react";
|
||||
import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2, Download } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { StatCard } from "@/components/StatCard";
|
||||
@@ -44,6 +44,7 @@ export function SubmissionsPage() {
|
||||
// Pending actions (for button spinners)
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [busyType, setBusyType] = useState<"approve" | "reject" | null>(null);
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
// Search & Filtering State
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -137,6 +138,20 @@ export function SubmissionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// The reviewer downloads the uploaded context before approving: the executed
|
||||
// Dockerfile lives inside it, so this is the only way to see the recipe.
|
||||
const handleDownloadContext = async (sub: Submission) => {
|
||||
setActionError(null);
|
||||
setDownloadingId(sub.id);
|
||||
try {
|
||||
await api.downloadSubmissionContext(sub.id);
|
||||
} catch (err) {
|
||||
setActionError(humanizeError(err));
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader icon={ClipboardCheck} title={t("submissions_title")} subtitle={t("submissions_subtitle")} />
|
||||
@@ -338,7 +353,24 @@ export function SubmissionsPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-foreground mb-1">{t("context_ref_label")}</p>
|
||||
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.context_ref}</pre>
|
||||
<div className="flex items-center gap-2">
|
||||
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all flex-1 min-w-0">{sub.context_ref}</pre>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 shrink-0 px-2 text-[10px]"
|
||||
onClick={() => handleDownloadContext(sub)}
|
||||
disabled={!!downloadingId}
|
||||
title={t("download_context_hint")}
|
||||
>
|
||||
{downloadingId === sub.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3 w-3" />
|
||||
)}
|
||||
<span className="ml-1">{t("download_context_btn")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{sub.image_ref && (
|
||||
<div>
|
||||
@@ -444,4 +476,4 @@ export function SubmissionsPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user