add users logic

This commit is contained in:
Efremenko Arhip
2026-07-26 14:38:24 +03:00
parent 16db1e41f7
commit a3db1a1e67
65 changed files with 3626 additions and 19 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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
+2
View File
@@ -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=
+37
View File
@@ -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
}
+5 -1
View File
@@ -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
+12
View File
@@ -26,6 +26,12 @@ type Config struct {
Token string
// Local operator UI credential. Empty disables the embedded UI entirely.
UIToken string
// Shared HS256 secret used to verify userservice-issued JWTs. When set, a
// submitter may authenticate with a JWT (in addition to workers using the
// shared token) and their jobs are stamped with owner_id. Empty disables
// user-JWT auth entirely — the pre-userservice behaviour. Must match the
// userservice's JWT_SECRET.
JWTSecret string
// Minimum log level: debug, info, warn, error.
LogLevel string
@@ -80,6 +86,7 @@ func LoadConfig() (Config, error) {
// former name, still honoured so existing .env files keep working.
Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")),
UIToken: os.Getenv("UI_AUTH_TOKEN"),
JWTSecret: os.Getenv("JWT_SECRET"),
LogLevel: getEnv("LOG_LEVEL", "info"),
LogFile: os.Getenv("LOG_FILE"),
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
@@ -100,6 +107,11 @@ func LoadConfig() (Config, error) {
if cfg.UIToken != "" && cfg.Token != "" && cfg.UIToken == cfg.Token {
return Config{}, fmt.Errorf("UI_AUTH_TOKEN must differ from the worker auth token")
}
// A short secret makes the HMAC brute-forceable; refuse a weak one rather
// than verify tokens against it.
if cfg.JWTSecret != "" && len(cfg.JWTSecret) < 32 {
return Config{}, fmt.Errorf("JWT_SECRET must be at least 32 bytes")
}
var err error
if cfg.DBMaxConns, err = getEnvInt32("DB_MAX_CONNS", cfg.DBMaxConns); err != nil {
@@ -28,14 +28,15 @@ var _ usecase.JobRepository = (*JobRepo)(nil)
var jobColumns = []string{
"id", "workload", "input_uri", "parameters", "status", "created_at", "completed_at",
"input_artifact_id", "result_artifact_id", "error_code", "error_message", "reducer_started_at",
"owner_id",
}
// Insert runs inside the caller's transaction, alongside the job's tasks — that
// is what makes "all tasks or none" hold.
func (r *JobRepo) Insert(ctx context.Context, j *domain.Job) error {
sql, args, err := psql.Insert("jobs").
Columns("id", "workload", "input_uri", "parameters", "status", "created_at").
Values(j.ID, j.Workload, j.InputURI, jsonbOrEmpty(j.Parameters), string(j.Status), j.CreatedAt).
Columns("id", "workload", "input_uri", "parameters", "status", "created_at", "owner_id").
Values(j.ID, j.Workload, j.InputURI, jsonbOrEmpty(j.Parameters), string(j.Status), j.CreatedAt, j.OwnerID).
ToSql()
if err != nil {
return err
@@ -59,7 +60,8 @@ func (r *JobRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
)
err = conn(ctx, r.pool).QueryRow(ctx, sql, args...).Scan(
&j.ID, &j.Workload, &j.InputURI, &j.Parameters, &status, &j.CreatedAt, &j.CompletedAt,
&j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt)
&j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt,
&j.OwnerID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrJobNotFound
}
+58
View File
@@ -0,0 +1,58 @@
// Package token verifies the HS256 JWTs minted by the userservice. The
// coordinator only ever *verifies* — it never issues — so this is a deliberately
// small counterpart to the userservice's issuer. Verification is local: the
// shared secret is enough, with no runtime call back to the userservice.
package token
import (
"fmt"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// Claims is the subset of a userservice token the coordinator cares about.
type Claims struct {
UserID uuid.UUID
Role string
}
// Verifier checks tokens against the shared HS256 secret.
type Verifier struct {
secret []byte
}
// NewVerifier returns a Verifier, or nil when secret is empty — a nil Verifier
// means user-JWT auth is disabled and only the shared service token is accepted.
func NewVerifier(secret string) *Verifier {
if secret == "" {
return nil
}
return &Verifier{secret: []byte(secret)}
}
type claims struct {
Role string `json:"role"`
jwt.RegisteredClaims
}
// Verify checks the signature and expiry and returns the identity. It pins the
// algorithm to HMAC, rejecting a token that asks for "none" or an RS256 public
// key — the classic algorithm-substitution attack.
func (v *Verifier) Verify(raw string) (Claims, error) {
var c claims
_, err := jwt.ParseWithClaims(raw, &c, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return v.secret, nil
})
if err != nil {
return Claims{}, err
}
id, err := uuid.Parse(c.Subject)
if err != nil {
return Claims{}, fmt.Errorf("token subject is not a uuid: %w", err)
}
return Claims{UserID: id, Role: c.Role}, nil
}
@@ -0,0 +1,79 @@
package token
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const secret = "coordinator-verify-secret-32-bytes!!"
func sign(t *testing.T, method jwt.SigningMethod, key any, sub, role string, exp time.Time) string {
t.Helper()
tok := jwt.NewWithClaims(method, claims{
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: sub,
ExpiresAt: jwt.NewNumericDate(exp),
},
})
raw, err := tok.SignedString(key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return raw
}
func TestNewVerifierNilWhenNoSecret(t *testing.T) {
if NewVerifier("") != nil {
t.Error("empty secret must yield a nil verifier (auth disabled)")
}
}
func TestVerifyRoundTrip(t *testing.T) {
v := NewVerifier(secret)
id := uuid.New()
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), id.String(), "admin", time.Now().Add(time.Hour))
claims, err := v.Verify(raw)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.UserID != id {
t.Errorf("UserID = %v, want %v", claims.UserID, id)
}
if claims.Role != "admin" {
t.Errorf("Role = %q, want admin", claims.Role)
}
}
func TestVerifyRejectsExpired(t *testing.T) {
v := NewVerifier(secret)
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", time.Now().Add(-time.Minute))
if _, err := v.Verify(raw); err == nil {
t.Error("expired token accepted")
}
}
func TestVerifyRejectsWrongSecret(t *testing.T) {
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", time.Now().Add(time.Hour))
if _, err := NewVerifier("another-secret-also-at-least-32-byte").Verify(raw); err == nil {
t.Error("token verified under the wrong secret")
}
}
func TestVerifyRejectsNoneAlg(t *testing.T) {
raw := sign(t, jwt.SigningMethodNone, jwt.UnsafeAllowNoneSignatureType, uuid.New().String(), "admin", time.Now().Add(time.Hour))
if _, err := NewVerifier(secret).Verify(raw); err == nil {
t.Error("none-signed token accepted")
}
}
func TestVerifyRejectsNonUUIDSubject(t *testing.T) {
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), "not-a-uuid", "user", time.Now().Add(time.Hour))
if _, err := NewVerifier(secret).Verify(raw); err == nil {
t.Error("non-uuid subject accepted")
}
}
@@ -9,6 +9,9 @@ import (
"net/http"
"strings"
"time"
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
)
type ctxKey string
@@ -41,25 +44,45 @@ func newRequestID() string {
// withAuth enforces the shared bearer token every worker presents.
// An empty token disables the check (local development only).
func withAuth(token string) func(http.Handler) http.Handler {
// withAuth authenticates a request one of two ways. Workers (and legacy
// submitters) present the shared service token. When user-JWT auth is enabled
// (verifier != nil), a submitter may instead present a userservice JWT; on
// success the requester is stamped into the context so the job use cases can
// record owner_id and enforce ownership. An empty token with no verifier
// disables auth entirely (dev only).
func withAuth(token string, verifier *tokenpkg.Verifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token == "" {
if token == "" && verifier == nil {
next.ServeHTTP(w, r)
return
}
presented := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
// Constant-time compare: a byte-by-byte early exit would let an
// attacker recover the token by timing responses.
if subtle.ConstantTimeCompare([]byte(presented), []byte(token)) != 1 {
w.Header().Set("WWW-Authenticate", "Bearer")
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "unauthorized",
RequestID: requestIDFrom(r.Context()),
})
// Shared service token: constant-time compare so a byte-by-byte
// early exit cannot leak the token through response timing.
if token != "" && subtle.ConstantTimeCompare([]byte(presented), []byte(token)) == 1 {
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
// Otherwise try a user JWT, if that path is configured.
if verifier != nil && presented != "" {
if claims, err := verifier.Verify(presented); err == nil {
ctx := authctx.With(r.Context(), authctx.Requester{
UserID: claims.UserID,
Role: claims.Role,
})
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
w.Header().Set("WWW-Authenticate", "Bearer")
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "unauthorized",
RequestID: requestIDFrom(r.Context()),
})
})
}
}
@@ -9,6 +9,7 @@ import (
"net/http"
"time"
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
)
@@ -40,19 +41,23 @@ type Server struct {
requestTimeout time.Duration
heartbeatInterval time.Duration
maxUploadBytes int64
// verifier validates userservice JWTs. nil disables user-JWT auth, leaving
// only the shared service token — the pre-userservice behaviour.
verifier *tokenpkg.Verifier
// ready probes downstream dependencies (the database) for /health. Kept as
// a func so the transport layer never imports pgx.
ready func(context.Context) error
}
func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration,
maxUploadBytes int64, ready func(context.Context) error) *Server {
maxUploadBytes int64, jwtSecret string, ready func(context.Context) error) *Server {
return &Server{
uc: uc,
log: log,
requestTimeout: requestTimeout,
heartbeatInterval: heartbeatInterval,
maxUploadBytes: maxUploadBytes,
verifier: tokenpkg.NewVerifier(jwtSecret),
ready: ready,
}
}
@@ -99,7 +104,7 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
mux.Handle("/", chain(protected,
withRequestID, // outermost: every response gets an ID,
withAccessLog(s.log), // including the 401s below
withAuth(token),
withAuth(token, s.verifier),
))
return mux
}
@@ -68,7 +68,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
if err != nil {
t.Fatalf("register test worker: %v", err)
}
srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, ready)
srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, "", ready)
ts := httptest.NewServer(srv.Handler(token, configuredUIToken))
t.Cleanup(ts.Close)
return &env{ts: ts, blobs: blobs, workerID: worker.ID.String()}
+7
View File
@@ -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
+39
View File
@@ -0,0 +1,39 @@
package usecase
import (
"context"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
// ownerFromContext returns the authenticated user id to stamp on a new job, or
// nil when the request was not authenticated as a user — worker or legacy
// traffic, or user-JWT auth disabled. A nil owner is stored as NULL.
func ownerFromContext(ctx context.Context) *uuid.UUID {
if r, ok := authctx.From(ctx); ok {
id := r.UserID
return &id
}
return nil
}
// authorizeJobAccess enforces that a non-admin user may only act on their own
// job. It returns ErrJobNotFound — not a 403 — on a mismatch, so the response
// never reveals that another user's job exists.
//
// Requests with no authenticated user (worker/legacy traffic, or JWT auth
// disabled) are not restricted here: the shared service token already gated
// them, and worker endpoints legitimately operate across all jobs.
func authorizeJobAccess(ctx context.Context, job *domain.Job) error {
r, ok := authctx.From(ctx)
if !ok || r.IsAdmin() {
return nil
}
if job.OwnerID == nil || *job.OwnerID != r.UserID {
return domain.ErrJobNotFound
}
return nil
}
@@ -0,0 +1,66 @@
package usecase
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
func TestOwnerFromContext(t *testing.T) {
if ownerFromContext(context.Background()) != nil {
t.Error("no requester must yield a nil owner")
}
id := uuid.New()
ctx := authctx.With(context.Background(), authctx.Requester{UserID: id, Role: "user"})
got := ownerFromContext(ctx)
if got == nil || *got != id {
t.Errorf("owner = %v, want %v", got, id)
}
}
func TestAuthorizeJobAccess(t *testing.T) {
owner := uuid.New()
other := uuid.New()
job := &domain.Job{ID: uuid.New(), OwnerID: &owner}
ctxOf := func(id uuid.UUID, role string) context.Context {
return authctx.With(context.Background(), authctx.Requester{UserID: id, Role: role})
}
cases := []struct {
name string
ctx context.Context
wantErr bool
}{
{"no requester (worker/legacy) allowed", context.Background(), false},
{"owner allowed", ctxOf(owner, "user"), false},
{"admin allowed", ctxOf(other, "admin"), false},
{"non-owner denied", ctxOf(other, "user"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := authorizeJobAccess(tc.ctx, job)
if tc.wantErr {
if !errors.Is(err, domain.ErrJobNotFound) {
t.Errorf("got %v, want ErrJobNotFound", err)
}
} else if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestAuthorizeJobAccessNilOwner(t *testing.T) {
// A legacy job with no owner must not be readable by an arbitrary user.
job := &domain.Job{ID: uuid.New(), OwnerID: nil}
ctx := authctx.With(context.Background(), authctx.Requester{UserID: uuid.New(), Role: "user"})
if err := authorizeJobAccess(ctx, job); !errors.Is(err, domain.ErrJobNotFound) {
t.Errorf("got %v, want ErrJobNotFound", err)
}
}
+3
View File
@@ -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
}
+1
View File
@@ -43,6 +43,7 @@ func (uc *SubmitDataset) Execute(ctx context.Context, in SubmitDatasetInput) (Su
if err != nil {
return SubmitDatasetResult{}, err
}
job.OwnerID = ownerFromContext(ctx)
// Everything written to blob storage, so a failed transaction can undo it.
var putKeys []string
@@ -0,0 +1,6 @@
BEGIN;
DROP INDEX IF EXISTS ix_jobs_owner;
ALTER TABLE jobs DROP COLUMN IF EXISTS owner_id;
COMMIT;
@@ -0,0 +1,14 @@
BEGIN;
-- Who submitted this job. Equals users.id from the userservice, taken from the
-- JWT `sub` claim. NOT a foreign key: users live in a separate service/database,
-- so integrity is guaranteed by the signed token, not by the DB.
--
-- Nullable because rows created before auth existed have no owner; new inserts
-- must supply it (enforced in the app, not the schema, during the MVP).
ALTER TABLE jobs ADD COLUMN owner_id uuid;
-- "List my jobs" / "admin filters by owner" scans by owner.
CREATE INDEX ix_jobs_owner ON jobs (owner_id);
COMMIT;