Unverified Commit 854320ac authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(submit): give the upload lane a lifecycle — withdraw + admin delete (#76)

Nothing ever removed a submission: users could not retract a pending row, no
route deleted blobs or rows, and the reaper never touches uploads — so every
upload accumulated on the 5 GiB PVC forever and the only cleanup was SQL or
kubectl against the store.

- Blobs.Delete on both transports (local: RemoveAll of the id-namespaced dir,
  id re-validated at the boundary; S3: idempotent object DELETE).
- Store: DeleteSubmission (admin, any status) and DeletePendingSubmission
  (owner+pending CAS — a reviewed row can never be withdrawn out from under
  its build).
- Manager.Delete / Manager.Withdraw delete the ROW first (under the CAS for
  withdraw) and the blob after, so a live row can never point at a reaped
  blob; a cleanup failure names the orphan explicitly instead of failing mute.
- API: DELETE /me/submissions/{id} (withdraw, app tier) and
  DELETE /api/v1/submissions/{id} (admin) both return the row as it was;
  audit events submission.withdraw / submission.delete; openapi documents both
  paths; admin route pinned in the admin-only table.
- Panel: two-step withdraw on a pending row (frees the pending slot and the
  storage budget); two-step delete on every admin row; zh/en copy; wire tests.

Unit: submit (withdraw happy path / wrong owner / reviewed row / no transport /
blob-cleanup failure), local+S3 delete idempotence, api handlers (200/404/409/
503 + route tier); pgint: withdraw CAS + admin delete exactly-once.
go vet/go test/gofmt clean; panel vitest 120 + typecheck green.
parent ad4d256d
Loading
Loading
Loading
Loading
+67 −0
Changes for docs/openapi.yaml: 67 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -4355,6 +4355,41 @@ paths:
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /api/v1/me/submissions/{id}:
    delete:
      tags: [submissions]
      operationId: withdrawSubmission
      summary: Withdraw your own pending submission (user side; user-directed lane over §16).
      description: >-
        Retracts the caller's own submission while it is still pending review:
        the row and its uploaded build context are deleted, freeing the pending
        slot and the per-user storage budget for a fresh submission. A reviewed
        submission is frozen (409 — its build may already be consuming the
        context), and a submission the caller does not own reads back as 404, so
        this endpoint cannot probe or clear another user's uploads.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: The withdrawn submission, as it was before the deletion.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Submission' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Submission has already been reviewed and cannot be withdrawn.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  # ------------------------------------------------------ external: admin ----
  /api/v1/servers/{name}:
    patch:
@@ -4750,3 +4785,35 @@ paths:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /api/v1/submissions/{id}:
    delete:
      tags: [submissions]
      operationId: deleteSubmission
      summary: Retire a submission outright — row and uploaded context (admin; user-directed lane over §16).
      description: >-
        Removes the submission and its uploaded build context, any status — the
        lane's only lifecycle valve, and the path that reclaims a rejected or
        consumed upload from the uploads PVC. The reviewer identity is recorded
        in the audit event, not on the (now deleted) row. Deleting an approved
        submission whose build is still running fails that build's context
        fetch; the admin has explicitly chosen to retire the artifact.
      x-felis-face: [external]
      x-felis-tier: admin
      security: [{ accessJWT: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: The deleted submission, as it was before the deletion.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Submission' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
+9 −0
Changes for internal/api/api.go: 9 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -518,6 +518,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
		// App-tier and owner-scoped (the id must belong to the principal), exactly
		// like the create/list routes above.
		{Method: "POST", Pattern: "/api/v1/me/submissions/{id}/context", h: a.handleUploadSubmissionContext},
		// Withdraw the caller's OWN pending submission: the row and its uploaded
		// context are deleted, freeing the pending slot and storage budget. Same
		// owner-scoping as the upload route — a reviewed submission is frozen (409)
		// and another user's id is invisible (404).
		{Method: "DELETE", Pattern: "/api/v1/me/submissions/{id}", h: a.handleWithdrawSubmission},
		// Admin (Zero-Trust) tier: create / mutate spec / image admission. These gate
		// on Principal.IsAdmin() inside the handler via the adminOnly wrapper, so the
		// boundary is exercised even where the body is a later-phase stub.
@@ -547,6 +552,10 @@ 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},
		// Retire a submission outright (row + uploaded context), any status. The
		// lane's lifecycle valve: without it, rejected/consumed uploads accumulated
		// on the uploads PVC forever — there is no other delete path.
		{Method: "DELETE", Pattern: "/api/v1/submissions/{id}", Admin: true, h: a.handleDeleteSubmission},
		// 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},
+49 −0
Changes for internal/api/submissions.go: 49 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -43,6 +43,13 @@ type SubmissionService interface {
	// Reject is the admin's other verdict: pending_review -> rejected with a
	// required reason; it starts no build.
	Reject(ctx context.Context, id, reviewedBy, reason string) (*submit.Submission, error)
	// Withdraw retracts the caller's OWN pending submission: the row and its
	// uploaded context are deleted. A reviewed submission is frozen (409) and a
	// submission the caller does not own reads back as 404, like the upload route.
	Withdraw(ctx context.Context, id, submittedBy string) (*submit.Submission, error)
	// Delete retires any submission outright (the admin lifecycle valve): the row
	// and its uploaded context are removed, any status.
	Delete(ctx context.Context, id string) (*submit.Submission, error)
	// OpenContext returns the stored build-context blob for the internal
	// context-fetch route: the build Pod's initContainer cannot mount the uploads
	// PVC across namespaces and holds no object-store credentials, so it streams
@@ -294,6 +301,48 @@ func (a *API) handleRejectSubmission(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, sub)
}

// handleWithdrawSubmission retracts the caller's own pending submission
// (app-tier): the row and its uploaded context are deleted, freeing the pending
// slot and the storage budget for a fresh submission. The submitter is the
// principal, never the body; a submission the caller does not own is reported as
// 404, so this endpoint cannot probe or clear another user's uploads, and a
// reviewed submission is 409 (its build may already be consuming the context).
func (a *API) handleWithdrawSubmission(w http.ResponseWriter, r *http.Request) {
	if a.Submissions == nil {
		writeError(w, r, errSubmissionsUnavailable)
		return
	}
	p := principalFromContext(r.Context())
	sub, err := a.Submissions.Withdraw(r.Context(), r.PathValue("id"), p.UserID)
	if err != nil {
		writeSubmitError(w, r, err)
		return
	}
	a.audit(r, p.Email, "submission.withdraw", sub.ID)
	writeJSON(w, http.StatusOK, sub)
}

// handleDeleteSubmission retires any submission outright (admin-tier): the row
// and its uploaded context are removed, any status. This is the lane's lifecycle
// valve — the only path that reclaims a rejected or consumed upload from the
// uploads PVC. The reviewer identity goes to the audit event, not the (now
// nonexistent) row. Deleting an approved submission whose build is still running
// fails that build's context fetch; the admin has explicitly chosen to retire it.
func (a *API) handleDeleteSubmission(w http.ResponseWriter, r *http.Request) {
	if a.Submissions == nil {
		writeError(w, r, errSubmissionsUnavailable)
		return
	}
	p := principalFromContext(r.Context())
	sub, err := a.Submissions.Delete(r.Context(), r.PathValue("id"))
	if err != nil {
		writeSubmitError(w, r, err)
		return
	}
	a.audit(r, p.Email, "submission.delete", sub.ID)
	writeJSON(w, http.StatusOK, sub)
}

// errSubmissionsUnavailable is returned when the approval lane is not configured
// on this api instance (a nil Submissions service), so the admin/app boundary is
// still exercised before the subsystem is wired in.
+85 −0
Changes for internal/api/submissions_test.go: 85 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -38,6 +38,11 @@ type fakeSubmissions struct {
	rejectedBy  string
	rejectReas  string
	rejectErr   error
	withdrawnID string
	withdrawBy  string
	withdrawErr error
	deletedID   string
	deleteErr   error
	openedID    string
	openBody    string
	openErr     error
@@ -88,6 +93,22 @@ func (f *fakeSubmissions) Reject(_ context.Context, id, reviewedBy, reason strin
	return &submit.Submission{ID: id, Status: submit.StatusRejected, ReviewedBy: reviewedBy, RejectReason: reason}, nil
}

func (f *fakeSubmissions) Withdraw(_ context.Context, id, submittedBy string) (*submit.Submission, error) {
	f.withdrawnID, f.withdrawBy = id, submittedBy
	if f.withdrawErr != nil {
		return nil, f.withdrawErr
	}
	return &submit.Submission{ID: id, SubmittedBy: submittedBy, Status: submit.StatusPendingReview}, nil
}

func (f *fakeSubmissions) Delete(_ context.Context, id string) (*submit.Submission, error) {
	f.deletedID = id
	if f.deleteErr != nil {
		return nil, f.deleteErr
	}
	return &submit.Submission{ID: id, Status: submit.StatusRejected}, nil
}

// openErr injects the OpenContext outcome; the body recorder lets the internal
// route test assert byte-exact streaming and the 404 mapping.
func (f *fakeSubmissions) OpenContext(_ context.Context, id string) (io.ReadCloser, error) {
@@ -249,6 +270,69 @@ func TestUploadSubmissionContextWithoutServiceIs503(t *testing.T) {
	}
}

// Withdraw retracts the caller's OWN pending submission: the submitter is the
// principal (never the body), a reviewed submission is 409, and a foreign id is
// 404 — the same posture as the upload route.
func TestWithdrawSubmission(t *testing.T) {
	t.Run("withdraws as the principal", func(t *testing.T) {
		fs := &fakeSubmissions{}
		w := do(appSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-3", "", nil)
		if w.Code != http.StatusOK {
			t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
		}
		if fs.withdrawnID != "sub-3" || fs.withdrawBy != "user-7" {
			t.Fatalf("withdraw forwarded (%q, %q), want (sub-3, user-7)", fs.withdrawnID, fs.withdrawBy)
		}
	})
	t.Run("reviewed submission is 409", func(t *testing.T) {
		fs := &fakeSubmissions{withdrawErr: submit.ErrAlreadyReviewed}
		w := do(appSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-3", "", nil)
		if w.Code != http.StatusConflict {
			t.Fatalf("code = %d, want 409 (%s)", w.Code, w.Body.String())
		}
		if got := decodeErr(t, w); got != "already_reviewed" {
			t.Errorf("error code = %q, want already_reviewed", got)
		}
	})
	t.Run("foreign or unknown id is 404", func(t *testing.T) {
		fs := &fakeSubmissions{withdrawErr: submit.ErrNotFound}
		w := do(appSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-x", "", nil)
		if w.Code != http.StatusNotFound {
			t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
		}
	})
	t.Run("no service is 503", func(t *testing.T) {
		app := appSubAPI(nil)
		app.Submissions = nil
		w := do(app.ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-3", "", nil)
		if w.Code != http.StatusServiceUnavailable {
			t.Fatalf("code = %d, want 503 (%s)", w.Code, w.Body.String())
		}
	})
}

// The admin delete retires any submission and maps the lane's 404; the route's
// admin gate itself is pinned by TestSubmissionAdminRoutesAreAdminOnly.
func TestDeleteSubmissionAdmin(t *testing.T) {
	t.Run("deletes the named row", func(t *testing.T) {
		fs := &fakeSubmissions{}
		w := do(adminSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/submissions/sub-8", "", nil)
		if w.Code != http.StatusOK {
			t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
		}
		if fs.deletedID != "sub-8" {
			t.Fatalf("delete forwarded id %q, want sub-8", fs.deletedID)
		}
	})
	t.Run("unknown is 404", func(t *testing.T) {
		fs := &fakeSubmissions{deleteErr: submit.ErrNotFound}
		w := do(adminSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/submissions/sub-x", "", nil)
		if w.Code != http.StatusNotFound {
			t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
		}
	})
}

// The "my uploads" list scopes strictly to the principal's id — there is no
// parameter that could widen it to another user's submissions.
func TestMySubmissionsScopesToPrincipal(t *testing.T) {
@@ -343,6 +427,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"}`},
		{"DELETE", "/api/v1/submissions/sub-1", ""},
		{"GET", "/api/v1/submissions/sub-1/context", ""},
	}
	for _, c := range cases {
+30 −0
Changes for internal/pgint/pgint_test.go: 30 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -1034,6 +1034,36 @@ func TestSubmitStoreContract(t *testing.T) {
	if got, _ = s.GetSubmission(ctx, id); got.BuildID == "" {
		t.Fatal("LinkBuild must persist build_id")
	}

	// Lifecycle: the withdraw CAS deletes ONLY the owner's still-pending row — a
	// wrong owner or a reviewed row can never delete through it — and the admin
	// path deletes any status, exactly once.
	id2 := "sub-w-" + suffix(t)
	if err := s.CreateSubmission(ctx, &submit.Submission{
		ID: id2, SubmittedBy: u.ID, DisplayName: "withdraw me",
		ContextRef: "s3://bucket/" + id2 + "/context.tar.gz",
		Status:     submit.StatusPendingReview, CreatedAt: now,
	}); err != nil {
		t.Fatalf("CreateSubmission(2): %v", err)
	}
	if ok, err := s.DeletePendingSubmission(ctx, id2, "someone-else"); err != nil || ok {
		t.Fatalf("withdraw by a non-owner = (%v, %v), want (false, nil)", ok, err)
	}
	if ok, err := s.DeletePendingSubmission(ctx, id, u.ID); err != nil || ok {
		t.Fatalf("withdraw of a reviewed row = (%v, %v), want (false, nil)", ok, err)
	}
	if ok, err := s.DeletePendingSubmission(ctx, id2, u.ID); err != nil || !ok {
		t.Fatalf("withdraw by the owner = (%v, %v), want (true, nil)", ok, err)
	}
	if _, err := s.GetSubmission(ctx, id2); !errors.Is(err, submit.ErrNotFound) {
		t.Fatalf("withdrawn row still readable: %v", err)
	}
	if ok, err := s.DeleteSubmission(ctx, id); err != nil || !ok {
		t.Fatalf("admin delete = (%v, %v), want (true, nil)", ok, err)
	}
	if ok, err := s.DeleteSubmission(ctx, id); err != nil || ok {
		t.Fatalf("second admin delete = (%v, %v), want (false, nil)", ok, err)
	}
}

// ---- builds --------------------------------------------------------------------
Loading