diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 85063b6..45205c3 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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] diff --git a/internal/api/api.go b/internal/api/api.go index bcde814..2827d09 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 diff --git a/internal/api/submissions.go b/internal/api/submissions.go index b785107..0034c17 100644 --- a/internal/api/submissions.go +++ b/internal/api/submissions.go @@ -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 { diff --git a/internal/api/submissions_test.go b/internal/api/submissions_test.go index b9f6085..5629d55 100644 --- a/internal/api/submissions_test.go +++ b/internal/api/submissions_test.go @@ -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) + } + }) +} diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index cba1d8a..b116a9d 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -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", diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index 47d8603..7792619 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -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": "日志", diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 31f1df7..2ef0a5a 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -473,6 +473,40 @@ export const api = { rejectSubmission: (id: string, reason: string) => request("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 => { + 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 ?? []), diff --git a/panel/src/pages/admin/ImageBuildPage.tsx b/panel/src/pages/admin/ImageBuildPage.tsx index 960f327..caf5de7 100644 --- a/panel/src/pages/admin/ImageBuildPage.tsx +++ b/panel/src/pages/admin/ImageBuildPage.tsx @@ -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" /> +

{t("dockerfile_audit_hint")}

{triggerError && } diff --git a/panel/src/pages/admin/SubmissionsPage.tsx b/panel/src/pages/admin/SubmissionsPage.tsx index fb6de60..b13d08e 100644 --- a/panel/src/pages/admin/SubmissionsPage.tsx +++ b/panel/src/pages/admin/SubmissionsPage.tsx @@ -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(null); const [busyType, setBusyType] = useState<"approve" | "reject" | null>(null); + const [downloadingId, setDownloadingId] = useState(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 (
@@ -338,7 +353,24 @@ export function SubmissionsPage() {

{t("context_ref_label")}

-
{sub.context_ref}
+
+
{sub.context_ref}
+ +
{sub.image_ref && (
@@ -444,4 +476,4 @@ export function SubmissionsPage() {
); -} \ No newline at end of file +}