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/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/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/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/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go index 46b077c..61de190 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" @@ -59,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{ @@ -74,12 +76,12 @@ 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), + 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), @@ -108,9 +110,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, 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/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/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/go.mod b/coordinator/go.mod index 4fc8bcc..7d9cb06 100644 --- a/coordinator/go.mod +++ b/coordinator/go.mod @@ -1,23 +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.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.19.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.55.0 // 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 d9c880a..df0b061 100644 --- a/coordinator/go.sum +++ b/coordinator/go.sum @@ -1,10 +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.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= @@ -21,20 +29,34 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq 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.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.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.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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.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/authctx/authctx.go b/coordinator/internal/authctx/authctx.go new file mode 100644 index 0000000..398cc38 --- /dev/null +++ b/coordinator/internal/authctx/authctx.go @@ -0,0 +1,43 @@ +// 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 + 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. +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/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/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/infra/config.go b/coordinator/internal/infra/config.go index 7d6db0e..5a414bd 100644 --- a/coordinator/internal/infra/config.go +++ b/coordinator/internal/infra/config.go @@ -26,6 +26,17 @@ 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 + // 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 @@ -50,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. @@ -80,6 +94,8 @@ 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"), + UserserviceURL: os.Getenv("USERSERVICE_URL"), LogLevel: getEnv("LOG_LEVEL", "info"), LogFile: os.Getenv("LOG_FILE"), StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"), @@ -90,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, } @@ -100,6 +117,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 { @@ -129,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/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/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/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/storage/postgres/integration_test.go b/coordinator/internal/storage/postgres/integration_test.go index 3252b00..ba974c3 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) } @@ -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), 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/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/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/storage/postgres/ui_read_repo.go b/coordinator/internal/storage/postgres/ui_read_repo.go index c759b2a..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 } @@ -44,6 +48,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 } 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 new file mode 100644 index 0000000..93b78ee --- /dev/null +++ b/coordinator/internal/token/verifier.go @@ -0,0 +1,60 @@ +// 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 + Verified bool +} + +// 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"` + Verified bool `json:"verified"` + 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, Verified: c.Verified}, nil +} diff --git a/coordinator/internal/token/verifier_test.go b/coordinator/internal/token/verifier_test.go new file mode 100644 index 0000000..9c3b0f4 --- /dev/null +++ b/coordinator/internal/token/verifier_test.go @@ -0,0 +1,98 @@ +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() + 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, + Verified: verified, + 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 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)") + } +} + +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/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 2808313..c304827 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,46 @@ 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, + Verified: claims.Verified, + }) + 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..6439da1 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -7,8 +7,11 @@ import ( "context" "log/slog" "net/http" + "strings" "time" + "github.com/emil28092005/SciMesh/coordinator/internal/metrics" + tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token" "github.com/emil28092005/SciMesh/coordinator/internal/usecase" ) @@ -40,23 +43,47 @@ 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 + // 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 + // 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 } func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration, - maxUploadBytes int64, 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, requestTimeout: requestTimeout, heartbeatInterval: heartbeatInterval, maxUploadBytes: maxUploadBytes, + verifier: tokenpkg.NewVerifier(jwtSecret), + userserviceURL: strings.TrimRight(userserviceURL, "/"), + httpClient: &http.Client{Timeout: 10 * time.Second}, + metrics: m, 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 { @@ -77,19 +104,57 @@ 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 { + // 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) { 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)) + } + 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)) + } 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. @@ -99,9 +164,10 @@ 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 + // 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 diff --git a/coordinator/internal/transport/http/server_test.go b/coordinator/internal/transport/http/server_test.go index e43086e..3d9748f 100644 --- a/coordinator/internal/transport/http/server_test.go +++ b/coordinator/internal/transport/http/server_test.go @@ -50,13 +50,13 @@ 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), + 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)), @@ -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/internal/transport/http/templates/admin.html b/coordinator/internal/transport/http/templates/admin.html new file mode 100644 index 0000000..37f704a --- /dev/null +++ b/coordinator/internal/transport/http/templates/admin.html @@ -0,0 +1,46 @@ +{{define "admin.html"}} + + + + + + Admin · SciMesh + + + +
+
+

Admin panel

User & run control

+
← DashboardProfile
+
+

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 af31dd1..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
- + New similarity search +
{{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 f0091e6..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 +
← 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/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/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/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_admin.go b/coordinator/internal/transport/http/ui_admin.go new file mode 100644 index 0000000..6287b91 --- /dev/null +++ b/coordinator/internal/transport/http/ui_admin.go @@ -0,0 +1,114 @@ +package http + +import ( + "context" + "io" + "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, []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, nil, err + } + req.Header.Set("Authorization", "Bearer "+bearer) + + resp, err := s.httpClient.Do(req) //nolint:gosec // G704: see above + if err != nil { + return 0, nil, err + } + defer func() { _ = resp.Body.Close() }() + 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_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") + } +} 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") + } +} 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/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) + } +} 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/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/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..6e476ec --- /dev/null +++ b/coordinator/internal/usecase/ownership.go @@ -0,0 +1,51 @@ +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 +} + +// 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. +// +// 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/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/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/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/task.go b/coordinator/internal/usecase/task.go index 1af5bf8..9cb9c40 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" @@ -46,11 +47,25 @@ 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 { 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. A shared-token caller (no + // requester) is a lab operator and may act as any worker. + 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. + if worker.TrustLevel == domain.WorkerUntrusted { + voterOwner = worker.OwnerID + } // Never trust caller-supplied capabilities: registration is the durable // worker identity and its allowlist. workloads = worker.Capabilities @@ -71,6 +86,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 @@ -106,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 { @@ -141,13 +160,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 @@ -157,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 { @@ -166,24 +197,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 } @@ -195,36 +237,105 @@ 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 { + // 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 { + 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 ------------------------------------------------------------ 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/ui.go b/coordinator/internal/usecase/ui.go index 96e5714..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" ) @@ -14,7 +15,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) @@ -85,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 } @@ -99,7 +124,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 } @@ -132,6 +157,7 @@ func (d *Dashboard) Overview(ctx context.Context, limit int) (DashboardView, err out.OnlineWorkers++ } } + out.Session = sessionViewFrom(ctx) return out, nil } @@ -140,6 +166,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 @@ -161,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} @@ -198,6 +230,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) + } +} 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/internal/usecase/usecase_test.go b/coordinator/internal/usecase/usecase_test.go index 2272c51..b64111f 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" @@ -34,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 @@ -61,24 +63,25 @@ 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.fail = usecase.NewFailTask(h.tasks, h.jobs, 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, 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) @@ -265,6 +268,184 @@ 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 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 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) + 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 TestUntrustedWorkerCanClaim(t *testing.T) { + h := newHarness() + h.seedJob(t, "w", 1) + 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) + } + // 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 || claimed == nil { + t.Fatalf("untrusted claim = (%v, %v), want a task", claimed, err) + } +} + +// 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) + } + + // 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) + } +} + func TestClaimRequiresWorkerID(t *testing.T) { h := newHarness() if _, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{}); !errors.Is(err, domain.ErrInvalidInput) { @@ -400,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.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/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/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/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; 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; diff --git a/coordinator/monitoring/grafana/dashboards/coordinator.json b/coordinator/monitoring/grafana/dashboards/coordinator.json new file mode 100644 index 0000000..e52c98e --- /dev/null +++ b/coordinator/monitoring/grafana/dashboards/coordinator.json @@ -0,0 +1,154 @@ +{ + "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" + } + ] + }, + { + "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}}" + } + ] + } + ] +} 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 f978ed1..3d58183 100755 --- a/coordinator/scripts/demo-ui.sh +++ b/coordinator/scripts/demo-ui.sh @@ -11,8 +11,17 @@ 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} +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 +# 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 +35,18 @@ logs_dir="$demo_dir/logs" 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" \ - 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" \ + -f "$coordinator_dir/docker-compose.monitoring.yml" "$@" } stop_workers() { @@ -57,10 +75,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 +133,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 +154,17 @@ 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 + Grafana: http://localhost:$grafana_port (anonymous view; admin/${GRAFANA_PASSWORD:-admin} to edit) + Prometheus: http://localhost:$prometheus_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 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/.dockerignore b/users/.dockerignore new file mode 100644 index 0000000..545cfcf --- /dev/null +++ b/users/.dockerignore @@ -0,0 +1,16 @@ +# 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 +/userservice +/bin/ +*.out +/data/ +/logs/ diff --git a/users/.env.example b/users/.env.example new file mode 100644 index 0000000..49803cf --- /dev/null +++ b/users/.env.example @@ -0,0 +1,29 @@ +# Copy to .env and adjust. All settings are read from the environment. + +USERSERVICE_ADDR=:8081 +DATABASE_URL=postgres://scimesh:scimesh@localhost:5433/scimesh_users?sslmode=disable + +# 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 + +# 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 +# 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 diff --git a/users/.gitignore b/users/.gitignore new file mode 100644 index 0000000..8ef0865 --- /dev/null +++ b/users/.gitignore @@ -0,0 +1,6 @@ +/userservice +/bin/ +.env +*.out +/logs/ +/data/ diff --git a/users/.golangci.yml b/users/.golangci.yml new file mode 100644 index 0000000..3492657 --- /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/users 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..83f5d20 --- /dev/null +++ b/users/README.md @@ -0,0 +1,82 @@ +# SciMesh userservice + +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. + +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: + +``` + 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 +``` + +## 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 | +| 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: + +- **`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 + +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 +# 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 +``` + +## Verify + +```sh +make test # unit tests +make check # vet, lint, race, integration, smoke — needs Docker +make smoke # end-to-end against a running service +``` + +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/cmd/userservice/main.go b/users/cmd/userservice/main.go new file mode 100644 index 0000000..97f55f4 --- /dev/null +++ b/users/cmd/userservice/main.go @@ -0,0 +1,82 @@ +// 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), + SetVerified: usecase.NewSetVerified(users), + SetRole: usecase.NewSetRole(users), + 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. + 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..ae02f26 --- /dev/null +++ b/users/internal/auth/jwt.go @@ -0,0 +1,72 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + + "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; 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"` + Verified bool `json:"verified"` + 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. 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: u.Role, + Verified: u.Verified, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: u.ID.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..a01fa7a --- /dev/null +++ b/users/internal/auth/jwt_test.go @@ -0,0 +1,77 @@ +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(&domain.User{ID: id, Role: domain.RoleAdmin, Verified: true}) + 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) + } + 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(&domain.User{ID: uuid.New(), Role: 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(&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 { + 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..77ca642 --- /dev/null +++ b/users/internal/domain/user.go @@ -0,0 +1,84 @@ +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 + // 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 +// 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..e326bce --- /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 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/config.go b/users/internal/infra/config.go new file mode 100644 index 0000000..cc8fb74 --- /dev/null +++ b/users/internal/infra/config.go @@ -0,0 +1,160 @@ +// 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 + + // 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. + 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"), + 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 == "" { + 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/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/infra/db.go b/users/internal/infra/db.go new file mode 100644 index 0000000..4a09674 --- /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 + // 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() + 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..80999e2 --- /dev/null +++ b/users/internal/infra/server.go @@ -0,0 +1,44 @@ +// Server: the HTTP listener, shut down cleanly on a signal. +package infra + +import ( + "context" + "errors" + "log/slog" + "net/http" + "time" +) + +const shutdownGrace = 15 * time.Second + +// 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, + 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) +} diff --git a/users/internal/memstore/memstore.go b/users/internal/memstore/memstore.go new file mode 100644 index 0000000..96307e9 --- /dev/null +++ b/users/internal/memstore/memstore.go @@ -0,0 +1,90 @@ +// 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 +} + +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 +} + +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 } + +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..89d37e5 --- /dev/null +++ b/users/internal/storage/postgres/integration_test.go @@ -0,0 +1,165 @@ +//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) + } +} + +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) + } +} + +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/retry.go b/users/internal/storage/postgres/retry.go new file mode 100644 index 0000000..3bd35ce --- /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 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" + 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 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() + 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..cb786fd --- /dev/null +++ b/users/internal/storage/postgres/user_repo.go @@ -0,0 +1,127 @@ +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", "verified", "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.Verified, 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...)) +} + +// 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 +} + +// 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 + role string + ) + 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 + } + 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..808ee3d --- /dev/null +++ b/users/internal/transport/http/dto.go @@ -0,0 +1,43 @@ +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"` + Verified bool `json:"verified"` + 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), + Verified: u.Verified, + 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..8ccbe7e --- /dev/null +++ b/users/internal/transport/http/errors.go @@ -0,0 +1,63 @@ +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, 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: + // 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..beaca96 --- /dev/null +++ b/users/internal/transport/http/handlers.go @@ -0,0 +1,131 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/users/internal/domain" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +// Handlers holds the use cases each endpoint drives. +type Handlers struct { + register *usecase.Register + login *usecase.Login + setVerified *usecase.SetVerified + setRole *usecase.SetRole + 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)) +} + +// 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) + } +} + +// 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 }`. +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..266b1b6 --- /dev/null +++ b/users/internal/transport/http/middleware.go @@ -0,0 +1,149 @@ +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" + "github.com/emil28092005/SciMesh/users/internal/domain" +) + +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 +} + +// 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 + 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..3a57af7 --- /dev/null +++ b/users/internal/transport/http/server.go @@ -0,0 +1,57 @@ +// 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/domain" + "github.com/emil28092005/SciMesh/users/internal/usecase" +) + +// UseCases bundles the application services the handlers drive. +type UseCases struct { + Register *usecase.Register + Login *usecase.Login + SetVerified *usecase.SetVerified + SetRole *usecase.SetRole + Users usecase.UserRepository +} + +// NewServer wires the routes and the middleware stack and returns the handler. +// 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, + setVerified: uc.SetVerified, + setRole: uc.SetRole, + 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))) + + // 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)) + 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 new file mode 100644 index 0000000..f0ee300 --- /dev/null +++ b/users/internal/transport/http/server_test.go @@ -0,0 +1,372 @@ +package http_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "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" +) + +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), + SetVerified: usecase.NewSetVerified(users), + SetRole: usecase.NewSetRole(users), + 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, err := http.NewRequestWithContext(context.Background(), method, path, &buf) + if err != nil { + t.Fatal(err) + } + 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) + } +} + +// 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), + SetVerified: usecase.NewSetVerified(users), + SetRole: usecase.NewSetRole(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(&domain.User{ID: uuid.New(), Role: domain.RoleUser}) + 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") + } +} + +// 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 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") + 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/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) + } +} diff --git a/users/internal/usecase/errorpaths_test.go b/users/internal/usecase/errorpaths_test.go new file mode 100644 index 0000000..4fa9a1e --- /dev/null +++ b/users/internal/usecase/errorpaths_test.go @@ -0,0 +1,109 @@ +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 +} +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 + 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(*domain.User) (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 } diff --git a/users/internal/usecase/errors.go b/users/internal/usecase/errors.go new file mode 100644 index 0000000..9a0b3e2 --- /dev/null +++ b/users/internal/usecase/errors.go @@ -0,0 +1,19 @@ +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") + ErrInvalidRole = errors.New("invalid role") +) diff --git a/users/internal/usecase/login.go b/users/internal/usecase/login.go new file mode 100644 index 0000000..9b8351d --- /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) + 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..d94bfd1 --- /dev/null +++ b/users/internal/usecase/ports.go @@ -0,0 +1,48 @@ +// 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) + // 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. +type PasswordHasher interface { + Hash(password string) (string, error) + Compare(hash, password string) error +} + +// 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(u *domain.User) (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/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) +} 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/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/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/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; 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 ✓"