add users logic
This commit is contained in:
@@ -110,7 +110,7 @@ func run() error {
|
||||
|
||||
// pool.Ping backs /health: readiness means the database answers, not just
|
||||
// that the process is alive.
|
||||
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, pool.Ping)
|
||||
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, pool.Ping)
|
||||
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken))
|
||||
|
||||
// Shutdown order matters, and defers alone cannot express it (they run
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.22
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
|
||||
@@ -5,6 +5,8 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package authctx carries the authenticated requester across the transport and
|
||||
// use-case layers without either one importing the other. The HTTP middleware
|
||||
// stamps a Requester after verifying a user's JWT; the job use cases read it to
|
||||
// record ownership and to enforce that a non-admin only touches their own jobs.
|
||||
package authctx
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Requester is the identity behind a request, derived from a verified JWT.
|
||||
// A request authenticated only by the shared worker/service token carries no
|
||||
// Requester at all (From returns ok=false), which is how worker traffic and
|
||||
// legacy unauthenticated-user traffic stay owner-less.
|
||||
type Requester struct {
|
||||
UserID uuid.UUID
|
||||
Role string
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the requester may act on any user's jobs.
|
||||
func (r Requester) IsAdmin() bool { return r.Role == "admin" }
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// With returns a copy of ctx carrying r.
|
||||
func With(ctx context.Context, r Requester) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, r)
|
||||
}
|
||||
|
||||
// From returns the requester stamped by the middleware, or ok=false when the
|
||||
// request was not authenticated as a user.
|
||||
func From(ctx context.Context) (Requester, bool) {
|
||||
r, ok := ctx.Value(ctxKey{}).(Requester)
|
||||
return r, ok
|
||||
}
|
||||
@@ -19,7 +19,11 @@ const (
|
||||
|
||||
// Job is one user submission that fans out into one or more tasks.
|
||||
type Job struct {
|
||||
ID uuid.UUID
|
||||
ID uuid.UUID
|
||||
// OwnerID is the userservice user who submitted the job (JWT `sub`). nil
|
||||
// when the job was created without user authentication. Not a foreign key:
|
||||
// users live in a separate service/database.
|
||||
OwnerID *uuid.UUID
|
||||
Workload string
|
||||
InputURI string // external input URI; empty for uploaded datasets
|
||||
InputArtifactID *uuid.UUID // uploaded input artifact; nil for URI submissions
|
||||
|
||||
@@ -26,6 +26,12 @@ type Config struct {
|
||||
Token string
|
||||
// Local operator UI credential. Empty disables the embedded UI entirely.
|
||||
UIToken string
|
||||
// Shared HS256 secret used to verify userservice-issued JWTs. When set, a
|
||||
// submitter may authenticate with a JWT (in addition to workers using the
|
||||
// shared token) and their jobs are stamped with owner_id. Empty disables
|
||||
// user-JWT auth entirely — the pre-userservice behaviour. Must match the
|
||||
// userservice's JWT_SECRET.
|
||||
JWTSecret string
|
||||
|
||||
// Minimum log level: debug, info, warn, error.
|
||||
LogLevel string
|
||||
@@ -80,6 +86,7 @@ func LoadConfig() (Config, error) {
|
||||
// former name, still honoured so existing .env files keep working.
|
||||
Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")),
|
||||
UIToken: os.Getenv("UI_AUTH_TOKEN"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
LogFile: os.Getenv("LOG_FILE"),
|
||||
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
|
||||
@@ -100,6 +107,11 @@ func LoadConfig() (Config, error) {
|
||||
if cfg.UIToken != "" && cfg.Token != "" && cfg.UIToken == cfg.Token {
|
||||
return Config{}, fmt.Errorf("UI_AUTH_TOKEN must differ from the worker auth token")
|
||||
}
|
||||
// A short secret makes the HMAC brute-forceable; refuse a weak one rather
|
||||
// than verify tokens against it.
|
||||
if cfg.JWTSecret != "" && len(cfg.JWTSecret) < 32 {
|
||||
return Config{}, fmt.Errorf("JWT_SECRET must be at least 32 bytes")
|
||||
}
|
||||
|
||||
var err error
|
||||
if cfg.DBMaxConns, err = getEnvInt32("DB_MAX_CONNS", cfg.DBMaxConns); err != nil {
|
||||
|
||||
@@ -28,14 +28,15 @@ var _ usecase.JobRepository = (*JobRepo)(nil)
|
||||
var jobColumns = []string{
|
||||
"id", "workload", "input_uri", "parameters", "status", "created_at", "completed_at",
|
||||
"input_artifact_id", "result_artifact_id", "error_code", "error_message", "reducer_started_at",
|
||||
"owner_id",
|
||||
}
|
||||
|
||||
// Insert runs inside the caller's transaction, alongside the job's tasks — that
|
||||
// is what makes "all tasks or none" hold.
|
||||
func (r *JobRepo) Insert(ctx context.Context, j *domain.Job) error {
|
||||
sql, args, err := psql.Insert("jobs").
|
||||
Columns("id", "workload", "input_uri", "parameters", "status", "created_at").
|
||||
Values(j.ID, j.Workload, j.InputURI, jsonbOrEmpty(j.Parameters), string(j.Status), j.CreatedAt).
|
||||
Columns("id", "workload", "input_uri", "parameters", "status", "created_at", "owner_id").
|
||||
Values(j.ID, j.Workload, j.InputURI, jsonbOrEmpty(j.Parameters), string(j.Status), j.CreatedAt, j.OwnerID).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -59,7 +60,8 @@ func (r *JobRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
|
||||
)
|
||||
err = conn(ctx, r.pool).QueryRow(ctx, sql, args...).Scan(
|
||||
&j.ID, &j.Workload, &j.InputURI, &j.Parameters, &status, &j.CreatedAt, &j.CompletedAt,
|
||||
&j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt)
|
||||
&j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt,
|
||||
&j.OwnerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrJobNotFound
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package token verifies the HS256 JWTs minted by the userservice. The
|
||||
// coordinator only ever *verifies* — it never issues — so this is a deliberately
|
||||
// small counterpart to the userservice's issuer. Verification is local: the
|
||||
// shared secret is enough, with no runtime call back to the userservice.
|
||||
package token
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Claims is the subset of a userservice token the coordinator cares about.
|
||||
type Claims struct {
|
||||
UserID uuid.UUID
|
||||
Role string
|
||||
}
|
||||
|
||||
// Verifier checks tokens against the shared HS256 secret.
|
||||
type Verifier struct {
|
||||
secret []byte
|
||||
}
|
||||
|
||||
// NewVerifier returns a Verifier, or nil when secret is empty — a nil Verifier
|
||||
// means user-JWT auth is disabled and only the shared service token is accepted.
|
||||
func NewVerifier(secret string) *Verifier {
|
||||
if secret == "" {
|
||||
return nil
|
||||
}
|
||||
return &Verifier{secret: []byte(secret)}
|
||||
}
|
||||
|
||||
type claims struct {
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Verify checks the signature and expiry and returns the identity. It pins the
|
||||
// algorithm to HMAC, rejecting a token that asks for "none" or an RS256 public
|
||||
// key — the classic algorithm-substitution attack.
|
||||
func (v *Verifier) Verify(raw string) (Claims, error) {
|
||||
var c claims
|
||||
_, err := jwt.ParseWithClaims(raw, &c, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return v.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
id, err := uuid.Parse(c.Subject)
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("token subject is not a uuid: %w", err)
|
||||
}
|
||||
return Claims{UserID: id, Role: c.Role}, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const secret = "coordinator-verify-secret-32-bytes!!"
|
||||
|
||||
func sign(t *testing.T, method jwt.SigningMethod, key any, sub, role string, exp time.Time) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(method, claims{
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: sub,
|
||||
ExpiresAt: jwt.NewNumericDate(exp),
|
||||
},
|
||||
})
|
||||
raw, err := tok.SignedString(key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestNewVerifierNilWhenNoSecret(t *testing.T) {
|
||||
if NewVerifier("") != nil {
|
||||
t.Error("empty secret must yield a nil verifier (auth disabled)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRoundTrip(t *testing.T) {
|
||||
v := NewVerifier(secret)
|
||||
id := uuid.New()
|
||||
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), id.String(), "admin", time.Now().Add(time.Hour))
|
||||
|
||||
claims, err := v.Verify(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if claims.UserID != id {
|
||||
t.Errorf("UserID = %v, want %v", claims.UserID, id)
|
||||
}
|
||||
if claims.Role != "admin" {
|
||||
t.Errorf("Role = %q, want admin", claims.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsExpired(t *testing.T) {
|
||||
v := NewVerifier(secret)
|
||||
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", time.Now().Add(-time.Minute))
|
||||
if _, err := v.Verify(raw); err == nil {
|
||||
t.Error("expired token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsWrongSecret(t *testing.T) {
|
||||
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", time.Now().Add(time.Hour))
|
||||
if _, err := NewVerifier("another-secret-also-at-least-32-byte").Verify(raw); err == nil {
|
||||
t.Error("token verified under the wrong secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsNoneAlg(t *testing.T) {
|
||||
raw := sign(t, jwt.SigningMethodNone, jwt.UnsafeAllowNoneSignatureType, uuid.New().String(), "admin", time.Now().Add(time.Hour))
|
||||
if _, err := NewVerifier(secret).Verify(raw); err == nil {
|
||||
t.Error("none-signed token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsNonUUIDSubject(t *testing.T) {
|
||||
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), "not-a-uuid", "user", time.Now().Add(time.Hour))
|
||||
if _, err := NewVerifier(secret).Verify(raw); err == nil {
|
||||
t.Error("non-uuid subject accepted")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
@@ -41,25 +44,45 @@ func newRequestID() string {
|
||||
|
||||
// withAuth enforces the shared bearer token every worker presents.
|
||||
// An empty token disables the check (local development only).
|
||||
func withAuth(token string) func(http.Handler) http.Handler {
|
||||
// withAuth authenticates a request one of two ways. Workers (and legacy
|
||||
// submitters) present the shared service token. When user-JWT auth is enabled
|
||||
// (verifier != nil), a submitter may instead present a userservice JWT; on
|
||||
// success the requester is stamped into the context so the job use cases can
|
||||
// record owner_id and enforce ownership. An empty token with no verifier
|
||||
// disables auth entirely (dev only).
|
||||
func withAuth(token string, verifier *tokenpkg.Verifier) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if token == "" {
|
||||
if token == "" && verifier == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
presented := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
// Constant-time compare: a byte-by-byte early exit would let an
|
||||
// attacker recover the token by timing responses.
|
||||
if subtle.ConstantTimeCompare([]byte(presented), []byte(token)) != 1 {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "unauthorized",
|
||||
RequestID: requestIDFrom(r.Context()),
|
||||
})
|
||||
|
||||
// Shared service token: constant-time compare so a byte-by-byte
|
||||
// early exit cannot leak the token through response timing.
|
||||
if token != "" && subtle.ConstantTimeCompare([]byte(presented), []byte(token)) == 1 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
// Otherwise try a user JWT, if that path is configured.
|
||||
if verifier != nil && presented != "" {
|
||||
if claims, err := verifier.Verify(presented); err == nil {
|
||||
ctx := authctx.With(r.Context(), authctx.Requester{
|
||||
UserID: claims.UserID,
|
||||
Role: claims.Role,
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "unauthorized",
|
||||
RequestID: requestIDFrom(r.Context()),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
@@ -40,19 +41,23 @@ type Server struct {
|
||||
requestTimeout time.Duration
|
||||
heartbeatInterval time.Duration
|
||||
maxUploadBytes int64
|
||||
// verifier validates userservice JWTs. nil disables user-JWT auth, leaving
|
||||
// only the shared service token — the pre-userservice behaviour.
|
||||
verifier *tokenpkg.Verifier
|
||||
// ready probes downstream dependencies (the database) for /health. Kept as
|
||||
// a func so the transport layer never imports pgx.
|
||||
ready func(context.Context) error
|
||||
}
|
||||
|
||||
func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration,
|
||||
maxUploadBytes int64, ready func(context.Context) error) *Server {
|
||||
maxUploadBytes int64, jwtSecret string, ready func(context.Context) error) *Server {
|
||||
return &Server{
|
||||
uc: uc,
|
||||
log: log,
|
||||
requestTimeout: requestTimeout,
|
||||
heartbeatInterval: heartbeatInterval,
|
||||
maxUploadBytes: maxUploadBytes,
|
||||
verifier: tokenpkg.NewVerifier(jwtSecret),
|
||||
ready: ready,
|
||||
}
|
||||
}
|
||||
@@ -99,7 +104,7 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
|
||||
mux.Handle("/", chain(protected,
|
||||
withRequestID, // outermost: every response gets an ID,
|
||||
withAccessLog(s.log), // including the 401s below
|
||||
withAuth(token),
|
||||
withAuth(token, s.verifier),
|
||||
))
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
|
||||
if err != nil {
|
||||
t.Fatalf("register test worker: %v", err)
|
||||
}
|
||||
srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, ready)
|
||||
srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, "", ready)
|
||||
ts := httptest.NewServer(srv.Handler(token, configuredUIToken))
|
||||
t.Cleanup(ts.Close)
|
||||
return &env{ts: ts, blobs: blobs, workerID: worker.ID.String()}
|
||||
|
||||
@@ -53,6 +53,7 @@ func (uc *CreateJob) Execute(ctx context.Context, in CreateJobInput) (*domain.Jo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
job.OwnerID = ownerFromContext(ctx)
|
||||
|
||||
err = uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||
if err := uc.jobs.Insert(ctx, job); err != nil {
|
||||
@@ -97,6 +98,9 @@ func (uc *CancelJob) Execute(ctx context.Context, jobID uuid.UUID) (int64, error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||
return err
|
||||
}
|
||||
if job.Status == domain.JobCancelled {
|
||||
return nil
|
||||
}
|
||||
@@ -132,6 +136,9 @@ func (uc *GetJobStatus) Execute(ctx context.Context, jobID uuid.UUID) (domain.Jo
|
||||
if err != nil {
|
||||
return domain.JobProgress{}, err
|
||||
}
|
||||
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||
return domain.JobProgress{}, err
|
||||
}
|
||||
counts, err := uc.tasks.CountByStatus(ctx, jobID)
|
||||
if err != nil {
|
||||
return domain.JobProgress{}, err
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
// ownerFromContext returns the authenticated user id to stamp on a new job, or
|
||||
// nil when the request was not authenticated as a user — worker or legacy
|
||||
// traffic, or user-JWT auth disabled. A nil owner is stored as NULL.
|
||||
func ownerFromContext(ctx context.Context) *uuid.UUID {
|
||||
if r, ok := authctx.From(ctx); ok {
|
||||
id := r.UserID
|
||||
return &id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Requests with no authenticated user (worker/legacy traffic, or JWT auth
|
||||
// disabled) are not restricted here: the shared service token already gated
|
||||
// them, and worker endpoints legitimately operate across all jobs.
|
||||
func authorizeJobAccess(ctx context.Context, job *domain.Job) error {
|
||||
r, ok := authctx.From(ctx)
|
||||
if !ok || r.IsAdmin() {
|
||||
return nil
|
||||
}
|
||||
if job.OwnerID == nil || *job.OwnerID != r.UserID {
|
||||
return domain.ErrJobNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
func TestOwnerFromContext(t *testing.T) {
|
||||
if ownerFromContext(context.Background()) != nil {
|
||||
t.Error("no requester must yield a nil owner")
|
||||
}
|
||||
id := uuid.New()
|
||||
ctx := authctx.With(context.Background(), authctx.Requester{UserID: id, Role: "user"})
|
||||
got := ownerFromContext(ctx)
|
||||
if got == nil || *got != id {
|
||||
t.Errorf("owner = %v, want %v", got, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeJobAccess(t *testing.T) {
|
||||
owner := uuid.New()
|
||||
other := uuid.New()
|
||||
job := &domain.Job{ID: uuid.New(), OwnerID: &owner}
|
||||
|
||||
ctxOf := func(id uuid.UUID, role string) context.Context {
|
||||
return authctx.With(context.Background(), authctx.Requester{UserID: id, Role: role})
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
wantErr bool
|
||||
}{
|
||||
{"no requester (worker/legacy) allowed", context.Background(), false},
|
||||
{"owner allowed", ctxOf(owner, "user"), false},
|
||||
{"admin allowed", ctxOf(other, "admin"), false},
|
||||
{"non-owner denied", ctxOf(other, "user"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := authorizeJobAccess(tc.ctx, job)
|
||||
if tc.wantErr {
|
||||
if !errors.Is(err, domain.ErrJobNotFound) {
|
||||
t.Errorf("got %v, want ErrJobNotFound", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeJobAccessNilOwner(t *testing.T) {
|
||||
// A legacy job with no owner must not be readable by an arbitrary user.
|
||||
job := &domain.Job{ID: uuid.New(), OwnerID: nil}
|
||||
ctx := authctx.With(context.Background(), authctx.Requester{UserID: uuid.New(), Role: "user"})
|
||||
if err := authorizeJobAccess(ctx, job); !errors.Is(err, domain.ErrJobNotFound) {
|
||||
t.Errorf("got %v, want ErrJobNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,9 @@ func (uc *GetJobResult) Execute(ctx context.Context, jobID uuid.UUID) (*domain.A
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if job.Status != domain.JobCompleted || job.ResultArtifactID == nil {
|
||||
return nil, nil, domain.ErrArtifactNotFound
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func (uc *SubmitDataset) Execute(ctx context.Context, in SubmitDatasetInput) (Su
|
||||
if err != nil {
|
||||
return SubmitDatasetResult{}, err
|
||||
}
|
||||
job.OwnerID = ownerFromContext(ctx)
|
||||
|
||||
// Everything written to blob storage, so a failed transaction can undo it.
|
||||
var putKeys []string
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS ix_jobs_owner;
|
||||
ALTER TABLE jobs DROP COLUMN IF EXISTS owner_id;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,14 @@
|
||||
BEGIN;
|
||||
|
||||
-- Who submitted this job. Equals users.id from the userservice, taken from the
|
||||
-- JWT `sub` claim. NOT a foreign key: users live in a separate service/database,
|
||||
-- so integrity is guaranteed by the signed token, not by the DB.
|
||||
--
|
||||
-- Nullable because rows created before auth existed have no owner; new inserts
|
||||
-- must supply it (enforced in the app, not the schema, during the MVP).
|
||||
ALTER TABLE jobs ADD COLUMN owner_id uuid;
|
||||
|
||||
-- "List my jobs" / "admin filters by owner" scans by owner.
|
||||
CREATE INDEX ix_jobs_owner ON jobs (owner_id);
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user