From a3db1a1e679a9757503f677924ea34b602e3bacb Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 14:38:24 +0300 Subject: [PATCH 01/24] add users logic --- coordinator/cmd/coordinator/main.go | 2 +- coordinator/go.mod | 1 + coordinator/go.sum | 2 + coordinator/internal/authctx/authctx.go | 37 +++ coordinator/internal/domain/job.go | 6 +- coordinator/internal/infra/config.go | 12 + .../internal/storage/postgres/job_repo.go | 8 +- coordinator/internal/token/verifier.go | 58 +++++ coordinator/internal/token/verifier_test.go | 79 ++++++ .../internal/transport/http/middleware.go | 45 +++- coordinator/internal/transport/http/server.go | 9 +- .../internal/transport/http/server_test.go | 2 +- coordinator/internal/usecase/job.go | 7 + coordinator/internal/usecase/ownership.go | 39 +++ .../internal/usecase/ownership_test.go | 66 +++++ coordinator/internal/usecase/reduce.go | 3 + coordinator/internal/usecase/upload.go | 1 + .../migrations/0011_job_owner.down.sql | 6 + coordinator/migrations/0011_job_owner.up.sql | 14 ++ users/.dockerignore | 14 ++ users/.env.example | 32 +++ users/.gitignore | 6 + users/.golangci.yml | 54 ++++ users/ARCHITECTURE.md | 144 +++++++++++ users/Dockerfile | 52 ++++ users/Makefile | 96 +++++++ users/README.md | 230 +++++++++++++++++ users/api/requests.http | 234 ++++++++++++++++++ users/cmd/userservice/main.go | 68 +++++ users/docker-compose.yml | 81 ++++++ users/go.mod | 24 ++ users/go.sum | 45 ++++ users/internal/auth/jwt.go | 67 +++++ users/internal/auth/jwt_test.go | 74 ++++++ users/internal/auth/password.go | 40 +++ users/internal/auth/password_test.go | 30 +++ users/internal/domain/errors.go | 11 + users/internal/domain/user.go | 80 ++++++ users/internal/domain/user_test.go | 62 +++++ users/internal/infra/clock.go | 13 + users/internal/infra/config.go | 151 +++++++++++ users/internal/infra/db.go | 65 +++++ users/internal/infra/logging.go | 65 +++++ users/internal/infra/server.go | 74 ++++++ users/internal/memstore/memstore.go | 66 +++++ users/internal/storage/postgres/builder.go | 11 + .../storage/postgres/integration_test.go | 109 ++++++++ users/internal/storage/postgres/retry.go | 82 ++++++ users/internal/storage/postgres/retry_test.go | 98 ++++++++ users/internal/storage/postgres/tx.go | 83 +++++++ users/internal/storage/postgres/user_repo.go | 85 +++++++ users/internal/transport/http/dto.go | 41 +++ users/internal/transport/http/errors.go | 61 +++++ users/internal/transport/http/handlers.go | 85 +++++++ users/internal/transport/http/middleware.go | 133 ++++++++++ users/internal/transport/http/server.go | 41 +++ users/internal/transport/http/server_test.go | 167 +++++++++++++ users/internal/usecase/errors.go | 18 ++ users/internal/usecase/login.go | 42 ++++ users/internal/usecase/ports.go | 41 +++ users/internal/usecase/register.go | 56 +++++ users/internal/usecase/usecase_test.go | 133 ++++++++++ users/migrations/0001_users.down.sql | 6 + users/migrations/0001_users.up.sql | 26 ++ users/scripts/smoke.sh | 52 ++++ 65 files changed, 3626 insertions(+), 19 deletions(-) create mode 100644 coordinator/internal/authctx/authctx.go create mode 100644 coordinator/internal/token/verifier.go create mode 100644 coordinator/internal/token/verifier_test.go create mode 100644 coordinator/internal/usecase/ownership.go create mode 100644 coordinator/internal/usecase/ownership_test.go create mode 100644 coordinator/migrations/0011_job_owner.down.sql create mode 100644 coordinator/migrations/0011_job_owner.up.sql create mode 100644 users/.dockerignore create mode 100644 users/.env.example create mode 100644 users/.gitignore create mode 100644 users/.golangci.yml create mode 100644 users/ARCHITECTURE.md create mode 100644 users/Dockerfile create mode 100644 users/Makefile create mode 100644 users/README.md create mode 100644 users/api/requests.http create mode 100644 users/cmd/userservice/main.go create mode 100644 users/docker-compose.yml create mode 100644 users/go.mod create mode 100644 users/go.sum create mode 100644 users/internal/auth/jwt.go create mode 100644 users/internal/auth/jwt_test.go create mode 100644 users/internal/auth/password.go create mode 100644 users/internal/auth/password_test.go create mode 100644 users/internal/domain/errors.go create mode 100644 users/internal/domain/user.go create mode 100644 users/internal/domain/user_test.go create mode 100644 users/internal/infra/clock.go create mode 100644 users/internal/infra/config.go create mode 100644 users/internal/infra/db.go create mode 100644 users/internal/infra/logging.go create mode 100644 users/internal/infra/server.go create mode 100644 users/internal/memstore/memstore.go create mode 100644 users/internal/storage/postgres/builder.go create mode 100644 users/internal/storage/postgres/integration_test.go create mode 100644 users/internal/storage/postgres/retry.go create mode 100644 users/internal/storage/postgres/retry_test.go create mode 100644 users/internal/storage/postgres/tx.go create mode 100644 users/internal/storage/postgres/user_repo.go create mode 100644 users/internal/transport/http/dto.go create mode 100644 users/internal/transport/http/errors.go create mode 100644 users/internal/transport/http/handlers.go create mode 100644 users/internal/transport/http/middleware.go create mode 100644 users/internal/transport/http/server.go create mode 100644 users/internal/transport/http/server_test.go create mode 100644 users/internal/usecase/errors.go create mode 100644 users/internal/usecase/login.go create mode 100644 users/internal/usecase/ports.go create mode 100644 users/internal/usecase/register.go create mode 100644 users/internal/usecase/usecase_test.go create mode 100644 users/migrations/0001_users.down.sql create mode 100644 users/migrations/0001_users.up.sql create mode 100755 users/scripts/smoke.sh diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go index 46b077c..2206bec 100644 --- a/coordinator/cmd/coordinator/main.go +++ b/coordinator/cmd/coordinator/main.go @@ -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 diff --git a/coordinator/go.mod b/coordinator/go.mod index 4fc8bcc..3b6d8e7 100644 --- a/coordinator/go.mod +++ b/coordinator/go.mod @@ -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 diff --git a/coordinator/go.sum b/coordinator/go.sum index d9c880a..8eea60b 100644 --- a/coordinator/go.sum +++ b/coordinator/go.sum @@ -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= diff --git a/coordinator/internal/authctx/authctx.go b/coordinator/internal/authctx/authctx.go new file mode 100644 index 0000000..74bf86e --- /dev/null +++ b/coordinator/internal/authctx/authctx.go @@ -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 +} diff --git a/coordinator/internal/domain/job.go b/coordinator/internal/domain/job.go index 16ec023..67ad075 100644 --- a/coordinator/internal/domain/job.go +++ b/coordinator/internal/domain/job.go @@ -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 diff --git a/coordinator/internal/infra/config.go b/coordinator/internal/infra/config.go index 7d6db0e..37f8d41 100644 --- a/coordinator/internal/infra/config.go +++ b/coordinator/internal/infra/config.go @@ -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 { diff --git a/coordinator/internal/storage/postgres/job_repo.go b/coordinator/internal/storage/postgres/job_repo.go index d889a23..eb859f2 100644 --- a/coordinator/internal/storage/postgres/job_repo.go +++ b/coordinator/internal/storage/postgres/job_repo.go @@ -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 } diff --git a/coordinator/internal/token/verifier.go b/coordinator/internal/token/verifier.go new file mode 100644 index 0000000..a366dcb --- /dev/null +++ b/coordinator/internal/token/verifier.go @@ -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 +} diff --git a/coordinator/internal/token/verifier_test.go b/coordinator/internal/token/verifier_test.go new file mode 100644 index 0000000..238d150 --- /dev/null +++ b/coordinator/internal/token/verifier_test.go @@ -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") + } +} diff --git a/coordinator/internal/transport/http/middleware.go b/coordinator/internal/transport/http/middleware.go index 2808313..bfa0dea 100644 --- a/coordinator/internal/transport/http/middleware.go +++ b/coordinator/internal/transport/http/middleware.go @@ -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()), + }) }) } } diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index 9eb56e2..b4e5a64 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -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 } diff --git a/coordinator/internal/transport/http/server_test.go b/coordinator/internal/transport/http/server_test.go index e43086e..e9463b3 100644 --- a/coordinator/internal/transport/http/server_test.go +++ b/coordinator/internal/transport/http/server_test.go @@ -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()} diff --git a/coordinator/internal/usecase/job.go b/coordinator/internal/usecase/job.go index eda4b49..2404cfe 100644 --- a/coordinator/internal/usecase/job.go +++ b/coordinator/internal/usecase/job.go @@ -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 diff --git a/coordinator/internal/usecase/ownership.go b/coordinator/internal/usecase/ownership.go new file mode 100644 index 0000000..c2d0105 --- /dev/null +++ b/coordinator/internal/usecase/ownership.go @@ -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 +} diff --git a/coordinator/internal/usecase/ownership_test.go b/coordinator/internal/usecase/ownership_test.go new file mode 100644 index 0000000..fd4297b --- /dev/null +++ b/coordinator/internal/usecase/ownership_test.go @@ -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) + } +} diff --git a/coordinator/internal/usecase/reduce.go b/coordinator/internal/usecase/reduce.go index e73f575..c1faf71 100644 --- a/coordinator/internal/usecase/reduce.go +++ b/coordinator/internal/usecase/reduce.go @@ -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 } diff --git a/coordinator/internal/usecase/upload.go b/coordinator/internal/usecase/upload.go index ad394db..4bd5caf 100644 --- a/coordinator/internal/usecase/upload.go +++ b/coordinator/internal/usecase/upload.go @@ -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 diff --git a/coordinator/migrations/0011_job_owner.down.sql b/coordinator/migrations/0011_job_owner.down.sql new file mode 100644 index 0000000..32ffe4f --- /dev/null +++ b/coordinator/migrations/0011_job_owner.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +DROP INDEX IF EXISTS ix_jobs_owner; +ALTER TABLE jobs DROP COLUMN IF EXISTS owner_id; + +COMMIT; diff --git a/coordinator/migrations/0011_job_owner.up.sql b/coordinator/migrations/0011_job_owner.up.sql new file mode 100644 index 0000000..64c9785 --- /dev/null +++ b/coordinator/migrations/0011_job_owner.up.sql @@ -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; diff --git a/users/.dockerignore b/users/.dockerignore new file mode 100644 index 0000000..a4d0d5c --- /dev/null +++ b/users/.dockerignore @@ -0,0 +1,14 @@ +# Keep the build context small and never bake secrets or local state into an image. +.env +.git +.gitignore +*.md +Makefile +docker-compose.yml +Dockerfile +.dockerignore + +# Local build artifacts +/coordinator +/bin/ +*.out diff --git a/users/.env.example b/users/.env.example new file mode 100644 index 0000000..4c1e596 --- /dev/null +++ b/users/.env.example @@ -0,0 +1,32 @@ +# Copy to .env and adjust. All settings are read from the environment. + +COORDINATOR_ADDR=:8080 +DATABASE_URL=postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable + +# Shared bearer token every worker must present. Leave empty to disable auth (dev only). +WORKER_AUTH_TOKEN=change-me + +# Optional local operator UI. Use a separate value; never reuse the worker token. +# When empty, /ui is disabled. +UI_AUTH_TOKEN= + +# Logging. LOG_LEVEL: debug|info|warn|error. LOG_FILE empty = stdout only; +# set a path to also write a size-rotated file (kept across restarts). +LOG_LEVEL=info +# LOG_FILE=./logs/coordinator.log + +# Directory where artifact bytes are stored. +COORDINATOR_STORAGE_DIR=./data +# Upper bound on an uploaded dataset or artifact body (bytes). Default 1 GiB. +MAX_UPLOAD_BYTES=1073741824 + +# Optional tuning (defaults shown). +DB_MAX_CONNS=10 +# How long to keep retrying the initial DB connection while Postgres boots. +DB_CONNECT_TIMEOUT=30s +REQUEST_TIMEOUT=15s +LEASE_DURATION=2m +DEFAULT_MAX_ATTEMPTS=3 +REAPER_INTERVAL=30s +# A worker silent longer than this is marked offline by the reaper. +WORKER_OFFLINE_AFTER=1m diff --git a/users/.gitignore b/users/.gitignore new file mode 100644 index 0000000..c8c4f7b --- /dev/null +++ b/users/.gitignore @@ -0,0 +1,6 @@ +/coordinator +/bin/ +.env +*.out +/logs/ +/data/ diff --git a/users/.golangci.yml b/users/.golangci.yml new file mode 100644 index 0000000..428bfed --- /dev/null +++ b/users/.golangci.yml @@ -0,0 +1,54 @@ +version: "2" + +run: + timeout: 3m + +linters: + # "standard" = errcheck, govet, ineffassign, staticcheck, unused. + default: standard + enable: + # Catches `err == ErrFoo` where errors.Is is required. Directly relevant + # here: domain exposes sentinel errors that use cases may wrap with %w. + - errorlint + # Returning nil after checking a non-nil error — a silent bug factory. + - nilerr + # http.Get/Do without a context: every outbound call must be cancellable. + - noctx + # Unclosed response bodies leak connections. + - bodyclose + # Common security mistakes (weak crypto, unhandled file perms). + - gosec + # Style and naming consistency. + - revive + - misspell + - unconvert + + settings: + errcheck: + # Deferred Close/Rollback are intentionally ignored in a few places + # (rollback after commit is a documented no-op). + check-type-assertions: true + revive: + rules: + - name: exported + disabled: true # internal packages need no exported-symbol comments + gosec: + excludes: + - G404 # math/rand is fine for jitter; nothing here is security-sensitive + + exclusions: + rules: + # Tests may skip error checks and use long literals freely. + - path: _test\.go + linters: + - errcheck + - gosec + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/emil28092005/SciMesh/coordinator diff --git a/users/ARCHITECTURE.md b/users/ARCHITECTURE.md new file mode 100644 index 0000000..77b6976 --- /dev/null +++ b/users/ARCHITECTURE.md @@ -0,0 +1,144 @@ +# Архитектура координатора + +Карта кода. Читать сверху вниз: сначала «где что лежит», потом «как проходит +запрос», в конце — «куда добавлять новое». + +--- + +## 1. Четыре слоя + +``` + infra конфиг, пул БД, часы, HTTP-сервер, reaper ← драйверы + transport HTTP-хендлеры ← входящее: кто зовёт нас + storage репозитории на SQL ← исходящее: кого зовём мы + usecase операции + ПОРТЫ (интерфейсы) ← прикладные правила + domain Task, Job и их инварианты ← бизнес-правила + + ┌── transport ──┐ + domain ◄── usecase ◄┤ ├◄── infra + └── storage ────┘ +``` + +`transport` и `storage` — один и тот же слой (в книгах он зовётся «адаптеры»), +просто разделённый по направлению: транспорт принимает запросы снаружи, storage +обращается наружу сам. Так путь к файлу говорит о его роли, а не о категории. + +**Единственное правило:** зависимости идут только внутрь. `domain` не импортирует +ничего из проекта. `usecase` видит только `domain`. `transport` и `storage` не +знают друг о друге. + +Проверить в любой момент: + +```sh +go list -f '{{range .Imports}}{{.}}{{"\n"}}{{end}}' ./internal/domain | grep internal +# пусто = правило соблюдено +``` + +--- + +## 2. Где что лежит + +| Файл | Что внутри | Строк | +| --- | --- | --- | +| `domain/task.go` | `Task` и **все** переходы состояний: аренда, завершение, провал, истечение | ~245 | +| `domain/job.go` | `Job`, разбиение на чанки, вывод статуса из счётчиков задач | ~107 | +| `domain/errors.go` | Нарушения бизнес-правил (`ErrLeaseConflict`, `ErrStaleAttempt`, …) | ~18 | +| `usecase/ports.go` | **Порты**: `TaskRepository`, `JobRepository`, `TxManager`, `Clock` | ~79 | +| `usecase/task.go` | Операции над задачей: claim, renew, complete, fail, expire | ~200 | +| `usecase/job.go` | Операции над job: create, status, results, stitch | ~180 | +| `usecase/dto.go` | Входные структуры юзкейсов | ~51 | +| `transport/http/server.go` | Роутер и сборка middleware | ~60 | +| `transport/http/handlers.go` | По хендлеру на эндпоинт | ~180 | +| `transport/http/dto.go` | JSON-форматы запросов и ответов | ~118 | +| `transport/http/middleware.go` | request-ID, access-лог, bearer-авторизация | ~103 | +| `transport/http/errors.go` | Маппинг доменных ошибок в HTTP-коды | ~55 | +| `storage/postgres/task_repo.go` | SQL по задачам, включая атомарный claim | ~109 | +| `storage/postgres/job_repo.go` | SQL по job'ам | ~39 | +| `storage/postgres/tx.go` | `TxManager`: транзакция через контекст | ~65 | +| `infra/*.go` | Конфиг, пул, часы, сервер, reaper | ~240 | +| `cmd/coordinator/main.go` | **Composition root** — единственное место со всеми конкретными типами | ~73 | + +--- + +## 3. Трасса запроса: `POST /tasks/claim` + +Как воркер получает задачу. Четыре остановки, по одной на слой: + +``` + ① transport/http/handlers.go → handleClaim + разбирает JSON, отдаёт usecase.ClaimTaskInput + │ + ▼ + ② usecase/task.go → ClaimTask.Execute + сначала подчищает протухшие аренды, потом просит одну задачу + через ПОРТ TaskRepository (реализацию не знает) + │ + ▼ + ③ usecase/ports.go → TaskRepository.ClaimNext + контракт: «атомарно выдай одну задачу» + │ + ▼ + ④ storage/postgres/task_repo.go → claimNextSQL + SELECT ... FOR UPDATE SKIP LOCKED + UPDATE одним запросом +``` + +Обратно поднимается `*domain.Task`, юзкейс сужает его до `domain.ClaimedTask` +(воркеру не отдаём `version`, `max_attempts` и чужие ошибки), хендлер +превращает в JSON. Пустая очередь — это `nil, nil` на шаге ② и `204` на ①. + +**Трасса `POST /tasks/{id}/result`** такая же, но с одним отличием: решение +принимает **сущность**, а не юзкейс. + +``` + handlers.go → CompleteTask.Execute → tx.WithinTx( + GetForUpdate → task.CompleteWith(...) ←── ЗДЕСЬ правила + │ (чужая аренда? устаревший + Update ←─────────────┘ attempt? повтор того же + syncJobStatus манифеста?) + ) +``` + +--- + +## 4. Куда добавлять новое + +| Хочу… | Правлю | +| --- | --- | +| новое бизнес-правило (когда задачу можно повторить) | `domain/task.go` + тест рядом | +| новую операцию (отменить job) | `usecase/job.go` + порт в `ports.go`, если нужен новый запрос к БД | +| новый HTTP-эндпоинт | `transport/http/handlers.go` + маршрут в `server.go` + DTO в `dto.go` | +| новый SQL-запрос | `storage/postgres/*_repo.go` | +| новую настройку | `infra/config.go` + `.env.example` | +| поменять код ответа на ошибку | `transport/http/errors.go` | + +**Правило при сомнении:** если код можно описать фразой «когда X, то Y» без +упоминания HTTP, SQL и конфигов — это `domain`. Если он оркеструет несколько +шагов и транзакцию — `usecase`. Если знает про JSON — `transport`, про SQL — `storage`. + +--- + +## 5. Три вещи, которые надо понять один раз + +**Порты объявляет потребитель.** `TaskRepository` описан в `usecase/ports.go`, а +реализован в `storage/postgres`. Поэтому `usecase` не импортирует `storage` — +стрелка зависимости смотрит внутрь, хотя вызов на рантайме идёт наружу. + +**Транзакция едет в контексте.** `TxManager.WithinTx` кладёт `pgx.Tx` в контекст +по неэкспортируемому ключу; репозитории достают её через `conn(ctx, pool)`. +Благодаря этому юзкейс говорит «сделай это атомарно», ни разу не упомянув pgx. + +**Атомарный claim нельзя разложить на шаги.** `ClaimNext` — один SQL-запрос, +потому что `SELECT` + отдельный `UPDATE` вернул бы гонку, при которой одну +задачу выдают двум воркерам. Поэтому `ClaimTask.Execute` выглядит тонким: там +нечего оркестровать, вся гарантия — внутри запроса. + +--- + +## 6. Что уже работает, а что заглушка + +Работает: слои и проводка, роутинг, авторизация, access-лог, маппинг ошибок, +транзакции, graceful shutdown, миграции, **весь domain с 12 юнит-тестами без БД**. + +Заглушки (`ErrNotImplemented` → HTTP 501): методы репозиториев. SQL для двух +главных операций уже написан в `task_repo.go` — `claimNextSQL` и +`expireLeasesSQL`, осталось их подключить. diff --git a/users/Dockerfile b/users/Dockerfile new file mode 100644 index 0000000..ffe514e --- /dev/null +++ b/users/Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1 +# +# Requires BuildKit (the RUN --mount cache lines below). Docker 23+ enables it +# by default when the buildx plugin is present; install `docker-buildx` if a +# build fails with "the --mount option requires BuildKit". + +# --- build stage ---------------------------------------------------------- +FROM golang:1.24-alpine AS build + +WORKDIR /src + +# Copy manifests first: this layer stays cached until dependencies actually +# change, so editing Go sources does not re-download the module graph. +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download + +COPY . . + +# The cache mounts persist the module cache and the compiler's build cache +# *across* builds, so a rebuild after a code edit recompiles only what changed +# instead of the whole dependency tree. +# +# CGO_ENABLED=0 produces a fully static binary, so the runtime image needs no +# libc. -trimpath strips local paths; -s -w drop the symbol table and DWARF. +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build \ + -trimpath -ldflags="-s -w" \ + -o /out/userservice ./cmd/userservice + +# --- runtime stage -------------------------------------------------------- +FROM alpine:3.20 + +# ca-certificates for outbound TLS; wget backs the container healthcheck. +RUN apk add --no-cache ca-certificates wget \ + && adduser -D -H -u 10001 userservice \ + # Pre-create the log dir owned by the non-root user. A named volume mounted + # here inherits this ownership from the image, so the process can write to it — + # a host bind mount, owned by root, could not. + && mkdir -p /var/log/scimesh \ + && chown -R userservice:userservice /var/log/scimesh + +COPY --from=build /out/userservice /usr/local/bin/userservice + +# Never run as root: a compromised process should not own the container. +USER userservice + +EXPOSE 8081 + +# Exec form, not shell: the binary becomes PID 1 and receives SIGTERM directly, +# which is what its graceful shutdown depends on. +ENTRYPOINT ["/usr/local/bin/userservice"] diff --git a/users/Makefile b/users/Makefile new file mode 100644 index 0000000..8f5c701 --- /dev/null +++ b/users/Makefile @@ -0,0 +1,96 @@ +.DEFAULT_GOAL := help + +.PHONY: help build run test test-integration vet lint tidy check migrate-up migrate-down up down down-clean logs ps psql smoke + +# `check` uses its own Compose project and host ports so it never touches a +# developer's local PostgreSQL or the normal `make up` stack. +CHECK_PROJECT ?= scimesh-users-check +CHECK_POSTGRES_PORT ?= 55433 +CHECK_USERSERVICE_PORT ?= 18081 +CHECK_HOST ?= http://localhost:$(CHECK_USERSERVICE_PORT) +CHECK_DATABASE_URL ?= postgres://scimesh:scimesh@localhost:$(CHECK_POSTGRES_PORT)/scimesh_users?sslmode=disable +CHECK_COMPOSE = POSTGRES_PORT=$(CHECK_POSTGRES_PORT) USERSERVICE_PORT=$(CHECK_USERSERVICE_PORT) docker compose -p $(CHECK_PROJECT) + +help: + @printf '%s\n' \ + 'SciMesh userservice commands:' \ + ' make up / make down Start or stop the userservice stack (Postgres + migrate + service).' \ + ' make check One command: vet, lint, race tests, integration, smoke (needs Docker).' \ + ' make test / make vet Run Go verification.' \ + ' make smoke Exercise the live API against a running service.' + +# --- build / run --------------------------------------------------------- +build: + go build ./... + +run: + go run ./cmd/userservice + +test: + go test ./... + +# Needs a running PostgreSQL with the migrations applied: +# make test-integration TEST_DATABASE_URL='postgres://...' +test-integration: + TEST_DATABASE_URL="$(TEST_DATABASE_URL)" go test -tags=integration ./... -v + +vet: + go vet ./... + +# Runs golangci-lint without installing it system-wide. +LINT_VERSION := v2.12.2 +lint: + @command -v golangci-lint >/dev/null 2>&1 \ + && golangci-lint run --build-tags=integration ./... \ + || go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(LINT_VERSION) run --build-tags=integration ./... + +tidy: + go mod tidy + +# One command for a reviewer: unit tests + vet + lint, then the stack up and the +# integration suite and the end-to-end smoke test. Needs Docker. +check: vet lint + go test -race ./... + $(CHECK_COMPOSE) up -d --build + @echo "waiting for the userservice to be ready..." + @attempt=0; until curl -fsS "$(CHECK_HOST)/health" >/dev/null; do \ + attempt=$$((attempt + 1)); \ + if [ $$attempt -ge 30 ]; then $(CHECK_COMPOSE) logs userservice; exit 1; fi; \ + sleep 1; \ + done + TEST_DATABASE_URL="$(CHECK_DATABASE_URL)" \ + go test -tags=integration ./internal/storage/postgres/ -v + HOST="$(CHECK_HOST)" ./scripts/smoke.sh + @echo "\nall checks passed ✓" + +# --- migrations ---------------------------------------------------------- +# Requires the golang-migrate CLI and DATABASE_URL, e.g.: +# export DATABASE_URL='postgres://scimesh:scimesh@localhost:5433/scimesh_users?sslmode=disable' +migrate-up: + migrate -path migrations -database "$(DATABASE_URL)" up + +migrate-down: + migrate -path migrations -database "$(DATABASE_URL)" down 1 + +# --- docker -------------------------------------------------------------- +up: + docker compose up -d --build + +down: + docker compose down + +down-clean: + docker compose down -v + +logs: + docker compose logs -f userservice + +ps: + docker compose ps + +psql: + docker compose exec postgres psql -U scimesh -d scimesh_users + +# --- api ------------------------------------------------------------------ +smoke: + ./scripts/smoke.sh diff --git a/users/README.md b/users/README.md new file mode 100644 index 0000000..7240b99 --- /dev/null +++ b/users/README.md @@ -0,0 +1,230 @@ +# SciMesh Coordinator + +Durable task-queue server for SciMesh, in Go on PostgreSQL. It owns all database +access; workers talk to it only over HTTP and never receive DB credentials. + +Built as a **modular monolith following Clean Architecture** — one binary, four +layers, dependencies pointing strictly inward. See +`docs/database-integration-task.md` and `docs/worker-daemon-task.md` in the repo +root for the full contract. + +## Layers + +``` + infra config, pgxpool, http.Server, clock ← frameworks & drivers + transport http handlers ← inbound: who calls us + storage sql repositories ← outbound: who we call + usecase business operations + PORTS ← application rules + domain Task, Job + their invariants ← enterprise rules + + ┌── transport ──┐ + domain ◄── usecase ◄┤ ├◄── infra + └── storage ────┘ +``` + +`transport` and `storage` are one layer — the "interface adapters" ring — split +by direction rather than by category, so a file's path tells you its role. + +The rule that matters: **source dependencies point only inward**. `domain` +imports nothing from this module; `usecase` sees only `domain`; `transport` and +`storage` know nothing of each other. Verify it at any time with: + +```sh +go list -f '{{range .Imports}}{{.}}{{"\n"}}{{end}}' ./internal/domain | grep internal # must be empty +``` + +## Layout + +``` +coordinator/ + cmd/coordinator/main.go # composition root: the only place with concrete types + internal/ + domain/ # entities + rules, no I/O + task.go Task, lease/complete/fail/expire transitions + job.go Job, chunk fan-out, status derivation + errors.go business-rule violations + usecase/ # one type per operation, dependencies injected + ports.go TaskRepository, JobRepository, TxManager, Clock + dto.go use-case boundary inputs + task.go claim, renew, complete, fail, expire + job.go create, status, results, stitch + transport/http/ # routing, DTOs, middleware, error mapping + storage/postgres/ # SQL behind the ports; TxManager via context + infra/ # config.go db.go clock.go server.go + migrations/ # golang-migrate SQL, run as an explicit command +``` + +A full map — file-by-file table, a request traced through every layer, and a +"where do I add X" guide — lives in [ARCHITECTURE.md](ARCHITECTURE.md). + +## Quickstart + +### With Docker (nothing to install but Docker) + +```sh +make up # Postgres → migrations → coordinator +curl localhost:8080/health +make logs # follow the coordinator +make down # stop (add down-clean to drop the DB volume) +``` + +To enable the local operator UI, set a separate credential before starting: + +```sh +UI_AUTH_TOKEN='local-ui-secret' make up +# Open http://localhost:8080/ui and use any username with this value as password. +``` + +The UI is disabled by default and never accepts the worker bearer token. +The **control room** shows live workers, recent runs, shard state/attempts, +safe failures, coordinator artifacts, and the final CSV for completed +similarity-search jobs. The job page follows the real stages: TSV accepted → +shards execute → workers return CSVs → `reducing` → final deterministic global +top-k result. It polls only its own coordinator read-model and never controls +or exposes worker processes. + +For a hands-on run, open `/ui`, choose **New similarity search**, select a +small ChEMBL-style TSV, then leave one or more `scimesh-worker` processes +running in separate terminals. The detail page updates every two seconds and +stops polling after a completed, failed, or cancelled job. Use **Preview CSV** +to inspect a bounded first page of a partial or completed final result before +downloading it. The UI never exposes source datasets or shard inputs; partial +CSVs remain available only as diagnostics. + +### One-command manual demo + +From the repository root, create the Python environment once, then start a +self-contained UI demo with two local reference workers: + +```sh +python3 -m venv .venv +.venv/bin/pip install -e '.[dev]' +make demo-ui +``` + +This uses a separate Docker project and ports `18080` (coordinator) and +`55432` (PostgreSQL), so it does not conflict with the normal stack. Open +`http://localhost:18080/ui`, use username `operator` and password +`demo-ui-secret`, upload a small ChEMBL TSV, and observe the workers process +it. Change the worker count with `make demo-ui WORKERS=3`; stop all demo +services and workers with `make demo-down`. + +The job page shows a live **Processing speed** graph in completed shards per +minute. It uses the coordinator snapshots observed by the open browser tab, so +it is a transparent local-session measurement rather than a persisted metric. +Use **Preview CSV** before downloading a partial diagnostic or completed final +result. Run `make help` from either the repository root or this directory for +the full list of demo commands. + +`up` starts three services in order: Postgres waits until `pg_isready` passes, a +one-shot `migrate` container applies the schema and exits, and only then does the +coordinator start — so it never queries a database that has no tables. + +> **Needs BuildKit.** The Dockerfile uses `RUN --mount=type=cache` to reuse the +> Go module and compiler caches between builds. If the build fails with +> *"the --mount option requires BuildKit"*, install the buildx plugin — +> `pacman -S docker-buildx` on Arch, `apt install docker-buildx-plugin` on Debian. + +### Locally, against your own Postgres + +```sh +cp .env.example .env # then edit DATABASE_URL / WORKER_AUTH_TOKEN + # it is loaded automatically — no export needed + +make tidy # fetch deps (needs network once) +make migrate-up # apply schema (needs the migrate CLI) +make run # start the server +``` + +## Configuration + +Settings come from the environment. A `.env` file is loaded at startup via +`godotenv` as a local-dev convenience (override its path with `ENV_FILE`): + +- a missing `.env` is not an error — production injects real env vars; +- **real environment variables always win** over the file, so an orchestrator's + values are never shadowed by a stale `.env` baked into an image. + +See `.env.example`; only `DATABASE_URL` is required. + +## Endpoints + +| Method | Path | Purpose | +| ------ | ---------------------------------- | --------------------------------------------- | +| POST | `/workers/register` | Register a worker, get its id | +| POST | `/jobs` | Create job + tasks from chunk URIs | +| POST | `/jobs/upload` | Upload a dataset; coordinator chunks it | +| GET | `/jobs/{job_id}` | Aggregate job progress | +| POST | `/tasks/claim` | Atomically lease one task (`204` if none) | +| GET | `/tasks/{task_id}/input` | Download the task's input shard | +| POST | `/tasks/{task_id}/heartbeat` | Renew the caller's lease (→ `running`) | +| PUT | `/tasks/{task_id}/artifacts/{name}`| Upload a partial-result artifact | +| POST | `/tasks/{task_id}/result` | Complete with an artifact id (idempotent) | +| POST | `/tasks/{task_id}/failure` | Record failure / retryable state | +| GET | `/artifacts/{artifact_id}/download`| Download an artifact by id | +| GET | `/health` | Readiness incl. database (unauthenticated) | + +The full contract is in [`docs/api-contract.md`](../docs/api-contract.md) and +[`docs/openapi.yaml`](../docs/openapi.yaml); a worker-author guide is in +[`docs/building-workers.md`](../docs/building-workers.md). + +## Poking the API + +Two ways, both checked in: + +```sh +make smoke # every endpoint, asserted; non-zero exit on failure +``` + +`api/requests.http` runs the same calls one at a time from an editor with a REST +client (VSCodium/VS Code "REST Client", JetBrains HTTP Client). Later requests +reuse ids captured from earlier responses, so it doubles as API documentation. + +## Status + +Works end to end: a worker registers, a dataset is uploaded and chunked into +shard tasks (or a job is created from chunk URIs), tasks are leased one at a +time, downloaded, heartbeated (`leased → running`), completed via uploaded +result artifacts, and reflected in job progress. A reaper reclaims expired +leases and marks silent workers offline. + +Done: schema + migrations, atomic claim (`FOR UPDATE SKIP LOCKED`), optimistic +concurrency, result/failure paths, lease expiry, worker registry + liveness, +artifact storage, dataset upload + chunking, request-size limits. + +Still stubbed: `StitchJob.Execute` — merging per-chunk top-k into the final CSV +is workload semantics that belongs to the Python side (reducer). + +## Tests + +Unit tests need **no database** — domain rules, use-case orchestration (over +in-memory `internal/memstore`), and HTTP handlers (via `httptest`): + +```sh +make test # go test ./... +make vet +make lint +go test -race ./... +``` + +Integration tests run against a **real PostgreSQL** (the spec forbids mocks +here — they verify `FOR UPDATE SKIP LOCKED`, optimistic concurrency, rollback): + +```sh +docker compose up -d +make test-integration TEST_DATABASE_URL='postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable' +``` + +CI (`.github/workflows/coordinator.yml`) runs vet, gofmt, race tests, lint, and +the integration suite against a Postgres service on every push and PR. + +For the complete local verification, including an isolated Docker PostgreSQL +and the HTTP smoke flow, run: + +```sh +make check +``` + +It uses Compose project `scimesh-check` and ports `55432`/`18080` by default, +so it does not connect to a PostgreSQL already running on `5432`. Override +`CHECK_POSTGRES_PORT`, `CHECK_COORDINATOR_PORT`, or `CHECK_PROJECT` if needed. diff --git a/users/api/requests.http b/users/api/requests.http new file mode 100644 index 0000000..6c50a59 --- /dev/null +++ b/users/api/requests.http @@ -0,0 +1,234 @@ +# SciMesh Coordinator — API requests +# +# Runnable from any editor with a REST client (VSCodium/VS Code "REST Client", +# JetBrains HTTP Client). Click "Send Request" above each block, top to bottom: +# later requests reuse ids captured from earlier responses. +# +# Start the stack first: docker compose up -d + +@host = http://localhost:8080 +@token = change-me +@worker = worker-1 + +### Readiness — the only unauthenticated endpoint (probes the database) +GET {{host}}/health + +### Auth check — no token must be rejected with 401 +POST {{host}}/tasks/claim +Content-Type: application/json + +{ "worker_id": "{{worker}}" } + +### 0. Register a worker (201) +# @name register +POST {{host}}/workers/register +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "name": "lab-worker-01", + "capabilities": ["similarity_search"], + "cpu_count": 8, + "memory_mb": 16384 +} + +@workerId = {{register.response.body.worker_id}} + +### 0b. Upload a dataset — the coordinator splits it into shard tasks (201) +# Text fields first, the file part last (it is streamed, not buffered). +# @name uploadJob +POST {{host}}/jobs/upload +Authorization: Bearer {{token}} +Content-Type: multipart/form-data; boundary=----scimesh + +------scimesh +Content-Disposition: form-data; name="workload" + +similarity_search +------scimesh +Content-Disposition: form-data; name="parameters" + +{"top_k":10} +------scimesh +Content-Disposition: form-data; name="chunk_rows" + +2 +------scimesh +Content-Disposition: form-data; name="file"; filename="chembl.tsv" +Content-Type: text/tab-separated-values + +id smiles +A CC +B CCC +C CCCC +D CCCCC +------scimesh-- + +### Download a task's input shard (200) — taskId must be a shard task from an +### uploaded job (claim one first; its input.uri is /tasks/{id}/input). +GET {{host}}/tasks/{{taskId}}/input +Authorization: Bearer {{token}} + +### 1. Create a job and its chunks (201) +# The coordinator splits the submission into one task per chunk, transactionally. +# @name createJob +POST {{host}}/jobs +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "workload": "similarity_search", + "input_uri": "s3://chembl/full.sdf", + "parameters": { "top_k": 10 }, + "chunks": [ + { "chunk_index": 0, "input_uri": "s3://chembl/shard-0.sdf", "input_sha256": "aaa", "max_attempts": 3 }, + { "chunk_index": 1, "input_uri": "s3://chembl/shard-1.sdf", "input_sha256": "bbb", "max_attempts": 3 }, + { "chunk_index": 2, "input_uri": "s3://chembl/shard-2.sdf", "input_sha256": "ccc", "max_attempts": 3 } + ] +} + +@jobId = {{createJob.response.body.id}} + +### 2. Claim a task (200, or 204 when the queue is empty) +# Each call leases a different task; run it repeatedly to see chunk_index advance. +# @name claim +POST {{host}}/tasks/claim +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "worker_id": "{{worker}}", + "capabilities": ["similarity_search"], + "max_concurrency": 1 +} + +@taskId = {{claim.response.body.task_id}} +@attempt = {{claim.response.body.attempt}} + +### 3. Heartbeat — renew the lease while the task is still running (200) +POST {{host}}/tasks/{{taskId}}/heartbeat +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "worker_id": "{{worker}}", + "attempt": {{attempt}} +} + +### 3a. Upload a partial-result artifact (200) — while the task is leased +# Identity travels in headers per the contract; the body is streamed as-is. +# @name uploadArtifact +PUT {{host}}/tasks/{{taskId}}/artifacts/result.csv +Authorization: Bearer {{token}} +Content-Type: text/csv +X-Worker-ID: {{worker}} +X-Task-Attempt: {{attempt}} + +query,match,score +CHEMBL25,CHEMBL139,0.87 + +@artifactId = {{uploadArtifact.response.body.artifact_id}} + +### 3b. Download the artifact by id (200) +GET {{host}}/artifacts/{{artifactId}}/download +Authorization: Bearer {{token}} + +### 3c. Upload a second artifact — used by the conflict check below (200) +# @name uploadArtifact2 +PUT {{host}}/tasks/{{taskId}}/artifacts/secondary.csv +Authorization: Bearer {{token}} +Content-Type: text/csv +X-Worker-ID: {{worker}} +X-Task-Attempt: {{attempt}} + +query,match,score +CHEMBL25,CHEMBL521,0.42 + +@artifactId2 = {{uploadArtifact2.response.body.artifact_id}} + +### 4. Submit the result, referencing the uploaded artifact (200) +POST {{host}}/tasks/{{taskId}}/result +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "worker_id": "{{worker}}", + "attempt": {{attempt}}, + "result": { "artifact_id": "{{artifactId}}", "content_type": "text/csv" }, + "metrics": { "elapsed_ms": 1234, "candidates": 50000 } +} + +### 4a. Replay the same result — must be idempotent (200, not 409) +POST {{host}}/tasks/{{taskId}}/result +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "worker_id": "{{worker}}", + "attempt": {{attempt}}, + "result": { "artifact_id": "{{artifactId}}" } +} + +### 4b. A different artifact for the same task — conflict (409) +POST {{host}}/tasks/{{taskId}}/result +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "worker_id": "{{worker}}", + "attempt": {{attempt}}, + "result": { "artifact_id": "{{artifactId2}}" } +} + +### 4c. Another worker submitting for this task — conflict (409) +POST {{host}}/tasks/{{taskId}}/result +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "worker_id": "impostor", + "attempt": {{attempt}}, + "result": { "artifact_id": "{{artifactId}}" } +} + +### 5. Report a failure instead (200) +# retryable=true returns the task to the queue while attempts remain; +# retryable=false fails it terminally. +POST {{host}}/tasks/{{taskId}}/failure +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "worker_id": "{{worker}}", + "attempt": {{attempt}}, + "error_code": "download_failed", + "error_message": "checksum mismatch on shard", + "retryable": true +} + +### 6. Job progress (200) +GET {{host}}/jobs/{{jobId}} +Authorization: Bearer {{token}} + +### --- error cases ------------------------------------------------------- + +### Malformed UUID in the path (400) +POST {{host}}/tasks/not-a-uuid/result +Authorization: Bearer {{token}} +Content-Type: application/json + +{ "worker_id": "{{worker}}", "attempt": 1, "result_uri": "s3://x", "result_sha256": "x" } + +### Unknown field in the body (400) — a misspelled key must not pass silently +POST {{host}}/tasks/claim +Authorization: Bearer {{token}} +Content-Type: application/json + +{ "worker_ID": "{{worker}}" } + +### Unknown job (404) +GET {{host}}/jobs/00000000-0000-0000-0000-000000000000 +Authorization: Bearer {{token}} + +### Stitching is not implemented yet (501) +# Any endpoint whose use case is still a stub answers 501. diff --git a/users/cmd/userservice/main.go b/users/cmd/userservice/main.go new file mode 100644 index 0000000..fbb0095 --- /dev/null +++ b/users/cmd/userservice/main.go @@ -0,0 +1,68 @@ +// Command userservice runs the SciMesh authentication service: it registers +// users, verifies logins, and issues the HS256 JWTs the coordinator trusts. +package main + +import ( + "context" + "fmt" + nethttp "net/http" + "os" + "os/signal" + "syscall" + + "github.com/emil28092005/SciMesh/users/internal/auth" + "github.com/emil28092005/SciMesh/users/internal/infra" + "github.com/emil28092005/SciMesh/users/internal/storage/postgres" + apihttp "github.com/emil28092005/SciMesh/users/internal/transport/http" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "fatal:", err) + os.Exit(1) + } +} + +func run() error { + // Cancelled on SIGINT/SIGTERM so the HTTP server drains in-flight requests + // instead of dropping them. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + cfg, err := infra.LoadConfig() + if err != nil { + return err + } + + log, closer, err := infra.NewLogger(cfg) + if err != nil { + return err + } + defer func() { _ = closer.Close() }() + + pool, err := infra.NewPool(ctx, cfg, log) + if err != nil { + return err + } + defer pool.Close() + + // Adapters implementing the usecase ports. + users := postgres.NewUserRepo(pool) + hasher := auth.NewHasher(cfg.BcryptCost) + clock := infra.NewClock() + issuer := auth.NewIssuer(cfg.JWTSecret, cfg.TokenTTL, clock.Now) + + uc := apihttp.UseCases{ + Register: usecase.NewRegister(users, hasher, clock), + Login: usecase.NewLogin(users, hasher, issuer), + Users: users, + } + + handler := apihttp.NewServer(log, uc, issuer) + // A blanket per-request deadline: bcrypt is bounded, so anything slower is a + // stuck handler we want to shed rather than hold a connection open. + handler = nethttp.TimeoutHandler(handler, cfg.RequestTimeout, `{"error":"request timeout"}`) + + return infra.RunServer(ctx, log, cfg.Addr, handler) +} diff --git a/users/docker-compose.yml b/users/docker-compose.yml new file mode 100644 index 0000000..f7e155b --- /dev/null +++ b/users/docker-compose.yml @@ -0,0 +1,81 @@ +# A self-contained stack for the userservice: its own PostgreSQL (a separate +# database from the coordinator's — different bounded context), a one-shot +# migration step, and the service. The project name and host ports differ from +# the coordinator's so both stacks can run side by side on one machine. +name: scimesh-users + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-scimesh} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scimesh} + POSTGRES_DB: ${POSTGRES_DB:-scimesh_users} + ports: + # 5433 on the host, so it never clashes with the coordinator's 5432. + - "${POSTGRES_PORT:-5433}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + # Everything else waits on this, so the check must prove the server + # accepts queries — not merely that the port is open. + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scimesh} -d ${POSTGRES_DB:-scimesh_users}"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 5s + + # One-shot: applies migrations, then exits. Schema changes stay an explicit + # deployment step — the service binary never migrates on startup. + migrate: + image: migrate/migrate:v4.17.1 + depends_on: + postgres: + condition: service_healthy + volumes: + - ./migrations:/migrations:ro + command: + - -path=/migrations + - -database=postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres:5432/${POSTGRES_DB:-scimesh_users}?sslmode=disable + - up + restart: on-failure + + userservice: + build: + context: . + depends_on: + postgres: + condition: service_healthy + # Start only once the schema exists, otherwise the first query fails. + migrate: + condition: service_completed_successfully + environment: + USERSERVICE_ADDR: ":8081" + # Host is the service name: compose resolves it on the project network. + DATABASE_URL: postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres:5432/${POSTGRES_DB:-scimesh_users}?sslmode=disable + # MUST match the coordinator's JWT secret so it can verify these tokens. + JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-at-least-32-bytes} + JWT_TTL: ${JWT_TTL:-24h} + DB_MAX_CONNS: "10" + REQUEST_TIMEOUT: "15s" + LOG_LEVEL: ${LOG_LEVEL:-info} + # Logs are teed to stdout (docker logs) and this rotated file on a named + # volume, so they survive a rebuild. + LOG_FILE: /var/log/scimesh/userservice.log + ports: + - "${USERSERVICE_PORT:-8081}:8081" + # A named volume (not a host bind mount): it inherits the image's directory + # ownership, so the non-root process can write to it. + volumes: + - userservice_logs:/var/log/scimesh + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/health"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + restart: unless-stopped + +volumes: + pgdata: + userservice_logs: diff --git a/users/go.mod b/users/go.mod new file mode 100644 index 0000000..86fce7a --- /dev/null +++ b/users/go.mod @@ -0,0 +1,24 @@ +module github.com/emil28092005/SciMesh/users + +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.3.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.6.0 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.17.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/users/go.sum b/users/go.sum new file mode 100644 index 0000000..9de4344 --- /dev/null +++ b/users/go.sum @@ -0,0 +1,45 @@ +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +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.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/users/internal/auth/jwt.go b/users/internal/auth/jwt.go new file mode 100644 index 0000000..1c029c5 --- /dev/null +++ b/users/internal/auth/jwt.go @@ -0,0 +1,67 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/users/internal/domain" +) + +// Claims is the payload of a signed token. Subject (from RegisteredClaims) is +// the user id — it becomes the coordinator's jobs.owner_id; Role drives +// authorization. Both services verify this token locally with the shared HS256 +// secret, so no runtime call back to the userservice is ever needed. +type Claims struct { + Role domain.Role `json:"role"` + jwt.RegisteredClaims +} + +// Issuer signs and verifies tokens with a shared HS256 secret. +type Issuer struct { + secret []byte + ttl time.Duration + now func() time.Time +} + +// NewIssuer builds an Issuer. now defaults to time.Now when nil; tests inject a +// fixed clock to make expiry deterministic. +func NewIssuer(secret string, ttl time.Duration, now func() time.Time) Issuer { + if now == nil { + now = time.Now + } + return Issuer{secret: []byte(secret), ttl: ttl, now: now} +} + +// Issue returns a signed token for the user, valid for the configured TTL. +func (i Issuer) Issue(userID uuid.UUID, role domain.Role) (string, error) { + now := i.now() + claims := Claims{ + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID.String(), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(i.secret) +} + +// Verify checks the signature and expiry and returns the claims. It pins the +// algorithm to HMAC, rejecting a token that asks for "none" or an RS256 public +// key — the classic algorithm-substitution attack against naive verifiers. +func (i Issuer) Verify(token string) (*Claims, error) { + var claims Claims + _, err := jwt.ParseWithClaims(token, &claims, 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 i.secret, nil + }) + if err != nil { + return nil, err + } + return &claims, nil +} diff --git a/users/internal/auth/jwt_test.go b/users/internal/auth/jwt_test.go new file mode 100644 index 0000000..c3d14b1 --- /dev/null +++ b/users/internal/auth/jwt_test.go @@ -0,0 +1,74 @@ +package auth + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/users/internal/domain" +) + +const testSecret = "test-secret-at-least-32-bytes-long!!" + +func TestIssueVerifyRoundTrip(t *testing.T) { + iss := NewIssuer(testSecret, time.Hour, nil) + id := uuid.New() + + token, err := iss.Issue(id, domain.RoleAdmin) + if err != nil { + t.Fatalf("issue: %v", err) + } + + claims, err := iss.Verify(token) + if err != nil { + t.Fatalf("verify: %v", err) + } + if claims.Subject != id.String() { + t.Errorf("sub = %q, want %q", claims.Subject, id.String()) + } + if claims.Role != domain.RoleAdmin { + t.Errorf("role = %q, want admin", claims.Role) + } +} + +func TestVerifyRejectsExpired(t *testing.T) { + // Negative TTL: the token is already expired when issued. + iss := NewIssuer(testSecret, -time.Minute, nil) + token, _ := iss.Issue(uuid.New(), domain.RoleUser) + + if _, err := iss.Verify(token); err == nil { + t.Error("expired token accepted") + } +} + +func TestVerifyRejectsWrongSecret(t *testing.T) { + token, _ := NewIssuer(testSecret, time.Hour, nil).Issue(uuid.New(), domain.RoleUser) + + other := NewIssuer("another-secret-also-32-bytes-long!!!", time.Hour, nil) + if _, err := other.Verify(token); err == nil { + t.Error("token verified under the wrong secret") + } +} + +func TestVerifyRejectsNoneAlgorithm(t *testing.T) { + // Forge a token signed with "none" — the classic algorithm-substitution + // attack. A verifier that trusts the header's alg would accept it. + tok := jwt.NewWithClaims(jwt.SigningMethodNone, Claims{ + Role: domain.RoleAdmin, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: uuid.New().String(), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + }) + raw, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatalf("sign none: %v", err) + } + + iss := NewIssuer(testSecret, time.Hour, nil) + if _, err := iss.Verify(raw); err == nil { + t.Error("none-signed token accepted") + } +} diff --git a/users/internal/auth/password.go b/users/internal/auth/password.go new file mode 100644 index 0000000..db790f4 --- /dev/null +++ b/users/internal/auth/password.go @@ -0,0 +1,40 @@ +// Package auth holds the cryptographic adapters — password hashing and JWT +// signing/verification. They implement use-case ports and keep bcrypt and the +// JWT library out of the domain and use-case layers. +package auth + +import "golang.org/x/crypto/bcrypt" + +// Hasher turns plaintext passwords into storable hashes and checks them back. +type Hasher struct { + cost int +} + +// NewHasher builds a Hasher. A cost of 0 uses bcrypt's default work factor. +func NewHasher(cost int) Hasher { + if cost == 0 { + cost = bcrypt.DefaultCost + } + return Hasher{cost: cost} +} + +// Hash returns the bcrypt hash of password. The salt and the cost are embedded +// in the returned string, so nothing else needs to be stored alongside it. +// +// bcrypt silently ignores input past 72 bytes; the use case rejects longer +// passwords before reaching here so a truncated tail never becomes a security +// surprise. +func (h Hasher) Hash(password string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(password), h.cost) + if err != nil { + return "", err + } + return string(b), nil +} + +// Compare reports whether password matches the stored hash. It returns a +// non-nil error (bcrypt.ErrMismatchedHashAndPassword) on any mismatch, which +// the caller collapses into a generic authentication failure. +func (h Hasher) Compare(hash, password string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} diff --git a/users/internal/auth/password_test.go b/users/internal/auth/password_test.go new file mode 100644 index 0000000..2094f3b --- /dev/null +++ b/users/internal/auth/password_test.go @@ -0,0 +1,30 @@ +package auth + +import "testing" + +func TestHashAndCompare(t *testing.T) { + h := NewHasher(0) // default cost + + hash, err := h.Hash("correct horse battery staple") + if err != nil { + t.Fatalf("hash: %v", err) + } + if hash == "correct horse battery staple" { + t.Fatal("hash must not equal the plaintext") + } + if err := h.Compare(hash, "correct horse battery staple"); err != nil { + t.Errorf("correct password rejected: %v", err) + } + if err := h.Compare(hash, "wrong password"); err == nil { + t.Error("wrong password accepted") + } +} + +func TestHashSaltsEachTime(t *testing.T) { + h := NewHasher(0) + a, _ := h.Hash("same") + b, _ := h.Hash("same") + if a == b { + t.Error("two hashes of the same password must differ (random salt)") + } +} diff --git a/users/internal/domain/errors.go b/users/internal/domain/errors.go new file mode 100644 index 0000000..39bd39a --- /dev/null +++ b/users/internal/domain/errors.go @@ -0,0 +1,11 @@ +package domain + +import "errors" + +// Domain validation errors. They describe an entity that cannot be constructed, +// independent of storage or transport, and the HTTP layer maps them to 400. +var ( + ErrEmptyEmail = errors.New("email is required") + ErrInvalidEmail = errors.New("email is not a valid address") + ErrEmptyPasswordHash = errors.New("password hash is required") +) diff --git a/users/internal/domain/user.go b/users/internal/domain/user.go new file mode 100644 index 0000000..45db392 --- /dev/null +++ b/users/internal/domain/user.go @@ -0,0 +1,80 @@ +package domain + +import ( + "net/mail" + "strings" + "time" + + "github.com/google/uuid" +) + +type Role string + +const ( + RoleAdmin Role = "admin" + RoleUser Role = "user" +) + +func (r Role) Valid() bool { + switch r { + case RoleAdmin, RoleUser: + return true + default: + return false + } +} + +type User struct { + ID uuid.UUID + Email string + PasswordHash string + Role Role + CreatedAt time.Time + UpdatedAt time.Time +} + +// NewUser builds a freshly registered account. It normalises the email and +// enforces every invariant a row must satisfy, so an invalid User cannot be +// constructed. The caller supplies the already-hashed password — hashing is an +// adapter's job, not the domain's. +// +// Registration always produces a plain user; promotion to admin is a manual, +// out-of-band operation, never something a request can trigger. +func NewUser(email, passwordHash string, now time.Time) (*User, error) { + email = NormalizeEmail(email) + if err := validateEmail(email); err != nil { + return nil, err + } + if passwordHash == "" { + return nil, ErrEmptyPasswordHash + } + return &User{ + ID: uuid.New(), + Email: email, + PasswordHash: passwordHash, + Role: RoleUser, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +// NormalizeEmail lower-cases and trims an address so that "Bob@X.com " and +// "bob@x.com" resolve to the same account. Every lookup and every insert must +// pass through here, matching the ck_users_email_lower database constraint. +func NormalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func validateEmail(email string) error { + if email == "" { + return ErrEmptyEmail + } + // A minimal shape check, not full RFC 5322: real deliverability is proven by + // sending mail, not by a regex. mail.ParseAddress also accepts the + // "Name " form, so we insist the parsed address equals the input. + addr, err := mail.ParseAddress(email) + if err != nil || addr.Address != email { + return ErrInvalidEmail + } + return nil +} diff --git a/users/internal/domain/user_test.go b/users/internal/domain/user_test.go new file mode 100644 index 0000000..9894cd2 --- /dev/null +++ b/users/internal/domain/user_test.go @@ -0,0 +1,62 @@ +package domain + +import ( + "errors" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestNewUserNormalisesAndValidates(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + + u, err := NewUser(" Bob@Example.COM ", "hashed", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.Email != "bob@example.com" { + t.Errorf("email not normalised: got %q", u.Email) + } + if u.Role != RoleUser { + t.Errorf("new user must default to RoleUser, got %q", u.Role) + } + if u.ID == uuid.Nil { + t.Error("new user must get an id") + } + if !u.CreatedAt.Equal(now) || !u.UpdatedAt.Equal(now) { + t.Error("timestamps not set from clock") + } +} + +func TestNewUserRejectsBadInput(t *testing.T) { + now := time.Now() + cases := []struct { + name string + email string + hash string + wantErr error + }{ + {"empty email", "", "h", ErrEmptyEmail}, + {"no domain", "bob", "h", ErrInvalidEmail}, + {"name form", "Bob ", "h", ErrInvalidEmail}, + {"empty hash", "bob@x.com", "", ErrEmptyPasswordHash}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewUser(tc.email, tc.hash, now) + if !errors.Is(err, tc.wantErr) { + t.Errorf("got %v, want %v", err, tc.wantErr) + } + }) + } +} + +func TestRoleValid(t *testing.T) { + if !RoleUser.Valid() || !RoleAdmin.Valid() { + t.Error("user and admin must be valid") + } + if Role("root").Valid() { + t.Error("unknown role must be invalid") + } +} diff --git a/users/internal/infra/clock.go b/users/internal/infra/clock.go new file mode 100644 index 0000000..eedbcde --- /dev/null +++ b/users/internal/infra/clock.go @@ -0,0 +1,13 @@ +// Clock: the real implementation of the usecase.Clock port. It lives out here +// because reading the system clock is infrastructure; tests substitute a fixed one. +package infra + +import "time" + +type System struct{} + +func NewClock() System { return System{} } + +// Now returns UTC so every timestamp the coordinator writes is comparable +// regardless of the host's timezone. +func (System) Now() time.Time { return time.Now().UTC() } diff --git a/users/internal/infra/config.go b/users/internal/infra/config.go new file mode 100644 index 0000000..410ee4b --- /dev/null +++ b/users/internal/infra/config.go @@ -0,0 +1,151 @@ +// Config: userservice settings, read only from the environment, so the same +// binary behaves identically in CI, local, and prod. +package infra + +import ( + "errors" + "fmt" + "io/fs" + "math" + "os" + "strconv" + "time" + + "github.com/joho/godotenv" +) + +// defaultEnvFile is loaded by LoadConfig unless ENV_FILE points elsewhere. +const defaultEnvFile = ".env" + +type Config struct { + // HTTP listen address, e.g. ":8081". + Addr string + // PostgreSQL connection string (pgx format / libpq URL). + DatabaseURL string + + // Shared HS256 secret used to sign JWTs. The coordinator verifies tokens + // with this same secret, so the two values MUST match. This is the only + // secret shared between the services. + JWTSecret string + // How long an issued token stays valid. + TokenTTL time.Duration + // bcrypt work factor. 0 falls back to the library default (currently 10). + BcryptCost int + + // Minimum log level: debug, info, warn, error. + LogLevel string + // Path to a rotated log file. Empty logs to stdout only. + LogFile string + + // Connection pool upper bound. + DBMaxConns int32 + // How long to keep retrying the initial database connection at startup + // before giving up. Covers a Postgres container that is still booting. + DBConnectTimeout time.Duration + // Per-request timeout applied to every handler. + RequestTimeout time.Duration +} + +// LoadConfig reads the environment and fails fast on anything required-but- +// missing or malformed, so a misconfigured process never limps along half-wired. +// +// A .env file (path overridable via ENV_FILE) is loaded first as a local-dev +// convenience. It only fills variables the environment does not already define. +func LoadConfig() (Config, error) { + envFile := os.Getenv("ENV_FILE") + if envFile == "" { + envFile = defaultEnvFile + } + // godotenv.Load never overwrites variables already present in the + // environment, so an orchestrator's values always beat the file. A missing + // file is expected in production, where env vars are injected directly. + if err := godotenv.Load(envFile); err != nil && !errors.Is(err, fs.ErrNotExist) { + return Config{}, fmt.Errorf("load env file %q: %w", envFile, err) + } + + cfg := Config{ + Addr: getEnv("USERSERVICE_ADDR", ":8081"), + DatabaseURL: os.Getenv("DATABASE_URL"), + JWTSecret: os.Getenv("JWT_SECRET"), + LogLevel: getEnv("LOG_LEVEL", "info"), + LogFile: os.Getenv("LOG_FILE"), + TokenTTL: 24 * time.Hour, + DBMaxConns: 10, + DBConnectTimeout: 30 * time.Second, + RequestTimeout: 15 * time.Second, + } + + if cfg.DatabaseURL == "" { + return Config{}, fmt.Errorf("DATABASE_URL is required") + } + if cfg.JWTSecret == "" { + return Config{}, fmt.Errorf("JWT_SECRET is required") + } + // A short secret makes the HMAC brute-forceable; refuse to start with one. + if len(cfg.JWTSecret) < 32 { + return Config{}, fmt.Errorf("JWT_SECRET must be at least 32 bytes") + } + + var err error + if cfg.TokenTTL, err = getEnvDuration("JWT_TTL", cfg.TokenTTL); err != nil { + return Config{}, err + } + if cfg.BcryptCost, err = getEnvInt("BCRYPT_COST", cfg.BcryptCost); err != nil { + return Config{}, err + } + if cfg.DBMaxConns, err = getEnvInt32("DB_MAX_CONNS", cfg.DBMaxConns); err != nil { + return Config{}, err + } + if cfg.DBConnectTimeout, err = getEnvDuration("DB_CONNECT_TIMEOUT", cfg.DBConnectTimeout); err != nil { + return Config{}, err + } + if cfg.RequestTimeout, err = getEnvDuration("REQUEST_TIMEOUT", cfg.RequestTimeout); err != nil { + return Config{}, err + } + + return cfg, nil +} + +func getEnv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func getEnvInt(key string, def int) (int, error) { + v := os.Getenv(key) + if v == "" { + return def, nil + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("%s: %w", key, err) + } + return n, nil +} + +func getEnvInt32(key string, def int32) (int32, error) { + n, err := getEnvInt(key, int(def)) + if err != nil { + return 0, err + } + // On 64-bit builds int is wider than int32, so an oversized value would + // wrap silently — DB_MAX_CONNS=2147483648 becoming a negative pool size. + if n < math.MinInt32 || n > math.MaxInt32 { + return 0, fmt.Errorf("%s: %d is out of range for int32", key, n) + } + return int32(n), nil +} + +func getEnvDuration(key string, def time.Duration) (time.Duration, error) { + v := os.Getenv(key) + if v == "" { + return def, nil + } + d, err := time.ParseDuration(v) + if err != nil { + return 0, fmt.Errorf("%s: %w", key, err) + } + return d, nil +} diff --git a/users/internal/infra/db.go b/users/internal/infra/db.go new file mode 100644 index 0000000..d1135cd --- /dev/null +++ b/users/internal/infra/db.go @@ -0,0 +1,65 @@ +// DB: the PostgreSQL connection pool. +package infra + +import ( + "context" + "log/slog" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/jackc/pgx/v5/pgxpool" +) + +// NewPool builds the single shared pool. The caller owns its lifetime and must +// Close() it on shutdown. +func NewPool(ctx context.Context, cfg Config, log *slog.Logger) (*pgxpool.Pool, error) { + poolCfg, err := pgxpool.ParseConfig(cfg.DatabaseURL) + if err != nil { + return nil, err + } + poolCfg.MaxConns = cfg.DBMaxConns + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return nil, err + } + // pgxpool.New is lazy, so a ping is needed to actually reach the server. + // It is retried because at startup — especially under docker-compose, where + // the coordinator can boot before Postgres is accepting connections — a + // service should wait for its database rather than crash-loop. + if err := pingWithRetry(ctx, pool, cfg.DBConnectTimeout, log); err != nil { + pool.Close() + return nil, err + } + return pool, nil +} + +// pingWithRetry waits for the database to accept connections, backing off +// between attempts until the budget elapses or ctx is cancelled. +// +// Unlike the transaction retry in storage/postgres, this retries *any* ping +// error: at startup a "connection refused" is the expected, retryable state, +// not an anomaly. +func pingWithRetry(ctx context.Context, pool *pgxpool.Pool, budget time.Duration, log *slog.Logger) error { + b := backoff.NewExponentialBackOff() + b.InitialInterval = 200 * time.Millisecond + b.MaxInterval = 3 * time.Second + b.MaxElapsedTime = budget + + attempt := 0 + return backoff.RetryNotify( + func() error { + // A bounded per-attempt timeout so one hung dial cannot eat the + // whole budget in a single try. + pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + return pool.Ping(pingCtx) + }, + backoff.WithContext(b, ctx), + func(err error, next time.Duration) { + attempt++ + log.Warn("database not ready, retrying", + "attempt", attempt, "retry_in", next.String(), "err", err) + }, + ) +} diff --git a/users/internal/infra/logging.go b/users/internal/infra/logging.go new file mode 100644 index 0000000..b5f49e3 --- /dev/null +++ b/users/internal/infra/logging.go @@ -0,0 +1,65 @@ +package infra + +import ( + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + + "gopkg.in/natefinch/lumberjack.v2" +) + +// NewLogger builds the process logger. +// +// It always writes JSON to stdout, so `docker logs` and any 12-factor log +// collector keep working. When LogFile is set it *also* writes to a +// size-rotated file, so logs survive a container rebuild instead of vanishing +// with the previous stdout stream. Rotation is delegated to lumberjack rather +// than hand-rolled. +// +// The returned Closer flushes and closes the file; call it on shutdown. +func NewLogger(cfg Config) (*slog.Logger, io.Closer, error) { + opts := &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)} + + var ( + out io.Writer = os.Stdout + closer io.Closer = noopCloser{} + ) + + if cfg.LogFile != "" { + if err := os.MkdirAll(filepath.Dir(cfg.LogFile), 0o750); err != nil { + return nil, nil, fmt.Errorf("create log directory: %w", err) + } + rotator := &lumberjack.Logger{ + Filename: cfg.LogFile, + MaxSize: 50, // megabytes before a rotation + MaxBackups: 5, // keep this many rotated files + MaxAge: 30, // days + Compress: true, + } + // Tee to both: the console stays live while the file is the durable copy. + out = io.MultiWriter(os.Stdout, rotator) + closer = rotator + } + + return slog.New(slog.NewJSONHandler(out, opts)), closer, nil +} + +func parseLevel(s string) slog.Level { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +type noopCloser struct{} + +func (noopCloser) Close() error { return nil } diff --git a/users/internal/infra/server.go b/users/internal/infra/server.go new file mode 100644 index 0000000..fc49a4c --- /dev/null +++ b/users/internal/infra/server.go @@ -0,0 +1,74 @@ +// Server: the HTTP listener and the background lease reaper, both shut down +// cleanly on a signal. +package infra + +import ( + "context" + "errors" + "log/slog" + "net/http" + "time" +) + +const shutdownGrace = 15 * time.Second + +// Run serves handler until ctx is cancelled, then drains in-flight requests. +func RunServer(ctx context.Context, log *slog.Logger, addr string, handler http.Handler) error { + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + + // Buffered so this goroutine can exit even when nobody reads the channel + // (the ctx.Done branch below) — an unbuffered send would leak it forever. + errCh := make(chan error, 1) + go func() { + log.Info("userservice listening", "addr", addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + log.Info("shutdown signal received") + } + + // A fresh context: ctx is already cancelled, and reusing it would abort the + // very requests we are trying to let finish. + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGrace) + defer cancel() + return srv.Shutdown(shutdownCtx) +} + +// RunReaper periodically reclaims tasks whose lease elapsed, so a worker that +// died without a heartbeat cannot strand its task in 'leased' forever. +// RunPeriodic invokes fn on an interval until ctx is done, logging how many rows +// each tick affected. It backs the background reapers (expired leases, offline +// workers) — each is a set-based UPDATE that is safe to run repeatedly and +// concurrently across coordinators. +func RunPeriodic(ctx context.Context, log *slog.Logger, name string, interval time.Duration, + fn func(context.Context) (int64, error)) { + + t := time.NewTicker(interval) + defer t.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + n, err := fn(ctx) + if err != nil { + log.Debug(name+" skipped", "err", err) + continue + } + if n > 0 { + log.Info(name, "count", n) + } + } + } +} diff --git a/users/internal/memstore/memstore.go b/users/internal/memstore/memstore.go new file mode 100644 index 0000000..8ca2f41 --- /dev/null +++ b/users/internal/memstore/memstore.go @@ -0,0 +1,66 @@ +// Package memstore provides in-memory implementations of the usecase ports for +// fast, deterministic tests that need no database. +package memstore + +import ( + "context" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/users/internal/domain" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +// UserRepo is an in-memory usecase.UserRepository. It stores copies, so callers +// mutating a returned user cannot corrupt the store. +type UserRepo struct { + mu sync.Mutex + byID map[uuid.UUID]domain.User + byEmail map[string]uuid.UUID +} + +func NewUserRepo() *UserRepo { + return &UserRepo{ + byID: make(map[uuid.UUID]domain.User), + byEmail: make(map[string]uuid.UUID), + } +} + +func (r *UserRepo) Insert(_ context.Context, u *domain.User) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.byEmail[u.Email]; ok { + return usecase.ErrEmailExists + } + r.byID[u.ID] = *u + r.byEmail[u.Email] = u.ID + return nil +} + +func (r *UserRepo) GetByEmail(_ context.Context, email string) (*domain.User, error) { + r.mu.Lock() + defer r.mu.Unlock() + id, ok := r.byEmail[email] + if !ok { + return nil, usecase.ErrUserNotFound + } + u := r.byID[id] + return &u, nil +} + +func (r *UserRepo) GetByID(_ context.Context, id uuid.UUID) (*domain.User, error) { + r.mu.Lock() + defer r.mu.Unlock() + u, ok := r.byID[id] + if !ok { + return nil, usecase.ErrUserNotFound + } + return &u, nil +} + +// Clock is a fixed usecase.Clock for deterministic tests. +type Clock struct{ T time.Time } + +func (c Clock) Now() time.Time { return c.T } diff --git a/users/internal/storage/postgres/builder.go b/users/internal/storage/postgres/builder.go new file mode 100644 index 0000000..0025347 --- /dev/null +++ b/users/internal/storage/postgres/builder.go @@ -0,0 +1,11 @@ +package postgres + +import sq "github.com/Masterminds/squirrel" + +// psql is the shared statement builder, fixed to PostgreSQL $N placeholders so +// no call site repeats PlaceholderFormat(sq.Dollar). +// +// Not everything goes through it. Two genuinely set-based statements stay as +// raw SQL — claimNext (a FOR UPDATE SKIP LOCKED CTE) and expireLeases (CASE +// logic in the SET) — because a builder would obscure them, not clarify them. +var psql = sq.StatementBuilder.PlaceholderFormat(sq.Dollar) diff --git a/users/internal/storage/postgres/integration_test.go b/users/internal/storage/postgres/integration_test.go new file mode 100644 index 0000000..d60e27b --- /dev/null +++ b/users/internal/storage/postgres/integration_test.go @@ -0,0 +1,109 @@ +//go:build integration + +// Integration tests run against a real PostgreSQL instance supplied through +// TEST_DATABASE_URL, with the userservice migrations already applied. A real DB +// is required because the guarantees under test — the unique-email constraint +// mapping to ErrEmailExists, the ck_users_email_lower check — are properties of +// Postgres, not of the Go code. +// +// docker compose up -d +// TEST_DATABASE_URL='postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable' \ +// go test -tags=integration ./internal/storage/postgres/ -v +package postgres + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/emil28092005/SciMesh/users/internal/domain" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +func testPool(t *testing.T) *pgxpool.Pool { + t.Helper() + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL is not set") + } + pool, err := pgxpool.New(context.Background(), url) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(pool.Close) + return pool +} + +// seedUser inserts a user with a unique email and removes it afterwards, so +// tests stay independent of each other and of leftovers from earlier runs. +func seedUser(t *testing.T, repo *UserRepo) *domain.User { + t.Helper() + email := fmt.Sprintf("it-%s@example.com", uuid.NewString()) + u, err := domain.NewUser(email, "$2a$04$abcdefghijklmnopqrstuv", time.Now().UTC()) + if err != nil { + t.Fatalf("build user: %v", err) + } + if err := repo.Insert(context.Background(), u); err != nil { + t.Fatalf("insert: %v", err) + } + t.Cleanup(func() { + _, _ = repo.pool.Exec(context.Background(), "DELETE FROM users WHERE id = $1", u.ID) + }) + return u +} + +func TestUserRepoInsertAndGet(t *testing.T) { + repo := NewUserRepo(testPool(t)) + ctx := context.Background() + want := seedUser(t, repo) + + byEmail, err := repo.GetByEmail(ctx, want.Email) + if err != nil { + t.Fatalf("GetByEmail: %v", err) + } + if byEmail.ID != want.ID || byEmail.Email != want.Email || byEmail.Role != domain.RoleUser { + t.Errorf("GetByEmail mismatch: %+v", byEmail) + } + + byID, err := repo.GetByID(ctx, want.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if byID.Email != want.Email { + t.Errorf("GetByID mismatch: %+v", byID) + } +} + +func TestUserRepoDuplicateEmail(t *testing.T) { + repo := NewUserRepo(testPool(t)) + existing := seedUser(t, repo) + + // A second user with the same email must hit the unique constraint and map + // to the port's sentinel error. + dup, err := domain.NewUser(existing.Email, "$2a$04$abcdefghijklmnopqrstuv", time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + err = repo.Insert(context.Background(), dup) + if !errors.Is(err, usecase.ErrEmailExists) { + t.Errorf("got %v, want ErrEmailExists", err) + } +} + +func TestUserRepoNotFound(t *testing.T) { + repo := NewUserRepo(testPool(t)) + ctx := context.Background() + + if _, err := repo.GetByID(ctx, uuid.New()); !errors.Is(err, usecase.ErrUserNotFound) { + t.Errorf("GetByID unknown: got %v, want ErrUserNotFound", err) + } + if _, err := repo.GetByEmail(ctx, "ghost@example.com"); !errors.Is(err, usecase.ErrUserNotFound) { + t.Errorf("GetByEmail unknown: got %v, want ErrUserNotFound", err) + } +} diff --git a/users/internal/storage/postgres/retry.go b/users/internal/storage/postgres/retry.go new file mode 100644 index 0000000..8f0ae86 --- /dev/null +++ b/users/internal/storage/postgres/retry.go @@ -0,0 +1,82 @@ +package postgres + +import ( + "context" + "errors" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/jackc/pgx/v5/pgconn" +) + +// Transient PostgreSQL failures. Under concurrent claiming these are expected +// rather than exceptional: two coordinators touching neighbouring rows can +// deadlock or fail to serialize, and the correct response is to try again. +const ( + codeSerializationFailure = "40001" + codeDeadlockDetected = "40P01" + codeTooManyConnections = "53300" + codeCannotConnectNow = "57P03" +) + +// Retry budget: short and bounded. A worker polling for tasks would rather get +// a fast error and poll again than have its request hang for half a minute. +const ( + retryInitialInterval = 50 * time.Millisecond + retryMaxInterval = 1 * time.Second + retryMaxElapsedTime = 5 * time.Second +) + +// isTransient reports whether err is worth retrying. +// +// The default is *not* to retry: a constraint violation or a syntax error will +// fail identically every time, and retrying it only multiplies the damage. +func isTransient(err error) bool { + if err == nil { + return false + } + // A cancelled caller does not want another attempt. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case codeSerializationFailure, codeDeadlockDetected, + codeTooManyConnections, codeCannotConnectNow: + return true + default: + return false + } + } + + // Connection-level trouble (dropped socket, closed pool). pgconn knows + // whether the query could have been executed before the failure — retrying + // a maybe-executed write would risk duplicating it. + return pgconn.SafeToRetry(err) +} + +// withRetry runs op, retrying only transient database failures with +// exponential backoff and jitter, and giving up as soon as ctx is done. +// +// Jitter matters here: without it, several coordinators that collide once will +// retry in lockstep and collide again at exactly the same moment. +func withRetry(ctx context.Context, op func(context.Context) error) error { + b := backoff.NewExponentialBackOff() + b.InitialInterval = retryInitialInterval + b.MaxInterval = retryMaxInterval + b.MaxElapsedTime = retryMaxElapsedTime + // RandomizationFactor defaults to 0.5, which is the jitter. + + return backoff.Retry(func() error { + err := op(ctx) + if err == nil { + return nil + } + if !isTransient(err) { + return backoff.Permanent(err) // stop now, do not burn the budget + } + return err + }, backoff.WithContext(b, ctx)) +} diff --git a/users/internal/storage/postgres/retry_test.go b/users/internal/storage/postgres/retry_test.go new file mode 100644 index 0000000..bdf77ec --- /dev/null +++ b/users/internal/storage/postgres/retry_test.go @@ -0,0 +1,98 @@ +package postgres + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgconn" +) + +func TestIsTransient(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"serialization failure", &pgconn.PgError{Code: codeSerializationFailure}, true}, + {"deadlock", &pgconn.PgError{Code: codeDeadlockDetected}, true}, + {"too many connections", &pgconn.PgError{Code: codeTooManyConnections}, true}, + // A unique-violation repeats identically forever — retrying is pointless. + {"unique violation", &pgconn.PgError{Code: "23505"}, false}, + {"syntax error", &pgconn.PgError{Code: "42601"}, false}, + {"context cancelled", context.Canceled, false}, + {"deadline exceeded", context.DeadlineExceeded, false}, + {"unknown error", errors.New("boom"), false}, + // Wrapping must not hide the cause: errors.As walks the chain. + {"wrapped deadlock", errors2Wrap(&pgconn.PgError{Code: codeDeadlockDetected}), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isTransient(tt.err); got != tt.want { + t.Errorf("isTransient(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +func errors2Wrap(err error) error { + return errors.Join(errors.New("query failed"), err) +} + +func TestWithRetrySucceedsAfterTransientFailures(t *testing.T) { + calls := 0 + err := withRetry(context.Background(), func(context.Context) error { + calls++ + if calls < 3 { + return &pgconn.PgError{Code: codeSerializationFailure} + } + return nil + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if calls != 3 { + t.Errorf("calls = %d, want 3", calls) + } +} + +func TestWithRetryStopsOnPermanentError(t *testing.T) { + permanent := &pgconn.PgError{Code: "23505"} // unique violation + calls := 0 + + err := withRetry(context.Background(), func(context.Context) error { + calls++ + return permanent + }) + + if !errors.Is(err, permanent) { + t.Errorf("err = %v, want the original error", err) + } + if calls != 1 { + t.Errorf("calls = %d, want 1 — a permanent error must not be retried", calls) + } +} + +func TestWithRetryHonoursContextCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + calls := 0 + start := time.Now() + err := withRetry(ctx, func(context.Context) error { + calls++ + return &pgconn.PgError{Code: codeDeadlockDetected} + }) + + if err == nil { + t.Fatal("expected an error once the context expired") + } + // Must abort at the deadline, not run the full 5s retry budget. + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("took %v, expected to stop at the context deadline", elapsed) + } +} diff --git a/users/internal/storage/postgres/tx.go b/users/internal/storage/postgres/tx.go new file mode 100644 index 0000000..b5ccc1c --- /dev/null +++ b/users/internal/storage/postgres/tx.go @@ -0,0 +1,83 @@ +// Package postgres implements the usecase repository ports on PostgreSQL. +// SQL and pgx types never escape this package. +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// querier is satisfied by both *pgxpool.Pool and pgx.Tx, letting every +// repository method run identically inside or outside a transaction. +type querier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) + SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults +} + +// txKey is an unexported struct type, so no other package can collide with it +// or reach the transaction we stash in the context. +type txKey struct{} + +// TxManager implements usecase.TxManager. +type TxManager struct { + pool *pgxpool.Pool +} + +func NewTxManager(pool *pgxpool.Pool) *TxManager { + return &TxManager{pool: pool} +} + +// WithinTx runs fn inside one transaction, committing on success and rolling +// back on any error or panic. +// +// The transaction travels in the context rather than in fn's signature, which +// is what lets the usecase layer express "do these repository calls atomically" +// without its port ever mentioning pgx. +// Retrying happens here, around the whole transaction, and deliberately not +// inside the repositories. Once Postgres aborts a transaction with a +// serialization failure or deadlock, every further statement in it fails too — +// replaying a single query would accomplish nothing. The unit of retry is +// Begin → fn → Commit. +// +// This is safe because fn re-reads its rows (via GetForUpdate) on each attempt, +// so a retry starts from the current state rather than stale entities. +func (m *TxManager) WithinTx(ctx context.Context, fn func(ctx context.Context) error) error { + if _, ok := ctx.Value(txKey{}).(pgx.Tx); ok { + // Already inside a transaction — join it. Retrying here would be wrong + // twice over: the outer transaction owns the retry, and re-running fn + // alone cannot undo what the outer one already wrote. + return fn(ctx) + } + + return withRetry(ctx, func(ctx context.Context) error { + return m.runTx(ctx, fn) + }) +} + +func (m *TxManager) runTx(ctx context.Context, fn func(ctx context.Context) error) error { + tx, err := m.pool.Begin(ctx) + if err != nil { + return err + } + // Rollback after a successful Commit is a no-op, so this defer is safe and + // also covers the panic path. + defer func() { _ = tx.Rollback(ctx) }() + + if err := fn(context.WithValue(ctx, txKey{}, tx)); err != nil { + return err + } + return tx.Commit(ctx) +} + +// conn returns the transaction bound to ctx, or the pool when there is none. +func conn(ctx context.Context, pool *pgxpool.Pool) querier { + if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok { + return tx + } + return pool +} diff --git a/users/internal/storage/postgres/user_repo.go b/users/internal/storage/postgres/user_repo.go new file mode 100644 index 0000000..b170fcf --- /dev/null +++ b/users/internal/storage/postgres/user_repo.go @@ -0,0 +1,85 @@ +package postgres + +import ( + "context" + "errors" + + sq "github.com/Masterminds/squirrel" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/emil28092005/SciMesh/users/internal/domain" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +// uniqueViolation is PostgreSQL's SQLSTATE for a unique-constraint breach. +const uniqueViolation = "23505" + +var userColumns = []string{"id", "email", "password_hash", "role", "created_at", "updated_at"} + +// UserRepo implements usecase.UserRepository on PostgreSQL. +type UserRepo struct { + pool *pgxpool.Pool +} + +func NewUserRepo(pool *pgxpool.Pool) *UserRepo { + return &UserRepo{pool: pool} +} + +func (r *UserRepo) Insert(ctx context.Context, u *domain.User) error { + sql, args, err := psql.Insert("users"). + Columns(userColumns...). + Values(u.ID, u.Email, u.PasswordHash, string(u.Role), u.CreatedAt, u.UpdatedAt). + ToSql() + if err != nil { + return err + } + if _, err := conn(ctx, r.pool).Exec(ctx, sql, args...); err != nil { + // A concurrent insert of the same email surfaces as a unique violation + // on uq_users_email; translate it to the port's sentinel so the use + // case never sees a driver type. + if isUniqueViolation(err) { + return usecase.ErrEmailExists + } + return err + } + return nil +} + +func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) { + return r.getBy(ctx, sq.Eq{"email": email}) +} + +func (r *UserRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) { + return r.getBy(ctx, sq.Eq{"id": id}) +} + +func (r *UserRepo) getBy(ctx context.Context, pred sq.Sqlizer) (*domain.User, error) { + sql, args, err := psql.Select(userColumns...).From("users").Where(pred).ToSql() + if err != nil { + return nil, err + } + return scanUser(conn(ctx, r.pool).QueryRow(ctx, sql, args...)) +} + +func scanUser(row pgx.Row) (*domain.User, error) { + var ( + u domain.User + role string + ) + if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &role, &u.CreatedAt, &u.UpdatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, usecase.ErrUserNotFound + } + return nil, err + } + u.Role = domain.Role(role) + return &u, nil +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation +} diff --git a/users/internal/transport/http/dto.go b/users/internal/transport/http/dto.go new file mode 100644 index 0000000..51b05e5 --- /dev/null +++ b/users/internal/transport/http/dto.go @@ -0,0 +1,41 @@ +package http + +import ( + "time" + + "github.com/emil28092005/SciMesh/users/internal/domain" +) + +// registerRequest / loginRequest are the JSON bodies clients POST. Kept separate +// from the domain so the wire format can evolve without touching the entity. +type registerRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// userResponse is the public view of a user. It never carries the password hash. +type userResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + CreatedAt string `json:"created_at"` +} + +type loginResponse struct { + Token string `json:"token"` + User userResponse `json:"user"` +} + +func toUserResponse(u *domain.User) userResponse { + return userResponse{ + ID: u.ID.String(), + Email: u.Email, + Role: string(u.Role), + CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339), + } +} diff --git a/users/internal/transport/http/errors.go b/users/internal/transport/http/errors.go new file mode 100644 index 0000000..5239bba --- /dev/null +++ b/users/internal/transport/http/errors.go @@ -0,0 +1,61 @@ +package http + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/emil28092005/SciMesh/users/internal/domain" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +// maxJSONBody caps a request body. Credentials are tiny; anything larger is a +// mistake or an attack, so reject it before allocating. +const maxJSONBody = 1 << 20 // 1 MiB + +type errorResponse struct { + Error string `json:"error"` + RequestID string `json:"request_id,omitempty"` +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// writeError maps a domain or use-case error to an HTTP status and a safe +// message, logging only genuine server faults (5xx). Client errors (4xx) are +// expected and stay out of the error log. +func writeError(w http.ResponseWriter, r *http.Request, log *slog.Logger, err error) { + status, msg := statusForError(err) + if status >= http.StatusInternalServerError { + log.Error("request failed", + "err", err, + "request_id", requestIDFrom(r.Context()), + "path", r.URL.Path, + ) + } + writeJSON(w, status, errorResponse{Error: msg, RequestID: requestIDFrom(r.Context())}) +} + +func statusForError(err error) (int, string) { + switch { + case errors.Is(err, usecase.ErrEmailExists): + return http.StatusConflict, "email already registered" + case errors.Is(err, usecase.ErrInvalidCredentials): + return http.StatusUnauthorized, "invalid email or password" + case errors.Is(err, usecase.ErrUserNotFound): + return http.StatusNotFound, "user not found" + case errors.Is(err, usecase.ErrPasswordTooShort): + return http.StatusBadRequest, "password must be at least 8 characters" + case errors.Is(err, usecase.ErrPasswordTooLong): + return http.StatusBadRequest, "password must be at most 72 bytes" + case errors.Is(err, domain.ErrEmptyEmail), errors.Is(err, domain.ErrInvalidEmail): + return http.StatusBadRequest, "email is not a valid address" + default: + // Don't leak internals; the real error is in the log under request_id. + return http.StatusInternalServerError, "internal error" + } +} diff --git a/users/internal/transport/http/handlers.go b/users/internal/transport/http/handlers.go new file mode 100644 index 0000000..055d609 --- /dev/null +++ b/users/internal/transport/http/handlers.go @@ -0,0 +1,85 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +// Handlers holds the use cases each endpoint drives. +type Handlers struct { + register *usecase.Register + login *usecase.Login + users usecase.UserRepository + log *slog.Logger +} + +// handleHealth is an unauthenticated liveness probe for the container and load +// balancer. +func (h *Handlers) handleHealth(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleRegister creates an account. It returns 201 with the public user view, +// 409 if the email is taken, or 400 on a malformed body / weak password. +func (h *Handlers) handleRegister(w http.ResponseWriter, r *http.Request) { + var req registerRequest + if !decodeJSON(w, r, &req) { + return + } + u, err := h.register.Execute(r.Context(), req.Email, req.Password) + if err != nil { + writeError(w, r, h.log, err) + return + } + writeJSON(w, http.StatusCreated, toUserResponse(u)) +} + +// handleLogin verifies credentials and returns a signed token plus the user. +func (h *Handlers) handleLogin(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if !decodeJSON(w, r, &req) { + return + } + token, u, err := h.login.Execute(r.Context(), req.Email, req.Password) + if err != nil { + writeError(w, r, h.log, err) + return + } + writeJSON(w, http.StatusOK, loginResponse{Token: token, User: toUserResponse(u)}) +} + +// handleMe returns the caller's own account, proving the token works end to end. +// It reads the user id the JWT middleware stashed in the context. +func (h *Handlers) handleMe(w http.ResponseWriter, r *http.Request) { + id, ok := userIDFrom(r.Context()) + if !ok { + unauthorized(w, r) + return + } + u, err := h.users.GetByID(r.Context(), id) + if err != nil { + writeError(w, r, h.log, err) + return + } + writeJSON(w, http.StatusOK, toUserResponse(u)) +} + +// decodeJSON reads a size-capped JSON body into dst, rejecting unknown fields. +// It writes a 400 and returns false on any problem, so callers can `if +// !decodeJSON(...) { return }`. +func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "invalid JSON body", + RequestID: requestIDFrom(r.Context()), + }) + return false + } + return true +} diff --git a/users/internal/transport/http/middleware.go b/users/internal/transport/http/middleware.go new file mode 100644 index 0000000..4d6ef66 --- /dev/null +++ b/users/internal/transport/http/middleware.go @@ -0,0 +1,133 @@ +package http + +import ( + "context" + "crypto/rand" + "encoding/hex" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/users/internal/auth" +) + +type ctxKey string + +const ( + requestIDKey ctxKey = "request_id" + userIDKey ctxKey = "user_id" + roleKey ctxKey = "role" +) + +// withRequestID stamps every request with an ID for correlated logs and error +// bodies. It wraps the auth middleware rather than the other way round, so even +// a rejected request carries an ID the caller can quote in a bug report. +func withRequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := newRequestID() + w.Header().Set("X-Request-ID", id) + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, id))) + }) +} + +func requestIDFrom(ctx context.Context) string { + if v, ok := ctx.Value(requestIDKey).(string); ok { + return v + } + return "" +} + +func newRequestID() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// tokenVerifier is the slice of auth.Issuer the JWT middleware needs. Taking an +// interface keeps the middleware testable with a stub verifier. +type tokenVerifier interface { + Verify(token string) (*auth.Claims, error) +} + +// withJWT verifies the Bearer token and stashes the caller's id and role in the +// request context. It rejects any request without a valid, unexpired HS256 +// token — this is what protects endpoints that act on a specific user. +func withJWT(v tokenVerifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if raw == "" { + unauthorized(w, r) + return + } + claims, err := v.Verify(raw) + if err != nil { + unauthorized(w, r) + return + } + id, err := uuid.Parse(claims.Subject) + if err != nil { + unauthorized(w, r) + return + } + ctx := context.WithValue(r.Context(), userIDKey, id) + ctx = context.WithValue(ctx, roleKey, claims.Role) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func unauthorized(w http.ResponseWriter, r *http.Request) { + w.Header().Set("WWW-Authenticate", "Bearer") + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "unauthorized", + RequestID: requestIDFrom(r.Context()), + }) +} + +// userIDFrom returns the authenticated caller's id, set by withJWT. +func userIDFrom(ctx context.Context) (uuid.UUID, bool) { + id, ok := ctx.Value(userIDKey).(uuid.UUID) + return id, ok +} + +// statusRecorder captures the status code for the access log. +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (s *statusRecorder) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +// withAccessLog records one structured line per request — the minimum needed to +// debug a distributed system after the fact. +func withAccessLog(log *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + log.Info("request", + "request_id", requestIDFrom(r.Context()), + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "duration_ms", time.Since(start).Milliseconds(), + ) + }) + } +} + +// chain applies middleware so that the first argument is the outermost layer. +func chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler { + for i := len(mw) - 1; i >= 0; i-- { + h = mw[i](h) + } + return h +} diff --git a/users/internal/transport/http/server.go b/users/internal/transport/http/server.go new file mode 100644 index 0000000..58a2a67 --- /dev/null +++ b/users/internal/transport/http/server.go @@ -0,0 +1,41 @@ +// Package http exposes the userservice over HTTP: registration, login, and a +// token-protected /me. It owns routing, request decoding, and error mapping; +// business rules live in the usecase layer. +package http + +import ( + "log/slog" + "net/http" + + "github.com/emil28092005/SciMesh/users/internal/auth" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +// UseCases bundles the application services the handlers drive. +type UseCases struct { + Register *usecase.Register + Login *usecase.Login + Users usecase.UserRepository +} + +// NewServer wires the routes and the middleware stack and returns the handler. +// The issuer verifies tokens for the protected /me route. +func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler { + h := &Handlers{ + register: uc.Register, + login: uc.Login, + users: uc.Users, + log: log, + } + + mux := http.NewServeMux() + // Method-aware patterns (Go 1.22+): a GET to /register is a 405, not a match. + mux.HandleFunc("GET /health", h.handleHealth) + mux.HandleFunc("POST /register", h.handleRegister) + mux.HandleFunc("POST /login", h.handleLogin) + // /me proves a token round-trips; it sits behind JWT auth. + mux.Handle("GET /me", chain(http.HandlerFunc(h.handleMe), withJWT(issuer))) + + // Outermost first: every request gets an ID and an access-log line. + return chain(mux, withRequestID, withAccessLog(log)) +} diff --git a/users/internal/transport/http/server_test.go b/users/internal/transport/http/server_test.go new file mode 100644 index 0000000..f74eb0f --- /dev/null +++ b/users/internal/transport/http/server_test.go @@ -0,0 +1,167 @@ +package http_test + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/emil28092005/SciMesh/users/internal/auth" + "github.com/emil28092005/SciMesh/users/internal/memstore" + apihttp "github.com/emil28092005/SciMesh/users/internal/transport/http" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +const secret = "server-test-secret-32-bytes-long!!!!" + +func newTestServer() http.Handler { + users := memstore.NewUserRepo() + hasher := auth.NewHasher(4) + clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)} + // Real clock for the issuer so tokens are valid at verification time. + issuer := auth.NewIssuer(secret, time.Hour, nil) + + uc := apihttp.UseCases{ + Register: usecase.NewRegister(users, hasher, clk), + Login: usecase.NewLogin(users, hasher, issuer), + Users: users, + } + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + return apihttp.NewServer(log, uc, issuer) +} + +func do(t *testing.T, h http.Handler, method, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + if body != nil { + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatal(err) + } + } + req := httptest.NewRequest(method, path, &buf) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestRegisterThenLoginThenMe(t *testing.T) { + h := newTestServer() + creds := map[string]string{"email": "flow@example.com", "password": "password123"} + + // Register -> 201 + rec := do(t, h, http.MethodPost, "/register", "", creds) + if rec.Code != http.StatusCreated { + t.Fatalf("register: got %d, body %s", rec.Code, rec.Body) + } + + // Login -> 200 with a token + rec = do(t, h, http.MethodPost, "/login", "", creds) + if rec.Code != http.StatusOK { + t.Fatalf("login: got %d, body %s", rec.Code, rec.Body) + } + var lr struct { + Token string `json:"token"` + User struct { + Email string `json:"email"` + Role string `json:"role"` + } `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &lr); err != nil { + t.Fatal(err) + } + if lr.Token == "" || lr.User.Email != "flow@example.com" || lr.User.Role != "user" { + t.Fatalf("unexpected login body: %+v", lr) + } + + // /me with the token -> 200, same user + rec = do(t, h, http.MethodGet, "/me", lr.Token, nil) + if rec.Code != http.StatusOK { + t.Fatalf("me: got %d, body %s", rec.Code, rec.Body) + } + var me struct { + Email string `json:"email"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil { + t.Fatal(err) + } + if me.Email != "flow@example.com" { + t.Errorf("me email = %q", me.Email) + } +} + +func TestRegisterDuplicate(t *testing.T) { + h := newTestServer() + creds := map[string]string{"email": "dup@example.com", "password": "password123"} + _ = do(t, h, http.MethodPost, "/register", "", creds) + + rec := do(t, h, http.MethodPost, "/register", "", creds) + if rec.Code != http.StatusConflict { + t.Errorf("duplicate register: got %d, want 409", rec.Code) + } +} + +func TestRegisterValidation(t *testing.T) { + h := newTestServer() + cases := []struct { + name string + body map[string]string + }{ + {"weak password", map[string]string{"email": "a@b.com", "password": "short"}}, + {"bad email", map[string]string{"email": "nope", "password": "password123"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := do(t, h, http.MethodPost, "/register", "", tc.body) + if rec.Code != http.StatusBadRequest { + t.Errorf("got %d, want 400", rec.Code) + } + }) + } +} + +func TestRegisterRejectsUnknownFields(t *testing.T) { + h := newTestServer() + rec := do(t, h, http.MethodPost, "/register", "", map[string]string{ + "email": "a@b.com", "password": "password123", "role": "admin", + }) + if rec.Code != http.StatusBadRequest { + t.Errorf("unknown field must be rejected: got %d", rec.Code) + } +} + +func TestLoginWrongPassword(t *testing.T) { + h := newTestServer() + _ = do(t, h, http.MethodPost, "/register", "", map[string]string{ + "email": "x@example.com", "password": "password123", + }) + rec := do(t, h, http.MethodPost, "/login", "", map[string]string{ + "email": "x@example.com", "password": "wrongpass1", + }) + if rec.Code != http.StatusUnauthorized { + t.Errorf("got %d, want 401", rec.Code) + } +} + +func TestMeRequiresToken(t *testing.T) { + h := newTestServer() + if rec := do(t, h, http.MethodGet, "/me", "", nil); rec.Code != http.StatusUnauthorized { + t.Errorf("no token: got %d, want 401", rec.Code) + } + if rec := do(t, h, http.MethodGet, "/me", "garbage.token.here", nil); rec.Code != http.StatusUnauthorized { + t.Errorf("bad token: got %d, want 401", rec.Code) + } +} + +func TestHealth(t *testing.T) { + h := newTestServer() + if rec := do(t, h, http.MethodGet, "/health", "", nil); rec.Code != http.StatusOK { + t.Errorf("health: got %d", rec.Code) + } +} diff --git a/users/internal/usecase/errors.go b/users/internal/usecase/errors.go new file mode 100644 index 0000000..4e8131d --- /dev/null +++ b/users/internal/usecase/errors.go @@ -0,0 +1,18 @@ +package usecase + +import "errors" + +var ( + // Repository-contract errors, returned by UserRepository implementations. + ErrEmailExists = errors.New("email already registered") + ErrUserNotFound = errors.New("user not found") + + // Use-case errors surfaced to the transport layer. + // + // ErrInvalidCredentials is deliberately returned for both an unknown email + // and a wrong password, so an attacker cannot use the response to learn + // which emails are registered. + ErrInvalidCredentials = errors.New("invalid email or password") + ErrPasswordTooShort = errors.New("password too short") + ErrPasswordTooLong = errors.New("password too long") +) diff --git a/users/internal/usecase/login.go b/users/internal/usecase/login.go new file mode 100644 index 0000000..7c57c3d --- /dev/null +++ b/users/internal/usecase/login.go @@ -0,0 +1,42 @@ +package usecase + +import ( + "context" + "errors" + + "github.com/emil28092005/SciMesh/users/internal/domain" +) + +// Login verifies credentials and issues a signed token. +type Login struct { + users UserRepository + hasher PasswordHasher + tokens TokenIssuer +} + +func NewLogin(users UserRepository, hasher PasswordHasher, tokens TokenIssuer) *Login { + return &Login{users: users, hasher: hasher, tokens: tokens} +} + +// Execute returns a signed token and the user on success. It returns +// ErrInvalidCredentials for both an unknown email and a wrong password so the +// two cases are indistinguishable to a caller probing for valid accounts. +func (l *Login) Execute(ctx context.Context, email, password string) (string, *domain.User, error) { + u, err := l.users.GetByEmail(ctx, domain.NormalizeEmail(email)) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return "", nil, ErrInvalidCredentials + } + return "", nil, err + } + + if err := l.hasher.Compare(u.PasswordHash, password); err != nil { + return "", nil, ErrInvalidCredentials + } + + token, err := l.tokens.Issue(u.ID, u.Role) + if err != nil { + return "", nil, err + } + return token, u, nil +} diff --git a/users/internal/usecase/ports.go b/users/internal/usecase/ports.go new file mode 100644 index 0000000..3f75323 --- /dev/null +++ b/users/internal/usecase/ports.go @@ -0,0 +1,41 @@ +// Package usecase holds the application logic — registration and login — plus +// the ports (interfaces) it depends on. The concrete adapters (PostgreSQL, +// bcrypt, JWT) are injected from cmd, so this package never imports them. +package usecase + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/users/internal/domain" +) + +// UserRepository persists and looks up users. Implementations return the +// sentinel errors in errors.go so the use cases can react without knowing about +// SQL or driver types. +type UserRepository interface { + // Insert stores a new user, returning ErrEmailExists if the email is taken. + Insert(ctx context.Context, u *domain.User) error + // GetByEmail returns the user with the (normalised) email, or ErrUserNotFound. + GetByEmail(ctx context.Context, email string) (*domain.User, error) + // GetByID returns the user with id, or ErrUserNotFound. + GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) +} + +// PasswordHasher hashes and verifies passwords. The bcrypt adapter satisfies it. +type PasswordHasher interface { + Hash(password string) (string, error) + Compare(hash, password string) error +} + +// TokenIssuer mints a signed access token for an authenticated user. +type TokenIssuer interface { + Issue(userID uuid.UUID, role domain.Role) (string, error) +} + +// Clock reads the current time; a fake one makes tests deterministic. +type Clock interface { + Now() time.Time +} diff --git a/users/internal/usecase/register.go b/users/internal/usecase/register.go new file mode 100644 index 0000000..1eef927 --- /dev/null +++ b/users/internal/usecase/register.go @@ -0,0 +1,56 @@ +package usecase + +import ( + "context" + + "github.com/emil28092005/SciMesh/users/internal/domain" +) + +const ( + // minPasswordLen is a floor, not a policy engine — enough to reject the + // obviously weak without pretending to measure real strength. + minPasswordLen = 8 + // maxPasswordLen is bcrypt's hard input limit: it ignores bytes past 72, so + // accepting a longer password would silently hash only its prefix. + maxPasswordLen = 72 +) + +// Register creates a new account: it validates the password, hashes it, builds +// the domain user, and persists it. +type Register struct { + users UserRepository + hasher PasswordHasher + clk Clock +} + +func NewRegister(users UserRepository, hasher PasswordHasher, clk Clock) *Register { + return &Register{users: users, hasher: hasher, clk: clk} +} + +// Execute registers email/password and returns the persisted user. The returned +// user carries no plaintext password, only its hash. +func (r *Register) Execute(ctx context.Context, email, password string) (*domain.User, error) { + if len(password) < minPasswordLen { + return nil, ErrPasswordTooShort + } + if len(password) > maxPasswordLen { + return nil, ErrPasswordTooLong + } + + hash, err := r.hasher.Hash(password) + if err != nil { + return nil, err + } + + // NewUser normalises the email and enforces its shape; it returns a domain + // validation error the transport layer maps to 400. + u, err := domain.NewUser(email, hash, r.clk.Now()) + if err != nil { + return nil, err + } + + if err := r.users.Insert(ctx, u); err != nil { + return nil, err + } + return u, nil +} diff --git a/users/internal/usecase/usecase_test.go b/users/internal/usecase/usecase_test.go new file mode 100644 index 0000000..2e470b8 --- /dev/null +++ b/users/internal/usecase/usecase_test.go @@ -0,0 +1,133 @@ +package usecase_test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/emil28092005/SciMesh/users/internal/auth" + "github.com/emil28092005/SciMesh/users/internal/domain" + "github.com/emil28092005/SciMesh/users/internal/memstore" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +const secret = "usecase-test-secret-32-bytes-long!!!" + +func newFixtures() (*usecase.Register, *usecase.Login, *memstore.UserRepo) { + users := memstore.NewUserRepo() + hasher := auth.NewHasher(4) // low cost keeps tests fast + clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)} + // The issuer uses the real clock (nil): token expiry is validated against + // wall-clock time, so a fixed issue-time would make tokens instantly stale. + issuer := auth.NewIssuer(secret, time.Hour, nil) + + reg := usecase.NewRegister(users, hasher, clk) + login := usecase.NewLogin(users, hasher, issuer) + return reg, login, users +} + +func TestRegisterSuccess(t *testing.T) { + reg, _, users := newFixtures() + + u, err := reg.Execute(context.Background(), "Alice@Example.com", "password123") + if err != nil { + t.Fatalf("register: %v", err) + } + if u.Email != "alice@example.com" { + t.Errorf("email not normalised: %q", u.Email) + } + if u.Role != domain.RoleUser { + t.Errorf("role = %q, want user", u.Role) + } + if strings.Contains(u.PasswordHash, "password123") { + t.Error("password stored in cleartext") + } + if _, err := users.GetByEmail(context.Background(), "alice@example.com"); err != nil { + t.Errorf("user not persisted: %v", err) + } +} + +func TestRegisterDuplicateEmail(t *testing.T) { + reg, _, _ := newFixtures() + ctx := context.Background() + + if _, err := reg.Execute(ctx, "dup@example.com", "password123"); err != nil { + t.Fatalf("first register: %v", err) + } + _, err := reg.Execute(ctx, "Dup@example.com", "password123") // different case, same email + if !errors.Is(err, usecase.ErrEmailExists) { + t.Errorf("got %v, want ErrEmailExists", err) + } +} + +func TestRegisterPasswordPolicy(t *testing.T) { + reg, _, _ := newFixtures() + ctx := context.Background() + + if _, err := reg.Execute(ctx, "a@b.com", "short"); !errors.Is(err, usecase.ErrPasswordTooShort) { + t.Errorf("short password: got %v", err) + } + long := strings.Repeat("x", 73) + if _, err := reg.Execute(ctx, "a@b.com", long); !errors.Is(err, usecase.ErrPasswordTooLong) { + t.Errorf("long password: got %v", err) + } +} + +func TestRegisterInvalidEmail(t *testing.T) { + reg, _, _ := newFixtures() + _, err := reg.Execute(context.Background(), "not-an-email", "password123") + if !errors.Is(err, domain.ErrInvalidEmail) { + t.Errorf("got %v, want ErrInvalidEmail", err) + } +} + +func TestLoginSuccess(t *testing.T) { + reg, login, _ := newFixtures() + ctx := context.Background() + if _, err := reg.Execute(ctx, "user@example.com", "password123"); err != nil { + t.Fatal(err) + } + + token, u, err := login.Execute(ctx, "User@Example.com", "password123") + if err != nil { + t.Fatalf("login: %v", err) + } + if token == "" { + t.Error("empty token") + } + if u.Email != "user@example.com" { + t.Errorf("wrong user returned: %q", u.Email) + } + + // The token must verify and carry this user's id. + claims, err := auth.NewIssuer(secret, time.Hour, nil).Verify(token) + if err != nil { + t.Fatalf("issued token does not verify: %v", err) + } + if claims.Subject != u.ID.String() { + t.Errorf("token sub = %q, want %q", claims.Subject, u.ID.String()) + } +} + +func TestLoginWrongPassword(t *testing.T) { + reg, login, _ := newFixtures() + ctx := context.Background() + if _, err := reg.Execute(ctx, "user@example.com", "password123"); err != nil { + t.Fatal(err) + } + + _, _, err := login.Execute(ctx, "user@example.com", "wrongpass1") + if !errors.Is(err, usecase.ErrInvalidCredentials) { + t.Errorf("got %v, want ErrInvalidCredentials", err) + } +} + +func TestLoginUnknownEmailIsIndistinguishable(t *testing.T) { + _, login, _ := newFixtures() + _, _, err := login.Execute(context.Background(), "ghost@example.com", "password123") + if !errors.Is(err, usecase.ErrInvalidCredentials) { + t.Errorf("unknown email must return ErrInvalidCredentials, got %v", err) + } +} diff --git a/users/migrations/0001_users.down.sql b/users/migrations/0001_users.down.sql new file mode 100644 index 0000000..86f518c --- /dev/null +++ b/users/migrations/0001_users.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +DROP TABLE IF EXISTS users; +DROP TYPE IF EXISTS user_role; + +COMMIT; diff --git a/users/migrations/0001_users.up.sql b/users/migrations/0001_users.up.sql new file mode 100644 index 0000000..f0a76f1 --- /dev/null +++ b/users/migrations/0001_users.up.sql @@ -0,0 +1,26 @@ +BEGIN; + +-- Static permission bundles. Roles rarely change, so the role→permission +-- mapping lives in code (auth middleware), not in a table. New role = deploy. +CREATE TYPE user_role AS ENUM ('user','admin'); + +-- One human account. The id is the stable identity that ends up in the JWT +-- `sub` claim; the coordinator stores it as jobs.owner_id. +CREATE TABLE users ( + id uuid PRIMARY KEY, + -- Login handle. App lowercases before insert/lookup, so uniqueness is + -- case-insensitive in practice. + email text NOT NULL, + -- Output of bcrypt/argon2. The salt and cost parameters are embedded in + -- this string, so there is NO separate salt column to store. + password_hash text NOT NULL, + role user_role NOT NULL DEFAULT 'user', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + -- No two accounts share a login. + CONSTRAINT uq_users_email UNIQUE (email), + CONSTRAINT ck_users_email_lower CHECK (email = lower(email)) +); + +COMMIT; diff --git a/users/scripts/smoke.sh b/users/scripts/smoke.sh new file mode 100755 index 0000000..d2e3614 --- /dev/null +++ b/users/scripts/smoke.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# End-to-end smoke test against a running userservice. Exercises the full auth +# flow and exits non-zero on the first unexpected status. +# +# HOST=http://localhost:8081 ./scripts/smoke.sh +set -euo pipefail + +HOST="${HOST:-http://localhost:8081}" +EMAIL="smoke-$(date +%s)-$RANDOM@example.com" +PASSWORD="password123" + +pass() { printf ' ok %s\n' "$1"; } +fail() { printf ' FAIL %s\n' "$1" >&2; exit 1; } + +# expect METHOD PATH WANT_STATUS [JSON_BODY] [BEARER] +# Prints the response body to stdout so callers can parse it. +expect() { + local method="$1" path="$2" want="$3" body="${4:-}" token="${5:-}" + local args=(-s -o /tmp/smoke_body -w '%{http_code}' -X "$method" "$HOST$path") + [ -n "$body" ] && args+=(-H 'Content-Type: application/json' -d "$body") + [ -n "$token" ] && args+=(-H "Authorization: Bearer $token") + local code + code="$(curl "${args[@]}")" + if [ "$code" != "$want" ]; then + printf 'body: %s\n' "$(cat /tmp/smoke_body)" >&2 + fail "$method $path -> $code (want $want)" + fi + pass "$method $path -> $code" + cat /tmp/smoke_body +} + +echo "smoke: $HOST (user $EMAIL)" + +expect GET /health 200 >/dev/null + +expect POST /register 201 "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" >/dev/null +expect POST /register 409 "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" >/dev/null +expect POST /register 400 "{\"email\":\"$EMAIL\",\"password\":\"short\"}" >/dev/null + +# Login and capture the token (extract the "token" JSON string field). +login_body="$(expect POST /login 200 "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}")" +TOKEN="$(printf '%s' "$login_body" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')" +[ -n "$TOKEN" ] || fail "login returned no token" +pass "captured token" + +expect POST /login 401 "{\"email\":\"$EMAIL\",\"password\":\"wrongpass1\"}" >/dev/null + +expect GET /me 200 "" "$TOKEN" >/dev/null +expect GET /me 401 "" >/dev/null +expect GET /me 401 "" "not-a-token" >/dev/null + +echo "smoke: all checks passed ✓" From 73196579e8b4cc8b98cbf1a7fa8399e9c87ef077 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 16:15:36 +0300 Subject: [PATCH 02/24] test(users): cover config, usecase error paths, and /me 500 path --- users/internal/infra/config_test.go | 101 ++++++++++++++++++ users/internal/transport/http/server_test.go | 42 ++++++++ users/internal/usecase/errorpaths_test.go | 103 +++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 users/internal/infra/config_test.go create mode 100644 users/internal/usecase/errorpaths_test.go diff --git a/users/internal/infra/config_test.go b/users/internal/infra/config_test.go new file mode 100644 index 0000000..57840b2 --- /dev/null +++ b/users/internal/infra/config_test.go @@ -0,0 +1,101 @@ +package infra + +import ( + "testing" + "time" +) + +const validSecret = "a-secret-that-is-at-least-32-bytes!!" + +// setBaseEnv wires the minimum valid environment. ENV_FILE points at a path that +// does not exist so a developer's stray .env never leaks into the test. +func setBaseEnv(t *testing.T) { + t.Helper() + t.Setenv("ENV_FILE", "/nonexistent/.env") + t.Setenv("DATABASE_URL", "postgres://u:p@localhost:5432/db?sslmode=disable") + t.Setenv("JWT_SECRET", validSecret) +} + +func TestLoadConfigDefaults(t *testing.T) { + setBaseEnv(t) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Addr != ":8081" { + t.Errorf("Addr default = %q, want :8081", cfg.Addr) + } + if cfg.TokenTTL != 24*time.Hour { + t.Errorf("TokenTTL default = %v, want 24h", cfg.TokenTTL) + } + if cfg.JWTSecret != validSecret { + t.Errorf("JWTSecret = %q", cfg.JWTSecret) + } +} + +func TestLoadConfigRequiresDatabaseURL(t *testing.T) { + setBaseEnv(t) + t.Setenv("DATABASE_URL", "") + + if _, err := LoadConfig(); err == nil { + t.Error("expected error when DATABASE_URL is empty") + } +} + +func TestLoadConfigRequiresJWTSecret(t *testing.T) { + setBaseEnv(t) + t.Setenv("JWT_SECRET", "") + + if _, err := LoadConfig(); err == nil { + t.Error("expected error when JWT_SECRET is empty") + } +} + +func TestLoadConfigRejectsShortJWTSecret(t *testing.T) { + setBaseEnv(t) + t.Setenv("JWT_SECRET", "too-short") + + if _, err := LoadConfig(); err == nil { + t.Error("expected error when JWT_SECRET is under 32 bytes") + } +} + +func TestLoadConfigOverrides(t *testing.T) { + setBaseEnv(t) + t.Setenv("USERSERVICE_ADDR", ":9000") + t.Setenv("JWT_TTL", "1h") + t.Setenv("BCRYPT_COST", "6") + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Addr != ":9000" { + t.Errorf("Addr = %q, want :9000", cfg.Addr) + } + if cfg.TokenTTL != time.Hour { + t.Errorf("TokenTTL = %v, want 1h", cfg.TokenTTL) + } + if cfg.BcryptCost != 6 { + t.Errorf("BcryptCost = %d, want 6", cfg.BcryptCost) + } +} + +func TestLoadConfigRejectsMalformedDuration(t *testing.T) { + setBaseEnv(t) + t.Setenv("JWT_TTL", "not-a-duration") + + if _, err := LoadConfig(); err == nil { + t.Error("expected error for malformed JWT_TTL") + } +} + +func TestLoadConfigRejectsMalformedInt(t *testing.T) { + setBaseEnv(t) + t.Setenv("BCRYPT_COST", "abc") + + if _, err := LoadConfig(); err == nil { + t.Error("expected error for malformed BCRYPT_COST") + } +} diff --git a/users/internal/transport/http/server_test.go b/users/internal/transport/http/server_test.go index f74eb0f..a81a128 100644 --- a/users/internal/transport/http/server_test.go +++ b/users/internal/transport/http/server_test.go @@ -2,7 +2,9 @@ package http_test import ( "bytes" + "context" "encoding/json" + "errors" "io" "log/slog" "net/http" @@ -10,7 +12,10 @@ import ( "testing" "time" + "github.com/google/uuid" + "github.com/emil28092005/SciMesh/users/internal/auth" + "github.com/emil28092005/SciMesh/users/internal/domain" "github.com/emil28092005/SciMesh/users/internal/memstore" apihttp "github.com/emil28092005/SciMesh/users/internal/transport/http" "github.com/emil28092005/SciMesh/users/internal/usecase" @@ -165,3 +170,40 @@ func TestHealth(t *testing.T) { t.Errorf("health: got %d", rec.Code) } } + +// failingUsers is a UserRepository whose reads fail with an unexpected (non- +// sentinel) error, so the handler must map it to 500 and not leak internals. +type failingUsers struct{ usecase.UserRepository } + +func (failingUsers) GetByID(context.Context, uuid.UUID) (*domain.User, error) { + return nil, errors.New("db exploded") +} + +func TestMeInternalError(t *testing.T) { + hasher := auth.NewHasher(4) + clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)} + issuer := auth.NewIssuer(secret, time.Hour, nil) + + users := failingUsers{UserRepository: memstore.NewUserRepo()} + uc := apihttp.UseCases{ + Register: usecase.NewRegister(users, hasher, clk), + Login: usecase.NewLogin(users, hasher, issuer), + Users: users, + } + h := apihttp.NewServer(slog.New(slog.NewTextHandler(io.Discard, nil)), uc, issuer) + + // A structurally valid token for a caller the failing repo can't load. + token, err := issuer.Issue(uuid.New(), "user") + if err != nil { + t.Fatal(err) + } + + rec := do(t, h, http.MethodGet, "/me", token, nil) + if rec.Code != http.StatusInternalServerError { + t.Errorf("got %d, want 500", rec.Code) + } + // The body must not disclose the underlying error. + if bytes.Contains(rec.Body.Bytes(), []byte("db exploded")) { + t.Error("internal error leaked to the client") + } +} diff --git a/users/internal/usecase/errorpaths_test.go b/users/internal/usecase/errorpaths_test.go new file mode 100644 index 0000000..d25f56d --- /dev/null +++ b/users/internal/usecase/errorpaths_test.go @@ -0,0 +1,103 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/users/internal/domain" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +// These stubs let a test inject failures the happy-path memstore never produces, +// so the use cases' error branches are exercised too. + +var errBoom = errors.New("boom") + +type stubRepo struct { + getByEmail func() (*domain.User, error) + insert func() error +} + +func (s stubRepo) Insert(context.Context, *domain.User) error { return s.insert() } +func (s stubRepo) GetByEmail(context.Context, string) (*domain.User, error) { + return s.getByEmail() +} +func (s stubRepo) GetByID(context.Context, uuid.UUID) (*domain.User, error) { + return nil, usecase.ErrUserNotFound +} + +type stubHasher struct { + hashErr error + compareErr error +} + +func (s stubHasher) Hash(string) (string, error) { + if s.hashErr != nil { + return "", s.hashErr + } + return "hashed", nil +} +func (s stubHasher) Compare(string, string) error { return s.compareErr } + +type stubIssuer struct{ err error } + +func (s stubIssuer) Issue(uuid.UUID, domain.Role) (string, error) { + if s.err != nil { + return "", s.err + } + return "token", nil +} + +func TestRegisterPropagatesHasherError(t *testing.T) { + clk := stubClock{time.Now()} + reg := usecase.NewRegister(stubRepo{}, stubHasher{hashErr: errBoom}, clk) + + _, err := reg.Execute(context.Background(), "a@b.com", "password123") + if !errors.Is(err, errBoom) { + t.Errorf("got %v, want errBoom", err) + } +} + +func TestRegisterPropagatesInsertError(t *testing.T) { + clk := stubClock{time.Now()} + repo := stubRepo{insert: func() error { return errBoom }} + reg := usecase.NewRegister(repo, stubHasher{}, clk) + + _, err := reg.Execute(context.Background(), "a@b.com", "password123") + if !errors.Is(err, errBoom) { + t.Errorf("got %v, want errBoom", err) + } +} + +func TestLoginPropagatesRepoError(t *testing.T) { + // A non-ErrUserNotFound repo error must surface as-is, not be masked as + // ErrInvalidCredentials. + repo := stubRepo{getByEmail: func() (*domain.User, error) { return nil, errBoom }} + login := usecase.NewLogin(repo, stubHasher{}, stubIssuer{}) + + _, _, err := login.Execute(context.Background(), "a@b.com", "password123") + if !errors.Is(err, errBoom) { + t.Errorf("got %v, want errBoom", err) + } +} + +func TestLoginPropagatesIssuerError(t *testing.T) { + repo := stubRepo{getByEmail: func() (*domain.User, error) { + return &domain.User{ID: uuid.New(), Email: "a@b.com", Role: domain.RoleUser}, nil + }} + // Hasher accepts the password (nil compareErr) so we reach token issuance. + login := usecase.NewLogin(repo, stubHasher{}, stubIssuer{err: errBoom}) + + _, _, err := login.Execute(context.Background(), "a@b.com", "password123") + if !errors.Is(err, errBoom) { + t.Errorf("got %v, want errBoom", err) + } +} + +type stubClock struct{ t time.Time } + +func (c stubClock) Now() time.Time { return c.t } From 1b1b971378a34982492aa061d061e8641c96d0c2 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 16:24:34 +0300 Subject: [PATCH 03/24] fix(coordinator): scan owner_id in UIReadRepo.ListJobs jobColumns gained owner_id but ListJobs' Scan still read 12 targets, so the query returned 13 columns and pgx failed at runtime. Only the integration tests (real DB) caught it; memstore-backed unit tests did not. --- coordinator/internal/storage/postgres/ui_read_repo.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coordinator/internal/storage/postgres/ui_read_repo.go b/coordinator/internal/storage/postgres/ui_read_repo.go index c759b2a..90b3536 100644 --- a/coordinator/internal/storage/postgres/ui_read_repo.go +++ b/coordinator/internal/storage/postgres/ui_read_repo.go @@ -44,6 +44,7 @@ func (r *UIReadRepo) ListJobs(ctx context.Context, limit int) ([]domain.Job, err if err := rows.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.OwnerID, ); err != nil { return nil, err } From 67407220c33e956f796efb9fb5d910faf3511d25 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 16:27:24 +0300 Subject: [PATCH 04/24] ci(users): add pipeline (vet, gofmt, race, lint, migrate, integration) Mirrors the coordinator workflow against a scimesh_users Postgres service. Switches the test request helper to http.NewRequestWithContext so the noctx linter passes on the go1.22 module. --- .github/workflows/users.yml | 66 ++++++++++++++++++++ users/internal/transport/http/server_test.go | 5 +- 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/users.yml diff --git a/.github/workflows/users.yml b/.github/workflows/users.yml new file mode 100644 index 0000000..e9620b8 --- /dev/null +++ b/.github/workflows/users.yml @@ -0,0 +1,66 @@ +name: users + +on: + push: + paths: + - "users/**" + - ".github/workflows/users.yml" + pull_request: + paths: + - "users/**" + - ".github/workflows/users.yml" + +defaults: + run: + working-directory: users + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: scimesh + POSTGRES_PASSWORD: scimesh + POSTGRES_DB: scimesh_users + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U scimesh" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + env: + TEST_DATABASE_URL: postgres://scimesh:scimesh@localhost:5432/scimesh_users?sslmode=disable + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: users/go.mod + cache-dependency-path: users/go.sum + + - name: go vet + run: go vet ./... + + - name: gofmt + run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1) + + - name: unit tests (race) + run: go test -race ./... + + - name: lint + run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run --build-tags=integration ./... + + - name: install migrate CLI + run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.17.1 + + - name: apply migrations + run: migrate -path migrations -database "$TEST_DATABASE_URL" up + + - name: integration tests + run: go test -tags=integration ./internal/storage/postgres/ -v diff --git a/users/internal/transport/http/server_test.go b/users/internal/transport/http/server_test.go index a81a128..2ec04fa 100644 --- a/users/internal/transport/http/server_test.go +++ b/users/internal/transport/http/server_test.go @@ -47,7 +47,10 @@ func do(t *testing.T, h http.Handler, method, path, token string, body any) *htt t.Fatal(err) } } - req := httptest.NewRequest(method, path, &buf) + req, err := http.NewRequestWithContext(context.Background(), method, path, &buf) + if err != nil { + t.Fatal(err) + } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } From 0c1f5f06d43af99a769186cb9fbfcbc2c23e2030 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 16:36:17 +0300 Subject: [PATCH 05/24] chore(users): remove coordinator leftovers, tidy for userservice - delete api/requests.http and ARCHITECTURE.md (coordinator content) - rewrite README.md and .env.example for the userservice - drop dead RunPeriodic (reaper machinery userservice has no use for) - fix .gitignore/.dockerignore/.golangci.yml module + artifact names - degeneralize stale copied comments that said "coordinator" --- users/.dockerignore | 4 +- users/.env.example | 29 +-- users/.gitignore | 2 +- users/.golangci.yml | 2 +- users/ARCHITECTURE.md | 144 ------------- users/README.md | 257 ++++------------------- users/api/requests.http | 234 --------------------- users/internal/infra/clock.go | 2 +- users/internal/infra/db.go | 2 +- users/internal/infra/server.go | 34 +-- users/internal/storage/postgres/retry.go | 6 +- 11 files changed, 68 insertions(+), 648 deletions(-) delete mode 100644 users/ARCHITECTURE.md delete mode 100644 users/api/requests.http diff --git a/users/.dockerignore b/users/.dockerignore index a4d0d5c..545cfcf 100644 --- a/users/.dockerignore +++ b/users/.dockerignore @@ -9,6 +9,8 @@ Dockerfile .dockerignore # Local build artifacts -/coordinator +/userservice /bin/ *.out +/data/ +/logs/ diff --git a/users/.env.example b/users/.env.example index 4c1e596..53b8ecf 100644 --- a/users/.env.example +++ b/users/.env.example @@ -1,32 +1,23 @@ # Copy to .env and adjust. All settings are read from the environment. -COORDINATOR_ADDR=:8080 -DATABASE_URL=postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable +USERSERVICE_ADDR=:8081 +DATABASE_URL=postgres://scimesh:scimesh@localhost:5433/scimesh_users?sslmode=disable -# Shared bearer token every worker must present. Leave empty to disable auth (dev only). -WORKER_AUTH_TOKEN=change-me - -# Optional local operator UI. Use a separate value; never reuse the worker token. -# When empty, /ui is disabled. -UI_AUTH_TOKEN= +# Shared HS256 secret used to sign JWTs. The coordinator verifies tokens with +# this SAME secret, so the two values must match exactly. Minimum 32 bytes. +JWT_SECRET=change-me-to-a-long-random-secret-min-32-bytes +# How long an issued token stays valid. +JWT_TTL=24h +# bcrypt work factor. Empty/0 uses the library default (10). +# BCRYPT_COST=10 # Logging. LOG_LEVEL: debug|info|warn|error. LOG_FILE empty = stdout only; # set a path to also write a size-rotated file (kept across restarts). LOG_LEVEL=info -# LOG_FILE=./logs/coordinator.log - -# Directory where artifact bytes are stored. -COORDINATOR_STORAGE_DIR=./data -# Upper bound on an uploaded dataset or artifact body (bytes). Default 1 GiB. -MAX_UPLOAD_BYTES=1073741824 +# LOG_FILE=./logs/userservice.log # Optional tuning (defaults shown). DB_MAX_CONNS=10 # How long to keep retrying the initial DB connection while Postgres boots. DB_CONNECT_TIMEOUT=30s REQUEST_TIMEOUT=15s -LEASE_DURATION=2m -DEFAULT_MAX_ATTEMPTS=3 -REAPER_INTERVAL=30s -# A worker silent longer than this is marked offline by the reaper. -WORKER_OFFLINE_AFTER=1m diff --git a/users/.gitignore b/users/.gitignore index c8c4f7b..8ef0865 100644 --- a/users/.gitignore +++ b/users/.gitignore @@ -1,4 +1,4 @@ -/coordinator +/userservice /bin/ .env *.out diff --git a/users/.golangci.yml b/users/.golangci.yml index 428bfed..3492657 100644 --- a/users/.golangci.yml +++ b/users/.golangci.yml @@ -51,4 +51,4 @@ formatters: settings: goimports: local-prefixes: - - github.com/emil28092005/SciMesh/coordinator + - github.com/emil28092005/SciMesh/users diff --git a/users/ARCHITECTURE.md b/users/ARCHITECTURE.md deleted file mode 100644 index 77b6976..0000000 --- a/users/ARCHITECTURE.md +++ /dev/null @@ -1,144 +0,0 @@ -# Архитектура координатора - -Карта кода. Читать сверху вниз: сначала «где что лежит», потом «как проходит -запрос», в конце — «куда добавлять новое». - ---- - -## 1. Четыре слоя - -``` - infra конфиг, пул БД, часы, HTTP-сервер, reaper ← драйверы - transport HTTP-хендлеры ← входящее: кто зовёт нас - storage репозитории на SQL ← исходящее: кого зовём мы - usecase операции + ПОРТЫ (интерфейсы) ← прикладные правила - domain Task, Job и их инварианты ← бизнес-правила - - ┌── transport ──┐ - domain ◄── usecase ◄┤ ├◄── infra - └── storage ────┘ -``` - -`transport` и `storage` — один и тот же слой (в книгах он зовётся «адаптеры»), -просто разделённый по направлению: транспорт принимает запросы снаружи, storage -обращается наружу сам. Так путь к файлу говорит о его роли, а не о категории. - -**Единственное правило:** зависимости идут только внутрь. `domain` не импортирует -ничего из проекта. `usecase` видит только `domain`. `transport` и `storage` не -знают друг о друге. - -Проверить в любой момент: - -```sh -go list -f '{{range .Imports}}{{.}}{{"\n"}}{{end}}' ./internal/domain | grep internal -# пусто = правило соблюдено -``` - ---- - -## 2. Где что лежит - -| Файл | Что внутри | Строк | -| --- | --- | --- | -| `domain/task.go` | `Task` и **все** переходы состояний: аренда, завершение, провал, истечение | ~245 | -| `domain/job.go` | `Job`, разбиение на чанки, вывод статуса из счётчиков задач | ~107 | -| `domain/errors.go` | Нарушения бизнес-правил (`ErrLeaseConflict`, `ErrStaleAttempt`, …) | ~18 | -| `usecase/ports.go` | **Порты**: `TaskRepository`, `JobRepository`, `TxManager`, `Clock` | ~79 | -| `usecase/task.go` | Операции над задачей: claim, renew, complete, fail, expire | ~200 | -| `usecase/job.go` | Операции над job: create, status, results, stitch | ~180 | -| `usecase/dto.go` | Входные структуры юзкейсов | ~51 | -| `transport/http/server.go` | Роутер и сборка middleware | ~60 | -| `transport/http/handlers.go` | По хендлеру на эндпоинт | ~180 | -| `transport/http/dto.go` | JSON-форматы запросов и ответов | ~118 | -| `transport/http/middleware.go` | request-ID, access-лог, bearer-авторизация | ~103 | -| `transport/http/errors.go` | Маппинг доменных ошибок в HTTP-коды | ~55 | -| `storage/postgres/task_repo.go` | SQL по задачам, включая атомарный claim | ~109 | -| `storage/postgres/job_repo.go` | SQL по job'ам | ~39 | -| `storage/postgres/tx.go` | `TxManager`: транзакция через контекст | ~65 | -| `infra/*.go` | Конфиг, пул, часы, сервер, reaper | ~240 | -| `cmd/coordinator/main.go` | **Composition root** — единственное место со всеми конкретными типами | ~73 | - ---- - -## 3. Трасса запроса: `POST /tasks/claim` - -Как воркер получает задачу. Четыре остановки, по одной на слой: - -``` - ① transport/http/handlers.go → handleClaim - разбирает JSON, отдаёт usecase.ClaimTaskInput - │ - ▼ - ② usecase/task.go → ClaimTask.Execute - сначала подчищает протухшие аренды, потом просит одну задачу - через ПОРТ TaskRepository (реализацию не знает) - │ - ▼ - ③ usecase/ports.go → TaskRepository.ClaimNext - контракт: «атомарно выдай одну задачу» - │ - ▼ - ④ storage/postgres/task_repo.go → claimNextSQL - SELECT ... FOR UPDATE SKIP LOCKED + UPDATE одним запросом -``` - -Обратно поднимается `*domain.Task`, юзкейс сужает его до `domain.ClaimedTask` -(воркеру не отдаём `version`, `max_attempts` и чужие ошибки), хендлер -превращает в JSON. Пустая очередь — это `nil, nil` на шаге ② и `204` на ①. - -**Трасса `POST /tasks/{id}/result`** такая же, но с одним отличием: решение -принимает **сущность**, а не юзкейс. - -``` - handlers.go → CompleteTask.Execute → tx.WithinTx( - GetForUpdate → task.CompleteWith(...) ←── ЗДЕСЬ правила - │ (чужая аренда? устаревший - Update ←─────────────┘ attempt? повтор того же - syncJobStatus манифеста?) - ) -``` - ---- - -## 4. Куда добавлять новое - -| Хочу… | Правлю | -| --- | --- | -| новое бизнес-правило (когда задачу можно повторить) | `domain/task.go` + тест рядом | -| новую операцию (отменить job) | `usecase/job.go` + порт в `ports.go`, если нужен новый запрос к БД | -| новый HTTP-эндпоинт | `transport/http/handlers.go` + маршрут в `server.go` + DTO в `dto.go` | -| новый SQL-запрос | `storage/postgres/*_repo.go` | -| новую настройку | `infra/config.go` + `.env.example` | -| поменять код ответа на ошибку | `transport/http/errors.go` | - -**Правило при сомнении:** если код можно описать фразой «когда X, то Y» без -упоминания HTTP, SQL и конфигов — это `domain`. Если он оркеструет несколько -шагов и транзакцию — `usecase`. Если знает про JSON — `transport`, про SQL — `storage`. - ---- - -## 5. Три вещи, которые надо понять один раз - -**Порты объявляет потребитель.** `TaskRepository` описан в `usecase/ports.go`, а -реализован в `storage/postgres`. Поэтому `usecase` не импортирует `storage` — -стрелка зависимости смотрит внутрь, хотя вызов на рантайме идёт наружу. - -**Транзакция едет в контексте.** `TxManager.WithinTx` кладёт `pgx.Tx` в контекст -по неэкспортируемому ключу; репозитории достают её через `conn(ctx, pool)`. -Благодаря этому юзкейс говорит «сделай это атомарно», ни разу не упомянув pgx. - -**Атомарный claim нельзя разложить на шаги.** `ClaimNext` — один SQL-запрос, -потому что `SELECT` + отдельный `UPDATE` вернул бы гонку, при которой одну -задачу выдают двум воркерам. Поэтому `ClaimTask.Execute` выглядит тонким: там -нечего оркестровать, вся гарантия — внутри запроса. - ---- - -## 6. Что уже работает, а что заглушка - -Работает: слои и проводка, роутинг, авторизация, access-лог, маппинг ошибок, -транзакции, graceful shutdown, миграции, **весь domain с 12 юнит-тестами без БД**. - -Заглушки (`ErrNotImplemented` → HTTP 501): методы репозиториев. SQL для двух -главных операций уже написан в `task_repo.go` — `claimNextSQL` и -`expireLeasesSQL`, осталось их подключить. diff --git a/users/README.md b/users/README.md index 7240b99..63b6587 100644 --- a/users/README.md +++ b/users/README.md @@ -1,230 +1,65 @@ -# SciMesh Coordinator +# SciMesh userservice -Durable task-queue server for SciMesh, in Go on PostgreSQL. It owns all database -access; workers talk to it only over HTTP and never receive DB credentials. +Authentication service for SciMesh, in Go on PostgreSQL. It owns user accounts +and issues the JWTs the coordinator trusts. It is a **separate bounded context** +from the coordinator: its own database, its own binary. The only thing shared +between the two services is the JWT signing secret. -Built as a **modular monolith following Clean Architecture** — one binary, four -layers, dependencies pointing strictly inward. See -`docs/database-integration-task.md` and `docs/worker-daemon-task.md` in the repo -root for the full contract. - -## Layers +Built as a modular monolith following Clean Architecture — one binary, four +layers, dependencies pointing strictly inward: ``` - infra config, pgxpool, http.Server, clock ← frameworks & drivers - transport http handlers ← inbound: who calls us - storage sql repositories ← outbound: who we call - usecase business operations + PORTS ← application rules - domain Task, Job + their invariants ← enterprise rules - - ┌── transport ──┐ - domain ◄── usecase ◄┤ ├◄── infra - └── storage ────┘ + infra config, DB pool, clock, HTTP server ← drivers + transport HTTP handlers + JWT middleware ← incoming + storage SQL repository ← outgoing + usecase Register / Login + PORTS (interfaces) ← application rules + domain User, Role, invariants ← business rules + auth bcrypt hasher, HS256 JWT issuer ← crypto adapters ``` -`transport` and `storage` are one layer — the "interface adapters" ring — split -by direction rather than by category, so a file's path tells you its role. - -The rule that matters: **source dependencies point only inward**. `domain` -imports nothing from this module; `usecase` sees only `domain`; `transport` and -`storage` know nothing of each other. Verify it at any time with: - -```sh -go list -f '{{range .Imports}}{{.}}{{"\n"}}{{end}}' ./internal/domain | grep internal # must be empty -``` - -## Layout - -``` -coordinator/ - cmd/coordinator/main.go # composition root: the only place with concrete types - internal/ - domain/ # entities + rules, no I/O - task.go Task, lease/complete/fail/expire transitions - job.go Job, chunk fan-out, status derivation - errors.go business-rule violations - usecase/ # one type per operation, dependencies injected - ports.go TaskRepository, JobRepository, TxManager, Clock - dto.go use-case boundary inputs - task.go claim, renew, complete, fail, expire - job.go create, status, results, stitch - transport/http/ # routing, DTOs, middleware, error mapping - storage/postgres/ # SQL behind the ports; TxManager via context - infra/ # config.go db.go clock.go server.go - migrations/ # golang-migrate SQL, run as an explicit command -``` - -A full map — file-by-file table, a request traced through every layer, and a -"where do I add X" guide — lives in [ARCHITECTURE.md](ARCHITECTURE.md). - -## Quickstart - -### With Docker (nothing to install but Docker) - -```sh -make up # Postgres → migrations → coordinator -curl localhost:8080/health -make logs # follow the coordinator -make down # stop (add down-clean to drop the DB volume) -``` - -To enable the local operator UI, set a separate credential before starting: - -```sh -UI_AUTH_TOKEN='local-ui-secret' make up -# Open http://localhost:8080/ui and use any username with this value as password. -``` - -The UI is disabled by default and never accepts the worker bearer token. -The **control room** shows live workers, recent runs, shard state/attempts, -safe failures, coordinator artifacts, and the final CSV for completed -similarity-search jobs. The job page follows the real stages: TSV accepted → -shards execute → workers return CSVs → `reducing` → final deterministic global -top-k result. It polls only its own coordinator read-model and never controls -or exposes worker processes. - -For a hands-on run, open `/ui`, choose **New similarity search**, select a -small ChEMBL-style TSV, then leave one or more `scimesh-worker` processes -running in separate terminals. The detail page updates every two seconds and -stops polling after a completed, failed, or cancelled job. Use **Preview CSV** -to inspect a bounded first page of a partial or completed final result before -downloading it. The UI never exposes source datasets or shard inputs; partial -CSVs remain available only as diagnostics. - -### One-command manual demo - -From the repository root, create the Python environment once, then start a -self-contained UI demo with two local reference workers: - -```sh -python3 -m venv .venv -.venv/bin/pip install -e '.[dev]' -make demo-ui -``` - -This uses a separate Docker project and ports `18080` (coordinator) and -`55432` (PostgreSQL), so it does not conflict with the normal stack. Open -`http://localhost:18080/ui`, use username `operator` and password -`demo-ui-secret`, upload a small ChEMBL TSV, and observe the workers process -it. Change the worker count with `make demo-ui WORKERS=3`; stop all demo -services and workers with `make demo-down`. - -The job page shows a live **Processing speed** graph in completed shards per -minute. It uses the coordinator snapshots observed by the open browser tab, so -it is a transparent local-session measurement rather than a persisted metric. -Use **Preview CSV** before downloading a partial diagnostic or completed final -result. Run `make help` from either the repository root or this directory for -the full list of demo commands. - -`up` starts three services in order: Postgres waits until `pg_isready` passes, a -one-shot `migrate` container applies the schema and exits, and only then does the -coordinator start — so it never queries a database that has no tables. - -> **Needs BuildKit.** The Dockerfile uses `RUN --mount=type=cache` to reuse the -> Go module and compiler caches between builds. If the build fails with -> *"the --mount option requires BuildKit"*, install the buildx plugin — -> `pacman -S docker-buildx` on Arch, `apt install docker-buildx-plugin` on Debian. - -### Locally, against your own Postgres - -```sh -cp .env.example .env # then edit DATABASE_URL / WORKER_AUTH_TOKEN - # it is loaded automatically — no export needed - -make tidy # fetch deps (needs network once) -make migrate-up # apply schema (needs the migrate CLI) -make run # start the server -``` - -## Configuration - -Settings come from the environment. A `.env` file is loaded at startup via -`godotenv` as a local-dev convenience (override its path with `ENV_FILE`): - -- a missing `.env` is not an error — production injects real env vars; -- **real environment variables always win** over the file, so an orchestrator's - values are never shadowed by a stale `.env` baked into an image. - -See `.env.example`; only `DATABASE_URL` is required. - ## Endpoints -| Method | Path | Purpose | -| ------ | ---------------------------------- | --------------------------------------------- | -| POST | `/workers/register` | Register a worker, get its id | -| POST | `/jobs` | Create job + tasks from chunk URIs | -| POST | `/jobs/upload` | Upload a dataset; coordinator chunks it | -| GET | `/jobs/{job_id}` | Aggregate job progress | -| POST | `/tasks/claim` | Atomically lease one task (`204` if none) | -| GET | `/tasks/{task_id}/input` | Download the task's input shard | -| POST | `/tasks/{task_id}/heartbeat` | Renew the caller's lease (→ `running`) | -| PUT | `/tasks/{task_id}/artifacts/{name}`| Upload a partial-result artifact | -| POST | `/tasks/{task_id}/result` | Complete with an artifact id (idempotent) | -| POST | `/tasks/{task_id}/failure` | Record failure / retryable state | -| GET | `/artifacts/{artifact_id}/download`| Download an artifact by id | -| GET | `/health` | Readiness incl. database (unauthenticated) | +| Method | Path | Auth | Purpose | +|--------|-------------|-------------|------------------------------------------| +| GET | `/health` | none | Liveness probe (checks the database) | +| POST | `/register` | none | Create an account (always role `user`) | +| POST | `/login` | none | Verify credentials, return a signed JWT | +| GET | `/me` | Bearer JWT | Return the caller's own account | -The full contract is in [`docs/api-contract.md`](../docs/api-contract.md) and -[`docs/openapi.yaml`](../docs/openapi.yaml); a worker-author guide is in -[`docs/building-workers.md`](../docs/building-workers.md). +Roles are `user` and `admin`. Registration always creates a `user`; promotion to +`admin` is a manual database operation, never a request. The role→permission +mapping lives in the coordinator's authorization checks, not in a table. -## Poking the API +## How it connects to the coordinator -Two ways, both checked in: +The coordinator never calls this service at runtime. A client logs in here, gets +a JWT, and presents it to the coordinator, which verifies the signature locally +with the same `JWT_SECRET` and reads `sub` (the user id) into `jobs.owner_id`. + +That link is **off by default**: until the coordinator is given a matching +`JWT_SECRET`, it accepts only the shared worker token and stores `owner_id` as +NULL. Set the same secret (≥ 32 bytes, byte-for-byte identical) on both services +to turn it on. + +## Run ```sh -make smoke # every endpoint, asserted; non-zero exit on failure +# whole stack: Postgres + migrations + the service on :8081 +make up + +# or locally against your own Postgres +cp .env.example .env # then edit JWT_SECRET and DATABASE_URL +make run ``` -`api/requests.http` runs the same calls one at a time from an editor with a REST -client (VSCodium/VS Code "REST Client", JetBrains HTTP Client). Later requests -reuse ids captured from earlier responses, so it doubles as API documentation. - -## Status - -Works end to end: a worker registers, a dataset is uploaded and chunked into -shard tasks (or a job is created from chunk URIs), tasks are leased one at a -time, downloaded, heartbeated (`leased → running`), completed via uploaded -result artifacts, and reflected in job progress. A reaper reclaims expired -leases and marks silent workers offline. - -Done: schema + migrations, atomic claim (`FOR UPDATE SKIP LOCKED`), optimistic -concurrency, result/failure paths, lease expiry, worker registry + liveness, -artifact storage, dataset upload + chunking, request-size limits. - -Still stubbed: `StitchJob.Execute` — merging per-chunk top-k into the final CSV -is workload semantics that belongs to the Python side (reducer). - -## Tests - -Unit tests need **no database** — domain rules, use-case orchestration (over -in-memory `internal/memstore`), and HTTP handlers (via `httptest`): +## Verify ```sh -make test # go test ./... -make vet -make lint -go test -race ./... +make test # unit tests +make check # vet, lint, race, integration, smoke — needs Docker +make smoke # end-to-end against a running service ``` -Integration tests run against a **real PostgreSQL** (the spec forbids mocks -here — they verify `FOR UPDATE SKIP LOCKED`, optimistic concurrency, rollback): - -```sh -docker compose up -d -make test-integration TEST_DATABASE_URL='postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable' -``` - -CI (`.github/workflows/coordinator.yml`) runs vet, gofmt, race tests, lint, and -the integration suite against a Postgres service on every push and PR. - -For the complete local verification, including an isolated Docker PostgreSQL -and the HTTP smoke flow, run: - -```sh -make check -``` - -It uses Compose project `scimesh-check` and ports `55432`/`18080` by default, -so it does not connect to a PostgreSQL already running on `5432`. Override -`CHECK_POSTGRES_PORT`, `CHECK_COORDINATOR_PORT`, or `CHECK_PROJECT` if needed. +Password hashing uses bcrypt (`golang.org/x/crypto/bcrypt`); the salt and cost +are embedded in the stored hash, so there is no separate salt column. Tokens are +HS256 (`github.com/golang-jwt/jwt/v5`). diff --git a/users/api/requests.http b/users/api/requests.http deleted file mode 100644 index 6c50a59..0000000 --- a/users/api/requests.http +++ /dev/null @@ -1,234 +0,0 @@ -# SciMesh Coordinator — API requests -# -# Runnable from any editor with a REST client (VSCodium/VS Code "REST Client", -# JetBrains HTTP Client). Click "Send Request" above each block, top to bottom: -# later requests reuse ids captured from earlier responses. -# -# Start the stack first: docker compose up -d - -@host = http://localhost:8080 -@token = change-me -@worker = worker-1 - -### Readiness — the only unauthenticated endpoint (probes the database) -GET {{host}}/health - -### Auth check — no token must be rejected with 401 -POST {{host}}/tasks/claim -Content-Type: application/json - -{ "worker_id": "{{worker}}" } - -### 0. Register a worker (201) -# @name register -POST {{host}}/workers/register -Authorization: Bearer {{token}} -Content-Type: application/json - -{ - "name": "lab-worker-01", - "capabilities": ["similarity_search"], - "cpu_count": 8, - "memory_mb": 16384 -} - -@workerId = {{register.response.body.worker_id}} - -### 0b. Upload a dataset — the coordinator splits it into shard tasks (201) -# Text fields first, the file part last (it is streamed, not buffered). -# @name uploadJob -POST {{host}}/jobs/upload -Authorization: Bearer {{token}} -Content-Type: multipart/form-data; boundary=----scimesh - -------scimesh -Content-Disposition: form-data; name="workload" - -similarity_search -------scimesh -Content-Disposition: form-data; name="parameters" - -{"top_k":10} -------scimesh -Content-Disposition: form-data; name="chunk_rows" - -2 -------scimesh -Content-Disposition: form-data; name="file"; filename="chembl.tsv" -Content-Type: text/tab-separated-values - -id smiles -A CC -B CCC -C CCCC -D CCCCC -------scimesh-- - -### Download a task's input shard (200) — taskId must be a shard task from an -### uploaded job (claim one first; its input.uri is /tasks/{id}/input). -GET {{host}}/tasks/{{taskId}}/input -Authorization: Bearer {{token}} - -### 1. Create a job and its chunks (201) -# The coordinator splits the submission into one task per chunk, transactionally. -# @name createJob -POST {{host}}/jobs -Authorization: Bearer {{token}} -Content-Type: application/json - -{ - "workload": "similarity_search", - "input_uri": "s3://chembl/full.sdf", - "parameters": { "top_k": 10 }, - "chunks": [ - { "chunk_index": 0, "input_uri": "s3://chembl/shard-0.sdf", "input_sha256": "aaa", "max_attempts": 3 }, - { "chunk_index": 1, "input_uri": "s3://chembl/shard-1.sdf", "input_sha256": "bbb", "max_attempts": 3 }, - { "chunk_index": 2, "input_uri": "s3://chembl/shard-2.sdf", "input_sha256": "ccc", "max_attempts": 3 } - ] -} - -@jobId = {{createJob.response.body.id}} - -### 2. Claim a task (200, or 204 when the queue is empty) -# Each call leases a different task; run it repeatedly to see chunk_index advance. -# @name claim -POST {{host}}/tasks/claim -Authorization: Bearer {{token}} -Content-Type: application/json - -{ - "worker_id": "{{worker}}", - "capabilities": ["similarity_search"], - "max_concurrency": 1 -} - -@taskId = {{claim.response.body.task_id}} -@attempt = {{claim.response.body.attempt}} - -### 3. Heartbeat — renew the lease while the task is still running (200) -POST {{host}}/tasks/{{taskId}}/heartbeat -Authorization: Bearer {{token}} -Content-Type: application/json - -{ - "worker_id": "{{worker}}", - "attempt": {{attempt}} -} - -### 3a. Upload a partial-result artifact (200) — while the task is leased -# Identity travels in headers per the contract; the body is streamed as-is. -# @name uploadArtifact -PUT {{host}}/tasks/{{taskId}}/artifacts/result.csv -Authorization: Bearer {{token}} -Content-Type: text/csv -X-Worker-ID: {{worker}} -X-Task-Attempt: {{attempt}} - -query,match,score -CHEMBL25,CHEMBL139,0.87 - -@artifactId = {{uploadArtifact.response.body.artifact_id}} - -### 3b. Download the artifact by id (200) -GET {{host}}/artifacts/{{artifactId}}/download -Authorization: Bearer {{token}} - -### 3c. Upload a second artifact — used by the conflict check below (200) -# @name uploadArtifact2 -PUT {{host}}/tasks/{{taskId}}/artifacts/secondary.csv -Authorization: Bearer {{token}} -Content-Type: text/csv -X-Worker-ID: {{worker}} -X-Task-Attempt: {{attempt}} - -query,match,score -CHEMBL25,CHEMBL521,0.42 - -@artifactId2 = {{uploadArtifact2.response.body.artifact_id}} - -### 4. Submit the result, referencing the uploaded artifact (200) -POST {{host}}/tasks/{{taskId}}/result -Authorization: Bearer {{token}} -Content-Type: application/json - -{ - "worker_id": "{{worker}}", - "attempt": {{attempt}}, - "result": { "artifact_id": "{{artifactId}}", "content_type": "text/csv" }, - "metrics": { "elapsed_ms": 1234, "candidates": 50000 } -} - -### 4a. Replay the same result — must be idempotent (200, not 409) -POST {{host}}/tasks/{{taskId}}/result -Authorization: Bearer {{token}} -Content-Type: application/json - -{ - "worker_id": "{{worker}}", - "attempt": {{attempt}}, - "result": { "artifact_id": "{{artifactId}}" } -} - -### 4b. A different artifact for the same task — conflict (409) -POST {{host}}/tasks/{{taskId}}/result -Authorization: Bearer {{token}} -Content-Type: application/json - -{ - "worker_id": "{{worker}}", - "attempt": {{attempt}}, - "result": { "artifact_id": "{{artifactId2}}" } -} - -### 4c. Another worker submitting for this task — conflict (409) -POST {{host}}/tasks/{{taskId}}/result -Authorization: Bearer {{token}} -Content-Type: application/json - -{ - "worker_id": "impostor", - "attempt": {{attempt}}, - "result": { "artifact_id": "{{artifactId}}" } -} - -### 5. Report a failure instead (200) -# retryable=true returns the task to the queue while attempts remain; -# retryable=false fails it terminally. -POST {{host}}/tasks/{{taskId}}/failure -Authorization: Bearer {{token}} -Content-Type: application/json - -{ - "worker_id": "{{worker}}", - "attempt": {{attempt}}, - "error_code": "download_failed", - "error_message": "checksum mismatch on shard", - "retryable": true -} - -### 6. Job progress (200) -GET {{host}}/jobs/{{jobId}} -Authorization: Bearer {{token}} - -### --- error cases ------------------------------------------------------- - -### Malformed UUID in the path (400) -POST {{host}}/tasks/not-a-uuid/result -Authorization: Bearer {{token}} -Content-Type: application/json - -{ "worker_id": "{{worker}}", "attempt": 1, "result_uri": "s3://x", "result_sha256": "x" } - -### Unknown field in the body (400) — a misspelled key must not pass silently -POST {{host}}/tasks/claim -Authorization: Bearer {{token}} -Content-Type: application/json - -{ "worker_ID": "{{worker}}" } - -### Unknown job (404) -GET {{host}}/jobs/00000000-0000-0000-0000-000000000000 -Authorization: Bearer {{token}} - -### Stitching is not implemented yet (501) -# Any endpoint whose use case is still a stub answers 501. diff --git a/users/internal/infra/clock.go b/users/internal/infra/clock.go index eedbcde..e326bce 100644 --- a/users/internal/infra/clock.go +++ b/users/internal/infra/clock.go @@ -8,6 +8,6 @@ type System struct{} func NewClock() System { return System{} } -// Now returns UTC so every timestamp the coordinator writes is comparable +// Now returns UTC so every timestamp this service writes is comparable // regardless of the host's timezone. func (System) Now() time.Time { return time.Now().UTC() } diff --git a/users/internal/infra/db.go b/users/internal/infra/db.go index d1135cd..4a09674 100644 --- a/users/internal/infra/db.go +++ b/users/internal/infra/db.go @@ -25,7 +25,7 @@ func NewPool(ctx context.Context, cfg Config, log *slog.Logger) (*pgxpool.Pool, } // pgxpool.New is lazy, so a ping is needed to actually reach the server. // It is retried because at startup — especially under docker-compose, where - // the coordinator can boot before Postgres is accepting connections — a + // this service can boot before Postgres is accepting connections — a // service should wait for its database rather than crash-loop. if err := pingWithRetry(ctx, pool, cfg.DBConnectTimeout, log); err != nil { pool.Close() diff --git a/users/internal/infra/server.go b/users/internal/infra/server.go index fc49a4c..80999e2 100644 --- a/users/internal/infra/server.go +++ b/users/internal/infra/server.go @@ -1,5 +1,4 @@ -// Server: the HTTP listener and the background lease reaper, both shut down -// cleanly on a signal. +// Server: the HTTP listener, shut down cleanly on a signal. package infra import ( @@ -12,7 +11,7 @@ import ( const shutdownGrace = 15 * time.Second -// Run serves handler until ctx is cancelled, then drains in-flight requests. +// RunServer serves handler until ctx is cancelled, then drains in-flight requests. func RunServer(ctx context.Context, log *slog.Logger, addr string, handler http.Handler) error { srv := &http.Server{ Addr: addr, @@ -43,32 +42,3 @@ func RunServer(ctx context.Context, log *slog.Logger, addr string, handler http. defer cancel() return srv.Shutdown(shutdownCtx) } - -// RunReaper periodically reclaims tasks whose lease elapsed, so a worker that -// died without a heartbeat cannot strand its task in 'leased' forever. -// RunPeriodic invokes fn on an interval until ctx is done, logging how many rows -// each tick affected. It backs the background reapers (expired leases, offline -// workers) — each is a set-based UPDATE that is safe to run repeatedly and -// concurrently across coordinators. -func RunPeriodic(ctx context.Context, log *slog.Logger, name string, interval time.Duration, - fn func(context.Context) (int64, error)) { - - t := time.NewTicker(interval) - defer t.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-t.C: - n, err := fn(ctx) - if err != nil { - log.Debug(name+" skipped", "err", err) - continue - } - if n > 0 { - log.Info(name, "count", n) - } - } - } -} diff --git a/users/internal/storage/postgres/retry.go b/users/internal/storage/postgres/retry.go index 8f0ae86..3bd35ce 100644 --- a/users/internal/storage/postgres/retry.go +++ b/users/internal/storage/postgres/retry.go @@ -9,8 +9,8 @@ import ( "github.com/jackc/pgx/v5/pgconn" ) -// Transient PostgreSQL failures. Under concurrent claiming these are expected -// rather than exceptional: two coordinators touching neighbouring rows can +// Transient PostgreSQL failures. Under concurrent writes these are expected +// rather than exceptional: two service instances touching neighbouring rows can // deadlock or fail to serialize, and the correct response is to try again. const ( codeSerializationFailure = "40001" @@ -60,7 +60,7 @@ func isTransient(err error) bool { // withRetry runs op, retrying only transient database failures with // exponential backoff and jitter, and giving up as soon as ctx is done. // -// Jitter matters here: without it, several coordinators that collide once will +// Jitter matters here: without it, several instances that collide once will // retry in lockstep and collide again at exactly the same moment. func withRetry(ctx context.Context, op func(context.Context) error) error { b := backoff.NewExponentialBackOff() From c6a66747eb945f7a635ceb9550c49b0ac6dbb2e0 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 19:10:01 +0300 Subject: [PATCH 06/24] feat(users): add admin-granted verified badge for trusted contributors - migration 0002: users.verified boolean, default false - verified rides in the JWT (role + verified claims) - POST /users/{id}/verify + /unverify, admin-only (403 otherwise) - Issue now takes the whole user so trust claims travel in the token - unit + integration + admin-flow tests --- users/README.md | 30 +++-- users/cmd/userservice/main.go | 7 +- users/internal/auth/jwt.go | 21 +-- users/internal/auth/jwt_test.go | 9 +- users/internal/domain/user.go | 8 +- users/internal/memstore/memstore.go | 12 ++ .../storage/postgres/integration_test.go | 38 ++++++ users/internal/storage/postgres/user_repo.go | 27 +++- users/internal/transport/http/dto.go | 2 + users/internal/transport/http/handlers.go | 32 ++++- users/internal/transport/http/middleware.go | 16 +++ users/internal/transport/http/server.go | 25 ++-- users/internal/transport/http/server_test.go | 127 +++++++++++++++++- users/internal/usecase/errorpaths_test.go | 5 +- users/internal/usecase/login.go | 2 +- users/internal/usecase/ports.go | 8 +- users/internal/usecase/verify.go | 24 ++++ users/internal/usecase/verify_test.go | 73 ++++++++++ users/migrations/0002_user_verified.down.sql | 5 + users/migrations/0002_user_verified.up.sql | 9 ++ 20 files changed, 429 insertions(+), 51 deletions(-) create mode 100644 users/internal/usecase/verify.go create mode 100644 users/internal/usecase/verify_test.go create mode 100644 users/migrations/0002_user_verified.down.sql create mode 100644 users/migrations/0002_user_verified.up.sql diff --git a/users/README.md b/users/README.md index 63b6587..9dd030a 100644 --- a/users/README.md +++ b/users/README.md @@ -19,16 +19,28 @@ layers, dependencies pointing strictly inward: ## Endpoints -| Method | Path | Auth | Purpose | -|--------|-------------|-------------|------------------------------------------| -| GET | `/health` | none | Liveness probe (checks the database) | -| POST | `/register` | none | Create an account (always role `user`) | -| POST | `/login` | none | Verify credentials, return a signed JWT | -| GET | `/me` | Bearer JWT | Return the caller's own account | +| Method | Path | Auth | Purpose | +|--------|---------------------------|--------------|---------------------------------------------| +| GET | `/health` | none | Liveness probe (checks the database) | +| POST | `/register` | none | Create an account (always role `user`) | +| POST | `/login` | none | Verify credentials, return a signed JWT | +| GET | `/me` | Bearer JWT | Return the caller's own account | +| POST | `/users/{id}/verify` | Bearer admin | Grant the trusted-contributor badge | +| POST | `/users/{id}/unverify` | Bearer admin | Revoke the badge | -Roles are `user` and `admin`. Registration always creates a `user`; promotion to -`admin` is a manual database operation, never a request. The role→permission -mapping lives in the coordinator's authorization checks, not in a table. +Two independent attributes live on an account: + +- **`role`** — `user` or `admin`. Governs what you may do with your own jobs. + Registration always creates a `user`; promotion to `admin` is a manual + database operation, never a request. +- **`verified`** — a boolean trust badge, granted **only by an admin** (the + `/verify` endpoints above, 403 for anyone else). It tells the coordinator + whether this user's volunteer workers are trusted: a verified contributor's + results are accepted directly, an unverified one's must pass quorum + cross-checking. Defaults to false. + +Both attributes ride in the JWT (`role`, `verified` claims), so the coordinator +reads them from the signed token without ever calling this service. ## How it connects to the coordinator diff --git a/users/cmd/userservice/main.go b/users/cmd/userservice/main.go index fbb0095..6b11926 100644 --- a/users/cmd/userservice/main.go +++ b/users/cmd/userservice/main.go @@ -54,9 +54,10 @@ func run() error { issuer := auth.NewIssuer(cfg.JWTSecret, cfg.TokenTTL, clock.Now) uc := apihttp.UseCases{ - Register: usecase.NewRegister(users, hasher, clock), - Login: usecase.NewLogin(users, hasher, issuer), - Users: users, + Register: usecase.NewRegister(users, hasher, clock), + Login: usecase.NewLogin(users, hasher, issuer), + SetVerified: usecase.NewSetVerified(users), + Users: users, } handler := apihttp.NewServer(log, uc, issuer) diff --git a/users/internal/auth/jwt.go b/users/internal/auth/jwt.go index 1c029c5..ae02f26 100644 --- a/users/internal/auth/jwt.go +++ b/users/internal/auth/jwt.go @@ -5,17 +5,19 @@ import ( "time" "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" "github.com/emil28092005/SciMesh/users/internal/domain" ) // Claims is the payload of a signed token. Subject (from RegisteredClaims) is // the user id — it becomes the coordinator's jobs.owner_id; Role drives -// authorization. Both services verify this token locally with the shared HS256 -// secret, so no runtime call back to the userservice is ever needed. +// authorization; Verified tells the coordinator whether this user's workers are +// trusted (results accepted without quorum). Both services verify this token +// locally with the shared HS256 secret, so no runtime call back to the +// userservice is ever needed. type Claims struct { - Role domain.Role `json:"role"` + Role domain.Role `json:"role"` + Verified bool `json:"verified"` jwt.RegisteredClaims } @@ -35,13 +37,16 @@ func NewIssuer(secret string, ttl time.Duration, now func() time.Time) Issuer { return Issuer{secret: []byte(secret), ttl: ttl, now: now} } -// Issue returns a signed token for the user, valid for the configured TTL. -func (i Issuer) Issue(userID uuid.UUID, role domain.Role) (string, error) { +// Issue returns a signed token for the user, valid for the configured TTL. It +// takes the whole user so every trust-bearing field (role, verified) travels in +// the token, keeping the two services from needing a runtime lookup. +func (i Issuer) Issue(u *domain.User) (string, error) { now := i.now() claims := Claims{ - Role: role, + Role: u.Role, + Verified: u.Verified, RegisteredClaims: jwt.RegisteredClaims{ - Subject: userID.String(), + Subject: u.ID.String(), IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)), }, diff --git a/users/internal/auth/jwt_test.go b/users/internal/auth/jwt_test.go index c3d14b1..a01fa7a 100644 --- a/users/internal/auth/jwt_test.go +++ b/users/internal/auth/jwt_test.go @@ -16,7 +16,7 @@ func TestIssueVerifyRoundTrip(t *testing.T) { iss := NewIssuer(testSecret, time.Hour, nil) id := uuid.New() - token, err := iss.Issue(id, domain.RoleAdmin) + token, err := iss.Issue(&domain.User{ID: id, Role: domain.RoleAdmin, Verified: true}) if err != nil { t.Fatalf("issue: %v", err) } @@ -31,12 +31,15 @@ func TestIssueVerifyRoundTrip(t *testing.T) { if claims.Role != domain.RoleAdmin { t.Errorf("role = %q, want admin", claims.Role) } + if !claims.Verified { + t.Error("verified claim not carried in token") + } } func TestVerifyRejectsExpired(t *testing.T) { // Negative TTL: the token is already expired when issued. iss := NewIssuer(testSecret, -time.Minute, nil) - token, _ := iss.Issue(uuid.New(), domain.RoleUser) + token, _ := iss.Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser}) if _, err := iss.Verify(token); err == nil { t.Error("expired token accepted") @@ -44,7 +47,7 @@ func TestVerifyRejectsExpired(t *testing.T) { } func TestVerifyRejectsWrongSecret(t *testing.T) { - token, _ := NewIssuer(testSecret, time.Hour, nil).Issue(uuid.New(), domain.RoleUser) + token, _ := NewIssuer(testSecret, time.Hour, nil).Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser}) other := NewIssuer("another-secret-also-32-bytes-long!!!", time.Hour, nil) if _, err := other.Verify(token); err == nil { diff --git a/users/internal/domain/user.go b/users/internal/domain/user.go index 45db392..77ca642 100644 --- a/users/internal/domain/user.go +++ b/users/internal/domain/user.go @@ -29,8 +29,12 @@ type User struct { Email string PasswordHash string Role Role - CreatedAt time.Time - UpdatedAt time.Time + // Verified marks a trusted contributor whose workers' results the + // coordinator accepts without quorum. Distinct from Role; granted by an + // admin, defaults to false. + Verified bool + CreatedAt time.Time + UpdatedAt time.Time } // NewUser builds a freshly registered account. It normalises the email and diff --git a/users/internal/memstore/memstore.go b/users/internal/memstore/memstore.go index 8ca2f41..f22f639 100644 --- a/users/internal/memstore/memstore.go +++ b/users/internal/memstore/memstore.go @@ -60,6 +60,18 @@ func (r *UserRepo) GetByID(_ context.Context, id uuid.UUID) (*domain.User, error return &u, nil } +func (r *UserRepo) SetVerified(_ context.Context, id uuid.UUID, verified bool) error { + r.mu.Lock() + defer r.mu.Unlock() + u, ok := r.byID[id] + if !ok { + return usecase.ErrUserNotFound + } + u.Verified = verified + r.byID[id] = u + return nil +} + // Clock is a fixed usecase.Clock for deterministic tests. type Clock struct{ T time.Time } diff --git a/users/internal/storage/postgres/integration_test.go b/users/internal/storage/postgres/integration_test.go index d60e27b..b229f95 100644 --- a/users/internal/storage/postgres/integration_test.go +++ b/users/internal/storage/postgres/integration_test.go @@ -107,3 +107,41 @@ func TestUserRepoNotFound(t *testing.T) { t.Errorf("GetByEmail unknown: got %v, want ErrUserNotFound", err) } } + +func TestUserRepoSetVerified(t *testing.T) { + repo := NewUserRepo(testPool(t)) + ctx := context.Background() + u := seedUser(t, repo) + + // A fresh row defaults to unverified. + got, err := repo.GetByID(ctx, u.ID) + if err != nil { + t.Fatal(err) + } + if got.Verified { + t.Fatal("new user must default to unverified") + } + + if err := repo.SetVerified(ctx, u.ID, true); err != nil { + t.Fatalf("grant: %v", err) + } + got, _ = repo.GetByID(ctx, u.ID) + if !got.Verified { + t.Error("verified flag not persisted") + } + + if err := repo.SetVerified(ctx, u.ID, false); err != nil { + t.Fatalf("revoke: %v", err) + } + got, _ = repo.GetByID(ctx, u.ID) + if got.Verified { + t.Error("verified flag not cleared") + } +} + +func TestUserRepoSetVerifiedUnknown(t *testing.T) { + repo := NewUserRepo(testPool(t)) + if err := repo.SetVerified(context.Background(), uuid.New(), true); !errors.Is(err, usecase.ErrUserNotFound) { + t.Errorf("got %v, want ErrUserNotFound", err) + } +} diff --git a/users/internal/storage/postgres/user_repo.go b/users/internal/storage/postgres/user_repo.go index b170fcf..0c4d997 100644 --- a/users/internal/storage/postgres/user_repo.go +++ b/users/internal/storage/postgres/user_repo.go @@ -17,7 +17,7 @@ import ( // uniqueViolation is PostgreSQL's SQLSTATE for a unique-constraint breach. const uniqueViolation = "23505" -var userColumns = []string{"id", "email", "password_hash", "role", "created_at", "updated_at"} +var userColumns = []string{"id", "email", "password_hash", "role", "verified", "created_at", "updated_at"} // UserRepo implements usecase.UserRepository on PostgreSQL. type UserRepo struct { @@ -31,7 +31,7 @@ func NewUserRepo(pool *pgxpool.Pool) *UserRepo { func (r *UserRepo) Insert(ctx context.Context, u *domain.User) error { sql, args, err := psql.Insert("users"). Columns(userColumns...). - Values(u.ID, u.Email, u.PasswordHash, string(u.Role), u.CreatedAt, u.UpdatedAt). + Values(u.ID, u.Email, u.PasswordHash, string(u.Role), u.Verified, u.CreatedAt, u.UpdatedAt). ToSql() if err != nil { return err @@ -64,12 +64,33 @@ func (r *UserRepo) getBy(ctx context.Context, pred sq.Sqlizer) (*domain.User, er return scanUser(conn(ctx, r.pool).QueryRow(ctx, sql, args...)) } +// SetVerified flips the verified flag and returns ErrUserNotFound when the id +// matches no row (so an admin verifying a deleted user gets a clean 404). +func (r *UserRepo) SetVerified(ctx context.Context, id uuid.UUID, verified bool) error { + sql, args, err := psql.Update("users"). + Set("verified", verified). + Set("updated_at", sq.Expr("now()")). + Where(sq.Eq{"id": id}). + ToSql() + if err != nil { + return err + } + tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return usecase.ErrUserNotFound + } + return nil +} + func scanUser(row pgx.Row) (*domain.User, error) { var ( u domain.User role string ) - if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &role, &u.CreatedAt, &u.UpdatedAt); err != nil { + if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &role, &u.Verified, &u.CreatedAt, &u.UpdatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, usecase.ErrUserNotFound } diff --git a/users/internal/transport/http/dto.go b/users/internal/transport/http/dto.go index 51b05e5..808ee3d 100644 --- a/users/internal/transport/http/dto.go +++ b/users/internal/transport/http/dto.go @@ -23,6 +23,7 @@ type userResponse struct { ID string `json:"id"` Email string `json:"email"` Role string `json:"role"` + Verified bool `json:"verified"` CreatedAt string `json:"created_at"` } @@ -36,6 +37,7 @@ func toUserResponse(u *domain.User) userResponse { ID: u.ID.String(), Email: u.Email, Role: string(u.Role), + Verified: u.Verified, CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339), } } diff --git a/users/internal/transport/http/handlers.go b/users/internal/transport/http/handlers.go index 055d609..615d6e7 100644 --- a/users/internal/transport/http/handlers.go +++ b/users/internal/transport/http/handlers.go @@ -5,15 +5,18 @@ import ( "log/slog" "net/http" + "github.com/google/uuid" + "github.com/emil28092005/SciMesh/users/internal/usecase" ) // Handlers holds the use cases each endpoint drives. type Handlers struct { - register *usecase.Register - login *usecase.Login - users usecase.UserRepository - log *slog.Logger + register *usecase.Register + login *usecase.Login + setVerified *usecase.SetVerified + users usecase.UserRepository + log *slog.Logger } // handleHealth is an unauthenticated liveness probe for the container and load @@ -67,6 +70,27 @@ func (h *Handlers) handleMe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, toUserResponse(u)) } +// handleSetVerified grants (verified=true) or revokes (false) the trusted- +// contributor badge for the user in the path. Admin-only; the withAdmin +// middleware has already enforced the role by the time this runs. +func (h *Handlers) handleSetVerified(verified bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "invalid user id", + RequestID: requestIDFrom(r.Context()), + }) + return + } + if err := h.setVerified.Execute(r.Context(), id, verified); err != nil { + writeError(w, r, h.log, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + // decodeJSON reads a size-capped JSON body into dst, rejecting unknown fields. // It writes a 400 and returns false on any problem, so callers can `if // !decodeJSON(...) { return }`. diff --git a/users/internal/transport/http/middleware.go b/users/internal/transport/http/middleware.go index 4d6ef66..266b1b6 100644 --- a/users/internal/transport/http/middleware.go +++ b/users/internal/transport/http/middleware.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/emil28092005/SciMesh/users/internal/auth" + "github.com/emil28092005/SciMesh/users/internal/domain" ) type ctxKey string @@ -94,6 +95,21 @@ func userIDFrom(ctx context.Context) (uuid.UUID, bool) { return id, ok } +// withAdmin rejects any caller whose token role is not admin. It must sit inside +// withJWT, which stamps the role after verifying the token. +func withAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if role, ok := r.Context().Value(roleKey).(domain.Role); !ok || role != domain.RoleAdmin { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "admin role required", + RequestID: requestIDFrom(r.Context()), + }) + return + } + next.ServeHTTP(w, r) + }) +} + // statusRecorder captures the status code for the access log. type statusRecorder struct { http.ResponseWriter diff --git a/users/internal/transport/http/server.go b/users/internal/transport/http/server.go index 58a2a67..256ec6f 100644 --- a/users/internal/transport/http/server.go +++ b/users/internal/transport/http/server.go @@ -13,19 +13,21 @@ import ( // UseCases bundles the application services the handlers drive. type UseCases struct { - Register *usecase.Register - Login *usecase.Login - Users usecase.UserRepository + Register *usecase.Register + Login *usecase.Login + SetVerified *usecase.SetVerified + Users usecase.UserRepository } // NewServer wires the routes and the middleware stack and returns the handler. -// The issuer verifies tokens for the protected /me route. +// The issuer verifies tokens for the JWT-protected routes. func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler { h := &Handlers{ - register: uc.Register, - login: uc.Login, - users: uc.Users, - log: log, + register: uc.Register, + login: uc.Login, + setVerified: uc.SetVerified, + users: uc.Users, + log: log, } mux := http.NewServeMux() @@ -36,6 +38,13 @@ func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler { // /me proves a token round-trips; it sits behind JWT auth. mux.Handle("GET /me", chain(http.HandlerFunc(h.handleMe), withJWT(issuer))) + // Admin-only: grant or revoke the trusted-contributor badge. withAdmin sits + // inside withJWT so the role is available from the verified token. + mux.Handle("POST /users/{id}/verify", + chain(h.handleSetVerified(true), withJWT(issuer), withAdmin)) + mux.Handle("POST /users/{id}/unverify", + chain(h.handleSetVerified(false), withJWT(issuer), withAdmin)) + // Outermost first: every request gets an ID and an access-log line. return chain(mux, withRequestID, withAccessLog(log)) } diff --git a/users/internal/transport/http/server_test.go b/users/internal/transport/http/server_test.go index 2ec04fa..b4d07a3 100644 --- a/users/internal/transport/http/server_test.go +++ b/users/internal/transport/http/server_test.go @@ -31,9 +31,10 @@ func newTestServer() http.Handler { issuer := auth.NewIssuer(secret, time.Hour, nil) uc := apihttp.UseCases{ - Register: usecase.NewRegister(users, hasher, clk), - Login: usecase.NewLogin(users, hasher, issuer), - Users: users, + Register: usecase.NewRegister(users, hasher, clk), + Login: usecase.NewLogin(users, hasher, issuer), + SetVerified: usecase.NewSetVerified(users), + Users: users, } log := slog.New(slog.NewTextHandler(io.Discard, nil)) return apihttp.NewServer(log, uc, issuer) @@ -189,14 +190,15 @@ func TestMeInternalError(t *testing.T) { users := failingUsers{UserRepository: memstore.NewUserRepo()} uc := apihttp.UseCases{ - Register: usecase.NewRegister(users, hasher, clk), - Login: usecase.NewLogin(users, hasher, issuer), - Users: users, + Register: usecase.NewRegister(users, hasher, clk), + Login: usecase.NewLogin(users, hasher, issuer), + SetVerified: usecase.NewSetVerified(users), + Users: users, } h := apihttp.NewServer(slog.New(slog.NewTextHandler(io.Discard, nil)), uc, issuer) // A structurally valid token for a caller the failing repo can't load. - token, err := issuer.Issue(uuid.New(), "user") + token, err := issuer.Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser}) if err != nil { t.Fatal(err) } @@ -210,3 +212,114 @@ func TestMeInternalError(t *testing.T) { t.Error("internal error leaked to the client") } } + +// mintToken issues a token with the package secret for a synthetic caller of the +// given role — enough to drive the admin-gated endpoints. +func mintToken(t *testing.T, role domain.Role) string { + t.Helper() + token, err := auth.NewIssuer(secret, time.Hour, nil).Issue(&domain.User{ID: uuid.New(), Role: role}) + if err != nil { + t.Fatal(err) + } + return token +} + +// registerUser creates an account and returns its id. +func registerUser(t *testing.T, h http.Handler, email string) string { + t.Helper() + rec := do(t, h, http.MethodPost, "/register", "", map[string]string{"email": email, "password": "password123"}) + if rec.Code != http.StatusCreated { + t.Fatalf("register: %d", rec.Code) + } + var reg struct { + ID string `json:"id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), ®); err != nil { + t.Fatal(err) + } + return reg.ID +} + +func TestAdminVerifiesUserEndToEnd(t *testing.T) { + h := newTestServer() + id := registerUser(t, h, "contrib@example.com") + + // Admin grants the badge. + rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", mintToken(t, domain.RoleAdmin), nil) + if rec.Code != http.StatusNoContent { + t.Fatalf("admin verify: got %d, body %s", rec.Code, rec.Body) + } + + // The change is visible when the contributor logs in. + rec = do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "contrib@example.com", "password": "password123"}) + var lr struct { + User struct { + Verified bool `json:"verified"` + } `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &lr); err != nil { + t.Fatal(err) + } + if !lr.User.Verified { + t.Error("verified badge not reflected after admin granted it") + } +} + +func TestVerifyRequiresAdminRole(t *testing.T) { + h := newTestServer() + id := registerUser(t, h, "someone@example.com") + + // A plain user token must not be able to grant the badge. + rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", mintToken(t, domain.RoleUser), nil) + if rec.Code != http.StatusForbidden { + t.Errorf("plain user: got %d, want 403", rec.Code) + } +} + +func TestVerifyRequiresAuth(t *testing.T) { + h := newTestServer() + rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/verify", "", nil) + if rec.Code != http.StatusUnauthorized { + t.Errorf("no token: got %d, want 401", rec.Code) + } +} + +func TestVerifyInvalidID(t *testing.T) { + h := newTestServer() + rec := do(t, h, http.MethodPost, "/users/not-a-uuid/verify", mintToken(t, domain.RoleAdmin), nil) + if rec.Code != http.StatusBadRequest { + t.Errorf("bad id: got %d, want 400", rec.Code) + } +} + +func TestVerifyUnknownUser(t *testing.T) { + h := newTestServer() + rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/verify", mintToken(t, domain.RoleAdmin), nil) + if rec.Code != http.StatusNotFound { + t.Errorf("unknown user: got %d, want 404", rec.Code) + } +} + +func TestUnverifyRevokes(t *testing.T) { + h := newTestServer() + id := registerUser(t, h, "revoke@example.com") + admin := mintToken(t, domain.RoleAdmin) + + if rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", admin, nil); rec.Code != http.StatusNoContent { + t.Fatalf("verify: %d", rec.Code) + } + if rec := do(t, h, http.MethodPost, "/users/"+id+"/unverify", admin, nil); rec.Code != http.StatusNoContent { + t.Fatalf("unverify: %d", rec.Code) + } + + rec := do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "revoke@example.com", "password": "password123"}) + var lr struct { + User struct { + Verified bool `json:"verified"` + } `json:"user"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &lr) + if lr.User.Verified { + t.Error("verified should be false after unverify") + } +} diff --git a/users/internal/usecase/errorpaths_test.go b/users/internal/usecase/errorpaths_test.go index d25f56d..92c2467 100644 --- a/users/internal/usecase/errorpaths_test.go +++ b/users/internal/usecase/errorpaths_test.go @@ -29,6 +29,9 @@ func (s stubRepo) GetByEmail(context.Context, string) (*domain.User, error) { func (s stubRepo) GetByID(context.Context, uuid.UUID) (*domain.User, error) { return nil, usecase.ErrUserNotFound } +func (s stubRepo) SetVerified(context.Context, uuid.UUID, bool) error { + return usecase.ErrUserNotFound +} type stubHasher struct { hashErr error @@ -45,7 +48,7 @@ func (s stubHasher) Compare(string, string) error { return s.compareErr } type stubIssuer struct{ err error } -func (s stubIssuer) Issue(uuid.UUID, domain.Role) (string, error) { +func (s stubIssuer) Issue(*domain.User) (string, error) { if s.err != nil { return "", s.err } diff --git a/users/internal/usecase/login.go b/users/internal/usecase/login.go index 7c57c3d..9b8351d 100644 --- a/users/internal/usecase/login.go +++ b/users/internal/usecase/login.go @@ -34,7 +34,7 @@ func (l *Login) Execute(ctx context.Context, email, password string) (string, *d return "", nil, ErrInvalidCredentials } - token, err := l.tokens.Issue(u.ID, u.Role) + token, err := l.tokens.Issue(u) if err != nil { return "", nil, err } diff --git a/users/internal/usecase/ports.go b/users/internal/usecase/ports.go index 3f75323..738082a 100644 --- a/users/internal/usecase/ports.go +++ b/users/internal/usecase/ports.go @@ -22,6 +22,9 @@ type UserRepository interface { GetByEmail(ctx context.Context, email string) (*domain.User, error) // GetByID returns the user with id, or ErrUserNotFound. GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) + // SetVerified toggles the verified flag, returning ErrUserNotFound if no + // such user exists. + SetVerified(ctx context.Context, id uuid.UUID, verified bool) error } // PasswordHasher hashes and verifies passwords. The bcrypt adapter satisfies it. @@ -30,9 +33,10 @@ type PasswordHasher interface { Compare(hash, password string) error } -// TokenIssuer mints a signed access token for an authenticated user. +// TokenIssuer mints a signed access token for an authenticated user. It takes +// the whole user so trust-bearing claims (role, verified) travel in the token. type TokenIssuer interface { - Issue(userID uuid.UUID, role domain.Role) (string, error) + Issue(u *domain.User) (string, error) } // Clock reads the current time; a fake one makes tests deterministic. diff --git a/users/internal/usecase/verify.go b/users/internal/usecase/verify.go new file mode 100644 index 0000000..2a4fc94 --- /dev/null +++ b/users/internal/usecase/verify.go @@ -0,0 +1,24 @@ +package usecase + +import ( + "context" + + "github.com/google/uuid" +) + +// SetVerified grants or revokes a user's trusted-contributor badge. Only an +// admin may call this (enforced in the transport layer); the use case itself +// just applies the change. +type SetVerified struct { + users UserRepository +} + +func NewSetVerified(users UserRepository) *SetVerified { + return &SetVerified{users: users} +} + +// Execute sets the verified flag on the target user, returning ErrUserNotFound +// if the user does not exist. +func (uc *SetVerified) Execute(ctx context.Context, id uuid.UUID, verified bool) error { + return uc.users.SetVerified(ctx, id, verified) +} diff --git a/users/internal/usecase/verify_test.go b/users/internal/usecase/verify_test.go new file mode 100644 index 0000000..1932ba6 --- /dev/null +++ b/users/internal/usecase/verify_test.go @@ -0,0 +1,73 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/users/internal/memstore" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +func TestSetVerifiedGrantsAndRevokes(t *testing.T) { + reg, _, users := newFixtures() + ctx := context.Background() + + u, err := reg.Execute(ctx, "contrib@example.com", "password123") + if err != nil { + t.Fatal(err) + } + if u.Verified { + t.Fatal("a fresh account must be unverified") + } + + sv := usecase.NewSetVerified(users) + + if err := sv.Execute(ctx, u.ID, true); err != nil { + t.Fatalf("grant: %v", err) + } + got, _ := users.GetByID(ctx, u.ID) + if !got.Verified { + t.Error("verified flag not set") + } + + if err := sv.Execute(ctx, u.ID, false); err != nil { + t.Fatalf("revoke: %v", err) + } + got, _ = users.GetByID(ctx, u.ID) + if got.Verified { + t.Error("verified flag not cleared") + } +} + +func TestSetVerifiedUnknownUser(t *testing.T) { + users := memstore.NewUserRepo() + sv := usecase.NewSetVerified(users) + + if err := sv.Execute(context.Background(), uuid.New(), true); !errors.Is(err, usecase.ErrUserNotFound) { + t.Errorf("got %v, want ErrUserNotFound", err) + } +} + +func TestLoginTokenCarriesVerified(t *testing.T) { + reg, login, users := newFixtures() + ctx := context.Background() + + u, err := reg.Execute(ctx, "trusted@example.com", "password123") + if err != nil { + t.Fatal(err) + } + if err := usecase.NewSetVerified(users).Execute(ctx, u.ID, true); err != nil { + t.Fatal(err) + } + + _, loggedIn, err := login.Execute(ctx, "trusted@example.com", "password123") + if err != nil { + t.Fatalf("login: %v", err) + } + if !loggedIn.Verified { + t.Error("login must reflect the granted verified flag") + } +} diff --git a/users/migrations/0002_user_verified.down.sql b/users/migrations/0002_user_verified.down.sql new file mode 100644 index 0000000..b52a55a --- /dev/null +++ b/users/migrations/0002_user_verified.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TABLE users DROP COLUMN IF EXISTS verified; + +COMMIT; diff --git a/users/migrations/0002_user_verified.up.sql b/users/migrations/0002_user_verified.up.sql new file mode 100644 index 0000000..7b8fe9e --- /dev/null +++ b/users/migrations/0002_user_verified.up.sql @@ -0,0 +1,9 @@ +BEGIN; + +-- A "verified" account is a trusted contributor: the coordinator accepts its +-- workers' results directly, without quorum cross-checking. Distinct from role +-- (which governs what a user may do with their own jobs). Granted by an admin, +-- never self-served; defaults to false, so a fresh account is untrusted. +ALTER TABLE users ADD COLUMN verified boolean NOT NULL DEFAULT false; + +COMMIT; From 80ff72a0fef1a68092130370eee024acf4587c93 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 19:18:50 +0300 Subject: [PATCH 07/24] =?UTF-8?q?feat(coordinator):=20worker=20trust=20tie?= =?UTF-8?q?rs=20(C1)=20=E2=80=94=20enroll=20volunteers,=20quarantine=20unt?= =?UTF-8?q?rusted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration 0012: workers.owner_id + trust_level (trusted/untrusted) - verifier/authctx read the JWT verified claim; IsTrusted() = admin||verified - /workers/register resolves trust from auth: service token or verified/admin JWT -> trusted; plain user JWT -> untrusted, tagged with owner_id - claim quarantines untrusted workers (no tasks) until quorum (C2) lands - unit tests for trust resolution, quarantine, and the verified claim Additive and backward compatible: shared-token workers stay trusted, so the existing worker flow and team tests are unchanged. Quorum verification (C2) is deferred. --- coordinator/internal/authctx/authctx.go | 10 ++- coordinator/internal/domain/worker.go | 28 ++++++-- .../internal/storage/postgres/worker_repo.go | 8 ++- coordinator/internal/token/verifier.go | 10 +-- coordinator/internal/token/verifier_test.go | 21 +++++- .../internal/transport/http/handlers.go | 19 +++++- .../internal/transport/http/middleware.go | 5 +- coordinator/internal/usecase/dto.go | 7 ++ coordinator/internal/usecase/task.go | 7 ++ coordinator/internal/usecase/usecase_test.go | 65 +++++++++++++++++++ coordinator/internal/usecase/worker.go | 7 ++ .../migrations/0012_worker_trust.down.sql | 8 +++ .../migrations/0012_worker_trust.up.sql | 18 +++++ 13 files changed, 195 insertions(+), 18 deletions(-) create mode 100644 coordinator/migrations/0012_worker_trust.down.sql create mode 100644 coordinator/migrations/0012_worker_trust.up.sql diff --git a/coordinator/internal/authctx/authctx.go b/coordinator/internal/authctx/authctx.go index 74bf86e..398cc38 100644 --- a/coordinator/internal/authctx/authctx.go +++ b/coordinator/internal/authctx/authctx.go @@ -15,13 +15,19 @@ import ( // 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 + UserID uuid.UUID + Role string + Verified bool } // IsAdmin reports whether the requester may act on any user's jobs. func (r Requester) IsAdmin() bool { return r.Role == "admin" } +// IsTrusted reports whether workers this requester registers produce results +// the coordinator accepts without quorum. Admins and verified contributors are +// trusted; a plain unverified user is not. +func (r Requester) IsTrusted() bool { return r.IsAdmin() || r.Verified } + type ctxKey struct{} // With returns a copy of ctx carrying r. diff --git a/coordinator/internal/domain/worker.go b/coordinator/internal/domain/worker.go index e7e0bf9..75ee27b 100644 --- a/coordinator/internal/domain/worker.go +++ b/coordinator/internal/domain/worker.go @@ -14,14 +14,30 @@ const ( WorkerOffline WorkerStatus = "offline" ) +// WorkerTrust says whether a worker's results are accepted directly or must +// clear quorum cross-checking. +type WorkerTrust string + +const ( + // WorkerTrusted — lab machine (shared token) or a verified/admin contributor. + WorkerTrusted WorkerTrust = "trusted" + // WorkerUntrusted — a plain enthusiast; results are quarantined until quorum. + WorkerUntrusted WorkerTrust = "untrusted" +) + // Worker is a registered process/machine allowed to claim tasks. Its // capabilities are the allowlisted workload names it can run; the coordinator // never hands it a task outside that set. type Worker struct { - ID uuid.UUID - Name string - Capabilities []string - Status WorkerStatus + ID uuid.UUID + Name string + Capabilities []string + Status WorkerStatus + // OwnerID is the userservice user who registered this worker; nil for a + // worker registered with the shared service token. + OwnerID *uuid.UUID + // TrustLevel decides whether this worker's results need quorum. + TrustLevel WorkerTrust LastHeartbeatAt time.Time CreatedAt time.Time UpdatedAt time.Time @@ -29,6 +45,9 @@ type Worker struct { // NewWorker registers a worker. A worker with no capabilities could never be // handed a task, so an empty set is rejected rather than silently stored. +// +// Trust defaults to WorkerTrusted (the shared-token lab worker); the caller +// overrides it for a volunteer registered through the userservice. func NewWorker(name string, capabilities []string, now time.Time) (*Worker, error) { if len(capabilities) == 0 { return nil, ErrInvalidInput @@ -38,6 +57,7 @@ func NewWorker(name string, capabilities []string, now time.Time) (*Worker, erro Name: name, Capabilities: capabilities, Status: WorkerOnline, + TrustLevel: WorkerTrusted, LastHeartbeatAt: now, CreatedAt: now, UpdatedAt: now, diff --git a/coordinator/internal/storage/postgres/worker_repo.go b/coordinator/internal/storage/postgres/worker_repo.go index 1df545e..5f75183 100644 --- a/coordinator/internal/storage/postgres/worker_repo.go +++ b/coordinator/internal/storage/postgres/worker_repo.go @@ -23,13 +23,13 @@ func NewWorkerRepo(pool *pgxpool.Pool) *WorkerRepo { return &WorkerRepo{pool: pool} } -var workerColumns = []string{"id", "name", "capabilities", "status", "last_heartbeat_at", "created_at", "updated_at"} +var workerColumns = []string{"id", "name", "capabilities", "status", "owner_id", "trust_level", "last_heartbeat_at", "created_at", "updated_at"} func (r *WorkerRepo) Insert(ctx context.Context, w *domain.Worker) error { sql, args, err := psql.Insert("workers"). Columns(workerColumns...). // capabilities is a jsonb column; pgx marshals the []string to a JSON array. - Values(w.ID, w.Name, w.Capabilities, string(w.Status), + Values(w.ID, w.Name, w.Capabilities, string(w.Status), w.OwnerID, string(w.TrustLevel), w.LastHeartbeatAt, w.CreatedAt, w.UpdatedAt). ToSql() if err != nil { @@ -95,11 +95,13 @@ func scanWorker(row pgx.Row) (*domain.Worker, error) { var ( w domain.Worker status string + trust string ) - if err := row.Scan(&w.ID, &w.Name, &w.Capabilities, &status, + if err := row.Scan(&w.ID, &w.Name, &w.Capabilities, &status, &w.OwnerID, &trust, &w.LastHeartbeatAt, &w.CreatedAt, &w.UpdatedAt); err != nil { return nil, err } w.Status = domain.WorkerStatus(status) + w.TrustLevel = domain.WorkerTrust(trust) return &w, nil } diff --git a/coordinator/internal/token/verifier.go b/coordinator/internal/token/verifier.go index a366dcb..93b78ee 100644 --- a/coordinator/internal/token/verifier.go +++ b/coordinator/internal/token/verifier.go @@ -13,8 +13,9 @@ import ( // Claims is the subset of a userservice token the coordinator cares about. type Claims struct { - UserID uuid.UUID - Role string + UserID uuid.UUID + Role string + Verified bool } // Verifier checks tokens against the shared HS256 secret. @@ -32,7 +33,8 @@ func NewVerifier(secret string) *Verifier { } type claims struct { - Role string `json:"role"` + Role string `json:"role"` + Verified bool `json:"verified"` jwt.RegisteredClaims } @@ -54,5 +56,5 @@ func (v *Verifier) Verify(raw string) (Claims, error) { if err != nil { return Claims{}, fmt.Errorf("token subject is not a uuid: %w", err) } - return Claims{UserID: id, Role: c.Role}, nil + return Claims{UserID: id, Role: c.Role, Verified: c.Verified}, nil } diff --git a/coordinator/internal/token/verifier_test.go b/coordinator/internal/token/verifier_test.go index 238d150..9c3b0f4 100644 --- a/coordinator/internal/token/verifier_test.go +++ b/coordinator/internal/token/verifier_test.go @@ -11,9 +11,15 @@ import ( 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() + return signVerified(t, method, key, sub, role, false, exp) +} + +func signVerified(t *testing.T, method jwt.SigningMethod, key any, sub, role string, verified bool, exp time.Time) string { t.Helper() tok := jwt.NewWithClaims(method, claims{ - Role: role, + Role: role, + Verified: verified, RegisteredClaims: jwt.RegisteredClaims{ Subject: sub, ExpiresAt: jwt.NewNumericDate(exp), @@ -26,6 +32,19 @@ func sign(t *testing.T, method jwt.SigningMethod, key any, sub, role string, exp return raw } +func TestVerifyCarriesVerifiedClaim(t *testing.T) { + v := NewVerifier(secret) + raw := signVerified(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", true, time.Now().Add(time.Hour)) + + claims, err := v.Verify(raw) + if err != nil { + t.Fatalf("verify: %v", err) + } + if !claims.Verified { + t.Error("verified claim not read from token") + } +} + func TestNewVerifierNilWhenNoSecret(t *testing.T) { if NewVerifier("") != nil { t.Error("empty secret must yield a nil verifier (auth disabled)") diff --git a/coordinator/internal/transport/http/handlers.go b/coordinator/internal/transport/http/handlers.go index e8c827b..b002cd6 100644 --- a/coordinator/internal/transport/http/handlers.go +++ b/coordinator/internal/transport/http/handlers.go @@ -12,6 +12,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/usecase" ) @@ -56,10 +57,24 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { return } - worker, err := s.uc.RegisterWorker.Execute(ctx, usecase.RegisterWorkerInput{ + // Resolve the worker's trust tier from how the caller authenticated: + // - shared service token (no requester) -> trusted lab worker + // - verified/admin user JWT -> trusted volunteer + // - plain user JWT -> untrusted (quarantined) + in := usecase.RegisterWorkerInput{ Name: req.Name, Capabilities: req.Capabilities, - }) + TrustLevel: domain.WorkerTrusted, + } + if requester, ok := authctx.From(ctx); ok { + id := requester.UserID + in.OwnerID = &id + if !requester.IsTrusted() { + in.TrustLevel = domain.WorkerUntrusted + } + } + + worker, err := s.uc.RegisterWorker.Execute(ctx, in) if err != nil { s.writeError(w, r, err) return diff --git a/coordinator/internal/transport/http/middleware.go b/coordinator/internal/transport/http/middleware.go index bfa0dea..c304827 100644 --- a/coordinator/internal/transport/http/middleware.go +++ b/coordinator/internal/transport/http/middleware.go @@ -70,8 +70,9 @@ func withAuth(token string, verifier *tokenpkg.Verifier) func(http.Handler) http 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, + UserID: claims.UserID, + Role: claims.Role, + Verified: claims.Verified, }) next.ServeHTTP(w, r.WithContext(ctx)) return diff --git a/coordinator/internal/usecase/dto.go b/coordinator/internal/usecase/dto.go index 17e0498..2ab4fe0 100644 --- a/coordinator/internal/usecase/dto.go +++ b/coordinator/internal/usecase/dto.go @@ -4,6 +4,8 @@ import ( "io" "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/domain" ) // Use-case boundary types. Adapters map their wire formats onto these, so the @@ -28,6 +30,11 @@ type ChunkInput struct { type RegisterWorkerInput struct { Name string Capabilities []string + // OwnerID is the userservice user registering this worker; nil for a + // shared-token registration. TrustLevel is resolved by the transport layer + // from how the caller authenticated. + OwnerID *uuid.UUID + TrustLevel domain.WorkerTrust } type ClaimTaskInput struct { diff --git a/coordinator/internal/usecase/task.go b/coordinator/internal/usecase/task.go index 1af5bf8..f724c07 100644 --- a/coordinator/internal/usecase/task.go +++ b/coordinator/internal/usecase/task.go @@ -51,6 +51,13 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl if err != nil { return nil, err } + // 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 + // simply idles. + if worker.TrustLevel == domain.WorkerUntrusted { + return nil, nil + } // Never trust caller-supplied capabilities: registration is the durable // worker identity and its allowlist. workloads = worker.Capabilities diff --git a/coordinator/internal/usecase/usecase_test.go b/coordinator/internal/usecase/usecase_test.go index 2272c51..b1ed39d 100644 --- a/coordinator/internal/usecase/usecase_test.go +++ b/coordinator/internal/usecase/usecase_test.go @@ -265,6 +265,71 @@ func TestClaimEmptyQueueReturnsNil(t *testing.T) { } } +func TestRegisterWorkerDefaultsToTrusted(t *testing.T) { + h := newHarness() + // A shared-token registration carries no owner and no explicit trust. + w, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{ + Name: "lab", Capabilities: []string{"w"}, + }) + if err != nil { + t.Fatal(err) + } + if w.TrustLevel != domain.WorkerTrusted { + t.Errorf("trust = %q, want trusted", w.TrustLevel) + } + if w.OwnerID != nil { + t.Errorf("owner = %v, want nil for a shared-token worker", w.OwnerID) + } +} + +func TestRegisterWorkerRecordsOwnerAndUntrusted(t *testing.T) { + h := newHarness() + owner := uuid.New() + w, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{ + Name: "volunteer", Capabilities: []string{"w"}, + OwnerID: &owner, TrustLevel: domain.WorkerUntrusted, + }) + if err != nil { + t.Fatal(err) + } + if w.TrustLevel != domain.WorkerUntrusted { + t.Errorf("trust = %q, want untrusted", w.TrustLevel) + } + if w.OwnerID == nil || *w.OwnerID != owner { + t.Errorf("owner = %v, want %v", w.OwnerID, owner) + } +} + +func TestUntrustedWorkerIsQuarantinedFromClaims(t *testing.T) { + h := newHarness() + h.seedJob(t, "w", 1) // a task is waiting + owner := uuid.New() + worker, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{ + Name: "volunteer", Capabilities: []string{"w"}, + OwnerID: &owner, TrustLevel: domain.WorkerUntrusted, + }) + if err != nil { + t.Fatal(err) + } + + // Even with a matching task available, an untrusted worker gets nothing: + // its results cannot be verified until quorum (C2) exists. + claimed, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{WorkerID: worker.ID.String()}) + if err != nil { + t.Fatalf("claim: %v", err) + } + if claimed != nil { + t.Error("untrusted worker must receive no task (quarantine)") + } + + // A trusted worker still drains the same queue. + trusted, _ := h.register.Execute(ctx, usecase.RegisterWorkerInput{Name: "lab", Capabilities: []string{"w"}}) + got, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{WorkerID: trusted.ID.String()}) + if err != nil || got == nil { + t.Fatalf("trusted claim = (%v, %v), want a task", got, err) + } +} + func TestClaimRequiresWorkerID(t *testing.T) { h := newHarness() if _, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{}); !errors.Is(err, domain.ErrInvalidInput) { diff --git a/coordinator/internal/usecase/worker.go b/coordinator/internal/usecase/worker.go index c8ccab2..572f8a2 100644 --- a/coordinator/internal/usecase/worker.go +++ b/coordinator/internal/usecase/worker.go @@ -22,6 +22,13 @@ func (uc *RegisterWorker) Execute(ctx context.Context, in RegisterWorkerInput) ( if err != nil { return nil, err } + w.OwnerID = in.OwnerID + // The transport layer resolves trust from the caller's credentials; fall + // back to the domain default (trusted) only when it was left unset, so a + // zero-value input never silently downgrades a shared-token worker. + if in.TrustLevel != "" { + w.TrustLevel = in.TrustLevel + } if err := uc.workers.Insert(ctx, w); err != nil { return nil, err } diff --git a/coordinator/migrations/0012_worker_trust.down.sql b/coordinator/migrations/0012_worker_trust.down.sql new file mode 100644 index 0000000..103c168 --- /dev/null +++ b/coordinator/migrations/0012_worker_trust.down.sql @@ -0,0 +1,8 @@ +BEGIN; + +DROP INDEX IF EXISTS ix_workers_owner; +ALTER TABLE workers DROP COLUMN IF EXISTS trust_level; +ALTER TABLE workers DROP COLUMN IF EXISTS owner_id; +DROP TYPE IF EXISTS worker_trust; + +COMMIT; diff --git a/coordinator/migrations/0012_worker_trust.up.sql b/coordinator/migrations/0012_worker_trust.up.sql new file mode 100644 index 0000000..36114fb --- /dev/null +++ b/coordinator/migrations/0012_worker_trust.up.sql @@ -0,0 +1,18 @@ +BEGIN; + +-- Whether a worker's results are accepted directly or must clear quorum. +-- 'trusted' — lab machine (shared token) or a verified/admin contributor. +-- 'untrusted' — a plain enthusiast; results are quarantined until quorum (C2). +CREATE TYPE worker_trust AS ENUM ('trusted', 'untrusted'); + +-- Who registered this worker (userservice user id, from the JWT sub). NULL for +-- workers registered with the shared service token. Not a foreign key: users +-- live in a separate service/database. +ALTER TABLE workers ADD COLUMN owner_id uuid; + +-- Existing rows were all shared-token lab workers, hence 'trusted'. +ALTER TABLE workers ADD COLUMN trust_level worker_trust NOT NULL DEFAULT 'trusted'; + +CREATE INDEX ix_workers_owner ON workers (owner_id); + +COMMIT; From 163cbe14bf42652442313fa847d4e5710fa58198 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 19:26:30 +0300 Subject: [PATCH 08/24] fix(coordinator): bind JWT caller to worker at claim (close quarantine bypass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trust tier was read off the caller-supplied worker_id, so a JWT user who knew any trusted worker's id could claim as it — draining and poisoning the trusted queue and bypassing the untrusted-worker quarantine entirely. Claim now requires a JWT caller to own the worker it acts as; a shared-token caller (lab operator) may still act as any worker. Claim is the sole grantor of a lease, so this also protects the downstream heartbeat/result/failure paths. Tests: reject claim as another user's worker; allow claim as own worker. --- coordinator/internal/usecase/task.go | 13 ++++++++ coordinator/internal/usecase/usecase_test.go | 33 ++++++++++++++++++++ 2 files changed, 46 insertions(+) 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 From a7e949a0a7e18d5c2d01ba6817b031195b2dcef2 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 19:44:06 +0300 Subject: [PATCH 09/24] feat(users): bootstrap first admin on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BOOTSTRAP_ADMIN_EMAIL/PASSWORD seed a role=admin account at boot if absent — the only way to get the first admin, since /register makes plain users and promotion needs an existing admin. Idempotent and race-safe. Tests included. --- users/.env.example | 6 ++ users/cmd/userservice/main.go | 12 ++++ users/internal/infra/config.go | 27 ++++++--- users/internal/usecase/bootstrap.go | 65 ++++++++++++++++++++++ users/internal/usecase/bootstrap_test.go | 71 ++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 users/internal/usecase/bootstrap.go create mode 100644 users/internal/usecase/bootstrap_test.go diff --git a/users/.env.example b/users/.env.example index 53b8ecf..49803cf 100644 --- a/users/.env.example +++ b/users/.env.example @@ -11,6 +11,12 @@ JWT_TTL=24h # bcrypt work factor. Empty/0 uses the library default (10). # BCRYPT_COST=10 +# First-admin bootstrap. When both are set and no such account exists, the +# service creates it with role=admin on startup (idempotent). This is the only +# way to get the first admin. Leave empty in production once seeded. +# BOOTSTRAP_ADMIN_EMAIL=root@scimesh.local +# BOOTSTRAP_ADMIN_PASSWORD=change-me-strong + # Logging. LOG_LEVEL: debug|info|warn|error. LOG_FILE empty = stdout only; # set a path to also write a size-rotated file (kept across restarts). LOG_LEVEL=info diff --git a/users/cmd/userservice/main.go b/users/cmd/userservice/main.go index 6b11926..175da99 100644 --- a/users/cmd/userservice/main.go +++ b/users/cmd/userservice/main.go @@ -60,6 +60,18 @@ func run() error { Users: users, } + // Seed the first admin, if configured. Idempotent: a no-op once it exists. + if cfg.BootstrapAdminEmail != "" && cfg.BootstrapAdminPassword != "" { + created, err := usecase.NewBootstrapAdmin(users, hasher, clock). + Execute(ctx, cfg.BootstrapAdminEmail, cfg.BootstrapAdminPassword) + if err != nil { + return fmt.Errorf("bootstrap admin: %w", err) + } + if created { + log.Info("bootstrap admin created", "email", cfg.BootstrapAdminEmail) + } + } + handler := apihttp.NewServer(log, uc, issuer) // A blanket per-request deadline: bcrypt is bounded, so anything slower is a // stuck handler we want to shed rather than hold a connection open. diff --git a/users/internal/infra/config.go b/users/internal/infra/config.go index 410ee4b..cc8fb74 100644 --- a/users/internal/infra/config.go +++ b/users/internal/infra/config.go @@ -32,6 +32,13 @@ type Config struct { // bcrypt work factor. 0 falls back to the library default (currently 10). BcryptCost int + // Optional first-admin bootstrap. When both are set and no such account + // exists, the service creates it with role=admin on startup — the only way + // to get the first admin, since /register always makes a plain user and + // promotion needs an existing admin. Idempotent: a no-op once created. + BootstrapAdminEmail string + BootstrapAdminPassword string + // Minimum log level: debug, info, warn, error. LogLevel string // Path to a rotated log file. Empty logs to stdout only. @@ -64,15 +71,17 @@ func LoadConfig() (Config, error) { } cfg := Config{ - Addr: getEnv("USERSERVICE_ADDR", ":8081"), - DatabaseURL: os.Getenv("DATABASE_URL"), - JWTSecret: os.Getenv("JWT_SECRET"), - LogLevel: getEnv("LOG_LEVEL", "info"), - LogFile: os.Getenv("LOG_FILE"), - TokenTTL: 24 * time.Hour, - DBMaxConns: 10, - DBConnectTimeout: 30 * time.Second, - RequestTimeout: 15 * time.Second, + Addr: getEnv("USERSERVICE_ADDR", ":8081"), + DatabaseURL: os.Getenv("DATABASE_URL"), + JWTSecret: os.Getenv("JWT_SECRET"), + BootstrapAdminEmail: os.Getenv("BOOTSTRAP_ADMIN_EMAIL"), + BootstrapAdminPassword: os.Getenv("BOOTSTRAP_ADMIN_PASSWORD"), + LogLevel: getEnv("LOG_LEVEL", "info"), + LogFile: os.Getenv("LOG_FILE"), + TokenTTL: 24 * time.Hour, + DBMaxConns: 10, + DBConnectTimeout: 30 * time.Second, + RequestTimeout: 15 * time.Second, } if cfg.DatabaseURL == "" { diff --git a/users/internal/usecase/bootstrap.go b/users/internal/usecase/bootstrap.go new file mode 100644 index 0000000..37b602f --- /dev/null +++ b/users/internal/usecase/bootstrap.go @@ -0,0 +1,65 @@ +package usecase + +import ( + "context" + "errors" + + "github.com/emil28092005/SciMesh/users/internal/domain" +) + +// BootstrapAdmin seeds the first admin account. It exists because there is no +// other way to create one: /register always makes a plain user, and promoting a +// user to admin requires an already-existing admin. Running it at startup with +// operator-supplied credentials breaks that chicken-and-egg. +type BootstrapAdmin struct { + users UserRepository + hasher PasswordHasher + clk Clock +} + +func NewBootstrapAdmin(users UserRepository, hasher PasswordHasher, clk Clock) *BootstrapAdmin { + return &BootstrapAdmin{users: users, hasher: hasher, clk: clk} +} + +// Execute creates the admin if it does not already exist, reporting whether it +// created one. It is idempotent: a second run (a restart) finds the account and +// does nothing, so it is safe to call on every boot. +func (uc *BootstrapAdmin) Execute(ctx context.Context, email, password string) (created bool, err error) { + email = domain.NormalizeEmail(email) + + if _, err := uc.users.GetByEmail(ctx, email); err == nil { + return false, nil // already bootstrapped + } else if !errors.Is(err, ErrUserNotFound) { + return false, err + } + + if len(password) < minPasswordLen { + return false, ErrPasswordTooShort + } + if len(password) > maxPasswordLen { + return false, ErrPasswordTooLong + } + + hash, err := uc.hasher.Hash(password) + if err != nil { + return false, err + } + u, err := domain.NewUser(email, hash, uc.clk.Now()) + if err != nil { + return false, err + } + // Direct role assignment is safe here: this is a trusted server-side seed, + // not a request. A root admin is also a trusted contributor. + u.Role = domain.RoleAdmin + u.Verified = true + + if err := uc.users.Insert(ctx, u); err != nil { + // A concurrent bootstrap (two replicas booting at once) is fine: whoever + // lost the race just observes the account now exists. + if errors.Is(err, ErrEmailExists) { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/users/internal/usecase/bootstrap_test.go b/users/internal/usecase/bootstrap_test.go new file mode 100644 index 0000000..a8ce1a2 --- /dev/null +++ b/users/internal/usecase/bootstrap_test.go @@ -0,0 +1,71 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/emil28092005/SciMesh/users/internal/auth" + "github.com/emil28092005/SciMesh/users/internal/domain" + "github.com/emil28092005/SciMesh/users/internal/memstore" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +func newBootstrap() (*usecase.BootstrapAdmin, *memstore.UserRepo) { + users := memstore.NewUserRepo() + hasher := auth.NewHasher(4) + clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)} + return usecase.NewBootstrapAdmin(users, hasher, clk), users +} + +func TestBootstrapCreatesAdmin(t *testing.T) { + bs, users := newBootstrap() + + created, err := bs.Execute(context.Background(), "Root@Example.com", "rootpassword") + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + if !created { + t.Fatal("expected an admin to be created") + } + + u, err := users.GetByEmail(context.Background(), "root@example.com") + if err != nil { + t.Fatalf("admin not persisted: %v", err) + } + if u.Role != domain.RoleAdmin { + t.Errorf("role = %q, want admin", u.Role) + } + if !u.Verified { + t.Error("bootstrap admin should be verified") + } +} + +func TestBootstrapIsIdempotent(t *testing.T) { + bs, users := newBootstrap() + ctx := context.Background() + + if _, err := bs.Execute(ctx, "root@example.com", "rootpassword"); err != nil { + t.Fatal(err) + } + created, err := bs.Execute(ctx, "root@example.com", "rootpassword") + if err != nil { + t.Fatalf("second run: %v", err) + } + if created { + t.Error("second run must not create a duplicate admin") + } + + // The account must still be a single admin. + if u, _ := users.GetByEmail(ctx, "root@example.com"); u.Role != domain.RoleAdmin { + t.Errorf("role changed: %q", u.Role) + } +} + +func TestBootstrapRejectsWeakPassword(t *testing.T) { + bs, _ := newBootstrap() + if _, err := bs.Execute(context.Background(), "root@example.com", "short"); !errors.Is(err, usecase.ErrPasswordTooShort) { + t.Errorf("got %v, want ErrPasswordTooShort", err) + } +} From 33f629f3878ab4af71932fe22976ce421d69fd90 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 19:54:39 +0300 Subject: [PATCH 10/24] feat(coordinator): UI login/register via userservice (cookie session) When JWT_SECRET + USERSERVICE_URL are set, the operator UI authenticates through userservice login/registration instead of the static UI_AUTH_TOKEN: - /ui/login, /ui/register, /ui/logout pages proxy to the userservice - successful login stores the JWT in an httpOnly, /ui-scoped cookie - withUISession verifies the cookie locally and stamps the requester - unset -> falls back to basic auth, so the team's existing flow is unchanged Tests cover the session gate, cookie set/clear, and the login/register proxy. --- coordinator/cmd/coordinator/main.go | 2 +- coordinator/internal/infra/config.go | 6 + coordinator/internal/transport/http/server.go | 73 ++++++-- .../internal/transport/http/server_test.go | 2 +- .../transport/http/templates/login.html | 27 +++ .../transport/http/templates/register.html | 28 +++ .../internal/transport/http/ui_auth.go | 171 +++++++++++++++++ .../transport/http/ui_auth_internal_test.go | 175 ++++++++++++++++++ 8 files changed, 469 insertions(+), 15 deletions(-) create mode 100644 coordinator/internal/transport/http/templates/login.html create mode 100644 coordinator/internal/transport/http/templates/register.html create mode 100644 coordinator/internal/transport/http/ui_auth.go create mode 100644 coordinator/internal/transport/http/ui_auth_internal_test.go diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go index 2206bec..a2275f8 100644 --- a/coordinator/cmd/coordinator/main.go +++ b/coordinator/cmd/coordinator/main.go @@ -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, cfg.JWTSecret, pool.Ping) + api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, 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 diff --git a/coordinator/internal/infra/config.go b/coordinator/internal/infra/config.go index 37f8d41..3d62e02 100644 --- a/coordinator/internal/infra/config.go +++ b/coordinator/internal/infra/config.go @@ -32,6 +32,11 @@ type Config struct { // user-JWT auth entirely — the pre-userservice behaviour. Must match the // userservice's JWT_SECRET. JWTSecret string + // Base URL of the userservice, e.g. http://userservice:8081. When set + // together with JWTSecret, the operator UI authenticates via userservice + // login/registration (cookie session) instead of the static UI_AUTH_TOKEN + // basic auth. Empty keeps the basic-auth UI. + UserserviceURL string // Minimum log level: debug, info, warn, error. LogLevel string @@ -87,6 +92,7 @@ func LoadConfig() (Config, error) { Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")), UIToken: os.Getenv("UI_AUTH_TOKEN"), JWTSecret: os.Getenv("JWT_SECRET"), + UserserviceURL: os.Getenv("USERSERVICE_URL"), LogLevel: getEnv("LOG_LEVEL", "info"), LogFile: os.Getenv("LOG_FILE"), StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"), diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index b4e5a64..ecd44a3 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -7,6 +7,7 @@ import ( "context" "log/slog" "net/http" + "strings" "time" tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token" @@ -44,13 +45,18 @@ type Server struct { // verifier validates userservice JWTs. nil disables user-JWT auth, leaving // only the shared service token — the pre-userservice behaviour. verifier *tokenpkg.Verifier + // userserviceURL is the base URL the UI proxies login/registration to. Empty + // keeps the static basic-auth UI. + userserviceURL string + // httpClient makes the login/register calls to the userservice. + httpClient *http.Client // 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, jwtSecret string, ready func(context.Context) error) *Server { + maxUploadBytes int64, jwtSecret, userserviceURL string, ready func(context.Context) error) *Server { return &Server{ uc: uc, log: log, @@ -58,10 +64,19 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval heartbeatInterval: heartbeatInterval, maxUploadBytes: maxUploadBytes, verifier: tokenpkg.NewVerifier(jwtSecret), + userserviceURL: strings.TrimRight(userserviceURL, "/"), + httpClient: &http.Client{Timeout: 10 * time.Second}, ready: ready, } } +// uiSessionMode reports whether the operator UI authenticates via userservice +// login (cookie session) rather than the static basic-auth token. It needs both +// a verifier (to check the JWT locally) and a userservice URL (to issue it). +func (s *Server) uiSessionMode() bool { + return s.verifier != nil && s.userserviceURL != "" +} + // Handler builds the router. Go 1.22's ServeMux matches on method and path // wildcards, so no third-party router is needed. func (s *Server) Handler(token string, uiToken ...string) http.Handler { @@ -82,19 +97,51 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", s.handleHealth) - if len(uiToken) > 0 && uiToken[0] != "" && s.uc.Dashboard != nil { + + hasBasicAuth := len(uiToken) > 0 && uiToken[0] != "" + if s.uc.Dashboard != nil && (s.uiSessionMode() || hasBasicAuth) { ui := http.NewServeMux() - ui.HandleFunc("GET /ui", s.handleUIHome) - ui.HandleFunc("GET /ui/jobs/new", s.handleUINewJob) - ui.HandleFunc("GET /ui/jobs/{job_id}", s.handleUIJob) - ui.HandleFunc("GET /ui/api/overview", s.handleUIOverviewJSON) - ui.HandleFunc("GET /ui/api/jobs/{job_id}", s.handleUIJobJSON) - ui.HandleFunc("POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob) - ui.HandleFunc("POST /ui/api/jobs/upload", s.handleUploadDataset) - ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload) - ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview) - mux.Handle("/ui", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin)) - mux.Handle("/ui/", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin)) + + // The operator application routes, all requiring an authenticated caller. + app := []struct { + pattern string + handler http.HandlerFunc + }{ + {"GET /ui", s.handleUIHome}, + {"GET /ui/jobs/new", s.handleUINewJob}, + {"GET /ui/jobs/{job_id}", s.handleUIJob}, + {"GET /ui/api/overview", s.handleUIOverviewJSON}, + {"GET /ui/api/jobs/{job_id}", s.handleUIJobJSON}, + {"POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob}, + {"POST /ui/api/jobs/upload", s.handleUploadDataset}, + {"GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload}, + {"GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview}, + } + + if s.uiSessionMode() { + // Public auth pages — reachable without a session so a user can log in. + ui.HandleFunc("GET /ui/login", s.handleUILoginForm) + ui.HandleFunc("POST /ui/login", s.handleUILogin) + ui.HandleFunc("GET /ui/register", s.handleUIRegisterForm) + ui.HandleFunc("POST /ui/register", s.handleUIRegister) + ui.HandleFunc("POST /ui/logout", s.handleUILogout) + gate := withUISession(s.verifier) + for _, rt := range app { + ui.Handle(rt.pattern, gate(rt.handler)) + } + } else { + for _, rt := range app { + ui.HandleFunc(rt.pattern, rt.handler) + } + } + + common := []func(http.Handler) http.Handler{withRequestID, withAccessLog(s.log)} + if !s.uiSessionMode() { + common = append(common, withBasicAuth(uiToken[0])) + } + common = append(common, withSameOrigin) + mux.Handle("/ui", chain(ui, common...)) + mux.Handle("/ui/", chain(ui, common...)) } else { // More specific than the protected catch-all: UI absence is not an auth // failure and does not disclose that a UI feature is configured elsewhere. diff --git a/coordinator/internal/transport/http/server_test.go b/coordinator/internal/transport/http/server_test.go index e9463b3..220083c 100644 --- a/coordinator/internal/transport/http/server_test.go +++ b/coordinator/internal/transport/http/server_test.go @@ -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()} diff --git a/coordinator/internal/transport/http/templates/login.html b/coordinator/internal/transport/http/templates/login.html new file mode 100644 index 0000000..9482e91 --- /dev/null +++ b/coordinator/internal/transport/http/templates/login.html @@ -0,0 +1,27 @@ +{{define "login.html"}} + + + + + + Sign in · SciMesh + + + +
+

SciMesh

+

Sign in

+
+ + + + + +
+ {{if .Error}}

{{.Error}}

{{end}} +

No account? Register

+
+ + +{{end}} diff --git a/coordinator/internal/transport/http/templates/register.html b/coordinator/internal/transport/http/templates/register.html new file mode 100644 index 0000000..e05e2fe --- /dev/null +++ b/coordinator/internal/transport/http/templates/register.html @@ -0,0 +1,28 @@ +{{define "register.html"}} + + + + + + Register · SciMesh + + + +
+

SciMesh

+

Create account

+
+ + + + +

At least 8 characters.

+ +
+ {{if .Error}}

{{.Error}}

{{end}} +

Already have an account? Sign in

+
+ + +{{end}} diff --git a/coordinator/internal/transport/http/ui_auth.go b/coordinator/internal/transport/http/ui_auth.go new file mode 100644 index 0000000..4741126 --- /dev/null +++ b/coordinator/internal/transport/http/ui_auth.go @@ -0,0 +1,171 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "time" + + "github.com/emil28092005/SciMesh/coordinator/internal/authctx" + tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token" +) + +// sessionCookie holds the userservice JWT for the operator UI. It is httpOnly so +// page scripts cannot read the token, and scoped to /ui so it never rides along +// with worker API calls. +const sessionCookie = "scimesh_session" + +// withUISession gates the operator UI on a valid userservice session cookie. +// A missing or invalid token redirects to the login page rather than returning +// 401, because the caller here is a browser, not an API client. On success it +// stamps the requester so downstream handlers can scope views by owner. +func withUISession(v tokenVerifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(sessionCookie) + if err != nil || c.Value == "" { + redirectToLogin(w, r) + return + } + claims, err := v.Verify(c.Value) + if err != nil { + // Expired or tampered: drop the stale cookie and re-authenticate. + clearSessionCookie(w, r) + redirectToLogin(w, r) + return + } + ctx := authctx.With(r.Context(), authctx.Requester{ + UserID: claims.UserID, + Role: claims.Role, + Verified: claims.Verified, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// tokenVerifier is satisfied by *token.Verifier; taking an interface keeps the +// UI auth testable with a stub. +type tokenVerifier interface { + Verify(raw string) (tokenpkg.Claims, error) +} + +func (s *Server) handleUILoginForm(w http.ResponseWriter, r *http.Request) { + s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error")}) +} + +func (s *Server) handleUIRegisterForm(w http.ResponseWriter, r *http.Request) { + s.renderUI(w, "register.html", map[string]any{"Error": r.URL.Query().Get("error")}) +} + +// handleUILogin exchanges the submitted credentials for a userservice token and +// stores it in the session cookie. The coordinator never sees or stores the +// password beyond forwarding it once. +func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) { + email, password := r.FormValue("email"), r.FormValue("password") + + status, body, err := s.callUserservice(r.Context(), "/login", email, password) + if err != nil { + s.log.Error("userservice login call", "err", err) + http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther) + return + } + if status != http.StatusOK { + http.Redirect(w, r, "/ui/login?error=invalid+email+or+password", http.StatusSeeOther) + return + } + + var resp struct { + Token string `json:"token"` + } + if err := json.Unmarshal(body, &resp); err != nil || resp.Token == "" { + http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther) + return + } + setSessionCookie(w, r, resp.Token) + http.Redirect(w, r, "/ui", http.StatusSeeOther) +} + +// handleUIRegister creates an account through the userservice, then sends the +// user to the login page. The new account is a plain user until an admin +// promotes or verifies it. +func (s *Server) handleUIRegister(w http.ResponseWriter, r *http.Request) { + email, password := r.FormValue("email"), r.FormValue("password") + + status, _, err := s.callUserservice(r.Context(), "/register", email, password) + if err != nil { + s.log.Error("userservice register call", "err", err) + http.Redirect(w, r, "/ui/register?error=service+unavailable", http.StatusSeeOther) + return + } + switch status { + case http.StatusCreated: + http.Redirect(w, r, "/ui/login?error=registered,+please+log+in", http.StatusSeeOther) + case http.StatusConflict: + http.Redirect(w, r, "/ui/register?error=email+already+registered", http.StatusSeeOther) + default: + http.Redirect(w, r, "/ui/register?error=invalid+email+or+password", http.StatusSeeOther) + } +} + +func (s *Server) handleUILogout(w http.ResponseWriter, r *http.Request) { + clearSessionCookie(w, r) + http.Redirect(w, r, "/ui/login", http.StatusSeeOther) +} + +// callUserservice POSTs credentials to the userservice and returns its status +// and body. It is the only runtime dependency on the userservice — login and +// registration; token verification stays local. +func (s *Server) callUserservice(ctx context.Context, path, email, password string) (int, []byte, error) { + payload, _ := json.Marshal(map[string]string{"email": email, "password": password}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.userserviceURL+path, bytes.NewReader(payload)) + if err != nil { + return 0, nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return 0, nil, err + } + defer func() { _ = resp.Body.Close() }() + + // Cap the response; login/register bodies are tiny. + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return 0, nil, err + } + return resp.StatusCode, body, nil +} + +func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) { + // Secure is set under TLS; a local demo runs plain HTTP, where forcing + // Secure would stop the browser from ever sending the cookie back. + http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design + Name: sessionCookie, + Value: token, + Path: "/ui", + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(24 * time.Hour), + }) +} + +func clearSessionCookie(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design + Name: sessionCookie, + Value: "", + Path: "/ui", + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +func redirectToLogin(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/ui/login", http.StatusSeeOther) +} diff --git a/coordinator/internal/transport/http/ui_auth_internal_test.go b/coordinator/internal/transport/http/ui_auth_internal_test.go new file mode 100644 index 0000000..081ad37 --- /dev/null +++ b/coordinator/internal/transport/http/ui_auth_internal_test.go @@ -0,0 +1,175 @@ +package http + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/authctx" + tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token" +) + +// newReq builds a request carrying a context, which http.NewRequestWithContext +// provides on go1.22 (httptest.NewRequestWithContext needs go1.23). +func newReq(method, target string, body io.Reader) *http.Request { + req, err := http.NewRequestWithContext(context.Background(), method, target, body) + if err != nil { + panic(err) + } + return req +} + +type stubVerifier struct { + claims tokenpkg.Claims + err error +} + +func (s stubVerifier) Verify(string) (tokenpkg.Claims, error) { return s.claims, s.err } + +func TestWithUISessionRedirectsWithoutCookie(t *testing.T) { + h := withUISession(stubVerifier{})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler must not run without a session") + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newReq(http.MethodGet, "/ui", nil)) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("got %d, want 303", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/ui/login" { + t.Errorf("redirect = %q, want /ui/login", loc) + } +} + +func TestWithUISessionAcceptsValidCookieAndStampsRequester(t *testing.T) { + id := uuid.New() + verifier := stubVerifier{claims: tokenpkg.Claims{UserID: id, Role: "admin", Verified: true}} + + var gotReq authctx.Requester + var ok bool + h := withUISession(verifier)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + gotReq, ok = authctx.From(r.Context()) + })) + + req := newReq(http.MethodGet, "/ui", nil) + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "valid.jwt"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if !ok || gotReq.UserID != id || gotReq.Role != "admin" || !gotReq.Verified { + t.Errorf("requester = %+v (ok=%v), want id=%v admin verified", gotReq, ok, id) + } +} + +func TestWithUISessionClearsInvalidCookie(t *testing.T) { + h := withUISession(stubVerifier{err: errors.New("expired")})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler must not run with an invalid token") + })) + req := newReq(http.MethodGet, "/ui", nil) + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "stale.jwt"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("got %d, want 303", rec.Code) + } + if c := rec.Result().Cookies(); len(c) == 0 || c[0].MaxAge >= 0 { + t.Error("stale cookie must be cleared (MaxAge < 0)") + } +} + +// newLoginServer builds a Server whose userservice calls hit stub. +func newLoginServer(stub *httptest.Server) *Server { + return &Server{ + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + userserviceURL: strings.TrimRight(stub.URL, "/"), + httpClient: stub.Client(), + } +} + +func postForm(path string, form url.Values) *http.Request { + req := newReq(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req +} + +func TestHandleUILoginSetsCookieOnSuccess(t *testing.T) { + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/login" { + t.Errorf("unexpected path %q", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"token":"issued.jwt.here"}`)) + })) + defer stub.Close() + s := newLoginServer(stub) + + rec := httptest.NewRecorder() + s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"password123"}})) + + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" { + t.Fatalf("got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location")) + } + cookies := rec.Result().Cookies() + if len(cookies) == 0 || cookies[0].Name != sessionCookie || cookies[0].Value != "issued.jwt.here" { + t.Errorf("session cookie not set: %+v", cookies) + } + if !cookies[0].HttpOnly { + t.Error("session cookie must be httpOnly") + } +} + +func TestHandleUILoginRejectsBadCredentials(t *testing.T) { + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer stub.Close() + s := newLoginServer(stub) + + rec := httptest.NewRecorder() + s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"wrong"}})) + + if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/ui/login?error=") { + t.Fatalf("got %d -> %q, want 303 -> /ui/login?error=", rec.Code, rec.Header().Get("Location")) + } + if len(rec.Result().Cookies()) != 0 { + t.Error("no cookie must be set on failed login") + } +} + +func TestHandleUIRegisterConflict(t *testing.T) { + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + })) + defer stub.Close() + s := newLoginServer(stub) + + rec := httptest.NewRecorder() + s.handleUIRegister(rec, postForm("/ui/register", url.Values{"email": {"dup@b.com"}, "password": {"password123"}})) + + if got := rec.Header().Get("Location"); !strings.Contains(got, "already+registered") { + t.Errorf("register conflict redirect = %q", got) + } +} + +func TestHandleUILogoutClearsCookie(t *testing.T) { + s := &Server{log: slog.New(slog.NewTextHandler(io.Discard, nil))} + rec := httptest.NewRecorder() + s.handleUILogout(rec, newReq(http.MethodPost, "/ui/logout", nil)) + + if rec.Header().Get("Location") != "/ui/login" { + t.Errorf("logout redirect = %q", rec.Header().Get("Location")) + } + c := rec.Result().Cookies() + if len(c) == 0 || c[0].MaxAge >= 0 { + t.Error("logout must clear the session cookie") + } +} From c8c6455caf36793382a5e1e716ae616471c45ef6 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 20:12:49 +0300 Subject: [PATCH 11/24] 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. --- coordinator/internal/memstore/ui_read.go | 5 +- .../storage/postgres/integration_test.go | 2 +- .../internal/storage/postgres/ui_read_repo.go | 8 +- coordinator/internal/usecase/ownership.go | 12 +++ coordinator/internal/usecase/preview.go | 5 ++ coordinator/internal/usecase/ui.go | 15 +++- coordinator/internal/usecase/ui_scope_test.go | 82 +++++++++++++++++++ 7 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 coordinator/internal/usecase/ui_scope_test.go 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) + } +} From 5a9a10c681054a341714f672f1c8e334c9194454 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 20:55:54 +0300 Subject: [PATCH 12/24] feat(demo): bring up coordinator + userservice, seed root admin make demo-ui now starts the userservice (own Postgres + migrations) beside the coordinator on a shared JWT secret, and seeds a root admin. The coordinator runs in UI session mode, so /ui opens a login page instead of a basic-auth prompt. - docker-compose.users.yml overlay: userservice stack + coordinator JWT wiring - demo-ui.sh: waits for the userservice, logs in as the seeded admin to poll the now session-gated dashboard, and prints the admin credentials - validated with docker compose config (6 services, merged env) --- coordinator/Makefile | 2 +- coordinator/docker-compose.users.yml | 66 ++++++++++++++++++++++++++++ coordinator/scripts/demo-ui.sh | 54 +++++++++++++++++++---- 3 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 coordinator/docker-compose.users.yml diff --git a/coordinator/Makefile b/coordinator/Makefile index 6f17ed6..54aa7ae 100644 --- a/coordinator/Makefile +++ b/coordinator/Makefile @@ -35,7 +35,7 @@ help: ' make demo-down Stop the demo services and workers.' \ ' make test / make vet Run Go verification.' \ '' \ - 'Demo UI: http://localhost:18080/ui (operator / demo-ui-secret).' + 'Demo UI: http://localhost:18080/ui (login page; admin root@scimesh.local / rootpassword).' demo-ui: @DEMO_PROJECT="$(DEMO_PROJECT)" \ diff --git a/coordinator/docker-compose.users.yml b/coordinator/docker-compose.users.yml new file mode 100644 index 0000000..30e5483 --- /dev/null +++ b/coordinator/docker-compose.users.yml @@ -0,0 +1,66 @@ +# Demo overlay: adds the userservice (its own Postgres + migrations) alongside +# the coordinator and wires the two together with a shared JWT secret, so the +# operator UI authenticates through userservice login/registration. +# +# Used only by scripts/demo-ui.sh, merged onto docker-compose.yml with a second +# -f. Not part of the plain `make up` stack. + +services: + postgres-users: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-scimesh} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scimesh} + POSTGRES_DB: scimesh_users + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scimesh} -d scimesh_users"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 5s + + migrate-users: + image: migrate/migrate:v4.17.1 + depends_on: + postgres-users: + condition: service_healthy + volumes: + - ../users/migrations:/migrations:ro + command: + - -path=/migrations + - -database=postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres-users:5432/scimesh_users?sslmode=disable + - up + restart: on-failure + + userservice: + build: + context: ../users + depends_on: + postgres-users: + condition: service_healthy + migrate-users: + condition: service_completed_successfully + environment: + USERSERVICE_ADDR: ":8081" + DATABASE_URL: postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres-users:5432/scimesh_users?sslmode=disable + JWT_SECRET: ${JWT_SECRET} + # Seeds the first admin the very first time it boots (idempotent after). + BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-root@scimesh.local} + BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD} + LOG_LEVEL: ${LOG_LEVEL:-info} + ports: + - "${USERSERVICE_PORT:-18081}:8081" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/health"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + restart: unless-stopped + + # Turn the coordinator UI into session mode: the same shared secret verifies + # userservice tokens locally, and USERSERVICE_URL is where login/register proxy. + coordinator: + environment: + JWT_SECRET: ${JWT_SECRET} + USERSERVICE_URL: http://userservice:8081 diff --git a/coordinator/scripts/demo-ui.sh b/coordinator/scripts/demo-ui.sh index f978ed1..3024b09 100755 --- a/coordinator/scripts/demo-ui.sh +++ b/coordinator/scripts/demo-ui.sh @@ -11,8 +11,15 @@ repo_dir=$(CDPATH= cd -- "$coordinator_dir/.." && pwd) project=${DEMO_PROJECT:-scimesh-demo} postgres_port=${DEMO_POSTGRES_PORT:-55432} coordinator_port=${DEMO_COORDINATOR_PORT:-18080} +userservice_port=${DEMO_USERSERVICE_PORT:-18081} ui_token=${DEMO_UI_TOKEN:-demo-ui-secret} worker_token=${DEMO_WORKER_TOKEN:-demo-worker-token} +# Shared HS256 secret; the coordinator verifies userservice tokens with it. Must +# be at least 32 bytes (both services refuse a shorter one). +jwt_secret=${DEMO_JWT_SECRET:-demo-jwt-secret-please-change-me-0123456789} +# The first admin, seeded into the userservice on first boot. +admin_email=${DEMO_ADMIN_EMAIL:-root@scimesh.local} +admin_password=${DEMO_ADMIN_PASSWORD:-rootpassword} workers=${DEMO_WORKERS:-2} demo_dir=${DEMO_DIR:-.demo} case "$demo_dir" in @@ -26,9 +33,15 @@ logs_dir="$demo_dir/logs" compose() { POSTGRES_PORT="$postgres_port" \ COORDINATOR_PORT="$coordinator_port" \ + USERSERVICE_PORT="$userservice_port" \ UI_AUTH_TOKEN="$ui_token" \ WORKER_AUTH_TOKEN="$worker_token" \ - docker compose -p "$project" -f "$coordinator_dir/docker-compose.yml" "$@" + JWT_SECRET="$jwt_secret" \ + BOOTSTRAP_ADMIN_EMAIL="$admin_email" \ + BOOTSTRAP_ADMIN_PASSWORD="$admin_password" \ + docker compose -p "$project" \ + -f "$coordinator_dir/docker-compose.yml" \ + -f "$coordinator_dir/docker-compose.users.yml" "$@" } stop_workers() { @@ -57,10 +70,29 @@ wait_for_coordinator() { done } +wait_for_userservice() { + local attempt=0 + until curl --fail --silent --show-error "http://localhost:$userservice_port/health" >/dev/null; do + attempt=$((attempt + 1)) + if (( attempt >= 45 )); then + echo "Userservice did not become ready. Recent logs:" >&2 + compose logs --tail=80 userservice >&2 || true + exit 1 + fi + sleep 1 + done +} + wait_for_workers() { - local attempt=0 registered overview + local attempt=0 registered overview cookie="$demo_dir/session.cookies" + # The dashboard API is behind a userservice session now, not basic auth. Log in + # as the seeded admin (who sees every worker) to obtain a session cookie. + curl --fail --silent -c "$cookie" \ + --data-urlencode "email=$admin_email" \ + --data-urlencode "password=$admin_password" \ + "http://localhost:$coordinator_port/ui/login" >/dev/null 2>&1 || true until false; do - overview=$(curl --fail --silent --show-error --user "operator:$ui_token" \ + overview=$(curl --fail --silent --show-error -b "$cookie" \ "http://localhost:$coordinator_port/ui/api/overview" 2>/dev/null || true) # The overview contains no jobs at demo startup, so every `id` belongs to # a registered worker. Avoid adding jq just for this local helper. @@ -96,6 +128,8 @@ start() { compose up -d --build echo "Waiting for the coordinator on http://localhost:$coordinator_port ..." wait_for_coordinator + echo "Waiting for the userservice on http://localhost:$userservice_port ..." + wait_for_userservice : > "$pid_file" for index in $(seq 1 "$workers"); do @@ -115,13 +149,15 @@ start() { SciMesh manual demo is ready. - UI: http://localhost:$coordinator_port/ui - Username: operator - Password: $ui_token - Workers: $workers local reference workers + UI: http://localhost:$coordinator_port/ui (shows a login page) + Admin login: $admin_email / $admin_password + Userservice: http://localhost:$userservice_port + Workers: $workers local reference workers -Upload a small ChEMBL TSV through “New similarity search”, then watch the job -page update. Worker logs are in $logs_dir. Stop everything with: +Sign in with the admin above, or register a new account from the login page. +The admin sees every job; a plain user sees only their own. Upload a small +ChEMBL TSV through “New similarity search”, then watch the job page update. +Worker logs are in $logs_dir. Stop everything with: make demo-down EOF From 49eb6627986c881d2ea4234aefce4e00ba144009 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 21:03:50 +0300 Subject: [PATCH 13/24] feat(coordinator): logout button in the operator UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard and job pages show a 'Log out' control (POST /ui/logout) and a 'Signed in · ' label when a userservice session is active. Under basic auth (no session) neither appears, so the fallback UI is unchanged. Threads a template-only Session view (json:"-") from authctx into the dashboard and job views. Tests assert the control renders only in session mode. --- .../transport/http/templates/dashboard.html | 2 +- .../transport/http/templates/job.html | 2 +- .../transport/http/ui_logout_internal_test.go | 42 +++++++++++++++++++ coordinator/internal/usecase/ui.go | 25 +++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 coordinator/internal/transport/http/ui_logout_internal_test.go diff --git a/coordinator/internal/transport/http/templates/dashboard.html b/coordinator/internal/transport/http/templates/dashboard.html index af31dd1..de530f3 100644 --- a/coordinator/internal/transport/http/templates/dashboard.html +++ b/coordinator/internal/transport/http/templates/dashboard.html @@ -13,7 +13,7 @@

Local scientific compute

SciMesh control room

Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.

Live overview · refreshes every 2 seconds
- + New similarity search +
{{if .Session}}Signed in · {{.Session.Role}}{{end}}+ New similarity search{{if .Session}}
{{end}}
How a search becomes a result
01Upload TSVThe coordinator validates and slices the dataset.
02Run shardsWorkers fingerprint molecules and return shard top-k CSVs.
03Merge exactlyThe coordinator ranks retained candidates deterministically.
04Download CSVA checksum-protected global result is ready.
diff --git a/coordinator/internal/transport/http/templates/job.html b/coordinator/internal/transport/http/templates/job.html index f0091e6..49f8289 100644 --- a/coordinator/internal/transport/http/templates/job.html +++ b/coordinator/internal/transport/http/templates/job.html @@ -12,7 +12,7 @@
- ← Back to control room +
← Back to control room{{if .Session}}
{{end}}

{{workloadLabel .Workload}}

Live pipeline

One job, shown from accepted input through its final coordinator-owned scientific result.

Live · refreshes every 2 seconds
{{statusLabel .Status}}

{{statusHint .Status}}

Completed shards are preserved.

{{.Completed}} of {{.Total}} shards complete

{{.Total}}total shards
{{.Completed}}completed
{{.Pending}}waiting
{{add .Leased .Running}}with workers
{{.Failed}}failed
{{.Cancelled}}stopped
diff --git a/coordinator/internal/transport/http/ui_logout_internal_test.go b/coordinator/internal/transport/http/ui_logout_internal_test.go new file mode 100644 index 0000000..37f15c2 --- /dev/null +++ b/coordinator/internal/transport/http/ui_logout_internal_test.go @@ -0,0 +1,42 @@ +package http + +import ( + "bytes" + "strings" + "testing" + + "github.com/emil28092005/SciMesh/coordinator/internal/usecase" +) + +func render(t *testing.T, name string, data any) string { + t.Helper() + var buf bytes.Buffer + if err := uiTemplates.ExecuteTemplate(&buf, name, data); err != nil { + t.Fatalf("render %s: %v", name, err) + } + return buf.String() +} + +func TestDashboardLogoutOnlyInSession(t *testing.T) { + withSession := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "admin"}}) + if !strings.Contains(withSession, "/ui/logout") || !strings.Contains(withSession, "Log out") { + t.Error("dashboard must show a logout control in session mode") + } + + noSession := render(t, "dashboard.html", usecase.DashboardView{}) + if strings.Contains(noSession, "/ui/logout") { + t.Error("dashboard must not show logout under basic auth (no session)") + } +} + +func TestJobLogoutOnlyInSession(t *testing.T) { + withSession := render(t, "job.html", usecase.JobDetailView{Session: &usecase.SessionView{Role: "user"}}) + if !strings.Contains(withSession, "/ui/logout") { + t.Error("job page must show a logout control in session mode") + } + + noSession := render(t, "job.html", usecase.JobDetailView{}) + if strings.Contains(noSession, "/ui/logout") { + t.Error("job page must not show logout under basic auth (no session)") + } +} diff --git a/coordinator/internal/usecase/ui.go b/coordinator/internal/usecase/ui.go index b1f1dad..a8624ab 100644 --- a/coordinator/internal/usecase/ui.go +++ b/coordinator/internal/usecase/ui.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" + "github.com/emil28092005/SciMesh/coordinator/internal/authctx" "github.com/emil28092005/SciMesh/coordinator/internal/domain" ) @@ -87,13 +88,35 @@ type DashboardView struct { ActiveJobs int `json:"active_jobs"` FinishedJobs int `json:"finished_jobs"` OnlineWorkers int `json:"online_workers"` + // Session is the signed-in user, when the UI runs in session mode. nil under + // basic auth. Template-only, never serialised to the polling JSON. + Session *SessionView `json:"-"` } + +// SessionView is the minimal identity the UI header needs to show who is signed +// in and to offer a logout control. +type SessionView struct { + Role string + Verified bool +} + +// sessionViewFrom builds the header session info from the request context, or +// nil when the caller is not an authenticated user (basic-auth operator). +func sessionViewFrom(ctx context.Context) *SessionView { + r, ok := authctx.From(ctx) + if !ok { + return nil + } + return &SessionView{Role: r.Role, Verified: r.Verified} +} + type JobDetailView struct { JobCard Tasks []TaskCard `json:"tasks"` Artifacts []ArtifactCard `json:"artifacts"` Parameters []ParameterCard `json:"parameters"` FinalResultAvailable bool `json:"final_result_available"` + Session *SessionView `json:"-"` } type Dashboard struct{ read UIReadRepository } @@ -134,6 +157,7 @@ func (d *Dashboard) Overview(ctx context.Context, limit int) (DashboardView, err out.OnlineWorkers++ } } + out.Session = sessionViewFrom(ctx) return out, nil } @@ -168,6 +192,7 @@ func (d *Dashboard) JobDetail(ctx context.Context, jobID uuid.UUID) (JobDetailVi Tasks: make([]TaskCard, 0, len(tasks)), Artifacts: make([]ArtifactCard, 0, len(artifacts)), Parameters: uiParameters(job.Parameters), + Session: sessionViewFrom(ctx), } for _, task := range tasks { card := TaskCard{ID: task.ID.String(), ChunkIndex: task.ChunkIndex, Status: string(task.Status), Attempt: task.Attempt, MaxAttempts: task.MaxAttempts, LeaseExpiresAt: task.LeaseExpiresAt, StartedAt: task.StartedAt, CompletedAt: task.CompletedAt} From 9a458ec4ef83650618800519d78e5050b8c3ff03 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 21:14:32 +0300 Subject: [PATCH 14/24] feat(users): admin promote/demote endpoints POST /users/{id}/promote and /demote set a user's role (admin/user), admin-only (403 otherwise). Mirrors the verify endpoints: SetRole use case + repo method, validated role. Unit, admin-flow, and integration tests included. --- users/README.md | 2 + users/cmd/userservice/main.go | 1 + users/internal/memstore/memstore.go | 12 +++++ .../storage/postgres/integration_test.go | 18 +++++++ users/internal/storage/postgres/user_repo.go | 21 +++++++++ users/internal/transport/http/errors.go | 2 + users/internal/transport/http/handlers.go | 22 +++++++++ users/internal/transport/http/server.go | 7 +++ users/internal/transport/http/server_test.go | 47 +++++++++++++++++++ users/internal/usecase/errorpaths_test.go | 3 ++ users/internal/usecase/errors.go | 1 + users/internal/usecase/ports.go | 3 ++ users/internal/usecase/role.go | 28 +++++++++++ 13 files changed, 167 insertions(+) create mode 100644 users/internal/usecase/role.go diff --git a/users/README.md b/users/README.md index 9dd030a..4a7efa8 100644 --- a/users/README.md +++ b/users/README.md @@ -27,6 +27,8 @@ layers, dependencies pointing strictly inward: | GET | `/me` | Bearer JWT | Return the caller's own account | | POST | `/users/{id}/verify` | Bearer admin | Grant the trusted-contributor badge | | POST | `/users/{id}/unverify` | Bearer admin | Revoke the badge | +| POST | `/users/{id}/promote` | Bearer admin | Set the user's role to admin | +| POST | `/users/{id}/demote` | Bearer admin | Set the user's role back to user | Two independent attributes live on an account: diff --git a/users/cmd/userservice/main.go b/users/cmd/userservice/main.go index 175da99..97f55f4 100644 --- a/users/cmd/userservice/main.go +++ b/users/cmd/userservice/main.go @@ -57,6 +57,7 @@ func run() error { Register: usecase.NewRegister(users, hasher, clock), Login: usecase.NewLogin(users, hasher, issuer), SetVerified: usecase.NewSetVerified(users), + SetRole: usecase.NewSetRole(users), Users: users, } diff --git a/users/internal/memstore/memstore.go b/users/internal/memstore/memstore.go index f22f639..96307e9 100644 --- a/users/internal/memstore/memstore.go +++ b/users/internal/memstore/memstore.go @@ -72,6 +72,18 @@ func (r *UserRepo) SetVerified(_ context.Context, id uuid.UUID, verified bool) e return nil } +func (r *UserRepo) SetRole(_ context.Context, id uuid.UUID, role domain.Role) error { + r.mu.Lock() + defer r.mu.Unlock() + u, ok := r.byID[id] + if !ok { + return usecase.ErrUserNotFound + } + u.Role = role + r.byID[id] = u + return nil +} + // Clock is a fixed usecase.Clock for deterministic tests. type Clock struct{ T time.Time } diff --git a/users/internal/storage/postgres/integration_test.go b/users/internal/storage/postgres/integration_test.go index b229f95..89d37e5 100644 --- a/users/internal/storage/postgres/integration_test.go +++ b/users/internal/storage/postgres/integration_test.go @@ -145,3 +145,21 @@ func TestUserRepoSetVerifiedUnknown(t *testing.T) { t.Errorf("got %v, want ErrUserNotFound", err) } } + +func TestUserRepoSetRole(t *testing.T) { + repo := NewUserRepo(testPool(t)) + ctx := context.Background() + u := seedUser(t, repo) + + if err := repo.SetRole(ctx, u.ID, domain.RoleAdmin); err != nil { + t.Fatalf("promote: %v", err) + } + got, _ := repo.GetByID(ctx, u.ID) + if got.Role != domain.RoleAdmin { + t.Errorf("role = %q, want admin", got.Role) + } + + if err := repo.SetRole(ctx, uuid.New(), domain.RoleAdmin); !errors.Is(err, usecase.ErrUserNotFound) { + t.Errorf("unknown user: got %v, want ErrUserNotFound", err) + } +} diff --git a/users/internal/storage/postgres/user_repo.go b/users/internal/storage/postgres/user_repo.go index 0c4d997..cb786fd 100644 --- a/users/internal/storage/postgres/user_repo.go +++ b/users/internal/storage/postgres/user_repo.go @@ -85,6 +85,27 @@ func (r *UserRepo) SetVerified(ctx context.Context, id uuid.UUID, verified bool) return nil } +// SetRole changes a user's role and returns ErrUserNotFound when the id matches +// no row. +func (r *UserRepo) SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error { + sql, args, err := psql.Update("users"). + Set("role", string(role)). + Set("updated_at", sq.Expr("now()")). + Where(sq.Eq{"id": id}). + ToSql() + if err != nil { + return err + } + tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return usecase.ErrUserNotFound + } + return nil +} + func scanUser(row pgx.Row) (*domain.User, error) { var ( u domain.User diff --git a/users/internal/transport/http/errors.go b/users/internal/transport/http/errors.go index 5239bba..8ccbe7e 100644 --- a/users/internal/transport/http/errors.go +++ b/users/internal/transport/http/errors.go @@ -52,6 +52,8 @@ func statusForError(err error) (int, string) { return http.StatusBadRequest, "password must be at least 8 characters" case errors.Is(err, usecase.ErrPasswordTooLong): return http.StatusBadRequest, "password must be at most 72 bytes" + case errors.Is(err, usecase.ErrInvalidRole): + return http.StatusBadRequest, "invalid role" case errors.Is(err, domain.ErrEmptyEmail), errors.Is(err, domain.ErrInvalidEmail): return http.StatusBadRequest, "email is not a valid address" default: diff --git a/users/internal/transport/http/handlers.go b/users/internal/transport/http/handlers.go index 615d6e7..beaca96 100644 --- a/users/internal/transport/http/handlers.go +++ b/users/internal/transport/http/handlers.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" + "github.com/emil28092005/SciMesh/users/internal/domain" "github.com/emil28092005/SciMesh/users/internal/usecase" ) @@ -15,6 +16,7 @@ type Handlers struct { register *usecase.Register login *usecase.Login setVerified *usecase.SetVerified + setRole *usecase.SetRole users usecase.UserRepository log *slog.Logger } @@ -91,6 +93,26 @@ func (h *Handlers) handleSetVerified(verified bool) http.HandlerFunc { } } +// handleSetRole promotes (admin) or demotes (user) the user in the path. Admin- +// only; the withAdmin middleware has already enforced the caller's role. +func (h *Handlers) handleSetRole(role domain.Role) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "invalid user id", + RequestID: requestIDFrom(r.Context()), + }) + return + } + if err := h.setRole.Execute(r.Context(), id, role); err != nil { + writeError(w, r, h.log, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + // decodeJSON reads a size-capped JSON body into dst, rejecting unknown fields. // It writes a 400 and returns false on any problem, so callers can `if // !decodeJSON(...) { return }`. diff --git a/users/internal/transport/http/server.go b/users/internal/transport/http/server.go index 256ec6f..3a57af7 100644 --- a/users/internal/transport/http/server.go +++ b/users/internal/transport/http/server.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/emil28092005/SciMesh/users/internal/auth" + "github.com/emil28092005/SciMesh/users/internal/domain" "github.com/emil28092005/SciMesh/users/internal/usecase" ) @@ -16,6 +17,7 @@ type UseCases struct { Register *usecase.Register Login *usecase.Login SetVerified *usecase.SetVerified + SetRole *usecase.SetRole Users usecase.UserRepository } @@ -26,6 +28,7 @@ func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler { register: uc.Register, login: uc.Login, setVerified: uc.SetVerified, + setRole: uc.SetRole, users: uc.Users, log: log, } @@ -44,6 +47,10 @@ func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler { chain(h.handleSetVerified(true), withJWT(issuer), withAdmin)) mux.Handle("POST /users/{id}/unverify", chain(h.handleSetVerified(false), withJWT(issuer), withAdmin)) + mux.Handle("POST /users/{id}/promote", + chain(h.handleSetRole(domain.RoleAdmin), withJWT(issuer), withAdmin)) + mux.Handle("POST /users/{id}/demote", + chain(h.handleSetRole(domain.RoleUser), withJWT(issuer), withAdmin)) // Outermost first: every request gets an ID and an access-log line. return chain(mux, withRequestID, withAccessLog(log)) diff --git a/users/internal/transport/http/server_test.go b/users/internal/transport/http/server_test.go index b4d07a3..f0ee300 100644 --- a/users/internal/transport/http/server_test.go +++ b/users/internal/transport/http/server_test.go @@ -34,6 +34,7 @@ func newTestServer() http.Handler { Register: usecase.NewRegister(users, hasher, clk), Login: usecase.NewLogin(users, hasher, issuer), SetVerified: usecase.NewSetVerified(users), + SetRole: usecase.NewSetRole(users), Users: users, } log := slog.New(slog.NewTextHandler(io.Discard, nil)) @@ -193,6 +194,7 @@ func TestMeInternalError(t *testing.T) { Register: usecase.NewRegister(users, hasher, clk), Login: usecase.NewLogin(users, hasher, issuer), SetVerified: usecase.NewSetVerified(users), + SetRole: usecase.NewSetRole(users), Users: users, } h := apihttp.NewServer(slog.New(slog.NewTextHandler(io.Discard, nil)), uc, issuer) @@ -300,6 +302,51 @@ func TestVerifyUnknownUser(t *testing.T) { } } +func TestAdminPromotesAndDemotes(t *testing.T) { + h := newTestServer() + id := registerUser(t, h, "promote@example.com") + admin := mintToken(t, domain.RoleAdmin) + + if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", admin, nil); rec.Code != http.StatusNoContent { + t.Fatalf("promote: got %d, body %s", rec.Code, rec.Body) + } + // The promoted user now logs in as an admin. + rec := do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "promote@example.com", "password": "password123"}) + var lr struct { + User struct { + Role string `json:"role"` + } `json:"user"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &lr) + if lr.User.Role != "admin" { + t.Errorf("role after promote = %q, want admin", lr.User.Role) + } + + if rec := do(t, h, http.MethodPost, "/users/"+id+"/demote", admin, nil); rec.Code != http.StatusNoContent { + t.Fatalf("demote: got %d", rec.Code) + } +} + +func TestPromoteRequiresAdmin(t *testing.T) { + h := newTestServer() + id := registerUser(t, h, "target@example.com") + + if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", mintToken(t, domain.RoleUser), nil); rec.Code != http.StatusForbidden { + t.Errorf("plain user promote: got %d, want 403", rec.Code) + } + if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", "", nil); rec.Code != http.StatusUnauthorized { + t.Errorf("no token: got %d, want 401", rec.Code) + } +} + +func TestPromoteUnknownUser(t *testing.T) { + h := newTestServer() + rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/promote", mintToken(t, domain.RoleAdmin), nil) + if rec.Code != http.StatusNotFound { + t.Errorf("unknown user promote: got %d, want 404", rec.Code) + } +} + func TestUnverifyRevokes(t *testing.T) { h := newTestServer() id := registerUser(t, h, "revoke@example.com") diff --git a/users/internal/usecase/errorpaths_test.go b/users/internal/usecase/errorpaths_test.go index 92c2467..4fa9a1e 100644 --- a/users/internal/usecase/errorpaths_test.go +++ b/users/internal/usecase/errorpaths_test.go @@ -32,6 +32,9 @@ func (s stubRepo) GetByID(context.Context, uuid.UUID) (*domain.User, error) { func (s stubRepo) SetVerified(context.Context, uuid.UUID, bool) error { return usecase.ErrUserNotFound } +func (s stubRepo) SetRole(context.Context, uuid.UUID, domain.Role) error { + return usecase.ErrUserNotFound +} type stubHasher struct { hashErr error diff --git a/users/internal/usecase/errors.go b/users/internal/usecase/errors.go index 4e8131d..9a0b3e2 100644 --- a/users/internal/usecase/errors.go +++ b/users/internal/usecase/errors.go @@ -15,4 +15,5 @@ var ( ErrInvalidCredentials = errors.New("invalid email or password") ErrPasswordTooShort = errors.New("password too short") ErrPasswordTooLong = errors.New("password too long") + ErrInvalidRole = errors.New("invalid role") ) diff --git a/users/internal/usecase/ports.go b/users/internal/usecase/ports.go index 738082a..d94bfd1 100644 --- a/users/internal/usecase/ports.go +++ b/users/internal/usecase/ports.go @@ -25,6 +25,9 @@ type UserRepository interface { // SetVerified toggles the verified flag, returning ErrUserNotFound if no // such user exists. SetVerified(ctx context.Context, id uuid.UUID, verified bool) error + // SetRole changes a user's role, returning ErrUserNotFound if no such user + // exists. + SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error } // PasswordHasher hashes and verifies passwords. The bcrypt adapter satisfies it. diff --git a/users/internal/usecase/role.go b/users/internal/usecase/role.go new file mode 100644 index 0000000..b756c47 --- /dev/null +++ b/users/internal/usecase/role.go @@ -0,0 +1,28 @@ +package usecase + +import ( + "context" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/users/internal/domain" +) + +// SetRole promotes or demotes a user. Only an admin may call this (enforced in +// the transport layer); the use case validates the target role and applies it. +type SetRole struct { + users UserRepository +} + +func NewSetRole(users UserRepository) *SetRole { + return &SetRole{users: users} +} + +// Execute assigns role to the user, returning ErrInvalidRole for an unknown role +// or ErrUserNotFound if the user does not exist. +func (uc *SetRole) Execute(ctx context.Context, id uuid.UUID, role domain.Role) error { + if !role.Valid() { + return ErrInvalidRole + } + return uc.users.SetRole(ctx, id, role) +} From df4bdc9de92633678714d44b733737761c17b909 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 21:27:39 +0300 Subject: [PATCH 15/24] feat(coordinator): admin panel at /ui/admin Admin-only page to promote/demote/verify/unverify a user by id. Actions proxy to the userservice forwarding the admin's session JWT, which the userservice re-checks (defense in depth). Non-admins are redirected off the panel; the Admin link shows only for admins. Admins already see all jobs on the dashboard. Tests: requireAdmin gate, bearer forwarding, action/id validation, admin link. --- coordinator/internal/transport/http/server.go | 3 + .../transport/http/templates/admin.html | 46 +++++++ .../transport/http/templates/dashboard.html | 2 +- .../internal/transport/http/ui_admin.go | 109 +++++++++++++++++ .../transport/http/ui_admin_internal_test.go | 113 ++++++++++++++++++ 5 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 coordinator/internal/transport/http/templates/admin.html create mode 100644 coordinator/internal/transport/http/ui_admin.go create mode 100644 coordinator/internal/transport/http/ui_admin_internal_test.go diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index ecd44a3..c5daa44 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -129,6 +129,9 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler { for _, rt := range app { ui.Handle(rt.pattern, gate(rt.handler)) } + // Admin panel: session + admin role. + ui.Handle("GET /ui/admin", chain(http.HandlerFunc(s.handleUIAdmin), gate, requireAdmin)) + ui.Handle("POST /ui/admin/user-action", chain(http.HandlerFunc(s.handleUIAdminUserAction), gate, requireAdmin)) } else { for _, rt := range app { ui.HandleFunc(rt.pattern, rt.handler) diff --git a/coordinator/internal/transport/http/templates/admin.html b/coordinator/internal/transport/http/templates/admin.html new file mode 100644 index 0000000..8bff008 --- /dev/null +++ b/coordinator/internal/transport/http/templates/admin.html @@ -0,0 +1,46 @@ +{{define "admin.html"}} + + + + + + Admin · SciMesh + + + +
+
+

Admin panel

User & run control

+ +
+

Signed in as {{.Role}}. Promote or verify a user by their id, and control every job from the dashboard.

+ + {{if .Msg}}
{{.Msg}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}} + +
+

Manage a user

+

Paste the user id (the JWT sub / the value shown at registration). Actions are applied immediately.

+
+ + +

Promote makes them an admin; Verify marks them a trusted contributor (their workers skip quorum).

+
+ + + + +
+
+
+ +
+

Jobs & tasks

+

As an admin you already see every user's jobs on the dashboard, with per-task status and job cancellation. A regular user sees only their own.

+ +
+
+ + +{{end}} diff --git a/coordinator/internal/transport/http/templates/dashboard.html b/coordinator/internal/transport/http/templates/dashboard.html index de530f3..2381cf8 100644 --- a/coordinator/internal/transport/http/templates/dashboard.html +++ b/coordinator/internal/transport/http/templates/dashboard.html @@ -13,7 +13,7 @@

Local scientific compute

SciMesh control room

Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.

Live overview · refreshes every 2 seconds
-
{{if .Session}}Signed in · {{.Session.Role}}{{end}}+ New similarity search{{if .Session}}
{{end}}
+
{{if .Session}}Signed in · {{.Session.Role}}{{end}}{{if and .Session (eq .Session.Role "admin")}}Admin{{end}}+ New similarity search{{if .Session}}
{{end}}
How a search becomes a result
01Upload TSVThe coordinator validates and slices the dataset.
02Run shardsWorkers fingerprint molecules and return shard top-k CSVs.
03Merge exactlyThe coordinator ranks retained candidates deterministically.
04Download CSVA checksum-protected global result is ready.
diff --git a/coordinator/internal/transport/http/ui_admin.go b/coordinator/internal/transport/http/ui_admin.go new file mode 100644 index 0000000..5d4e05f --- /dev/null +++ b/coordinator/internal/transport/http/ui_admin.go @@ -0,0 +1,109 @@ +package http + +import ( + "context" + "net/http" + "net/url" + "strings" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/authctx" +) + +// adminUserActions are the userservice endpoints the admin panel may invoke, by +// their path suffix. A whitelist so a crafted form can never proxy an arbitrary +// path. +var adminUserActions = map[string]bool{ + "promote": true, + "demote": true, + "verify": true, + "unverify": true, +} + +// requireAdmin gates a route on the session caller being an admin. It runs +// inside withUISession, which has already stamped the requester. A non-admin is +// sent back to the dashboard rather than shown the panel. +func requireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if req, ok := authctx.From(r.Context()); !ok || !req.IsAdmin() { + http.Redirect(w, r, "/ui", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) handleUIAdmin(w http.ResponseWriter, r *http.Request) { + role := "" + if req, ok := authctx.From(r.Context()); ok { + role = req.Role + } + s.renderUI(w, "admin.html", map[string]any{ + "Role": role, + "Msg": r.URL.Query().Get("msg"), + "Error": r.URL.Query().Get("error"), + }) +} + +// handleUIAdminUserAction proxies a user-management action to the userservice, +// forwarding the admin's session token so the userservice re-checks the role. +// The user id and action come from the form, so a single static form action can +// drive every operation. +func (s *Server) handleUIAdminUserAction(w http.ResponseWriter, r *http.Request) { + userID := strings.TrimSpace(r.FormValue("user_id")) + action := r.FormValue("action") + + if !adminUserActions[action] { + http.Redirect(w, r, "/ui/admin?error=unknown+action", http.StatusSeeOther) + return + } + if _, err := uuid.Parse(userID); err != nil { + http.Redirect(w, r, "/ui/admin?error=invalid+user+id", http.StatusSeeOther) + return + } + c, err := r.Cookie(sessionCookie) + if err != nil { + redirectToLogin(w, r) + return + } + + status, err := s.callUserserviceAuthed(r.Context(), http.MethodPost, "/users/"+userID+"/"+action, c.Value) + if err != nil { + s.log.Error("admin action proxy", "err", err, "action", action) + http.Redirect(w, r, "/ui/admin?error=service+unavailable", http.StatusSeeOther) + return + } + switch status { + case http.StatusNoContent: + http.Redirect(w, r, "/ui/admin?msg="+url.QueryEscape(action+" applied"), http.StatusSeeOther) + case http.StatusNotFound: + http.Redirect(w, r, "/ui/admin?error=user+not+found", http.StatusSeeOther) + case http.StatusForbidden, http.StatusUnauthorized: + http.Redirect(w, r, "/ui/admin?error=not+authorized", http.StatusSeeOther) + default: + http.Redirect(w, r, "/ui/admin?error=action+failed", http.StatusSeeOther) + } +} + +// callUserserviceAuthed makes an authenticated call to the userservice, passing +// the caller's JWT through as a bearer token. Used for admin actions; login and +// registration use the unauthenticated callUserservice. +func (s *Server) callUserserviceAuthed(ctx context.Context, method, path, bearer string) (int, error) { + // path is not attacker-controlled: the caller composes it only from a + // uuid-validated id and an action from a fixed whitelist, and the host is + // the operator-configured userservice — so the SSRF taint gosec sees here + // cannot reach an arbitrary destination. + req, err := http.NewRequestWithContext(ctx, method, s.userserviceURL+path, nil) //nolint:gosec // G704: path is validated, host is config + if err != nil { + return 0, err + } + req.Header.Set("Authorization", "Bearer "+bearer) + + resp, err := s.httpClient.Do(req) //nolint:gosec // G704: see above + if err != nil { + return 0, err + } + defer func() { _ = resp.Body.Close() }() + return resp.StatusCode, nil +} diff --git a/coordinator/internal/transport/http/ui_admin_internal_test.go b/coordinator/internal/transport/http/ui_admin_internal_test.go new file mode 100644 index 0000000..92f3c27 --- /dev/null +++ b/coordinator/internal/transport/http/ui_admin_internal_test.go @@ -0,0 +1,113 @@ +package http + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/authctx" + "github.com/emil28092005/SciMesh/coordinator/internal/usecase" +) + +func adminReq(t *testing.T, role string) *http.Request { + t.Helper() + req := newReq(http.MethodGet, "/ui/admin", nil) + return req.WithContext(authctx.With(context.Background(), authctx.Requester{UserID: uuid.New(), Role: role})) +} + +func TestRequireAdminAllowsAdminOnly(t *testing.T) { + reached := false + h := requireAdmin(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) + + // Admin passes through. + h.ServeHTTP(httptest.NewRecorder(), adminReq(t, "admin")) + if !reached { + t.Error("admin must reach the handler") + } + + // Plain user is redirected to the dashboard. + reached = false + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminReq(t, "user")) + if reached { + t.Error("non-admin must not reach the handler") + } + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" { + t.Errorf("non-admin got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location")) + } +} + +func TestAdminUserActionForwardsBearer(t *testing.T) { + targetID := uuid.NewString() + var gotAuth, gotPath string + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotPath = r.URL.Path + w.WriteHeader(http.StatusNoContent) + })) + defer stub.Close() + s := newLoginServer(stub) + + req := newReq(http.MethodPost, "/ui/admin/user-action", + strings.NewReader(url.Values{"user_id": {targetID}, "action": {"promote"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "admin.jwt.token"}) + rec := httptest.NewRecorder() + s.handleUIAdminUserAction(rec, req) + + if gotAuth != "Bearer admin.jwt.token" { + t.Errorf("forwarded auth = %q, want the admin bearer", gotAuth) + } + if gotPath != "/users/"+targetID+"/promote" { + t.Errorf("forwarded path = %q", gotPath) + } + if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "msg=") { + t.Errorf("got %d -> %q, want 303 with a success msg", rec.Code, rec.Header().Get("Location")) + } +} + +func TestAdminUserActionRejectsUnknownAction(t *testing.T) { + s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("userservice must not be called for an invalid action") + }))) + req := newReq(http.MethodPost, "/ui/admin/user-action", + strings.NewReader(url.Values{"user_id": {uuid.NewString()}, "action": {"delete"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "x"}) + rec := httptest.NewRecorder() + s.handleUIAdminUserAction(rec, req) + if !strings.Contains(rec.Header().Get("Location"), "error=") { + t.Errorf("unknown action redirect = %q, want an error", rec.Header().Get("Location")) + } +} + +func TestAdminUserActionRejectsBadID(t *testing.T) { + s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("userservice must not be called for an invalid id") + }))) + req := newReq(http.MethodPost, "/ui/admin/user-action", + strings.NewReader(url.Values{"user_id": {"not-a-uuid"}, "action": {"promote"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "x"}) + rec := httptest.NewRecorder() + s.handleUIAdminUserAction(rec, req) + if !strings.Contains(rec.Header().Get("Location"), "error=") { + t.Errorf("bad id redirect = %q, want an error", rec.Header().Get("Location")) + } +} + +func TestDashboardAdminLinkOnlyForAdmin(t *testing.T) { + admin := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "admin"}}) + if !strings.Contains(admin, "/ui/admin") { + t.Error("admin must see the Admin link") + } + user := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "user"}}) + if strings.Contains(user, "/ui/admin") { + t.Error("a plain user must not see the Admin link") + } +} From 4ac19999a9a81800828b5cf0e30b0e83d0feb23f Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 21:39:08 +0300 Subject: [PATCH 16/24] feat(coordinator): profile page at /ui/profile Shows the signed-in user's id, email, role, verified status, and created-at by proxying the session token to the userservice /me endpoint (email/created_at are not in the JWT). Profile link added to the dashboard, job, and admin headers. Tests: /me proxy forwards the bearer and renders the account; redirect without a session. --- coordinator/internal/transport/http/server.go | 1 + .../transport/http/templates/admin.html | 2 +- .../transport/http/templates/dashboard.html | 2 +- .../transport/http/templates/job.html | 2 +- .../transport/http/templates/profile.html | 32 ++++++++++++ .../internal/transport/http/ui_admin.go | 15 ++++-- .../internal/transport/http/ui_profile.go | 49 +++++++++++++++++++ .../http/ui_profile_internal_test.go | 43 ++++++++++++++++ 8 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 coordinator/internal/transport/http/templates/profile.html create mode 100644 coordinator/internal/transport/http/ui_profile.go create mode 100644 coordinator/internal/transport/http/ui_profile_internal_test.go diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index c5daa44..7598f1a 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -129,6 +129,7 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler { for _, rt := range app { ui.Handle(rt.pattern, gate(rt.handler)) } + ui.Handle("GET /ui/profile", gate(http.HandlerFunc(s.handleUIProfile))) // Admin panel: session + admin role. ui.Handle("GET /ui/admin", chain(http.HandlerFunc(s.handleUIAdmin), gate, requireAdmin)) ui.Handle("POST /ui/admin/user-action", chain(http.HandlerFunc(s.handleUIAdminUserAction), gate, requireAdmin)) diff --git a/coordinator/internal/transport/http/templates/admin.html b/coordinator/internal/transport/http/templates/admin.html index 8bff008..37f704a 100644 --- a/coordinator/internal/transport/http/templates/admin.html +++ b/coordinator/internal/transport/http/templates/admin.html @@ -12,7 +12,7 @@

Admin panel

User & run control

- +

Signed in as {{.Role}}. Promote or verify a user by their id, and control every job from the dashboard.

diff --git a/coordinator/internal/transport/http/templates/dashboard.html b/coordinator/internal/transport/http/templates/dashboard.html index 2381cf8..f4ee300 100644 --- a/coordinator/internal/transport/http/templates/dashboard.html +++ b/coordinator/internal/transport/http/templates/dashboard.html @@ -13,7 +13,7 @@

Local scientific compute

SciMesh control room

Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.

Live overview · refreshes every 2 seconds
-
{{if .Session}}Signed in · {{.Session.Role}}{{end}}{{if and .Session (eq .Session.Role "admin")}}Admin{{end}}+ New similarity search{{if .Session}}
{{end}}
+
{{if .Session}}Signed in · {{.Session.Role}}{{end}}{{if .Session}}Profile{{end}}{{if and .Session (eq .Session.Role "admin")}}Admin{{end}}+ New similarity search{{if .Session}}
{{end}}
How a search becomes a result
01Upload TSVThe coordinator validates and slices the dataset.
02Run shardsWorkers fingerprint molecules and return shard top-k CSVs.
03Merge exactlyThe coordinator ranks retained candidates deterministically.
04Download CSVA checksum-protected global result is ready.
diff --git a/coordinator/internal/transport/http/templates/job.html b/coordinator/internal/transport/http/templates/job.html index 49f8289..c9c77b2 100644 --- a/coordinator/internal/transport/http/templates/job.html +++ b/coordinator/internal/transport/http/templates/job.html @@ -12,7 +12,7 @@
-
← Back to control room{{if .Session}}
{{end}}
+
← Back to control room{{if .Session}}
Profile
{{end}}

{{workloadLabel .Workload}}

Live pipeline

One job, shown from accepted input through its final coordinator-owned scientific result.

Live · refreshes every 2 seconds
{{statusLabel .Status}}

{{statusHint .Status}}

Completed shards are preserved.

{{.Completed}} of {{.Total}} shards complete

{{.Total}}total shards
{{.Completed}}completed
{{.Pending}}waiting
{{add .Leased .Running}}with workers
{{.Failed}}failed
{{.Cancelled}}stopped
diff --git a/coordinator/internal/transport/http/templates/profile.html b/coordinator/internal/transport/http/templates/profile.html new file mode 100644 index 0000000..c84ed30 --- /dev/null +++ b/coordinator/internal/transport/http/templates/profile.html @@ -0,0 +1,32 @@ +{{define "profile.html"}} + + + + + + Profile · SciMesh + + + +
+
+

Account

Your profile

+ +
+ + {{if .Error}}
{{.Error}}
{{end}} + {{with .Profile}} +
+
User id{{.ID}}
+
Email{{.Email}}
+
Role{{.Role}}
+
Verified contributor{{if .Verified}}yes{{else}}no{{end}}
+
Member since{{.CreatedAt}}
+
+

Your user id is what the coordinator stores as the owner of every job you submit. Give it to an admin to be promoted or verified.

+ {{end}} +
+ + +{{end}} diff --git a/coordinator/internal/transport/http/ui_admin.go b/coordinator/internal/transport/http/ui_admin.go index 5d4e05f..6287b91 100644 --- a/coordinator/internal/transport/http/ui_admin.go +++ b/coordinator/internal/transport/http/ui_admin.go @@ -2,6 +2,7 @@ package http import ( "context" + "io" "net/http" "net/url" "strings" @@ -68,7 +69,7 @@ func (s *Server) handleUIAdminUserAction(w http.ResponseWriter, r *http.Request) return } - status, err := s.callUserserviceAuthed(r.Context(), http.MethodPost, "/users/"+userID+"/"+action, c.Value) + status, _, err := s.callUserserviceAuthed(r.Context(), http.MethodPost, "/users/"+userID+"/"+action, c.Value) if err != nil { s.log.Error("admin action proxy", "err", err, "action", action) http.Redirect(w, r, "/ui/admin?error=service+unavailable", http.StatusSeeOther) @@ -89,21 +90,25 @@ func (s *Server) handleUIAdminUserAction(w http.ResponseWriter, r *http.Request) // callUserserviceAuthed makes an authenticated call to the userservice, passing // the caller's JWT through as a bearer token. Used for admin actions; login and // registration use the unauthenticated callUserservice. -func (s *Server) callUserserviceAuthed(ctx context.Context, method, path, bearer string) (int, error) { +func (s *Server) callUserserviceAuthed(ctx context.Context, method, path, bearer string) (int, []byte, error) { // path is not attacker-controlled: the caller composes it only from a // uuid-validated id and an action from a fixed whitelist, and the host is // the operator-configured userservice — so the SSRF taint gosec sees here // cannot reach an arbitrary destination. req, err := http.NewRequestWithContext(ctx, method, s.userserviceURL+path, nil) //nolint:gosec // G704: path is validated, host is config if err != nil { - return 0, err + return 0, nil, err } req.Header.Set("Authorization", "Bearer "+bearer) resp, err := s.httpClient.Do(req) //nolint:gosec // G704: see above if err != nil { - return 0, err + return 0, nil, err } defer func() { _ = resp.Body.Close() }() - return resp.StatusCode, nil + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return 0, nil, err + } + return resp.StatusCode, body, nil } diff --git a/coordinator/internal/transport/http/ui_profile.go b/coordinator/internal/transport/http/ui_profile.go new file mode 100644 index 0000000..320ed5b --- /dev/null +++ b/coordinator/internal/transport/http/ui_profile.go @@ -0,0 +1,49 @@ +package http + +import ( + "encoding/json" + "net/http" +) + +// profileView is the account data shown on the profile page, mirroring the +// userservice /me response. +type profileView struct { + ID string `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + Verified bool `json:"verified"` + CreatedAt string `json:"created_at"` +} + +// handleUIProfile shows the signed-in user's own account. It proxies the +// session token to the userservice /me endpoint, which is the authority on the +// account (email and created_at are not in the JWT). +func (s *Server) handleUIProfile(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(sessionCookie) + if err != nil { + redirectToLogin(w, r) + return + } + status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/me", c.Value) + if err != nil { + s.log.Error("profile /me proxy", "err", err) + s.renderUI(w, "profile.html", map[string]any{"Error": "userservice unavailable"}) + return + } + if status == http.StatusUnauthorized { + clearSessionCookie(w, r) + redirectToLogin(w, r) + return + } + if status != http.StatusOK { + s.renderUI(w, "profile.html", map[string]any{"Error": "could not load your account"}) + return + } + + var p profileView + if err := json.Unmarshal(body, &p); err != nil { + s.renderUI(w, "profile.html", map[string]any{"Error": "could not read your account"}) + return + } + s.renderUI(w, "profile.html", map[string]any{"Profile": p}) +} diff --git a/coordinator/internal/transport/http/ui_profile_internal_test.go b/coordinator/internal/transport/http/ui_profile_internal_test.go new file mode 100644 index 0000000..882ab6a --- /dev/null +++ b/coordinator/internal/transport/http/ui_profile_internal_test.go @@ -0,0 +1,43 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestProfileProxiesMe(t *testing.T) { + var gotAuth, gotPath string + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth, gotPath = r.Header.Get("Authorization"), r.URL.Path + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","email":"me@example.com","role":"user","verified":false,"created_at":"2026-07-26T00:00:00Z"}`)) + })) + defer stub.Close() + s := newLoginServer(stub) + + req := newReq(http.MethodGet, "/ui/profile", nil) + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "my.jwt"}) + rec := httptest.NewRecorder() + s.handleUIProfile(rec, req) + + if gotAuth != "Bearer my.jwt" || gotPath != "/me" { + t.Fatalf("proxy: auth=%q path=%q", gotAuth, gotPath) + } + body := rec.Body.String() + if !strings.Contains(body, "me@example.com") || !strings.Contains(body, "11111111-1111-1111-1111-111111111111") { + t.Error("profile page must show the email and id") + } +} + +func TestProfileRedirectsWithoutCookie(t *testing.T) { + s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("must not call userservice without a session") + }))) + rec := httptest.NewRecorder() + s.handleUIProfile(rec, newReq(http.MethodGet, "/ui/profile", nil)) + if rec.Code != http.StatusSeeOther { + t.Errorf("no cookie: got %d, want 303 redirect", rec.Code) + } +} From e584cfc48110c331621600e8d47c1a4b9996624b Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 21:47:27 +0300 Subject: [PATCH 17/24] feat(coordinator): Prometheus /metrics endpoint Adds internal/metrics: a private registry with the Go runtime + process collectors and HTTP RED instrumentation (scimesh_http_requests_total and request_duration_seconds), labelled by method/status and a normalized route so per-id paths collapse to {id} and never blow up label cardinality. /metrics is unauthenticated (like /health) for a Prometheus scraper; the middleware wraps the whole router so every request is measured once. --- coordinator/go.mod | 17 ++- coordinator/go.sum | 44 +++++-- coordinator/internal/metrics/metrics.go | 112 ++++++++++++++++++ coordinator/internal/metrics/metrics_test.go | 47 ++++++++ coordinator/internal/transport/http/server.go | 9 +- 5 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 coordinator/internal/metrics/metrics.go create mode 100644 coordinator/internal/metrics/metrics_test.go diff --git a/coordinator/go.mod b/coordinator/go.mod index 3b6d8e7..9d805cc 100644 --- a/coordinator/go.mod +++ b/coordinator/go.mod @@ -1,24 +1,33 @@ module github.com/emil28092005/SciMesh/coordinator -go 1.22 +go 1.25.0 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/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.6.0 github.com/joho/godotenv v1.5.1 + github.com/prometheus/client_golang v1.24.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect golang.org/x/crypto v0.17.0 // indirect - golang.org/x/sync v0.1.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/coordinator/go.sum b/coordinator/go.sum index 8eea60b..198473a 100644 --- a/coordinator/go.sum +++ b/coordinator/go.sum @@ -1,12 +1,18 @@ github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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= @@ -19,24 +25,46 @@ github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= diff --git a/coordinator/internal/metrics/metrics.go b/coordinator/internal/metrics/metrics.go new file mode 100644 index 0000000..1070e72 --- /dev/null +++ b/coordinator/internal/metrics/metrics.go @@ -0,0 +1,112 @@ +// Package metrics exposes Prometheus instrumentation for the coordinator: an +// HTTP RED middleware (rate, errors, duration) plus the standard Go runtime and +// process collectors, all on a private registry so nothing leaks in from global +// state. +package metrics + +import ( + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +type Metrics struct { + reg *prometheus.Registry + requests *prometheus.CounterVec + duration *prometheus.HistogramVec +} + +// New builds the registry and registers the runtime, process, and HTTP metrics. +func New() *Metrics { + reg := prometheus.NewRegistry() + reg.MustRegister( + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + ) + + requests := prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "scimesh", + Subsystem: "http", + Name: "requests_total", + Help: "HTTP requests, labelled by method, normalized route, and status.", + }, []string{"method", "route", "status"}) + + duration := prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "scimesh", + Subsystem: "http", + Name: "request_duration_seconds", + Help: "HTTP request duration in seconds.", + Buckets: prometheus.DefBuckets, + }, []string{"method", "route"}) + + reg.MustRegister(requests, duration) + return &Metrics{reg: reg, requests: requests, duration: duration} +} + +// Handler serves the metrics in Prometheus text format. +func (m *Metrics) Handler() http.Handler { + return promhttp.HandlerFor(m.reg, promhttp.HandlerOpts{}) +} + +// Registry exposes the registry so callers can register extra collectors. +func (m *Metrics) Registry() *prometheus.Registry { return m.reg } + +// Middleware records one request into the RED metrics. It normalizes the path +// so per-id routes collapse to a single low-cardinality label. +func (m *Metrics) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + + route := normalizeRoute(r.URL.Path) + m.requests.WithLabelValues(r.Method, route, strconv.Itoa(rec.status)).Inc() + m.duration.WithLabelValues(r.Method, route).Observe(time.Since(start).Seconds()) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (s *statusRecorder) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +var uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// normalizeRoute collapses uuid and numeric path segments to {id}, keeping the +// route label cardinality bounded (otherwise every job/task id would be its own +// time series). +func normalizeRoute(path string) string { + if path == "" { + return "/" + } + segs := strings.Split(path, "/") + for i, s := range segs { + if s == "" { + continue + } + if uuidRe.MatchString(s) || isAllDigits(s) { + segs[i] = "{id}" + } + } + return strings.Join(segs, "/") +} + +func isAllDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return s != "" +} diff --git a/coordinator/internal/metrics/metrics_test.go b/coordinator/internal/metrics/metrics_test.go new file mode 100644 index 0000000..75729ee --- /dev/null +++ b/coordinator/internal/metrics/metrics_test.go @@ -0,0 +1,47 @@ +package metrics + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNormalizeRoute(t *testing.T) { + cases := map[string]string{ + "/health": "/health", + "/jobs/3f2504e0-4f89-41d3-9a0c-0305e82c3301": "/jobs/{id}", + "/tasks/3f2504e0-4f89-41d3-9a0c-0305e82c3301/result": "/tasks/{id}/result", + "/ui/jobs/12345": "/ui/jobs/{id}", + "/": "/", + } + for in, want := range cases { + if got := normalizeRoute(in); got != want { + t.Errorf("normalizeRoute(%q) = %q, want %q", in, got, want) + } + } +} + +func TestMiddlewareAndHandler(t *testing.T) { + m := New() + h := m.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + })) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/jobs/3f2504e0-4f89-41d3-9a0c-0305e82c3301", nil) + h.ServeHTTP(httptest.NewRecorder(), req) + + // Scrape and confirm the request was recorded under the normalized route. + rec := httptest.NewRecorder() + greq, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil) + m.Handler().ServeHTTP(rec, greq) + + body := rec.Body.String() + if !strings.Contains(body, `scimesh_http_requests_total{method="POST",route="/jobs/{id}",status="201"}`) { + t.Errorf("requests_total not recorded as expected; body:\n%s", body) + } + if !strings.Contains(body, "go_goroutines") { + t.Error("Go runtime collector not registered") + } +} diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index 7598f1a..79d080a 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/emil28092005/SciMesh/coordinator/internal/metrics" tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token" "github.com/emil28092005/SciMesh/coordinator/internal/usecase" ) @@ -50,6 +51,8 @@ type Server struct { userserviceURL string // httpClient makes the login/register calls to the userservice. httpClient *http.Client + // metrics holds the Prometheus registry and HTTP instrumentation. + metrics *metrics.Metrics // ready probes downstream dependencies (the database) for /health. Kept as // a func so the transport layer never imports pgx. ready func(context.Context) error @@ -66,6 +69,7 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval verifier: tokenpkg.NewVerifier(jwtSecret), userserviceURL: strings.TrimRight(userserviceURL, "/"), httpClient: &http.Client{Timeout: 10 * time.Second}, + metrics: metrics.New(), ready: ready, } } @@ -97,6 +101,8 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", s.handleHealth) + // Unauthenticated like /health, so a Prometheus scraper needs no credential. + mux.Handle("GET /metrics", s.metrics.Handler()) hasBasicAuth := len(uiToken) > 0 && uiToken[0] != "" if s.uc.Dashboard != nil && (s.uiSessionMode() || hasBasicAuth) { @@ -157,7 +163,8 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler { withAccessLog(s.log), // including the 401s below withAuth(token, s.verifier), )) - return mux + // Measure every request once, outermost, with a normalized route label. + return s.metrics.Middleware(mux) } // handleHealth reports readiness. It probes the database so an orchestrator From 779ff8c10e8235431101a2808a9e991071c3382d Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 21:49:52 +0300 Subject: [PATCH 18/24] feat(demo): Prometheus + Grafana with a provisioned dashboard make demo-ui now also starts Prometheus (scrapes coordinator:8080/metrics) and Grafana with a provisioned datasource and a SciMesh Coordinator dashboard (request rate & p95 by route, status mix, goroutines, RSS). Grafana allows anonymous viewing so the dashboard opens without a login; admin/admin to edit. - monitoring/prometheus.yml + grafana provisioning + dashboard JSON - docker-compose.monitoring.yml overlay (third -f in demo-ui.sh) - demo prints the Grafana and Prometheus URLs --- coordinator/docker-compose.monitoring.yml | 30 +++++++ .../grafana/dashboards/coordinator.json | 88 +++++++++++++++++++ .../provisioning/dashboards/provider.yml | 10 +++ .../provisioning/datasources/prometheus.yml | 10 +++ coordinator/monitoring/prometheus.yml | 10 +++ coordinator/scripts/demo-ui.sh | 9 +- 6 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 coordinator/docker-compose.monitoring.yml create mode 100644 coordinator/monitoring/grafana/dashboards/coordinator.json create mode 100644 coordinator/monitoring/grafana/provisioning/dashboards/provider.yml create mode 100644 coordinator/monitoring/grafana/provisioning/datasources/prometheus.yml create mode 100644 coordinator/monitoring/prometheus.yml diff --git a/coordinator/docker-compose.monitoring.yml b/coordinator/docker-compose.monitoring.yml new file mode 100644 index 0000000..1833a98 --- /dev/null +++ b/coordinator/docker-compose.monitoring.yml @@ -0,0 +1,30 @@ +# Demo overlay: Prometheus scrapes the coordinator's /metrics, Grafana shows the +# provisioned SciMesh dashboard. Merged by scripts/demo-ui.sh with a third -f. +# Both share the coordinator's compose network, so Prometheus reaches it by name. + +services: + prometheus: + image: prom/prometheus:v2.54.1 + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + ports: + - "${PROMETHEUS_PORT:-19090}:9090" + restart: unless-stopped + + grafana: + image: grafana/grafana:11.2.0 + depends_on: + - prometheus + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin} + # Anonymous viewing so the demo dashboard opens without a login. + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer + GF_USERS_DEFAULT_THEME: dark + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + ports: + - "${GRAFANA_PORT:-13000}:3000" + restart: unless-stopped diff --git a/coordinator/monitoring/grafana/dashboards/coordinator.json b/coordinator/monitoring/grafana/dashboards/coordinator.json new file mode 100644 index 0000000..65cd5ff --- /dev/null +++ b/coordinator/monitoring/grafana/dashboards/coordinator.json @@ -0,0 +1,88 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "graphTooltip": 1, + "schemaVersion": 39, + "tags": ["scimesh"], + "time": { "from": "now-15m", "to": "now" }, + "refresh": "5s", + "title": "SciMesh Coordinator", + "uid": "scimesh-coordinator", + "panels": [ + { + "type": "timeseries", + "title": "HTTP request rate by route", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum by (route) (rate(scimesh_http_requests_total[1m]))", + "legendFormat": "{{route}}" + } + ] + }, + { + "type": "timeseries", + "title": "p95 latency by route", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.95, sum by (le, route) (rate(scimesh_http_request_duration_seconds_bucket[5m])))", + "legendFormat": "{{route}}" + } + ] + }, + { + "type": "timeseries", + "title": "Requests by status", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum by (status) (rate(scimesh_http_requests_total[1m]))", + "legendFormat": "{{status}}" + } + ] + }, + { + "type": "timeseries", + "title": "Goroutines", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 6, "x": 12, "y": 8 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "go_goroutines{job=\"coordinator\"}", + "legendFormat": "goroutines" + } + ] + }, + { + "type": "timeseries", + "title": "Resident memory", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 6, "x": 18, "y": 8 }, + "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "process_resident_memory_bytes{job=\"coordinator\"}", + "legendFormat": "rss" + } + ] + } + ] +} diff --git a/coordinator/monitoring/grafana/provisioning/dashboards/provider.yml b/coordinator/monitoring/grafana/provisioning/dashboards/provider.yml new file mode 100644 index 0000000..efb8556 --- /dev/null +++ b/coordinator/monitoring/grafana/provisioning/dashboards/provider.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +providers: + - name: SciMesh + type: file + disableDeletion: false + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/coordinator/monitoring/grafana/provisioning/datasources/prometheus.yml b/coordinator/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..00f9915 --- /dev/null +++ b/coordinator/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/coordinator/monitoring/prometheus.yml b/coordinator/monitoring/prometheus.yml new file mode 100644 index 0000000..2ebe7f7 --- /dev/null +++ b/coordinator/monitoring/prometheus.yml @@ -0,0 +1,10 @@ +# Prometheus scrape config for the SciMesh demo. Prometheus runs in the same +# compose network as the coordinator, so it reaches it by service name. +global: + scrape_interval: 5s + evaluation_interval: 5s + +scrape_configs: + - job_name: coordinator + static_configs: + - targets: ["coordinator:8080"] diff --git a/coordinator/scripts/demo-ui.sh b/coordinator/scripts/demo-ui.sh index 3024b09..3d58183 100755 --- a/coordinator/scripts/demo-ui.sh +++ b/coordinator/scripts/demo-ui.sh @@ -12,6 +12,8 @@ project=${DEMO_PROJECT:-scimesh-demo} postgres_port=${DEMO_POSTGRES_PORT:-55432} coordinator_port=${DEMO_COORDINATOR_PORT:-18080} userservice_port=${DEMO_USERSERVICE_PORT:-18081} +prometheus_port=${DEMO_PROMETHEUS_PORT:-19090} +grafana_port=${DEMO_GRAFANA_PORT:-13000} ui_token=${DEMO_UI_TOKEN:-demo-ui-secret} worker_token=${DEMO_WORKER_TOKEN:-demo-worker-token} # Shared HS256 secret; the coordinator verifies userservice tokens with it. Must @@ -34,6 +36,8 @@ compose() { POSTGRES_PORT="$postgres_port" \ COORDINATOR_PORT="$coordinator_port" \ USERSERVICE_PORT="$userservice_port" \ + PROMETHEUS_PORT="$prometheus_port" \ + GRAFANA_PORT="$grafana_port" \ UI_AUTH_TOKEN="$ui_token" \ WORKER_AUTH_TOKEN="$worker_token" \ JWT_SECRET="$jwt_secret" \ @@ -41,7 +45,8 @@ compose() { BOOTSTRAP_ADMIN_PASSWORD="$admin_password" \ docker compose -p "$project" \ -f "$coordinator_dir/docker-compose.yml" \ - -f "$coordinator_dir/docker-compose.users.yml" "$@" + -f "$coordinator_dir/docker-compose.users.yml" \ + -f "$coordinator_dir/docker-compose.monitoring.yml" "$@" } stop_workers() { @@ -152,6 +157,8 @@ SciMesh manual demo is ready. UI: http://localhost:$coordinator_port/ui (shows a login page) Admin login: $admin_email / $admin_password Userservice: http://localhost:$userservice_port + Grafana: http://localhost:$grafana_port (anonymous view; admin/${GRAFANA_PASSWORD:-admin} to edit) + Prometheus: http://localhost:$prometheus_port Workers: $workers local reference workers Sign in with the admin above, or register a new account from the login page. From e9cf6f084224ac7ec0617e02da2e26fc2e70f602 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 21:59:49 +0300 Subject: [PATCH 19/24] build(coordinator): go 1.25 (prometheus deps require it), bump Docker base The metrics deps pull in modules whose go directive is 1.25, so go mod tidy raised the module to go 1.25.0. The build image is bumped golang:1.24 -> golang:1.25-alpine to match (the image runs GOTOOLCHAIN=local and can't auto-fetch a newer toolchain). CI reads go-version-file, so it follows along. client_golang pinned to v1.19.1 (stable, same API). --- coordinator/Dockerfile | 2 +- coordinator/go.mod | 4 ++-- coordinator/go.sum | 20 ++++++-------------- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/coordinator/Dockerfile b/coordinator/Dockerfile index fc28a60..1eafa17 100644 --- a/coordinator/Dockerfile +++ b/coordinator/Dockerfile @@ -5,7 +5,7 @@ # build fails with "the --mount option requires BuildKit". # --- build stage ---------------------------------------------------------- -FROM golang:1.24-alpine AS build +FROM golang:1.25-alpine AS build WORKDIR /src diff --git a/coordinator/go.mod b/coordinator/go.mod index 9d805cc..7d9cb06 100644 --- a/coordinator/go.mod +++ b/coordinator/go.mod @@ -9,7 +9,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.6.0 github.com/joho/godotenv v1.5.1 - github.com/prometheus/client_golang v1.24.1 + github.com/prometheus/client_golang v1.19.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) @@ -23,7 +23,7 @@ require ( github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.21.1 // indirect golang.org/x/crypto v0.17.0 // indirect golang.org/x/sync v0.22.0 // indirect diff --git a/coordinator/go.sum b/coordinator/go.sum index 198473a..df0b061 100644 --- a/coordinator/go.sum +++ b/coordinator/go.sum @@ -25,10 +25,6 @@ github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= @@ -37,24 +33,20 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= -github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= -github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= -go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= From dcabfcd0c302cbcd7bbde08ef5b0f2027b649514 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 22:16:35 +0300 Subject: [PATCH 20/24] =?UTF-8?q?feat(coordinator):=20business=20metrics?= =?UTF-8?q?=20=E2=80=94=20jobs/tasks/workers=20by=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scrape-time collector reports scimesh_tasks/jobs/workers gauges keyed by status, sourced from cheap GROUP BY queries (StatsRepo), zero-filled across all known statuses so the dashboard shows flat zeros instead of gaps. A failed query yields no samples for that scrape rather than crashing it. Metrics is now built in main so the DB-backed collector can be registered (NewServer takes *metrics.Metrics; nil self-provisions for tests). Grafana dashboard gains a Domain state row: tasks/jobs/workers by status and a queue- depth stat. --- coordinator/cmd/coordinator/main.go | 12 +++- coordinator/internal/metrics/business.go | 66 +++++++++++++++++++ coordinator/internal/metrics/business_test.go | 51 ++++++++++++++ .../internal/storage/postgres/stats_repo.go | 65 ++++++++++++++++++ coordinator/internal/transport/http/server.go | 7 +- .../internal/transport/http/server_test.go | 2 +- .../grafana/dashboards/coordinator.json | 66 +++++++++++++++++++ 7 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 coordinator/internal/metrics/business.go create mode 100644 coordinator/internal/metrics/business_test.go create mode 100644 coordinator/internal/storage/postgres/stats_repo.go diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go index a2275f8..f9f26f6 100644 --- a/coordinator/cmd/coordinator/main.go +++ b/coordinator/cmd/coordinator/main.go @@ -9,6 +9,7 @@ import ( "syscall" "github.com/emil28092005/SciMesh/coordinator/internal/infra" + "github.com/emil28092005/SciMesh/coordinator/internal/metrics" "github.com/emil28092005/SciMesh/coordinator/internal/storage/blob" "github.com/emil28092005/SciMesh/coordinator/internal/storage/postgres" httptransport "github.com/emil28092005/SciMesh/coordinator/internal/transport/http" @@ -108,9 +109,18 @@ func run() error { }(r.name, r.fn) } + // Business metrics: gauges of tasks/jobs/workers by status, sampled from the + // database on every Prometheus scrape. + statsRepo := postgres.NewStatsRepo(pool) + m := metrics.New() + m.RegisterBusiness(func(ctx context.Context) (metrics.Stats, error) { + tasks, jobs, workers, err := statsRepo.Counts(ctx) + return metrics.Stats{Tasks: tasks, Jobs: jobs, Workers: workers}, err + }) + // 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, cfg.JWTSecret, cfg.UserserviceURL, pool.Ping) + api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, 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 diff --git a/coordinator/internal/metrics/business.go b/coordinator/internal/metrics/business.go new file mode 100644 index 0000000..1a87348 --- /dev/null +++ b/coordinator/internal/metrics/business.go @@ -0,0 +1,66 @@ +package metrics + +import ( + "context" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +// Stats is a point-in-time snapshot of the coordinator's domain state: counts of +// tasks, jobs, and workers keyed by their status. Maps are expected to be +// zero-filled by the provider so every known status is always present, giving +// the dashboard flat zero lines instead of gaps. +type Stats struct { + Tasks map[string]int + Jobs map[string]int + Workers map[string]int +} + +// StatsFunc returns the current snapshot. It is called on every scrape, so it +// must be a cheap aggregate query. +type StatsFunc func(context.Context) (Stats, error) + +// RegisterBusiness registers a collector that reports domain-state gauges +// (scimesh_tasks/jobs/workers by status) sourced from collect on each scrape. +// Deriving the gauges at scrape time keeps them fresh without a background +// goroutine, and a failed query simply yields no samples for that scrape. +func (m *Metrics) RegisterBusiness(collect StatsFunc) { + m.reg.MustRegister(&businessCollector{ + collect: collect, + tasks: prometheus.NewDesc("scimesh_tasks", "Tasks by status.", []string{"status"}, nil), + jobs: prometheus.NewDesc("scimesh_jobs", "Jobs by status.", []string{"status"}, nil), + workers: prometheus.NewDesc("scimesh_workers", "Workers by status.", []string{"status"}, nil), + }) +} + +type businessCollector struct { + collect StatsFunc + tasks, jobs, workers *prometheus.Desc +} + +func (c *businessCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.tasks + ch <- c.jobs + ch <- c.workers +} + +func (c *businessCollector) Collect(ch chan<- prometheus.Metric) { + // A bounded query so one slow scrape cannot stall Prometheus. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + s, err := c.collect(ctx) + if err != nil { + return // no samples this scrape; Prometheus keeps the last value + } + emit(ch, c.tasks, s.Tasks) + emit(ch, c.jobs, s.Jobs) + emit(ch, c.workers, s.Workers) +} + +func emit(ch chan<- prometheus.Metric, desc *prometheus.Desc, counts map[string]int) { + for status, n := range counts { + ch <- prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, float64(n), status) + } +} diff --git a/coordinator/internal/metrics/business_test.go b/coordinator/internal/metrics/business_test.go new file mode 100644 index 0000000..d127ce8 --- /dev/null +++ b/coordinator/internal/metrics/business_test.go @@ -0,0 +1,51 @@ +package metrics + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func scrape(t *testing.T, m *Metrics) string { + t.Helper() + rec := httptest.NewRecorder() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil) + m.Handler().ServeHTTP(rec, req) + return rec.Body.String() +} + +func TestBusinessCollectorEmitsGauges(t *testing.T) { + m := New() + m.RegisterBusiness(func(context.Context) (Stats, error) { + return Stats{ + Tasks: map[string]int{"pending": 3, "running": 1, "completed": 0}, + Jobs: map[string]int{"running": 2}, + Workers: map[string]int{"online": 4}, + }, nil + }) + + body := scrape(t, m) + for _, want := range []string{ + `scimesh_tasks{status="pending"} 3`, + `scimesh_tasks{status="completed"} 0`, + `scimesh_jobs{status="running"} 2`, + `scimesh_workers{status="online"} 4`, + } { + if !strings.Contains(body, want) { + t.Errorf("metrics missing %q\n%s", want, body) + } + } +} + +func TestBusinessCollectorSkipsOnError(t *testing.T) { + m := New() + m.RegisterBusiness(func(context.Context) (Stats, error) { + return Stats{}, errors.New("db down") + }) + if strings.Contains(scrape(t, m), "scimesh_tasks") { + t.Error("a failed snapshot must emit no business samples") + } +} diff --git a/coordinator/internal/storage/postgres/stats_repo.go b/coordinator/internal/storage/postgres/stats_repo.go new file mode 100644 index 0000000..9be7e87 --- /dev/null +++ b/coordinator/internal/storage/postgres/stats_repo.go @@ -0,0 +1,65 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/emil28092005/SciMesh/coordinator/internal/domain" +) + +// Known statuses per entity, so counts are zero-filled and every status is +// always present in the metrics (a flat 0 line beats a gap on the dashboard). +var ( + taskStatuses = []string{string(domain.TaskPending), string(domain.TaskLeased), string(domain.TaskRunning), string(domain.TaskCompleted), string(domain.TaskFailed), string(domain.TaskCancelled)} + jobStatuses = []string{string(domain.JobPending), string(domain.JobRunning), string(domain.JobReducing), string(domain.JobCompleted), string(domain.JobFailed), string(domain.JobCancelled)} + workerStatuses = []string{string(domain.WorkerOnline), string(domain.WorkerBusy), string(domain.WorkerOffline)} +) + +// StatsRepo answers the aggregate status counts the business metrics report. It +// runs one cheap GROUP BY per entity; the collector calls this on every scrape. +type StatsRepo struct { + pool *pgxpool.Pool +} + +func NewStatsRepo(pool *pgxpool.Pool) *StatsRepo { + return &StatsRepo{pool: pool} +} + +// Counts returns status->count maps for tasks, jobs, and workers, each +// zero-filled across its known statuses. +func (r *StatsRepo) Counts(ctx context.Context) (tasks, jobs, workers map[string]int, err error) { + if tasks, err = r.countByStatus(ctx, "tasks", taskStatuses); err != nil { + return nil, nil, nil, err + } + if jobs, err = r.countByStatus(ctx, "jobs", jobStatuses); err != nil { + return nil, nil, nil, err + } + if workers, err = r.countByStatus(ctx, "workers", workerStatuses); err != nil { + return nil, nil, nil, err + } + return tasks, jobs, workers, nil +} + +func (r *StatsRepo) countByStatus(ctx context.Context, table string, known []string) (map[string]int, error) { + out := make(map[string]int, len(known)) + for _, s := range known { + out[s] = 0 // zero-fill + } + // table is a fixed internal constant, never user input — safe to format. + rows, err := r.pool.Query(ctx, fmt.Sprintf("SELECT status, count(*) FROM %s GROUP BY status", table)) + if err != nil { + return nil, fmt.Errorf("count %s by status: %w", table, err) + } + defer rows.Close() + for rows.Next() { + var status string + var n int + if err := rows.Scan(&status, &n); err != nil { + return nil, err + } + out[status] = n // an unknown status still shows up, which is a useful signal + } + return out, rows.Err() +} diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index 79d080a..6439da1 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -59,7 +59,10 @@ type Server struct { } func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration, - maxUploadBytes int64, jwtSecret, userserviceURL string, ready func(context.Context) error) *Server { + maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error) *Server { + if m == nil { + m = metrics.New() + } return &Server{ uc: uc, log: log, @@ -69,7 +72,7 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval verifier: tokenpkg.NewVerifier(jwtSecret), userserviceURL: strings.TrimRight(userserviceURL, "/"), httpClient: &http.Client{Timeout: 10 * time.Second}, - metrics: metrics.New(), + metrics: m, ready: ready, } } diff --git a/coordinator/internal/transport/http/server_test.go b/coordinator/internal/transport/http/server_test.go index 220083c..96dc777 100644 --- a/coordinator/internal/transport/http/server_test.go +++ b/coordinator/internal/transport/http/server_test.go @@ -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, "", "", nil, ready) ts := httptest.NewServer(srv.Handler(token, configuredUIToken)) t.Cleanup(ts.Close) return &env{ts: ts, blobs: blobs, workerID: worker.ID.String()} diff --git a/coordinator/monitoring/grafana/dashboards/coordinator.json b/coordinator/monitoring/grafana/dashboards/coordinator.json index 65cd5ff..e52c98e 100644 --- a/coordinator/monitoring/grafana/dashboards/coordinator.json +++ b/coordinator/monitoring/grafana/dashboards/coordinator.json @@ -83,6 +83,72 @@ "legendFormat": "rss" } ] + }, + { + "type": "row", + "title": "Domain state", + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 16 } + }, + { + "type": "timeseries", + "title": "Tasks by status", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 }, + "fieldConfig": { "defaults": { "unit": "short", "custom": { "stacking": { "mode": "normal" }, "fillOpacity": 30 } }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "scimesh_tasks", + "legendFormat": "{{status}}" + } + ] + }, + { + "type": "timeseries", + "title": "Jobs by status", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 }, + "fieldConfig": { "defaults": { "unit": "short", "custom": { "stacking": { "mode": "normal" }, "fillOpacity": 30 } }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "scimesh_jobs", + "legendFormat": "{{status}}" + } + ] + }, + { + "type": "stat", + "title": "Queue depth (pending tasks)", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 6, "x": 0, "y": 25 }, + "fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "thresholds" }, "thresholds": { "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 50 }, { "color": "red", "value": 500 } ] } }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum(scimesh_tasks{status=\"pending\"})", + "legendFormat": "pending" + } + ] + }, + { + "type": "timeseries", + "title": "Workers by status", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 18, "x": 6, "y": 25 }, + "fieldConfig": { "defaults": { "unit": "short", "custom": { "stacking": { "mode": "normal" }, "fillOpacity": 30 } }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "scimesh_workers", + "legendFormat": "{{status}}" + } + ] } ] } From 18d58cce847b4bb5724704d7b60d7154e2123aed Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 22:55:51 +0300 Subject: [PATCH 21/24] feat(coordinator): quorum verification for untrusted (volunteer) results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the C1 quarantine with real verification. Trusted results (lab token, verified, or admin worker) are accepted directly as before. An untrusted worker's result is recorded as one vote per (task, owner) in a new task_results table; the task only completes once QUORUM_SIZE distinct owners submit the same result hash, otherwise it returns to the queue for another independent compute. - migration 0013 task_results (one vote per owner, quorum by result_sha256) - CompleteTask branches on worker trust; unknown worker defaults trusted (safe: completing needs the lease, whose owner is always a known registered worker) - claim drops the quarantine and excludes chunks the owner already voted on - domain Task.ReleaseAfterVote; QUORUM_SIZE config (default 2) - unit tests: trusted direct-complete, untrusted needs-quorum, can-claim Reducer and job done/total logic untouched — still one completed task per chunk. --- coordinator/cmd/coordinator/main.go | 17 +-- coordinator/internal/domain/task.go | 31 +++++ coordinator/internal/infra/config.go | 10 ++ coordinator/internal/memstore/memstore.go | 33 +++++ .../internal/storage/postgres/task_repo.go | 5 +- .../storage/postgres/task_result_repo.go | 45 ++++++ .../internal/transport/http/server_test.go | 2 +- coordinator/internal/usecase/ports.go | 9 ++ coordinator/internal/usecase/task.go | 129 ++++++++++++++---- coordinator/internal/usecase/usecase_test.go | 107 +++++++++++---- .../migrations/0013_task_results.down.sql | 5 + .../migrations/0013_task_results.up.sql | 23 ++++ 12 files changed, 356 insertions(+), 60 deletions(-) create mode 100644 coordinator/internal/storage/postgres/task_result_repo.go create mode 100644 coordinator/migrations/0013_task_results.down.sql create mode 100644 coordinator/migrations/0013_task_results.up.sql diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go index f9f26f6..dd6e33d 100644 --- a/coordinator/cmd/coordinator/main.go +++ b/coordinator/cmd/coordinator/main.go @@ -60,13 +60,14 @@ func run() error { } var ( - clk = infra.NewClock() - tx = postgres.NewTxManager(pool) - taskRepo = postgres.NewTaskRepo(pool) - jobRepo = postgres.NewJobRepo(pool) - workerRepo = postgres.NewWorkerRepo(pool) - artifactRepo = postgres.NewArtifactRepo(pool) - uiReadRepo = postgres.NewUIReadRepo(pool) + clk = infra.NewClock() + tx = postgres.NewTxManager(pool) + taskRepo = postgres.NewTaskRepo(pool) + jobRepo = postgres.NewJobRepo(pool) + workerRepo = postgres.NewWorkerRepo(pool) + artifactRepo = postgres.NewArtifactRepo(pool) + uiReadRepo = postgres.NewUIReadRepo(pool) + taskResultRepo = postgres.NewTaskResultRepo(pool) ) useCases := httptransport.UseCases{ @@ -75,7 +76,7 @@ func run() error { SubmitDataset: usecase.NewSubmitDataset(blobStore, artifactRepo, jobRepo, taskRepo, tx, clk, cfg.DefaultMaxAttempts), ClaimTask: usecase.NewClaimTask(taskRepo, jobRepo, workerRepo, tx, clk, cfg.LeaseDuration), RenewLease: usecase.NewRenewLease(taskRepo, workerRepo, tx, clk, cfg.LeaseDuration), - CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, tx, clk), + CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, workerRepo, taskResultRepo, tx, clk, cfg.QuorumSize), ReduceJob: usecase.NewReduceJob(jobRepo, taskRepo, artifactRepo, blobStore, tx, clk), FailTask: usecase.NewFailTask(taskRepo, jobRepo, tx, clk), GetJobStatus: usecase.NewGetJobStatus(jobRepo, taskRepo), diff --git a/coordinator/internal/domain/task.go b/coordinator/internal/domain/task.go index 7864660..1148f18 100644 --- a/coordinator/internal/domain/task.go +++ b/coordinator/internal/domain/task.go @@ -24,6 +24,10 @@ const ( // ErrCodeLeaseExpired marks tasks failed by the reaper rather than by a worker. const ErrCodeLeaseExpired = "lease_expired" +// ErrCodeQuorumFailed marks a task whose untrusted results never reached a +// verifying quorum before its attempts ran out. +const ErrCodeQuorumFailed = "quorum_failed" + // Task is one independently executable chunk of a job. // // Nullable columns are pointers so "no lease" stays distinguishable from @@ -216,6 +220,33 @@ func (t *Task) CompleteWith(resultArtifactID uuid.UUID, metrics map[string]any, return nil } +// ReleaseAfterVote returns an untrusted worker's task to the queue after its +// result was recorded as a quorum vote but quorum was not yet reached, so a +// different owner can compute it independently. When no attempts remain the task +// fails: its untrusted results could not be verified. +func (t *Task) ReleaseAfterVote(worker string, attempt int, now time.Time) error { + if t.Status == TaskCompleted { + return nil // settled by a concurrent quorum + } + if err := t.verifyLease(worker, attempt, now); err != nil { + return err + } + t.LeaseOwner = nil + t.LeaseExpiresAt = nil + t.Version++ + + if t.CanRetry() { + t.Status = TaskPending + return nil + } + code, msg := ErrCodeQuorumFailed, "untrusted results did not reach quorum" + t.ErrorCode = &code + t.ErrorMessage = &msg + t.Status = TaskFailed + t.CompletedAt = &now + return nil +} + // Fail records a worker-reported failure. A retryable failure with attempts // left returns the task to the queue; otherwise it terminates as failed. func (t *Task) Fail(worker string, attempt int, code, message string, retryable bool, now time.Time) error { diff --git a/coordinator/internal/infra/config.go b/coordinator/internal/infra/config.go index 3d62e02..5a414bd 100644 --- a/coordinator/internal/infra/config.go +++ b/coordinator/internal/infra/config.go @@ -61,6 +61,9 @@ type Config struct { LeaseDuration time.Duration // Default attempt ceiling for newly created tasks. DefaultMaxAttempts int + // How many distinct owners must agree on an untrusted result before it is + // accepted (trusted workers are accepted directly). + QuorumSize int // How often the background lease-reaper runs. ReaperInterval time.Duration // A worker silent for longer than this is marked offline by the reaper. @@ -103,6 +106,7 @@ func LoadConfig() (Config, error) { HeartbeatInterval: 15 * time.Second, LeaseDuration: 2 * time.Minute, DefaultMaxAttempts: 3, + QuorumSize: 2, ReaperInterval: 30 * time.Second, WorkerOfflineAfter: 1 * time.Minute, } @@ -147,6 +151,12 @@ func LoadConfig() (Config, error) { if cfg.DefaultMaxAttempts, err = getEnvInt("DEFAULT_MAX_ATTEMPTS", cfg.DefaultMaxAttempts); err != nil { return Config{}, err } + if cfg.QuorumSize, err = getEnvInt("QUORUM_SIZE", cfg.QuorumSize); err != nil { + return Config{}, err + } + if cfg.QuorumSize < 1 { + return Config{}, fmt.Errorf("QUORUM_SIZE must be positive") + } if cfg.DefaultMaxAttempts < 1 { return Config{}, fmt.Errorf("DEFAULT_MAX_ATTEMPTS must be positive") } diff --git a/coordinator/internal/memstore/memstore.go b/coordinator/internal/memstore/memstore.go index 28f4318..9c911e4 100644 --- a/coordinator/internal/memstore/memstore.go +++ b/coordinator/internal/memstore/memstore.go @@ -417,3 +417,36 @@ func contains(ss []string, s string) bool { } return false } + +// TaskResultRepo is an in-memory usecase.TaskResultRepository: one vote per +// (task, owner). +type TaskResultRepo struct { + mu sync.Mutex + votes map[uuid.UUID]map[uuid.UUID]string // taskID -> ownerID -> sha256 +} + +func NewTaskResultRepo() *TaskResultRepo { + return &TaskResultRepo{votes: make(map[uuid.UUID]map[uuid.UUID]string)} +} + +func (r *TaskResultRepo) RecordVote(_ context.Context, taskID, ownerID uuid.UUID, sha256 string, _ uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.votes[taskID] == nil { + r.votes[taskID] = make(map[uuid.UUID]string) + } + r.votes[taskID][ownerID] = sha256 + return nil +} + +func (r *TaskResultRepo) CountAgreeing(_ context.Context, taskID uuid.UUID, sha256 string) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + for _, s := range r.votes[taskID] { + if s == sha256 { + n++ + } + } + return n, nil +} diff --git a/coordinator/internal/storage/postgres/task_repo.go b/coordinator/internal/storage/postgres/task_repo.go index 11b0616..a373087 100644 --- a/coordinator/internal/storage/postgres/task_repo.go +++ b/coordinator/internal/storage/postgres/task_repo.go @@ -84,6 +84,9 @@ WITH candidate AS ( WHERE status = 'pending' AND attempt < max_attempts AND (cardinality($1::text[]) = 0 OR workload = ANY($1)) + AND ($5::uuid IS NULL OR NOT EXISTS ( + SELECT 1 FROM task_results tr + WHERE tr.task_id = tasks.id AND tr.owner_id = $5)) ORDER BY created_at, chunk_index FOR UPDATE SKIP LOCKED LIMIT 1 @@ -108,7 +111,7 @@ func (r *TaskRepo) ClaimNext(ctx context.Context, f usecase.ClaimFilter) (*domai var task *domain.Task err := withRetry(ctx, func(ctx context.Context) error { - row := conn(ctx, r.pool).QueryRow(ctx, claimNextSQL, workloads, f.Owner, f.LeaseUntil, f.Now) + row := conn(ctx, r.pool).QueryRow(ctx, claimNextSQL, workloads, f.Owner, f.LeaseUntil, f.Now, f.VoterOwner) t, err := scanTask(row) if errors.Is(err, pgx.ErrNoRows) { task = nil diff --git a/coordinator/internal/storage/postgres/task_result_repo.go b/coordinator/internal/storage/postgres/task_result_repo.go new file mode 100644 index 0000000..aa5907a --- /dev/null +++ b/coordinator/internal/storage/postgres/task_result_repo.go @@ -0,0 +1,45 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TaskResultRepo records and tallies quorum votes for untrusted task results. +type TaskResultRepo struct { + pool *pgxpool.Pool +} + +func NewTaskResultRepo(pool *pgxpool.Pool) *TaskResultRepo { + return &TaskResultRepo{pool: pool} +} + +// RecordVote stores (or replaces) one owner's vote for a task's result. +func (r *TaskResultRepo) RecordVote(ctx context.Context, taskID, ownerID uuid.UUID, sha256 string, artifactID uuid.UUID) error { + const sql = ` +INSERT INTO task_results (task_id, owner_id, result_sha256, result_artifact_id) +VALUES ($1, $2, $3, $4) +ON CONFLICT (task_id, owner_id) DO UPDATE +SET result_sha256 = EXCLUDED.result_sha256, + result_artifact_id = EXCLUDED.result_artifact_id, + created_at = now()` + if _, err := conn(ctx, r.pool).Exec(ctx, sql, taskID, ownerID, sha256, artifactID); err != nil { + return fmt.Errorf("record vote: %w", err) + } + return nil +} + +// CountAgreeing returns how many distinct owners have voted for the given result +// hash on this task — the size of the agreeing set the quorum is measured +// against. +func (r *TaskResultRepo) CountAgreeing(ctx context.Context, taskID uuid.UUID, sha256 string) (int, error) { + const sql = `SELECT count(DISTINCT owner_id) FROM task_results WHERE task_id = $1 AND result_sha256 = $2` + var n int + if err := conn(ctx, r.pool).QueryRow(ctx, sql, taskID, sha256).Scan(&n); err != nil { + return 0, fmt.Errorf("count agreeing: %w", err) + } + return n, nil +} diff --git a/coordinator/internal/transport/http/server_test.go b/coordinator/internal/transport/http/server_test.go index 96dc777..56cb049 100644 --- a/coordinator/internal/transport/http/server_test.go +++ b/coordinator/internal/transport/http/server_test.go @@ -50,7 +50,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur SubmitDataset: usecase.NewSubmitDataset(blobs, arts, jobs, tasks, tx, clk, 3), ClaimTask: usecase.NewClaimTask(tasks, jobs, work, tx, clk, lease), RenewLease: usecase.NewRenewLease(tasks, work, tx, clk, lease), - CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, tx, clk), + CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, work, memstore.NewTaskResultRepo(), tx, clk, 2), ReduceJob: usecase.NewReduceJob(jobs, tasks, arts, blobs, tx, clk), FailTask: usecase.NewFailTask(tasks, jobs, tx, clk), GetJobStatus: usecase.NewGetJobStatus(jobs, tasks), diff --git a/coordinator/internal/usecase/ports.go b/coordinator/internal/usecase/ports.go index 933453c..d903816 100644 --- a/coordinator/internal/usecase/ports.go +++ b/coordinator/internal/usecase/ports.go @@ -23,6 +23,15 @@ type ClaimFilter struct { Owner string // worker ID taking the lease Now time.Time LeaseUntil time.Time + // VoterOwner, when set, excludes tasks this owner has already voted on, so + // an untrusted worker never verifies its own chunk twice. + VoterOwner *uuid.UUID +} + +// TaskResultRepository records and tallies quorum votes for untrusted results. +type TaskResultRepository interface { + RecordVote(ctx context.Context, taskID, ownerID uuid.UUID, sha256 string, artifactID uuid.UUID) error + CountAgreeing(ctx context.Context, taskID uuid.UUID, sha256 string) (int, error) } // TaskRepository persists tasks. diff --git a/coordinator/internal/usecase/task.go b/coordinator/internal/usecase/task.go index 1d96591..0fec738 100644 --- a/coordinator/internal/usecase/task.go +++ b/coordinator/internal/usecase/task.go @@ -2,6 +2,7 @@ package usecase import ( "context" + "errors" "time" "github.com/google/uuid" @@ -47,6 +48,7 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl return nil, domain.ErrInvalidInput } workloads := in.Workloads + var voterOwner *uuid.UUID if workerID, err := uuid.Parse(in.WorkerID); err == nil { worker, err := uc.workers.Get(ctx, workerID) if err != nil { @@ -55,21 +57,18 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl // 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. + // knows a trusted worker's id claim as it. A shared-token caller (no + // requester) is a lab operator and may act as any worker. 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 - // simply idles. + // An untrusted volunteer may claim, but never a chunk its owner has + // already voted on — so quorum needs genuinely independent computations. if worker.TrustLevel == domain.WorkerUntrusted { - return nil, nil + voterOwner = worker.OwnerID } // Never trust caller-supplied capabilities: registration is the durable // worker identity and its allowlist. @@ -91,6 +90,7 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl Owner: in.WorkerID, Now: now, LeaseUntil: now.Add(uc.leaseDuration), + VoterOwner: voterOwner, }) if err != nil { return err @@ -161,13 +161,22 @@ type CompleteTask struct { tasks TaskRepository jobs JobRepository artifacts ArtifactRepository + workers WorkerRepository + results TaskResultRepository tx TxManager clock Clock + // quorum is how many distinct owners must agree on an untrusted result + // before it is accepted; a trusted worker's result is accepted directly. + quorum int } func NewCompleteTask(tasks TaskRepository, jobs JobRepository, artifacts ArtifactRepository, - tx TxManager, clock Clock) *CompleteTask { - return &CompleteTask{tasks: tasks, jobs: jobs, artifacts: artifacts, tx: tx, clock: clock} + workers WorkerRepository, results TaskResultRepository, tx TxManager, clock Clock, quorum int) *CompleteTask { + if quorum < 1 { + quorum = 2 + } + return &CompleteTask{tasks: tasks, jobs: jobs, artifacts: artifacts, workers: workers, + results: results, tx: tx, clock: clock, quorum: quorum} } // Execute applies the result and, when that was the job's last outstanding @@ -186,24 +195,35 @@ func (uc *CompleteTask) Execute(ctx context.Context, in CompleteTaskInput) (*dom } // Rule 10: never trust a worker-supplied artifact reference. The result // must be an artifact the coordinator itself stored for *this* task. - if err := uc.verifyResultArtifact(ctx, in.TaskID, in.Attempt, in.ResultArtifactID); err != nil { + art, err := uc.verifyResultArtifact(ctx, in.TaskID, in.Attempt, in.ResultArtifactID) + if err != nil { + return err + } + + trusted, ownerID, err := uc.workerTrust(ctx, in.WorkerID) + if err != nil { return err } now := uc.clock.Now() + + // Untrusted (volunteer) worker: record a vote and only complete once a + // quorum of distinct owners agree; otherwise return the task to the queue. + if !trusted { + return uc.recordVote(ctx, task, in, art, ownerID, now, &out) + } + + // Trusted worker (lab token, verified, or admin): accept directly. before := task.Version - if err := task.CompleteWith(in.ResultArtifactID, in.Metrics, - in.WorkerID, in.Attempt, now); err != nil { + if err := task.CompleteWith(in.ResultArtifactID, in.Metrics, in.WorkerID, in.Attempt, now); err != nil { return err } out = task - // A replay of an already-recorded result leaves the entity untouched. // Writing anyway would fail the optimistic-concurrency guard (the stored // version already equals ours) and turn an idempotent call into a 409. if task.Version == before { return nil } - if err := uc.tasks.Update(ctx, task); err != nil { return err } @@ -215,18 +235,81 @@ func (uc *CompleteTask) Execute(ctx context.Context, in CompleteTaskInput) (*dom return out, nil } -// verifyResultArtifact enforces that the referenced artifact was stored by the -// coordinator for this exact task. It stops a worker from completing task B with -// an artifact it uploaded for task A, and from naming an id that isn't a result. -func (uc *CompleteTask) verifyResultArtifact(ctx context.Context, taskID uuid.UUID, attempt int, artifactID uuid.UUID) error { - art, err := uc.artifacts.Get(ctx, artifactID) +// recordVote handles an untrusted result: it stores the vote, then completes the +// task when the submitter's result hash has reached quorum, or returns the task +// to the queue so another owner can compute it independently. +func (uc *CompleteTask) recordVote(ctx context.Context, task *domain.Task, in CompleteTaskInput, + art *domain.Artifact, ownerID uuid.UUID, now time.Time, out **domain.Task) error { + + *out = task + if task.Status == domain.TaskCompleted { + return nil // already settled by an earlier quorum; nothing to record + } + if err := uc.results.RecordVote(ctx, task.ID, ownerID, art.SHA256, in.ResultArtifactID); err != nil { + return err + } + agree, err := uc.results.CountAgreeing(ctx, task.ID, art.SHA256) if err != nil { return err } - if art.TaskID == nil || *art.TaskID != taskID || art.Attempt == nil || *art.Attempt != attempt || art.Kind != domain.ArtifactPartialResult { - return domain.ErrResultConflict + + if agree >= uc.quorum { + // The submitter's own (already verified) artifact carries the winning + // hash, so complete with it. + if err := task.CompleteWith(in.ResultArtifactID, in.Metrics, in.WorkerID, in.Attempt, now); err != nil { + return err + } + } else if err := task.ReleaseAfterVote(in.WorkerID, in.Attempt, now); err != nil { + return err } - return nil + + if err := uc.tasks.Update(ctx, task); err != nil { + return err + } + return syncJobStatus(ctx, uc.jobs, uc.tasks, task.JobID, now) +} + +// workerTrust reports whether the worker's results are accepted directly, and +// the owner to attribute a vote to when they are not. +func (uc *CompleteTask) workerTrust(ctx context.Context, workerID string) (trusted bool, ownerID uuid.UUID, err error) { + // When the worker can't be resolved, default to trusted — the pre-quorum + // behaviour. This is safe because completing a task requires holding its + // lease, and the lease owner is always a real registered worker whose trust + // is therefore known; only an untrusted worker ever takes the quorum path. + id, err := uuid.Parse(workerID) + if err != nil { + return true, uuid.Nil, nil + } + w, err := uc.workers.Get(ctx, id) + if err != nil { + if errors.Is(err, domain.ErrWorkerNotFound) { + return true, uuid.Nil, nil + } + return false, uuid.Nil, err + } + if w.TrustLevel != domain.WorkerUntrusted { + return true, uuid.Nil, nil + } + if w.OwnerID == nil { + // An untrusted worker always has an owner (it registered via a user JWT); + // a missing one is a data error, not a silent trust upgrade. + return false, uuid.Nil, domain.ErrInvalidInput + } + return false, *w.OwnerID, nil +} + +// verifyResultArtifact enforces that the referenced artifact was stored by the +// coordinator for this exact task. It stops a worker from completing task B with +// an artifact it uploaded for task A, and from naming an id that isn't a result. +func (uc *CompleteTask) verifyResultArtifact(ctx context.Context, taskID uuid.UUID, attempt int, artifactID uuid.UUID) (*domain.Artifact, error) { + art, err := uc.artifacts.Get(ctx, artifactID) + if err != nil { + return nil, err + } + if art.TaskID == nil || *art.TaskID != taskID || art.Attempt == nil || *art.Attempt != attempt || art.Kind != domain.ArtifactPartialResult { + return nil, domain.ErrResultConflict + } + return art, nil } // --- FailTask ------------------------------------------------------------ diff --git a/coordinator/internal/usecase/usecase_test.go b/coordinator/internal/usecase/usecase_test.go index c0a4566..246c452 100644 --- a/coordinator/internal/usecase/usecase_test.go +++ b/coordinator/internal/usecase/usecase_test.go @@ -35,12 +35,13 @@ func (s expiringBlobStore) Put(ctx context.Context, key string, body io.Reader) // harness wires every use case to in-memory stores so orchestration can be // tested without a database. type harness struct { - tasks *memstore.TaskRepo - jobs *memstore.JobRepo - work *memstore.WorkerRepo - arts *memstore.ArtifactRepo - blobs *memstore.BlobStore - clk *memstore.Clock + tasks *memstore.TaskRepo + jobs *memstore.JobRepo + work *memstore.WorkerRepo + arts *memstore.ArtifactRepo + blobs *memstore.BlobStore + clk *memstore.Clock + taskResults *memstore.TaskResultRepo createJob *usecase.CreateJob submit *usecase.SubmitDataset @@ -62,19 +63,20 @@ type harness struct { func newHarness() *harness { h := &harness{ - tasks: memstore.NewTaskRepo(), - jobs: memstore.NewJobRepo(), - work: memstore.NewWorkerRepo(), - arts: memstore.NewArtifactRepo(), - blobs: memstore.NewBlobStore(), - clk: memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)), + tasks: memstore.NewTaskRepo(), + jobs: memstore.NewJobRepo(), + work: memstore.NewWorkerRepo(), + arts: memstore.NewArtifactRepo(), + blobs: memstore.NewBlobStore(), + clk: memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)), + taskResults: memstore.NewTaskResultRepo(), } tx := memstore.Tx{} h.createJob = usecase.NewCreateJob(h.jobs, h.tasks, tx, h.clk) h.submit = usecase.NewSubmitDataset(h.blobs, h.arts, h.jobs, h.tasks, tx, h.clk, 3) h.claim = usecase.NewClaimTask(h.tasks, h.jobs, h.work, tx, h.clk, lease) h.renew = usecase.NewRenewLease(h.tasks, h.work, tx, h.clk, lease) - h.complete = usecase.NewCompleteTask(h.tasks, h.jobs, h.arts, tx, h.clk) + h.complete = usecase.NewCompleteTask(h.tasks, h.jobs, h.arts, h.work, h.taskResults, tx, h.clk, 2) h.fail = usecase.NewFailTask(h.tasks, h.jobs, tx, h.clk) h.status = usecase.NewGetJobStatus(h.jobs, h.tasks) h.results = usecase.NewListResults(h.tasks) @@ -333,9 +335,9 @@ func TestJWTCallerClaimsAsOwnTrustedWorker(t *testing.T) { } } -func TestUntrustedWorkerIsQuarantinedFromClaims(t *testing.T) { +func TestUntrustedWorkerCanClaim(t *testing.T) { h := newHarness() - h.seedJob(t, "w", 1) // a task is waiting + h.seedJob(t, "w", 1) owner := uuid.New() worker, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{ Name: "volunteer", Capabilities: []string{"w"}, @@ -344,22 +346,73 @@ func TestUntrustedWorkerIsQuarantinedFromClaims(t *testing.T) { if err != nil { t.Fatal(err) } - - // Even with a matching task available, an untrusted worker gets nothing: - // its results cannot be verified until quorum (C2) exists. + // Volunteers are no longer quarantined — they may claim; their results are + // gated by quorum at completion, not by withholding work. claimed, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{WorkerID: worker.ID.String()}) - if err != nil { - t.Fatalf("claim: %v", err) + if err != nil || claimed == nil { + t.Fatalf("untrusted claim = (%v, %v), want a task", claimed, err) } - if claimed != nil { - t.Error("untrusted worker must receive no task (quarantine)") +} + +// registerUntrusted registers a volunteer worker under a fresh owner. +func (h *harness) registerUntrusted(t *testing.T, name, workload string) (*domain.Worker, uuid.UUID) { + t.Helper() + owner := uuid.New() + w, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{ + Name: name, Capabilities: []string{workload}, + OwnerID: &owner, TrustLevel: domain.WorkerUntrusted, + }) + if err != nil { + t.Fatal(err) + } + return w, owner +} + +func TestUntrustedResultNeedsQuorum(t *testing.T) { + h := newHarness() + jobID := h.seedJob(t, "w", 1) + if err := h.jobs.UpdateStatus(ctx, jobID, domain.JobRunning, nil); err != nil { + t.Fatal(err) + } + w1, _ := h.registerUntrusted(t, "v1", "w") + w2, _ := h.registerUntrusted(t, "v2", "w") + + // First volunteer computes and submits — one vote, not yet quorum (2). + taskID, attempt := h.leaseOne(t, w1.ID.String(), "w") + art1 := h.uploadResult(t, taskID, w1.ID.String(), attempt) + if _, err := h.complete.Execute(ctx, usecase.CompleteTaskInput{TaskID: taskID, WorkerID: w1.ID.String(), Attempt: attempt, ResultArtifactID: art1}); err != nil { + t.Fatalf("first vote: %v", err) + } + if tk, _ := h.tasks.Get(ctx, taskID); tk.Status != domain.TaskPending { + t.Fatalf("after one vote status = %s, want pending", tk.Status) } - // A trusted worker still drains the same queue. - trusted, _ := h.register.Execute(ctx, usecase.RegisterWorkerInput{Name: "lab", Capabilities: []string{"w"}}) - got, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{WorkerID: trusted.ID.String()}) - if err != nil || got == nil { - t.Fatalf("trusted claim = (%v, %v), want a task", got, err) + // Second volunteer (distinct owner) computes the same bytes -> quorum -> done. + taskID2, attempt2 := h.leaseOne(t, w2.ID.String(), "w") + art2 := h.uploadResult(t, taskID2, w2.ID.String(), attempt2) + if _, err := h.complete.Execute(ctx, usecase.CompleteTaskInput{TaskID: taskID2, WorkerID: w2.ID.String(), Attempt: attempt2, ResultArtifactID: art2}); err != nil { + t.Fatalf("second vote: %v", err) + } + if tk, _ := h.tasks.Get(ctx, taskID); tk.Status != domain.TaskCompleted { + t.Fatalf("after quorum status = %s, want completed", tk.Status) + } +} + +func TestTrustedResultCompletesDirectly(t *testing.T) { + h := newHarness() + jobID := h.seedJob(t, "w", 1) + if err := h.jobs.UpdateStatus(ctx, jobID, domain.JobRunning, nil); err != nil { + t.Fatal(err) + } + // A trusted (default) worker's single result completes the task immediately. + worker, _ := h.register.Execute(ctx, usecase.RegisterWorkerInput{Name: "lab", Capabilities: []string{"w"}}) + taskID, attempt := h.leaseOne(t, worker.ID.String(), "w") + art := h.uploadResult(t, taskID, worker.ID.String(), attempt) + if _, err := h.complete.Execute(ctx, usecase.CompleteTaskInput{TaskID: taskID, WorkerID: worker.ID.String(), Attempt: attempt, ResultArtifactID: art}); err != nil { + t.Fatal(err) + } + if tk, _ := h.tasks.Get(ctx, taskID); tk.Status != domain.TaskCompleted { + t.Fatalf("trusted result status = %s, want completed", tk.Status) } } diff --git a/coordinator/migrations/0013_task_results.down.sql b/coordinator/migrations/0013_task_results.down.sql new file mode 100644 index 0000000..dc4fc2c --- /dev/null +++ b/coordinator/migrations/0013_task_results.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +DROP TABLE IF EXISTS task_results; + +COMMIT; diff --git a/coordinator/migrations/0013_task_results.up.sql b/coordinator/migrations/0013_task_results.up.sql new file mode 100644 index 0000000..c71f33f --- /dev/null +++ b/coordinator/migrations/0013_task_results.up.sql @@ -0,0 +1,23 @@ +BEGIN; + +-- Quorum votes for a task computed by untrusted (volunteer) workers. A trusted +-- worker's result completes the task directly and never lands here; an untrusted +-- result is recorded as one vote, and the task is only completed once enough +-- distinct owners submit the same result_sha256. +-- +-- One vote per (task, owner): a single volunteer cannot stuff the ballot by +-- running many workers under one account. A resubmission updates their vote. +CREATE TABLE task_results ( + task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + owner_id uuid NOT NULL, + result_sha256 text NOT NULL, + result_artifact_id uuid NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + + PRIMARY KEY (task_id, owner_id) +); + +-- Quorum check groups a task's votes by result_sha256. +CREATE INDEX ix_task_results_quorum ON task_results (task_id, result_sha256); + +COMMIT; From 6f14eeb32e6c5f40d184c3972a562decf31b696e Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Mon, 27 Jul 2026 11:05:36 +0300 Subject: [PATCH 22/24] fix(coordinator): silence nilerr on unresolvable-worker trust fallback --- coordinator/internal/usecase/task.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/coordinator/internal/usecase/task.go b/coordinator/internal/usecase/task.go index 0fec738..ca4ca4b 100644 --- a/coordinator/internal/usecase/task.go +++ b/coordinator/internal/usecase/task.go @@ -278,7 +278,9 @@ func (uc *CompleteTask) workerTrust(ctx context.Context, workerID string) (trust // is therefore known; only an untrusted worker ever takes the quorum path. id, err := uuid.Parse(workerID) if err != nil { - return true, uuid.Nil, nil + // An unparseable worker id means the worker can't be resolved; fall back + // to the trusted default rather than surfacing the parse error. + return true, uuid.Nil, nil //nolint:nilerr // unresolvable worker → trusted (pre-quorum default) } w, err := uc.workers.Get(ctx, id) if err != nil { From 172ff76fb892fadd2a98b21d0347e2d67a051670 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Mon, 27 Jul 2026 11:29:52 +0300 Subject: [PATCH 23/24] fix(test): update NewCompleteTask call to new signature Pass workers/results repos and quorum in the postgres integration test, matching the constructor change that added quorum voting. --- coordinator/internal/storage/postgres/integration_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coordinator/internal/storage/postgres/integration_test.go b/coordinator/internal/storage/postgres/integration_test.go index 4a97a7d..ba974c3 100644 --- a/coordinator/internal/storage/postgres/integration_test.go +++ b/coordinator/internal/storage/postgres/integration_test.go @@ -406,8 +406,9 @@ func TestCompleteTaskReplayIsIdempotent(t *testing.T) { job, _ := seedJob(t, pool, 1) tasks, jobs, artifacts, tx := NewTaskRepo(pool), NewJobRepo(pool), NewArtifactRepo(pool), NewTxManager(pool) + workers, results := NewWorkerRepo(pool), NewTaskResultRepo(pool) clk := fixedClock{now: time.Now().UTC()} - uc := usecase.NewCompleteTask(tasks, jobs, artifacts, tx, clk) + uc := usecase.NewCompleteTask(tasks, jobs, artifacts, workers, results, tx, clk, 2) claimed, err := tasks.ClaimNext(ctx, usecase.ClaimFilter{ Owner: "worker-1", Now: clk.now, LeaseUntil: clk.now.Add(time.Minute), From fa76133efc96daa9740a1030d273d338d22b3e72 Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 27 Jul 2026 22:23:08 +0300 Subject: [PATCH 24/24] Secure user worker operations --- PLAN.md | 4 +- coordinator/cmd/coordinator/main.go | 4 +- .../internal/transport/http/server_test.go | 4 +- coordinator/internal/usecase/artifact.go | 8 +++- coordinator/internal/usecase/task.go | 30 +++++++----- coordinator/internal/usecase/usecase_test.go | 36 ++++++++++++-- .../internal/usecase/worker_authorization.go | 33 +++++++++++++ docs/user-service-api-contract.md | 47 +++++++++++++++++++ users/README.md | 3 ++ 9 files changed, 147 insertions(+), 22 deletions(-) create mode 100644 coordinator/internal/usecase/worker_authorization.go create mode 100644 docs/user-service-api-contract.md diff --git a/PLAN.md b/PLAN.md index 91f5922..2e43ebc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -872,7 +872,9 @@ workload logic into the service. **Acceptance criteria:** -- the service has a versioned, documented API and owns user identity data; +- the service has a versioned, documented API in + [`docs/user-service-api-contract.md`](docs/user-service-api-contract.md) and + owns user identity data; - credentials and authentication tokens are stored and handled securely; they are never logged or exposed to workers; - authenticated identity is propagated to coordinator requests through an diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go index dd6e33d..61de190 100644 --- a/coordinator/cmd/coordinator/main.go +++ b/coordinator/cmd/coordinator/main.go @@ -78,10 +78,10 @@ func run() error { RenewLease: usecase.NewRenewLease(taskRepo, workerRepo, tx, clk, cfg.LeaseDuration), CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, workerRepo, taskResultRepo, tx, clk, cfg.QuorumSize), ReduceJob: usecase.NewReduceJob(jobRepo, taskRepo, artifactRepo, blobStore, tx, clk), - FailTask: usecase.NewFailTask(taskRepo, jobRepo, tx, clk), + FailTask: usecase.NewFailTask(taskRepo, jobRepo, workerRepo, tx, clk), GetJobStatus: usecase.NewGetJobStatus(jobRepo, taskRepo), CancelJob: usecase.NewCancelJob(jobRepo, taskRepo, tx, clk), - UploadArtifact: usecase.NewUploadArtifact(taskRepo, artifactRepo, blobStore, tx, clk), + UploadArtifact: usecase.NewUploadArtifact(taskRepo, workerRepo, artifactRepo, blobStore, tx, clk), DownloadArtifact: usecase.NewDownloadArtifact(artifactRepo, blobStore), GetJobResult: usecase.NewGetJobResult(jobRepo, usecase.NewDownloadArtifact(artifactRepo, blobStore)), GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore), diff --git a/coordinator/internal/transport/http/server_test.go b/coordinator/internal/transport/http/server_test.go index 56cb049..3d9748f 100644 --- a/coordinator/internal/transport/http/server_test.go +++ b/coordinator/internal/transport/http/server_test.go @@ -52,11 +52,11 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur RenewLease: usecase.NewRenewLease(tasks, work, tx, clk, lease), CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, work, memstore.NewTaskResultRepo(), tx, clk, 2), ReduceJob: usecase.NewReduceJob(jobs, tasks, arts, blobs, tx, clk), - FailTask: usecase.NewFailTask(tasks, jobs, tx, clk), + FailTask: usecase.NewFailTask(tasks, jobs, work, tx, clk), GetJobStatus: usecase.NewGetJobStatus(jobs, tasks), GetJobResult: usecase.NewGetJobResult(jobs, downloadArtifact), CancelJob: usecase.NewCancelJob(jobs, tasks, tx, clk), - UploadArtifact: usecase.NewUploadArtifact(tasks, arts, blobs, tx, clk), + UploadArtifact: usecase.NewUploadArtifact(tasks, work, arts, blobs, tx, clk), DownloadArtifact: downloadArtifact, GetTaskInput: usecase.NewGetTaskInput(tasks, arts, blobs), Dashboard: usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, work, arts)), diff --git a/coordinator/internal/usecase/artifact.go b/coordinator/internal/usecase/artifact.go index 7a0187f..4c10657 100644 --- a/coordinator/internal/usecase/artifact.go +++ b/coordinator/internal/usecase/artifact.go @@ -12,18 +12,22 @@ import ( // UploadArtifact stores a worker's partial-result bytes and records the metadata. type UploadArtifact struct { tasks TaskRepository + workers WorkerRepository artifacts ArtifactRepository blobs BlobStore tx TxManager clk Clock } -func NewUploadArtifact(tasks TaskRepository, artifacts ArtifactRepository, +func NewUploadArtifact(tasks TaskRepository, workers WorkerRepository, artifacts ArtifactRepository, blobs BlobStore, tx TxManager, clk Clock) *UploadArtifact { - return &UploadArtifact{tasks: tasks, artifacts: artifacts, blobs: blobs, tx: tx, clk: clk} + return &UploadArtifact{tasks: tasks, workers: workers, artifacts: artifacts, blobs: blobs, tx: tx, clk: clk} } func (uc *UploadArtifact) Execute(ctx context.Context, in UploadArtifactInput) (*domain.Artifact, error) { + if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil { + return nil, err + } task, err := uc.tasks.Get(ctx, in.TaskID) if err != nil { return nil, err diff --git a/coordinator/internal/usecase/task.go b/coordinator/internal/usecase/task.go index ca4ca4b..9cb9c40 100644 --- a/coordinator/internal/usecase/task.go +++ b/coordinator/internal/usecase/task.go @@ -7,7 +7,6 @@ import ( "github.com/google/uuid" - "github.com/emil28092005/SciMesh/coordinator/internal/authctx" "github.com/emil28092005/SciMesh/coordinator/internal/domain" ) @@ -59,11 +58,8 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl // tier would be read off a caller-supplied worker_id, letting anyone who // knows a trusted worker's id claim as it. A shared-token caller (no // requester) is a lab operator and may act as any worker. - 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 - } + if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil { + return nil, err } // An untrusted volunteer may claim, but never a chunk its owner has // already voted on — so quorum needs genuinely independent computations. @@ -126,6 +122,9 @@ func NewRenewLease(tasks TaskRepository, workers WorkerRepository, tx TxManager, // locked: two concurrent heartbeats must not interleave into a lost update. // Whether the caller may renew at all is decided by the entity, not here. func (uc *RenewLease) Execute(ctx context.Context, in RenewLeaseInput) (*domain.ClaimedTask, error) { + if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil { + return nil, err + } var claimed domain.ClaimedTask err := uc.tx.WithinTx(ctx, func(ctx context.Context) error { @@ -186,6 +185,9 @@ func NewCompleteTask(tasks TaskRepository, jobs JobRepository, artifacts Artifac // Lease ownership, staleness, and idempotent replays are all decided by // Task.CompleteWith; this use case only orchestrates. func (uc *CompleteTask) Execute(ctx context.Context, in CompleteTaskInput) (*domain.Task, error) { + if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil { + return nil, err + } var out *domain.Task err := uc.tx.WithinTx(ctx, func(ctx context.Context) error { @@ -317,19 +319,23 @@ func (uc *CompleteTask) verifyResultArtifact(ctx context.Context, taskID uuid.UU // --- FailTask ------------------------------------------------------------ type FailTask struct { - tasks TaskRepository - jobs JobRepository - tx TxManager - clock Clock + tasks TaskRepository + jobs JobRepository + workers WorkerRepository + tx TxManager + clock Clock } -func NewFailTask(tasks TaskRepository, jobs JobRepository, tx TxManager, clock Clock) *FailTask { - return &FailTask{tasks: tasks, jobs: jobs, tx: tx, clock: clock} +func NewFailTask(tasks TaskRepository, jobs JobRepository, workers WorkerRepository, tx TxManager, clock Clock) *FailTask { + return &FailTask{tasks: tasks, jobs: jobs, workers: workers, tx: tx, clock: clock} } // Execute delegates the requeue-or-terminate decision to Task.Fail, then keeps // the parent job's status consistent in the same transaction. func (uc *FailTask) Execute(ctx context.Context, in FailTaskInput) (*domain.Task, error) { + if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil { + return nil, err + } var out *domain.Task err := uc.tx.WithinTx(ctx, func(ctx context.Context) error { diff --git a/coordinator/internal/usecase/usecase_test.go b/coordinator/internal/usecase/usecase_test.go index 246c452..b64111f 100644 --- a/coordinator/internal/usecase/usecase_test.go +++ b/coordinator/internal/usecase/usecase_test.go @@ -77,11 +77,11 @@ func newHarness() *harness { h.claim = usecase.NewClaimTask(h.tasks, h.jobs, h.work, tx, h.clk, lease) h.renew = usecase.NewRenewLease(h.tasks, h.work, tx, h.clk, lease) h.complete = usecase.NewCompleteTask(h.tasks, h.jobs, h.arts, h.work, h.taskResults, tx, h.clk, 2) - h.fail = usecase.NewFailTask(h.tasks, h.jobs, tx, h.clk) + h.fail = usecase.NewFailTask(h.tasks, h.jobs, h.work, tx, h.clk) h.status = usecase.NewGetJobStatus(h.jobs, h.tasks) h.results = usecase.NewListResults(h.tasks) h.register = usecase.NewRegisterWorker(h.work, h.clk) - h.uploadArt = usecase.NewUploadArtifact(h.tasks, h.arts, h.blobs, tx, h.clk) + h.uploadArt = usecase.NewUploadArtifact(h.tasks, h.work, h.arts, h.blobs, tx, h.clk) h.downloadArt = usecase.NewDownloadArtifact(h.arts, h.blobs) h.getInput = usecase.NewGetTaskInput(h.tasks, h.arts, h.blobs) h.expire = usecase.NewExpireLeases(h.tasks, h.jobs, tx, h.clk) @@ -318,6 +318,36 @@ func TestJWTCallerCannotClaimAsAnotherUsersWorker(t *testing.T) { } } +func TestJWTCallerCannotMutateAnotherUsersWorkerLease(t *testing.T) { + h := newHarness() + h.seedJob(t, "w", 1) + victimOwner := uuid.New() + victim, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{ + Name: "victim", Capabilities: []string{"w"}, OwnerID: &victimOwner, TrustLevel: domain.WorkerTrusted, + }) + if err != nil { + t.Fatal(err) + } + claimed, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{WorkerID: victim.ID.String()}) + if err != nil || claimed == nil { + t.Fatalf("claim = (%v, %v)", claimed, err) + } + attacker := authctx.With(ctx, authctx.Requester{UserID: uuid.New(), Role: "user"}) + + if _, err := h.renew.Execute(attacker, usecase.RenewLeaseInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt}); !errors.Is(err, domain.ErrWorkerNotFound) { + t.Errorf("foreign heartbeat err = %v, want ErrWorkerNotFound", err) + } + if _, err := h.fail.Execute(attacker, usecase.FailTaskInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt, ErrorCode: "x"}); !errors.Is(err, domain.ErrWorkerNotFound) { + t.Errorf("foreign failure err = %v, want ErrWorkerNotFound", err) + } + if _, err := h.uploadArt.Execute(attacker, usecase.UploadArtifactInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt, Filename: "x.csv", ContentType: "text/csv", Body: strings.NewReader("x")}); !errors.Is(err, domain.ErrWorkerNotFound) { + t.Errorf("foreign upload err = %v, want ErrWorkerNotFound", err) + } + if _, err := h.complete.Execute(attacker, usecase.CompleteTaskInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt, ResultArtifactID: uuid.New()}); !errors.Is(err, domain.ErrWorkerNotFound) { + t.Errorf("foreign result err = %v, want ErrWorkerNotFound", err) + } +} + func TestJWTCallerClaimsAsOwnTrustedWorker(t *testing.T) { h := newHarness() h.seedJob(t, "w", 1) @@ -551,7 +581,7 @@ func TestUploadRejectsLeaseThatExpiresDuringStreaming(t *testing.T) { h.seedJob(t, "w", 1) taskID, attempt := h.leaseOne(t, "w1", "w") h.uploadArt = usecase.NewUploadArtifact( - h.tasks, h.arts, expiringBlobStore{BlobStore: h.blobs, clock: h.clk}, memstore.Tx{}, h.clk, + h.tasks, h.work, h.arts, expiringBlobStore{BlobStore: h.blobs, clock: h.clk}, memstore.Tx{}, h.clk, ) _, err := h.uploadArt.Execute(ctx, usecase.UploadArtifactInput{ diff --git a/coordinator/internal/usecase/worker_authorization.go b/coordinator/internal/usecase/worker_authorization.go new file mode 100644 index 0000000..258e889 --- /dev/null +++ b/coordinator/internal/usecase/worker_authorization.go @@ -0,0 +1,33 @@ +package usecase + +import ( + "context" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/authctx" + "github.com/emil28092005/SciMesh/coordinator/internal/domain" +) + +// authorizeWorkerOwner binds a JWT-authenticated requester to a worker. The +// shared coordinator token intentionally has no requester and retains its +// existing operator privileges. +func authorizeWorkerOwner(ctx context.Context, workers WorkerRepository, workerID string) error { + requester, ok := authctx.From(ctx) + if !ok { + return nil + } + id, err := uuid.Parse(workerID) + if err != nil { + return domain.ErrWorkerNotFound + } + worker, err := workers.Get(ctx, id) + if err != nil { + return err + } + if worker.OwnerID == nil || *worker.OwnerID != requester.UserID { + // Mask ownership and existence from another user. + return domain.ErrWorkerNotFound + } + return nil +} diff --git a/docs/user-service-api-contract.md b/docs/user-service-api-contract.md new file mode 100644 index 0000000..91cd3c9 --- /dev/null +++ b/docs/user-service-api-contract.md @@ -0,0 +1,47 @@ +# SciMesh User Service API contract (v1) + +**Status:** `v1`. The User Service owns user accounts and issues access tokens. +The coordinator never receives user passwords and never accesses the User +Service database. + +## Authentication boundary + +- User Service signs access tokens; coordinator verifies them before accepting + user-scoped requests. +- Tokens contain a UUID `sub`, `role` (`user` or `admin`), `verified`, `iat`, + and `exp` claims. +- A user-authenticated caller may operate only workers whose `owner_id` equals + `sub`. This applies to claim, heartbeat, result, failure, and artifact upload. +- Worker traffic authenticated with the coordinator's shared worker token has + no user identity and remains an operator-only compatibility path. +- Role or verification changes take effect when the access token is renewed. + Deployments needing immediate revocation must use a short token lifetime or a + revocation mechanism before enabling volunteer-worker trust. + +## Endpoints + +All JSON request bodies reject unknown fields and are size-limited. Error +responses are JSON with a stable `error` value and request ID. + +| Method | Path | Auth | Success | +| --- | --- | --- | --- | +| `GET` | `/health` | none | `200 {"status":"ok"}` | +| `POST` | `/register` | none | `201` user object | +| `POST` | `/login` | none | `200` user object and access token | +| `GET` | `/me` | Bearer access token | `200` current user | +| `POST` | `/users/{id}/verify` | Bearer admin token | `204` | +| `POST` | `/users/{id}/unverify` | Bearer admin token | `204` | +| `POST` | `/users/{id}/promote` | Bearer admin token | `204` | +| `POST` | `/users/{id}/demote` | Bearer admin token | `204` | + +`POST /register` accepts `{ "email": string, "password": string }` and +always creates role `user` with `verified: false`. `POST /login` accepts the +same shape and returns `{ "token": string, "user": User }`. Password hashes, +JWT signing material, and raw tokens must never be logged. + +## Coordinator integration tests + +The coordinator must test that a JWT user cannot claim or mutate another +user's worker lease, including heartbeat, failure, result, and artifact upload. +Job and artifact access is restricted to the job owner unless the caller has +the admin role. diff --git a/users/README.md b/users/README.md index 4a7efa8..83f5d20 100644 --- a/users/README.md +++ b/users/README.md @@ -5,6 +5,9 @@ and issues the JWTs the coordinator trusts. It is a **separate bounded context** from the coordinator: its own database, its own binary. The only thing shared between the two services is the JWT signing secret. +The versioned external contract is +[`docs/user-service-api-contract.md`](../docs/user-service-api-contract.md). + Built as a modular monolith following Clean Architecture — one binary, four layers, dependencies pointing strictly inward: