Unverified Commit 168a3754 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(api,panel): reviewer context download for submissions; dockerfile field...

feat(api,panel): reviewer context download for submissions; dockerfile field documented as audit-only (audit #45)
parent edd9d63f
Loading
Loading
Loading
Loading
+51 −4
Changes for docs/openapi.yaml: 51 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -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]
+3 −0
Changes for internal/api/api.go: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+39 −2
Changes for internal/api/submissions.go: 39 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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 {
+44 −0
Changes for internal/api/submissions_test.go: 44 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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)
		}
	})
}
+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
@@ -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