Loading docs/openapi.yaml +51 −4 Changes for docs/openapi.yaml: 51 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -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. Loading Loading @@ -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] Loading internal/api/api.go +3 −0 Changes for internal/api/api.go: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading internal/api/submissions.go +39 −2 Changes for internal/api/submissions.go: 39 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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 { Loading internal/api/submissions_test.go +44 −0 Changes for internal/api/submissions_test.go: 44 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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{}) Loading Loading @@ -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) } }) } panel/src/i18n/resources/en-US/admin.json +3 −0 Changes for panel/src/i18n/resources/en-US/admin.json: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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", Loading Loading
docs/openapi.yaml +51 −4 Changes for docs/openapi.yaml: 51 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -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. Loading Loading @@ -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] Loading
internal/api/api.go +3 −0 Changes for internal/api/api.go: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading
internal/api/submissions.go +39 −2 Changes for internal/api/submissions.go: 39 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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 { Loading
internal/api/submissions_test.go +44 −0 Changes for internal/api/submissions_test.go: 44 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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{}) Loading Loading @@ -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) } }) }
panel/src/i18n/resources/en-US/admin.json +3 −0 Changes for panel/src/i18n/resources/en-US/admin.json: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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", Loading