diff --git a/coordinator/internal/usecase/task.go b/coordinator/internal/usecase/task.go index f724c07..1d96591 100644 --- a/coordinator/internal/usecase/task.go +++ b/coordinator/internal/usecase/task.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" + "github.com/emil28092005/SciMesh/coordinator/internal/authctx" "github.com/emil28092005/SciMesh/coordinator/internal/domain" ) @@ -51,6 +52,18 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl if err != nil { return nil, err } + // Bind the caller to the worker it claims as. A JWT-authenticated + // volunteer may operate only its own workers; without this the trust + // tier would be read off a caller-supplied worker_id, letting anyone who + // knows a trusted worker's id claim as it and bypass the quarantine + // below. A shared-token caller (no requester) is a lab operator and may + // act as any worker, preserving the original behaviour. + if r, ok := authctx.From(ctx); ok { + if worker.OwnerID == nil || *worker.OwnerID != r.UserID { + // Don't disclose that another user's worker exists. + return nil, domain.ErrWorkerNotFound + } + } // C1 quarantine: an untrusted volunteer worker may register but receives // no tasks, because there is not yet (until quorum, C2) any way to verify // its results. Report an empty queue rather than an error, so its poller diff --git a/coordinator/internal/usecase/usecase_test.go b/coordinator/internal/usecase/usecase_test.go index b1ed39d..c0a4566 100644 --- a/coordinator/internal/usecase/usecase_test.go +++ b/coordinator/internal/usecase/usecase_test.go @@ -11,6 +11,7 @@ import ( "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" @@ -300,6 +301,38 @@ func TestRegisterWorkerRecordsOwnerAndUntrusted(t *testing.T) { } } +func TestJWTCallerCannotClaimAsAnotherUsersWorker(t *testing.T) { + h := newHarness() + h.seedJob(t, "w", 1) + + // A trusted lab worker owned by nobody (shared-token registration). + victim, _ := h.register.Execute(ctx, usecase.RegisterWorkerInput{Name: "lab", Capabilities: []string{"w"}}) + + // An attacker authenticated as a JWT user tries to claim as the lab worker. + attacker := authctx.With(ctx, authctx.Requester{UserID: uuid.New(), Role: "user"}) + claimed, err := h.claim.Execute(attacker, usecase.ClaimTaskInput{WorkerID: victim.ID.String()}) + if !errors.Is(err, domain.ErrWorkerNotFound) { + t.Fatalf("claim as another's worker = (%v, %v), want ErrWorkerNotFound", claimed, err) + } +} + +func TestJWTCallerClaimsAsOwnTrustedWorker(t *testing.T) { + h := newHarness() + h.seedJob(t, "w", 1) + owner := uuid.New() + + // The user's own worker, trusted (e.g. a verified contributor). + mine, _ := h.register.Execute(ctx, usecase.RegisterWorkerInput{ + Name: "mine", Capabilities: []string{"w"}, OwnerID: &owner, TrustLevel: domain.WorkerTrusted, + }) + + callerCtx := authctx.With(ctx, authctx.Requester{UserID: owner, Role: "user", Verified: true}) + got, err := h.claim.Execute(callerCtx, usecase.ClaimTaskInput{WorkerID: mine.ID.String()}) + if err != nil || got == nil { + t.Fatalf("own trusted worker claim = (%v, %v), want a task", got, err) + } +} + func TestUntrustedWorkerIsQuarantinedFromClaims(t *testing.T) { h := newHarness() h.seedJob(t, "w", 1) // a task is waiting