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:
@@ -27,7 +27,7 @@ var _ usecase.UIReadRepository = (*UIReadRepo)(nil)
|
|||||||
func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
|
func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
|
||||||
return r.jobs.Get(ctx, id)
|
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 {
|
if limit < 1 || limit > 100 {
|
||||||
return nil, domain.ErrInvalidInput
|
return nil, domain.ErrInvalidInput
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,9 @@ func (r *UIReadRepo) ListJobs(_ context.Context, limit int) ([]domain.Job, error
|
|||||||
defer r.jobs.mu.Unlock()
|
defer r.jobs.mu.Unlock()
|
||||||
out := make([]domain.Job, 0, len(r.jobs.jobs))
|
out := make([]domain.Job, 0, len(r.jobs.jobs))
|
||||||
for _, job := range r.jobs.jobs {
|
for _, job := range r.jobs.jobs {
|
||||||
|
if owner != nil && (job.OwnerID == nil || *job.OwnerID != *owner) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, *job)
|
out = append(out, *job)
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool {
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ func TestUIReadRepoListsReducerFields(t *testing.T) {
|
|||||||
if claimed, err := jobs.ClaimReduction(ctx, job.ID, time.Now().UTC()); err != nil || !claimed {
|
if claimed, err := jobs.ClaimReduction(ctx, job.ID, time.Now().UTC()); err != nil || !claimed {
|
||||||
t.Fatalf("claim reduction = (%v, %v)", claimed, err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("list UI jobs: %v", err)
|
t.Fatalf("list UI jobs: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,15 @@ func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, err
|
|||||||
return 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 {
|
if limit < 1 || limit > 100 {
|
||||||
return nil, domain.ErrInvalidInput
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ func ownerFromContext(ctx context.Context) *uuid.UUID {
|
|||||||
return nil
|
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
|
// 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
|
// job. It returns ErrJobNotFound — not a 403 — on a mismatch, so the response
|
||||||
// never reveals that another user's job exists.
|
// never reveals that another user's job exists.
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ func (p *PreviewArtifact) Execute(ctx context.Context, jobID, artifactID uuid.UU
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ArtifactPreviewView{}, err
|
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)
|
artifacts, err := p.read.ListArtifactsByJob(ctx, jobID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ArtifactPreviewView{}, err
|
return ArtifactPreviewView{}, err
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import (
|
|||||||
// It intentionally exposes no storage paths or credentials.
|
// It intentionally exposes no storage paths or credentials.
|
||||||
type UIReadRepository interface {
|
type UIReadRepository interface {
|
||||||
GetJob(ctx context.Context, jobID uuid.UUID) (*domain.Job, error)
|
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)
|
ListTasksByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Task, error)
|
||||||
ListTasksByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[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)
|
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 NewDashboard(read UIReadRepository) *Dashboard { return &Dashboard{read: read} }
|
||||||
|
|
||||||
func (d *Dashboard) Overview(ctx context.Context, limit int) (DashboardView, error) {
|
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 {
|
if err != nil {
|
||||||
return DashboardView{}, err
|
return DashboardView{}, err
|
||||||
}
|
}
|
||||||
@@ -140,6 +142,11 @@ func (d *Dashboard) JobDetail(ctx context.Context, jobID uuid.UUID) (JobDetailVi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return JobDetailView{}, err
|
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)
|
tasks, err := d.read.ListTasksByJob(ctx, jobID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return JobDetailView{}, err
|
return JobDetailView{}, err
|
||||||
@@ -198,6 +205,10 @@ func (d *Dashboard) DownloadableArtifactBelongsToJob(ctx context.Context, jobID,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
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)
|
artifacts, err := d.read.ListArtifactsByJob(ctx, jobID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user