feat(coordinator): owner-scope UI views by logged-in user

With a UI session, the dashboard and job pages are scoped to the caller:
- Overview lists only the user's own jobs (admin/basic-auth operator: all)
- JobDetail, artifact download, and preview 404 on another user's job
- scoping keys off authctx: no requester (basic auth) still sees everything,
  so the fallback operator UI is unchanged

ListJobs gains an owner filter (SQL WHERE) so paging stays correct per user.
Tests cover Overview scoping and cross-user JobDetail rejection.
This commit is contained in:
Efremenko Arhip
2026-07-26 20:12:49 +03:00
parent 33f629f387
commit c8c6455caf
7 changed files with 123 additions and 6 deletions
+13 -2
View File
@@ -14,7 +14,9 @@ import (
// It intentionally exposes no storage paths or credentials.
type UIReadRepository interface {
GetJob(ctx context.Context, jobID uuid.UUID) (*domain.Job, error)
ListJobs(ctx context.Context, limit int) ([]domain.Job, error)
// ListJobs returns the most recent jobs. A non-nil owner restricts the list
// to that user's jobs; nil returns all (operator/admin view).
ListJobs(ctx context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error)
ListTasksByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Task, error)
ListTasksByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID][]domain.Task, error)
ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error)
@@ -99,7 +101,7 @@ type Dashboard struct{ read UIReadRepository }
func NewDashboard(read UIReadRepository) *Dashboard { return &Dashboard{read: read} }
func (d *Dashboard) Overview(ctx context.Context, limit int) (DashboardView, error) {
jobs, err := d.read.ListJobs(ctx, limit)
jobs, err := d.read.ListJobs(ctx, uiOwnerFilter(ctx), limit)
if err != nil {
return DashboardView{}, err
}
@@ -140,6 +142,11 @@ func (d *Dashboard) JobDetail(ctx context.Context, jobID uuid.UUID) (JobDetailVi
if err != nil {
return JobDetailView{}, err
}
// A plain user may only open their own job; a mismatch reads as not-found so
// the page never reveals another user's job exists.
if err := authorizeJobAccess(ctx, job); err != nil {
return JobDetailView{}, err
}
tasks, err := d.read.ListTasksByJob(ctx, jobID)
if err != nil {
return JobDetailView{}, err
@@ -198,6 +205,10 @@ func (d *Dashboard) DownloadableArtifactBelongsToJob(ctx context.Context, jobID,
if err != nil {
return false, err
}
// Not the caller's job (and not admin): treat as if the artifact is absent.
if err := authorizeJobAccess(ctx, job); err != nil {
return false, nil //nolint:nilerr // masking the authz error as "not found" is intentional
}
artifacts, err := d.read.ListArtifactsByJob(ctx, jobID)
if err != nil {
return false, err