diff --git a/coordinator/internal/memstore/ui_read.go b/coordinator/internal/memstore/ui_read.go index 23bff9d..cc2c681 100644 --- a/coordinator/internal/memstore/ui_read.go +++ b/coordinator/internal/memstore/ui_read.go @@ -27,7 +27,7 @@ var _ usecase.UIReadRepository = (*UIReadRepo)(nil) func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, error) { return r.jobs.Get(ctx, id) } -func (r *UIReadRepo) ListJobs(_ context.Context, limit int) ([]domain.Job, error) { +func (r *UIReadRepo) ListJobs(_ context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error) { if limit < 1 || limit > 100 { return nil, domain.ErrInvalidInput } @@ -35,6 +35,9 @@ func (r *UIReadRepo) ListJobs(_ context.Context, limit int) ([]domain.Job, error defer r.jobs.mu.Unlock() out := make([]domain.Job, 0, len(r.jobs.jobs)) for _, job := range r.jobs.jobs { + if owner != nil && (job.OwnerID == nil || *job.OwnerID != *owner) { + continue + } out = append(out, *job) } sort.Slice(out, func(i, j int) bool { diff --git a/coordinator/internal/storage/postgres/integration_test.go b/coordinator/internal/storage/postgres/integration_test.go index 3252b00..4a97a7d 100644 --- a/coordinator/internal/storage/postgres/integration_test.go +++ b/coordinator/internal/storage/postgres/integration_test.go @@ -144,7 +144,7 @@ func TestUIReadRepoListsReducerFields(t *testing.T) { if claimed, err := jobs.ClaimReduction(ctx, job.ID, time.Now().UTC()); err != nil || !claimed { t.Fatalf("claim reduction = (%v, %v)", claimed, err) } - listed, err := NewUIReadRepo(pool).ListJobs(ctx, 20) + listed, err := NewUIReadRepo(pool).ListJobs(ctx, nil, 20) if err != nil { t.Fatalf("list UI jobs: %v", err) } diff --git a/coordinator/internal/storage/postgres/ui_read_repo.go b/coordinator/internal/storage/postgres/ui_read_repo.go index 90b3536..5c8a6a6 100644 --- a/coordinator/internal/storage/postgres/ui_read_repo.go +++ b/coordinator/internal/storage/postgres/ui_read_repo.go @@ -24,11 +24,15 @@ func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, err return job, err } -func (r *UIReadRepo) ListJobs(ctx context.Context, limit int) ([]domain.Job, error) { +func (r *UIReadRepo) ListJobs(ctx context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error) { if limit < 1 || limit > 100 { return nil, domain.ErrInvalidInput } - sql, args, err := psql.Select(jobColumns...).From("jobs").OrderBy("created_at DESC", "id DESC").Limit(uint64(limit)).ToSql() + q := psql.Select(jobColumns...).From("jobs") + if owner != nil { + q = q.Where(sq.Eq{"owner_id": *owner}) + } + sql, args, err := q.OrderBy("created_at DESC", "id DESC").Limit(uint64(limit)).ToSql() if err != nil { return nil, err } diff --git a/coordinator/internal/usecase/ownership.go b/coordinator/internal/usecase/ownership.go index c2d0105..6e476ec 100644 --- a/coordinator/internal/usecase/ownership.go +++ b/coordinator/internal/usecase/ownership.go @@ -20,6 +20,18 @@ func ownerFromContext(ctx context.Context) *uuid.UUID { return nil } +// uiOwnerFilter returns the owner a UI listing must be restricted to: nil for an +// operator/admin or an unauthenticated (basic-auth) session, which see all jobs, +// or the caller's id for a plain user, who sees only their own. +func uiOwnerFilter(ctx context.Context) *uuid.UUID { + r, ok := authctx.From(ctx) + if !ok || r.IsAdmin() { + return nil + } + id := r.UserID + return &id +} + // authorizeJobAccess enforces that a non-admin user may only act on their own // job. It returns ErrJobNotFound — not a 403 — on a mismatch, so the response // never reveals that another user's job exists. diff --git a/coordinator/internal/usecase/preview.go b/coordinator/internal/usecase/preview.go index 5401c85..295e23c 100644 --- a/coordinator/internal/usecase/preview.go +++ b/coordinator/internal/usecase/preview.go @@ -53,6 +53,11 @@ func (p *PreviewArtifact) Execute(ctx context.Context, jobID, artifactID uuid.UU if err != nil { return ArtifactPreviewView{}, err } + // Another user's job (and not admin): report not-found, matching the + // artifact-absent response so nothing about it leaks. + if err := authorizeJobAccess(ctx, job); err != nil { + return ArtifactPreviewView{}, domain.ErrArtifactNotFound + } artifacts, err := p.read.ListArtifactsByJob(ctx, jobID) if err != nil { return ArtifactPreviewView{}, err diff --git a/coordinator/internal/usecase/ui.go b/coordinator/internal/usecase/ui.go index 96e5714..b1f1dad 100644 --- a/coordinator/internal/usecase/ui.go +++ b/coordinator/internal/usecase/ui.go @@ -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 diff --git a/coordinator/internal/usecase/ui_scope_test.go b/coordinator/internal/usecase/ui_scope_test.go new file mode 100644 index 0000000..de3a618 --- /dev/null +++ b/coordinator/internal/usecase/ui_scope_test.go @@ -0,0 +1,82 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/authctx" + "github.com/emil28092005/SciMesh/coordinator/internal/domain" + "github.com/emil28092005/SciMesh/coordinator/internal/memstore" + "github.com/emil28092005/SciMesh/coordinator/internal/usecase" +) + +func newDashboard() (*usecase.Dashboard, *memstore.JobRepo) { + jobs := memstore.NewJobRepo() + tasks := memstore.NewTaskRepo() + workers := memstore.NewWorkerRepo() + artifacts := memstore.NewArtifactRepo() + return usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, workers, artifacts)), jobs +} + +func ownedJob(t *testing.T, jobs *memstore.JobRepo, owner uuid.UUID) uuid.UUID { + t.Helper() + o := owner + job := &domain.Job{ID: uuid.New(), Workload: "similarity-search", Status: domain.JobRunning, OwnerID: &o, CreatedAt: time.Now().UTC()} + if err := jobs.Insert(context.Background(), job); err != nil { + t.Fatalf("insert owned job: %v", err) + } + return job.ID +} + +func userCtx(id uuid.UUID, role string) context.Context { + return authctx.With(context.Background(), authctx.Requester{UserID: id, Role: role}) +} + +func TestOverviewScopesJobsByOwner(t *testing.T) { + dash, jobs := newDashboard() + alice, bob := uuid.New(), uuid.New() + ownedJob(t, jobs, alice) + ownedJob(t, jobs, bob) + + // A plain user sees only their own job. + v, err := dash.Overview(userCtx(alice, "user"), 20) + if err != nil { + t.Fatal(err) + } + if len(v.Jobs) != 1 { + t.Errorf("alice sees %d jobs, want 1", len(v.Jobs)) + } + + // An admin sees every job. + if v, _ := dash.Overview(userCtx(uuid.New(), "admin"), 20); len(v.Jobs) != 2 { + t.Errorf("admin sees %d jobs, want 2", len(v.Jobs)) + } + + // No requester (basic-auth operator) sees every job — unchanged behaviour. + if v, _ := dash.Overview(context.Background(), 20); len(v.Jobs) != 2 { + t.Errorf("operator sees %d jobs, want 2", len(v.Jobs)) + } +} + +func TestJobDetailRejectsAnotherUsersJob(t *testing.T) { + dash, jobs := newDashboard() + alice, bob := uuid.New(), uuid.New() + jobID := ownedJob(t, jobs, alice) + + // Bob cannot open Alice's job. + if _, err := dash.JobDetail(userCtx(bob, "user"), jobID); !errors.Is(err, domain.ErrJobNotFound) { + t.Errorf("bob: got %v, want ErrJobNotFound", err) + } + // Alice can. + if _, err := dash.JobDetail(userCtx(alice, "user"), jobID); err != nil { + t.Errorf("alice: unexpected error %v", err) + } + // Admin can. + if _, err := dash.JobDetail(userCtx(uuid.New(), "admin"), jobID); err != nil { + t.Errorf("admin: unexpected error %v", err) + } +}