From 1473bbe2a8706366f436f2a487e9939b569a76c3 Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 2 Aug 2026 19:49:54 +0300 Subject: [PATCH] Embed the userservice and add serve/agent subcommands for one-binary operation --- coordinator/cmd/coordinator/agent_cmd.go | 85 ++++ coordinator/cmd/coordinator/main.go | 33 +- coordinator/cmd/coordinator/serve_cmd.go | 318 +++++++++++++++ coordinator/internal/agent/config.go | 6 +- coordinator/internal/userservice/auth/jwt.go | 72 ++++ .../internal/userservice/auth/jwt_test.go | 77 ++++ .../internal/userservice/auth/password.go | 40 ++ .../userservice/auth/password_test.go | 30 ++ .../internal/userservice/domain/errors.go | 13 + .../internal/userservice/domain/user.go | 84 ++++ .../internal/userservice/domain/user_test.go | 62 +++ .../internal/userservice/domain/workerkey.go | 84 ++++ .../userservice/domain/workerkey_test.go | 62 +++ coordinator/internal/userservice/embedded.go | 113 ++++++ .../internal/userservice/memstore/memstore.go | 90 +++++ .../userservice/storage/sqlite/migrate.go | 81 ++++ .../storage/sqlite/migrations/0001_users.sql | 24 ++ .../userservice/storage/sqlite/sqlite.go | 256 ++++++++++++ .../userservice/storage/sqlite/sqlite_test.go | 137 +++++++ .../userservice/transport/http/dto.go | 93 +++++ .../userservice/transport/http/errors.go | 69 ++++ .../userservice/transport/http/handlers.go | 231 +++++++++++ .../userservice/transport/http/middleware.go | 149 +++++++ .../userservice/transport/http/server.go | 73 ++++ .../userservice/transport/http/server_test.go | 372 ++++++++++++++++++ .../internal/userservice/usecase/bootstrap.go | 65 +++ .../userservice/usecase/bootstrap_test.go | 71 ++++ .../userservice/usecase/errorpaths_test.go | 109 +++++ .../internal/userservice/usecase/errors.go | 27 ++ .../internal/userservice/usecase/login.go | 42 ++ .../internal/userservice/usecase/ports.go | 67 ++++ .../internal/userservice/usecase/register.go | 56 +++ .../internal/userservice/usecase/role.go | 28 ++ .../userservice/usecase/usecase_test.go | 133 +++++++ .../internal/userservice/usecase/verify.go | 24 ++ .../userservice/usecase/verify_test.go | 73 ++++ .../internal/userservice/usecase/workerkey.go | 112 ++++++ .../userservice/usecase/workerkey_test.go | 186 +++++++++ 38 files changed, 3638 insertions(+), 9 deletions(-) create mode 100644 coordinator/cmd/coordinator/agent_cmd.go create mode 100644 coordinator/cmd/coordinator/serve_cmd.go create mode 100644 coordinator/internal/userservice/auth/jwt.go create mode 100644 coordinator/internal/userservice/auth/jwt_test.go create mode 100644 coordinator/internal/userservice/auth/password.go create mode 100644 coordinator/internal/userservice/auth/password_test.go create mode 100644 coordinator/internal/userservice/domain/errors.go create mode 100644 coordinator/internal/userservice/domain/user.go create mode 100644 coordinator/internal/userservice/domain/user_test.go create mode 100644 coordinator/internal/userservice/domain/workerkey.go create mode 100644 coordinator/internal/userservice/domain/workerkey_test.go create mode 100644 coordinator/internal/userservice/embedded.go create mode 100644 coordinator/internal/userservice/memstore/memstore.go create mode 100644 coordinator/internal/userservice/storage/sqlite/migrate.go create mode 100644 coordinator/internal/userservice/storage/sqlite/migrations/0001_users.sql create mode 100644 coordinator/internal/userservice/storage/sqlite/sqlite.go create mode 100644 coordinator/internal/userservice/storage/sqlite/sqlite_test.go create mode 100644 coordinator/internal/userservice/transport/http/dto.go create mode 100644 coordinator/internal/userservice/transport/http/errors.go create mode 100644 coordinator/internal/userservice/transport/http/handlers.go create mode 100644 coordinator/internal/userservice/transport/http/middleware.go create mode 100644 coordinator/internal/userservice/transport/http/server.go create mode 100644 coordinator/internal/userservice/transport/http/server_test.go create mode 100644 coordinator/internal/userservice/usecase/bootstrap.go create mode 100644 coordinator/internal/userservice/usecase/bootstrap_test.go create mode 100644 coordinator/internal/userservice/usecase/errorpaths_test.go create mode 100644 coordinator/internal/userservice/usecase/errors.go create mode 100644 coordinator/internal/userservice/usecase/login.go create mode 100644 coordinator/internal/userservice/usecase/ports.go create mode 100644 coordinator/internal/userservice/usecase/register.go create mode 100644 coordinator/internal/userservice/usecase/role.go create mode 100644 coordinator/internal/userservice/usecase/usecase_test.go create mode 100644 coordinator/internal/userservice/usecase/verify.go create mode 100644 coordinator/internal/userservice/usecase/verify_test.go create mode 100644 coordinator/internal/userservice/usecase/workerkey.go create mode 100644 coordinator/internal/userservice/usecase/workerkey_test.go diff --git a/coordinator/cmd/coordinator/agent_cmd.go b/coordinator/cmd/coordinator/agent_cmd.go new file mode 100644 index 0000000..eb6edc3 --- /dev/null +++ b/coordinator/cmd/coordinator/agent_cmd.go @@ -0,0 +1,85 @@ +package main + +import ( + "flag" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "github.com/emil28092005/SciMesh/coordinator/internal/agent" +) + +// runAgent implements `coordinator agent`: the worker agent as a subcommand of +// the same binary, so one file can serve the whole platform. `serve` spawns +// these for its local workers. +func runAgent(args []string) error { + flags := flag.NewFlagSet("agent", flag.ContinueOnError) + flags.Usage = func() { + _, _ = fmt.Fprintf(flags.Output(), "usage: coordinator agent [options]\n") + _, _ = fmt.Fprintf(flags.Output(), "Runs as a worker agent: claims tasks, executes SDK workloads in a\n") + _, _ = fmt.Fprintf(flags.Output(), "Python subprocess, uploads results.\n\n") + flags.PrintDefaults() + } + var ( + coordinatorURL = flags.String("coordinator-url", os.Getenv("COORDINATOR_URL"), "coordinator base URL") + token = flags.String("token", os.Getenv("WORKER_AUTH_TOKEN"), "worker bearer token") + workDir = flags.String("work-dir", os.Getenv("WORK_DIR"), "worker work directory") + name = flags.String("name", os.Getenv("WORKER_NAME"), "worker name (default: hostname)") + workerID = flags.String("worker-id", os.Getenv("WORKER_ID"), "persistent worker id (optional)") + cpuCount = flags.Int("cpu", envInt("CPU_COUNT", 1), "advertised CPU cores") + memoryMB = flags.Int("memory-mb", envInt("MEMORY_MB", 1024), "advertised memory in MiB") + poll = flags.Duration("poll-interval", 2*time.Second, "claim poll interval") + taskRunner = flags.String("task-runner", os.Getenv("TASK_RUNNER"), "python command + args that run scimesh.worker.task") + maxTasks = flags.Int("max-tasks", envInt("MAX_TASKS", 0), "stop after N completed tasks (0 = unlimited)") + exitWhenIdle = flags.Bool("exit-when-idle", false, "exit when the queue is empty") + ) + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() > 0 { + return fmt.Errorf("agent takes no positional arguments") + } + if *coordinatorURL == "" || *token == "" || *workDir == "" { + return fmt.Errorf("--coordinator-url, --token, and --work-dir are required") + } + if *taskRunner == "" { + *taskRunner = "python -m scimesh.worker.task" + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + config := agent.Config{ + CoordinatorURL: strings.TrimRight(*coordinatorURL, "/"), + Capabilities: agent.DefaultCapabilities(), + Token: *token, + WorkerName: *name, + WorkerID: *workerID, + WorkDir: *workDir, + CPUCount: *cpuCount, + MemoryMB: *memoryMB, + PollInterval: *poll, + RequestTimeout: 30 * time.Second, + Heartbeat: 15 * time.Second, + TaskRunner: strings.Fields(*taskRunner), + MaxTasks: *maxTasks, + ExitWhenIdle: *exitWhenIdle, + } + tokens := agent.NewTokenProvider("", "", config.Token, config.RequestTimeout) + client := agent.NewClient(config.CoordinatorURL, tokens, config.RequestTimeout) + runner := agent.NewTaskRunner(config.TaskRunner) + daemon := agent.NewDaemon(&config, client, runner, logger) + return daemon.RunForever() +} + +func envInt(name string, fallback int) int { + raw := os.Getenv(name) + if raw == "" { + return fallback + } + var n int + if _, err := fmt.Sscanf(raw, "%d", &n); err != nil { + return fallback + } + return n +} diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go index 1f0deea..68f5f45 100644 --- a/coordinator/cmd/coordinator/main.go +++ b/coordinator/cmd/coordinator/main.go @@ -26,11 +26,24 @@ var version = "dev" func main() { args := os.Args[1:] - if len(args) > 0 && args[0] == "setup" { - if err := runSetup(args[1:]); err != nil { - os.Exit(1) + if len(args) > 0 { + switch args[0] { + case "setup": + if err := runSetup(args[1:]); err != nil { + os.Exit(1) + } + return + case "serve": + if err := runServe(args[1:]); err != nil { + os.Exit(1) + } + return + case "agent": + if err := runAgent(args[1:]); err != nil { + os.Exit(1) + } + return } - return } showVersion := flag.Bool("version", false, "print the build version and exit") flag.Parse() @@ -64,8 +77,6 @@ type storageDeps struct { } func run() error { - // Bootstrap logger, used only until config says where logs should go. It - // writes to stderr so it never contaminates the configured stdout stream. boot := slog.New(slog.NewJSONHandler(os.Stderr, nil)) cfg, err := infra.LoadConfig() @@ -73,6 +84,16 @@ func run() error { boot.Error("load config", "err", err) return err } + return runWithConfig(cfg) +} + +// runWithConfig boots the coordinator server with an explicit config. The +// `serve` subcommand builds such a config for the single-binary mode; the +// plain `coordinator` binary loads it from the environment. +func runWithConfig(cfg infra.Config) error { + // Bootstrap logger, used only until config says where logs should go. It + // writes to stderr so it never contaminates the configured stdout stream. + boot := slog.New(slog.NewJSONHandler(os.Stderr, nil)) // The real logger: stdout plus an optional rotated file (LOG_FILE). log, logCloser, err := infra.NewLogger(cfg) diff --git a/coordinator/cmd/coordinator/serve_cmd.go b/coordinator/cmd/coordinator/serve_cmd.go new file mode 100644 index 0000000..5ab2466 --- /dev/null +++ b/coordinator/cmd/coordinator/serve_cmd.go @@ -0,0 +1,318 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "flag" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/emil28092005/SciMesh/coordinator/internal/infra" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice" +) + +// runServe implements `coordinator serve`: the single-binary mode for a +// scientist. It provisions a data directory (default ~/.scimesh) with the +// coordinator and userservice sqlite databases, secrets, the admin account, +// and optionally local worker agents — then runs the same server run() does. +func runServe(args []string) error { + flags := flag.NewFlagSet("serve", flag.ContinueOnError) + flags.Usage = func() { + _, _ = fmt.Fprintf(flags.Output(), "usage: coordinator serve [options]\n") + _, _ = fmt.Fprintf(flags.Output(), "Runs the whole SciMesh platform from one binary: embedded databases, the\n") + _, _ = fmt.Fprintf(flags.Output(), "userservice, and optional local workers. No PostgreSQL or Docker needed.\n\n") + flags.PrintDefaults() + } + var ( + dataDir = flags.String("data-dir", defaultDataDir(), "data directory (default: ~/.scimesh)") + addr = flags.String("addr", "127.0.0.1:8080", "listen address") + workers = flags.Int("workers", 1, "number of local worker agents to spawn") + open = flags.Bool("open", false, "open the UI in the browser") + docsDir = flags.String("docs-dir", "", "built MkDocs site directory to serve at /ui/docs/") + email = flags.String("admin-email", "admin@scimesh.local", "admin account email") + password = flags.String("admin-password", "", "admin password (generated on first run when empty)") + ) + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() > 0 { + return fmt.Errorf("serve takes no positional arguments") + } + if *workers < 0 { + return fmt.Errorf("--workers must be >= 0") + } + + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + if err := os.MkdirAll(*dataDir, 0o750); err != nil { + return fmt.Errorf("create data dir: %w", err) + } + + // 1. Secrets, persisted in the data dir so restarts keep working. + jwtSecret, err := loadOrGenerate(filepath.Join(*dataDir, "jwt.secret")) + if err != nil { + return err + } + workerToken, err := loadOrGenerate(filepath.Join(*dataDir, "worker.token")) + if err != nil { + return err + } + + // 2. Admin account: generated once and printed, remembered for later boots. + if *password == "" { + *password, err = loadOrGenerate(filepath.Join(*dataDir, "admin.password")) + if err != nil { + return err + } + } + + // 3. Scientific runtime: ensure the managed venv (best effort). + venvPython := filepath.Join(*dataDir, "venv", binName("bin/python")) + ensureRuntime(log, *dataDir, venvPython) + + // 4. Embedded userservice on the loopback interface. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + usersAddr, closeUsers, err := userservice.Serve(ctx, userservice.Config{ + DBPath: filepath.Join(*dataDir, "users.db"), + JWTSecret: jwtSecret, + AdminEmail: *email, + AdminPassword: *password, + Log: log, + }) + if err != nil { + return fmt.Errorf("embedded userservice: %w", err) + } + defer func() { _ = closeUsers() }() + + // 5. Local worker agents before the server, so they can claim immediately. + coordinatorURL := "http://" + *addr + agents, err := spawnAgents(ctx, log, *dataDir, *workers, coordinatorURL, workerToken, venvPython) + if err != nil { + return err + } + defer stopAgents(agents) + + // 6. The coordinator server itself. + cfg := infra.Config{ + Addr: *addr, + DatabaseEngine: "sqlite", + DBPath: filepath.Join(*dataDir, "scimesh.db"), + Token: workerToken, + JWTSecret: jwtSecret, + UserserviceURL: "http://" + usersAddr, + PublicUserserviceURL: "http://" + usersAddr, + LogLevel: "info", + StorageDir: filepath.Join(*dataDir, "artifacts"), + DocsDir: *docsDir, + MaxUploadBytes: 1 << 30, + DBMaxConns: 4, + DBConnectTimeout: 10 * time.Second, + RequestTimeout: 15 * time.Second, + HeartbeatInterval: 15 * time.Second, + LeaseDuration: 2 * time.Minute, + DefaultMaxAttempts: 3, + QuorumSize: 2, + ReaperInterval: 30 * time.Second, + WorkerOfflineAfter: 1 * time.Minute, + AutoMigrate: true, + } + if *open { + openBrowser("http://" + *addr + "/ui") + } + + // Print the login once the server is about to start. + fmt.Printf("\nSciMesh is starting at http://%s/ui\n", *addr) + fmt.Printf(" admin login: %s / %s\n", *email, *password) + if runtimeStatus(venvPython) { + fmt.Printf(" scientific runtime: ready (%s)\n", venvPython) + } else { + fmt.Printf(" scientific runtime: NOT ready — install Python 3, then restart serve\n") + } + fmt.Printf(" data directory: %s\n\n", *dataDir) + + err = runWithConfig(cfg) + cancel() + stopAgents(agents) + return err +} + +// defaultDataDir returns the platform-appropriate data directory. +func defaultDataDir() string { + if dir := os.Getenv("SCIMESH_DATA_DIR"); dir != "" { + return dir + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return ".scimesh" + } + return filepath.Join(home, ".scimesh") +} + +// loadOrGenerate reads a secret file, creating it with fresh random content +// (chmod 0600) when missing. +// #nosec G304 -- the path is an operator-supplied secret file inside the data dir. +func loadOrGenerate(path string) (string, error) { + if raw, err := os.ReadFile(path); err == nil { + return strings.TrimSpace(string(raw)), nil + } + buffer := make([]byte, 32) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + secret := hex.EncodeToString(buffer) + if err := os.WriteFile(path, []byte(secret+"\n"), 0o600); err != nil { + return "", fmt.Errorf("write %s: %w", path, err) + } + return secret, nil +} + +// spawnAgents starts `coordinator agent` subprocesses that claim tasks from +// the coordinator. Each gets its own work directory under the data dir. +func spawnAgents(ctx context.Context, log *slog.Logger, dataDir string, count int, + coordinatorURL, token, venvPython string) ([]*exec.Cmd, error) { + + var agents []*exec.Cmd + for i := 0; i < count; i++ { + workDir := filepath.Join(dataDir, "workers", fmt.Sprintf("%d", i)) + if err := os.MkdirAll(workDir, 0o750); err != nil { + return agents, err + } + taskRunner := defaultTaskRunner(venvPython) + // #nosec G204,G702 -- the command is this binary itself with operator flags. + cmd := exec.CommandContext(ctx, os.Args[0], "agent", + "--coordinator-url", coordinatorURL, + "--token", token, + "--work-dir", workDir, + "--task-runner", taskRunner, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return agents, fmt.Errorf("start local agent %d: %w", i, err) + } + agents = append(agents, cmd) + log.Info("local worker agent started", "index", i) + } + return agents, nil +} + +// stopAgents terminates the spawned agents and waits briefly for them. +func stopAgents(agents []*exec.Cmd) { + for _, agent := range agents { + if agent.Process != nil { + _ = agent.Process.Kill() + } + } + done := make(chan struct{}) + go func() { + for _, agent := range agents { + _, _ = agent.Process.Wait() + } + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + } +} + +// defaultTaskRunner picks the managed venv python when present, else the +// system `python`. +func defaultTaskRunner(venvPython string) string { + if runtimeStatus(venvPython) { + return venvPython + " -m scimesh.worker.task" + } + return "python -m scimesh.worker.task" +} + +// ensureRuntime creates the managed venv and installs scimesh into it, unless +// it already exists. Best effort: a missing Python only logs a hint. +func ensureRuntime(log *slog.Logger, dataDir, venvPython string) { + if runtimeStatus(venvPython) { + return + } + python := findPython() + if python == "" { + log.Warn("python3 not found; local workers need it to run scientific workloads") + return + } + log.Info("creating the scientific runtime venv", "python", python) + venvDir := filepath.Dir(filepath.Dir(venvPython)) + // #nosec G204 -- python comes from PATH and venvDir from the data dir. + create := exec.CommandContext(context.Background(), python, "-m", "venv", venvDir) + if out, err := create.CombinedOutput(); err != nil { + log.Warn("venv creation failed; local workers need a manual Python install", "err", err, "output", string(out)) + return + } + pip := filepath.Join(venvDir, binName("bin/pip")) + // The scimesh package is installed from an explicit source only: the PyPI + // name is not ours yet, so `pip install scimesh` would fetch a stranger's + // package. Operators publish a wheel or index via SCIMESH_PIP_PACKAGE. + source := os.Getenv("SCIMESH_PIP_PACKAGE") + if source == "" { + log.Warn("scientific runtime venv created, but scimesh is not installed", + "hint", pip+" install (or set SCIMESH_PIP_PACKAGE)") + return + } + // #nosec G204,G702 -- pip and source are operator-configured paths. + install := exec.CommandContext(context.Background(), pip, "install", source) + if out, err := install.CombinedOutput(); err != nil { + log.Warn("pip install failed", "err", err, "output", string(out)) + return + } + log.Info("scientific runtime installed", "venv", venvDir) +} + +// findPython locates a usable python3. +func findPython() string { + for _, candidate := range []string{"python3", "python"} { + path, err := exec.LookPath(candidate) + if err == nil { + return path + } + } + return "" +} + +// runtimeStatus reports whether the managed venv python exists. +func runtimeStatus(venvPython string) bool { + info, err := os.Stat(venvPython) + return err == nil && !info.IsDir() +} + +// binName adapts a relative path to the platform layout. +func binName(relative string) string { + if runtime.GOOS == "windows" { + parts := strings.Split(relative, "/") + parts[len(parts)-1] += ".exe" + return strings.Join(parts, string(filepath.Separator)) + } + return relative +} + +// openBrowser opens the UI in the platform's default browser. +func openBrowser(target string) { + command := "" + switch runtime.GOOS { + case "darwin": + command = "open" + case "windows": + command = "rundll32" + default: + command = "xdg-open" + } + if command == "rundll32" { + // #nosec G204 -- target is the local UI URL the operator asked to open. + _ = exec.CommandContext(context.Background(), "rundll32", "url.dll,FileProtocolHandler", target).Start() + return + } + // #nosec G204 -- target is the local UI URL the operator asked to open. + _ = exec.CommandContext(context.Background(), command, target).Start() +} diff --git a/coordinator/internal/agent/config.go b/coordinator/internal/agent/config.go index 20890f7..8b6841f 100644 --- a/coordinator/internal/agent/config.go +++ b/coordinator/internal/agent/config.go @@ -101,7 +101,7 @@ func LoadConfig() (*Config, error) { return nil, err } if len(capabilities) == 0 { - capabilities = defaultCapabilities() + capabilities = DefaultCapabilities() } runner, err := envList("TASK_RUNNER") if err != nil { @@ -160,11 +160,11 @@ func durationEnv(name string, fallback time.Duration) (time.Duration, error) { return parsed, nil } -// defaultCapabilities derives the worker's advertised capabilities from the +// DefaultCapabilities derives the worker's advertised capabilities from the // embedded workload catalog, so an agent is workload-agnostic out of the box: // it claims whatever enabled workloads the coordinator library declares. // Explicit CAPABILITIES still overrides this for operators who want a subset. -func defaultCapabilities() []string { +func DefaultCapabilities() []string { catalog, err := workloads.Load() if err != nil { return []string{"similarity-search"} diff --git a/coordinator/internal/userservice/auth/jwt.go b/coordinator/internal/userservice/auth/jwt.go new file mode 100644 index 0000000..5ad3bbb --- /dev/null +++ b/coordinator/internal/userservice/auth/jwt.go @@ -0,0 +1,72 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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/coordinator/internal/userservice/auth/jwt_test.go b/coordinator/internal/userservice/auth/jwt_test.go new file mode 100644 index 0000000..7a09288 --- /dev/null +++ b/coordinator/internal/userservice/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/coordinator/internal/userservice/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/coordinator/internal/userservice/auth/password.go b/coordinator/internal/userservice/auth/password.go new file mode 100644 index 0000000..db790f4 --- /dev/null +++ b/coordinator/internal/userservice/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/coordinator/internal/userservice/auth/password_test.go b/coordinator/internal/userservice/auth/password_test.go new file mode 100644 index 0000000..2094f3b --- /dev/null +++ b/coordinator/internal/userservice/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/coordinator/internal/userservice/domain/errors.go b/coordinator/internal/userservice/domain/errors.go new file mode 100644 index 0000000..05de0a8 --- /dev/null +++ b/coordinator/internal/userservice/domain/errors.go @@ -0,0 +1,13 @@ +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") + + ErrWorkerKeyNameTooLong = errors.New("worker key name is too long") +) diff --git a/coordinator/internal/userservice/domain/user.go b/coordinator/internal/userservice/domain/user.go new file mode 100644 index 0000000..77ca642 --- /dev/null +++ b/coordinator/internal/userservice/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/coordinator/internal/userservice/domain/user_test.go b/coordinator/internal/userservice/domain/user_test.go new file mode 100644 index 0000000..9894cd2 --- /dev/null +++ b/coordinator/internal/userservice/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/coordinator/internal/userservice/domain/workerkey.go b/coordinator/internal/userservice/domain/workerkey.go new file mode 100644 index 0000000..f0f0024 --- /dev/null +++ b/coordinator/internal/userservice/domain/workerkey.go @@ -0,0 +1,84 @@ +package domain + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + // workerKeyLabel makes a key self-describing when it turns up in a log or an + // env var, and lets a client sanity-check the shape before exchanging it. + workerKeyLabel = "scimesh_wk_live_" + // workerKeyRandomBytes is the entropy behind the secret. 24 bytes (192 bits) + // is far beyond guessable, which is why the stored hash needs no salt. + workerKeyRandomBytes = 24 + // workerKeyPrefixChars is how much of the random tail we keep, alongside the + // label, as the non-secret identifier shown in the UI. + workerKeyPrefixChars = 8 + // workerKeyNameMax caps the user-supplied label. + workerKeyNameMax = 100 + // workerKeyDefaultName is used when the caller supplies no label. + workerKeyDefaultName = "my machine" +) + +// WorkerKey is a long-lived, per-user credential for running a worker. The +// secret itself is never stored — only TokenHash — so the plaintext returned by +// NewWorkerKey is the one and only chance to show it to the user. +type WorkerKey struct { + ID uuid.UUID + UserID uuid.UUID + Name string + TokenHash string + Prefix string + CreatedAt time.Time + LastUsedAt *time.Time + RevokedAt *time.Time +} + +// NewWorkerKey mints a key for a user and returns both the entity (carrying only +// the hash) and the one-time plaintext to hand back to the caller. The label is +// trimmed and defaulted; an over-long one is rejected. +func NewWorkerKey(userID uuid.UUID, name string, now time.Time) (*WorkerKey, string, error) { + name = strings.TrimSpace(name) + if name == "" { + name = workerKeyDefaultName + } + if len(name) > workerKeyNameMax { + return nil, "", ErrWorkerKeyNameTooLong + } + + b := make([]byte, workerKeyRandomBytes) + if _, err := rand.Read(b); err != nil { + return nil, "", err + } + // URL-safe, unpadded: the key rides in env vars and shell commands, so it + // must contain no '=', '+', or '/' that a shell might mangle. + raw := workerKeyLabel + base64.RawURLEncoding.EncodeToString(b) + + key := &WorkerKey{ + ID: uuid.New(), + UserID: userID, + Name: name, + TokenHash: HashWorkerKey(raw), + Prefix: raw[:len(workerKeyLabel)+workerKeyPrefixChars], + CreatedAt: now, + } + return key, raw, nil +} + +// HashWorkerKey returns the hex SHA-256 of a presented key. Exchange hashes the +// incoming key the same way and looks the row up by it, so the plaintext never +// has to be compared directly. +func HashWorkerKey(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +// Revoked reports whether the key has been retired and must no longer exchange. +func (k *WorkerKey) Revoked() bool { return k.RevokedAt != nil } diff --git a/coordinator/internal/userservice/domain/workerkey_test.go b/coordinator/internal/userservice/domain/workerkey_test.go new file mode 100644 index 0000000..0cbd68e --- /dev/null +++ b/coordinator/internal/userservice/domain/workerkey_test.go @@ -0,0 +1,62 @@ +package domain_test + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain" +) + +func TestNewWorkerKeyShape(t *testing.T) { + owner := uuid.New() + now := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC) + + key, raw, err := domain.NewWorkerKey(owner, "home-desktop", now) + if err != nil { + t.Fatalf("NewWorkerKey: %v", err) + } + if !strings.HasPrefix(raw, "scimesh_wk_live_") { + t.Errorf("raw key has no recognisable label: %q", raw) + } + if key.TokenHash != domain.HashWorkerKey(raw) { + t.Error("stored hash does not match the plaintext") + } + if key.TokenHash == raw || strings.Contains(key.TokenHash, raw) { + t.Error("plaintext leaked into the stored hash") + } + if !strings.HasPrefix(raw, key.Prefix) { + t.Errorf("prefix %q is not a leading slice of the key", key.Prefix) + } + if key.UserID != owner || key.CreatedAt != now || key.Revoked() { + t.Errorf("unexpected key metadata: %+v", key) + } +} + +func TestNewWorkerKeyDefaultsBlankName(t *testing.T) { + key, _, err := domain.NewWorkerKey(uuid.New(), " ", time.Now()) + if err != nil { + t.Fatalf("NewWorkerKey: %v", err) + } + if key.Name == "" { + t.Error("blank name was not defaulted") + } +} + +func TestNewWorkerKeyRejectsLongName(t *testing.T) { + _, _, err := domain.NewWorkerKey(uuid.New(), strings.Repeat("x", 101), time.Now()) + if !errors.Is(err, domain.ErrWorkerKeyNameTooLong) { + t.Errorf("got %v, want ErrWorkerKeyNameTooLong", err) + } +} + +func TestNewWorkerKeyUniquePerCall(t *testing.T) { + a, rawA, _ := domain.NewWorkerKey(uuid.New(), "a", time.Now()) + b, rawB, _ := domain.NewWorkerKey(uuid.New(), "b", time.Now()) + if rawA == rawB || a.TokenHash == b.TokenHash || a.ID == b.ID { + t.Error("two keys collided; generation is not random") + } +} diff --git a/coordinator/internal/userservice/embedded.go b/coordinator/internal/userservice/embedded.go new file mode 100644 index 0000000..bc58f9a --- /dev/null +++ b/coordinator/internal/userservice/embedded.go @@ -0,0 +1,113 @@ +// Package userservice is the SciMesh authentication service, embedded into the +// coordinator binary for single-binary deployments. The packages here are the +// same code the standalone `users/` service runs, with its PostgreSQL storage +// replaced by an embedded SQLite backend. The coordinator's HTTP layer talks +// to it through the usual USERSERVICE_URL proxy, so no proxy code changes. +package userservice + +import ( + "context" + "fmt" + "log/slog" + "net" + "net/http" + "time" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/storage/sqlite" + usershttp "github.com/emil28092005/SciMesh/coordinator/internal/userservice/transport/http" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase" +) + +// Config wires the embedded userservice. +type Config struct { + // DBPath is the sqlite database file (for example /users.db). + DBPath string + // JWTSecret must equal the coordinator's JWT_SECRET so tokens verify. + JWTSecret string + // AdminEmail/AdminPassword bootstrap the first admin on first run. + AdminEmail string + AdminPassword string + // Log receives the service's log lines. + Log *slog.Logger +} + +// Serve runs the embedded userservice until ctx is cancelled. It listens only +// on the loopback interface; the coordinator proxies to it internally. +func Serve(ctx context.Context, cfg Config) (string, func() error, error) { + db, err := sqlite.Open(cfg.DBPath) + if err != nil { + return "", nil, err + } + if err := sqlite.Migrate(ctx, db, cfg.Log); err != nil { + _ = db.Close() + return "", nil, err + } + + clock := NewClock() + users := sqlite.NewUserRepo(db) + workerKeys := sqlite.NewWorkerKeyRepo(db) + hasher := auth.NewHasher(0) + issuer := auth.NewIssuer(cfg.JWTSecret, 24*time.Hour, clock.Now) + + uc := usershttp.UseCases{ + Register: usecase.NewRegister(users, hasher, clock), + Login: usecase.NewLogin(users, hasher, issuer), + SetVerified: usecase.NewSetVerified(users), + SetRole: usecase.NewSetRole(users), + CreateWorkerKey: usecase.NewCreateWorkerKey(workerKeys, clock), + ListWorkerKeys: usecase.NewListWorkerKeys(workerKeys), + RevokeWorkerKey: usecase.NewRevokeWorkerKey(workerKeys), + ExchangeWorkerKey: usecase.NewExchangeWorkerKey(workerKeys, users, issuer, 24*time.Hour), + Users: users, + } + + if cfg.AdminEmail != "" && cfg.AdminPassword != "" { + created, err := usecase.NewBootstrapAdmin(users, hasher, clock). + Execute(ctx, cfg.AdminEmail, cfg.AdminPassword) + if err != nil { + _ = db.Close() + return "", nil, fmt.Errorf("bootstrap admin: %w", err) + } + if created { + cfg.Log.Info("embedded userservice created the admin account", "email", cfg.AdminEmail) + } + } + + handler := usershttp.NewServer(cfg.Log, uc, issuer) + handler = http.TimeoutHandler(handler, 15*time.Second, `{"error":"request timeout"}`) + + // A fixed loopback port lets the coordinator's proxy reuse its config + // unchanged; collisions are unlikely (no other process binds 18081 on a + // fresh machine) and fail loudly. + listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:18081") + if err != nil { + _ = db.Close() + return "", nil, fmt.Errorf("listen for embedded userservice: %w", err) + } + server := &http.Server{ + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + } + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + cfg.Log.Error("embedded userservice stopped", "err", err) + } + }() + return listener.Addr().String(), func() error { return db.Close() }, nil +} + +// NewClock returns the userservice's wall clock. +func NewClock() *Clock { return &Clock{} } + +// Clock implements the userservice usecase clock. +type Clock struct{} + +// Now returns the current UTC time. +func (c *Clock) Now() time.Time { return time.Now().UTC() } diff --git a/coordinator/internal/userservice/memstore/memstore.go b/coordinator/internal/userservice/memstore/memstore.go new file mode 100644 index 0000000..ed7c80e --- /dev/null +++ b/coordinator/internal/userservice/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/coordinator/internal/userservice/domain" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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/coordinator/internal/userservice/storage/sqlite/migrate.go b/coordinator/internal/userservice/storage/sqlite/migrate.go new file mode 100644 index 0000000..8e2ce46 --- /dev/null +++ b/coordinator/internal/userservice/storage/sqlite/migrate.go @@ -0,0 +1,81 @@ +package sqlite + +import ( + "context" + "database/sql" + "embed" + "fmt" + "log/slog" + "regexp" + "sort" + "strconv" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +var migrationNamePattern = regexp.MustCompile(`^([0-9]+)_[a-z0-9_]+\.sql$`) + +// Migrate applies every embedded migration above the PRAGMA user_version +// watermark, each inside its own transaction. +func Migrate(ctx context.Context, db *sql.DB, log *slog.Logger) error { + entries, err := migrationFiles.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read embedded migrations: %w", err) + } + type file struct { + version int + name string + } + var files []file + byVersion := map[int]string{} + for _, entry := range entries { + match := migrationNamePattern.FindStringSubmatch(entry.Name()) + if match == nil { + continue + } + version, err := strconv.Atoi(match[1]) + if err != nil { + return fmt.Errorf("migration %q has an invalid version: %w", entry.Name(), err) + } + body, err := migrationFiles.ReadFile("migrations/" + entry.Name()) + if err != nil { + return fmt.Errorf("read migration %q: %w", entry.Name(), err) + } + byVersion[version] = string(body) + files = append(files, file{version: version, name: entry.Name()}) + } + if len(files) == 0 { + return fmt.Errorf("no userservice migrations are embedded") + } + sort.Slice(files, func(i, j int) bool { return files[i].version < files[j].version }) + + var applied int + if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&applied); err != nil { + return fmt.Errorf("read schema version: %w", err) + } + for _, item := range files { + if item.version <= applied { + continue + } + if log != nil { + log.Info("applying userservice migration", "version", item.version, "file", item.name) + } + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, byVersion[item.version]); err != nil { + _ = tx.Rollback() + return fmt.Errorf("apply migration %s: %w", item.name, err) + } + if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", item.version)); err != nil { + _ = tx.Rollback() + return fmt.Errorf("advance schema version after %s: %w", item.name, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %s: %w", item.name, err) + } + } + return nil +} diff --git a/coordinator/internal/userservice/storage/sqlite/migrations/0001_users.sql b/coordinator/internal/userservice/storage/sqlite/migrations/0001_users.sql new file mode 100644 index 0000000..02e5fa0 --- /dev/null +++ b/coordinator/internal/userservice/storage/sqlite/migrations/0001_users.sql @@ -0,0 +1,24 @@ +-- 0001: userservice schema. Users and long-lived worker keys, in the same +-- style as the coordinator's sqlite schema: TEXT ids, INTEGER timestamps. +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('admin','user')), + verified INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS worker_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + revoked_at INTEGER +); + +CREATE INDEX IF NOT EXISTS ix_worker_keys_user ON worker_keys (user_id); diff --git a/coordinator/internal/userservice/storage/sqlite/sqlite.go b/coordinator/internal/userservice/storage/sqlite/sqlite.go new file mode 100644 index 0000000..65c2815 --- /dev/null +++ b/coordinator/internal/userservice/storage/sqlite/sqlite.go @@ -0,0 +1,256 @@ +// Package sqlite implements the userservice repository ports on an embedded +// SQLite database, mirroring the coordinator's single-binary storage choice. +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + _ "modernc.org/sqlite" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase" +) + +const userColumns = `id, email, password_hash, role, verified, created_at, updated_at` + +// scanUser maps one row onto a domain.User. +func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) { + var ( + u domain.User + role string + verified int64 + ) + var created, updated sql.NullInt64 + if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &role, &verified, &created, &updated); err != nil { + return nil, err + } + u.Role = domain.Role(role) + u.Verified = verified != 0 + u.CreatedAt = decodeTime(created.Int64) + u.UpdatedAt = decodeTime(updated.Int64) + return &u, nil +} + +// UserRepo implements usecase.UserRepository on SQLite. +type UserRepo struct { + db *sql.DB +} + +func NewUserRepo(db *sql.DB) *UserRepo { + return &UserRepo{db: db} +} + +func (r *UserRepo) Insert(ctx context.Context, u *domain.User) error { + _, err := r.db.ExecContext(ctx, ` +INSERT INTO users (id, email, password_hash, role, verified, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?)`, + u.ID.String(), u.Email, u.PasswordHash, string(u.Role), boolInt(u.Verified), + u.CreatedAt.UnixNano(), u.UpdatedAt.UnixNano()) + if err != nil && isUnique(err) { + return usecase.ErrEmailExists + } + return err +} + +func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) { + return r.getBy(ctx, "email = ?", email) +} + +func (r *UserRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) { + return r.getBy(ctx, "id = ?", id.String()) +} + +func (r *UserRepo) getBy(ctx context.Context, clause string, arg any) (*domain.User, error) { + // #nosec G202 -- clause is an internal constant, never user input. + row := r.db.QueryRowContext(ctx, "SELECT "+userColumns+" FROM users WHERE "+clause, arg) + user, err := scanUser(row) + return user, mapErrNoRows(err, usecase.ErrUserNotFound) +} + +func (r *UserRepo) SetVerified(ctx context.Context, id uuid.UUID, verified bool) error { + res, err := r.db.ExecContext(ctx, + "UPDATE users SET verified = ?, updated_at = ? WHERE id = ?", + boolInt(verified), time.Now().UnixNano(), id.String()) + if err != nil { + return err + } + return rowsAffectedOrNotFound(res, usecase.ErrUserNotFound) +} + +func (r *UserRepo) SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error { + res, err := r.db.ExecContext(ctx, + "UPDATE users SET role = ?, updated_at = ? WHERE id = ?", + string(role), time.Now().UnixNano(), id.String()) + if err != nil { + return err + } + return rowsAffectedOrNotFound(res, usecase.ErrUserNotFound) +} + +const workerKeyColumns = `id, user_id, name, token_hash, prefix, created_at, last_used_at, revoked_at` + +func scanWorkerKey(row interface{ Scan(dest ...any) error }) (*domain.WorkerKey, error) { + var ( + k domain.WorkerKey + lastUsed sql.NullInt64 + revoked sql.NullInt64 + created sql.NullInt64 + ) + if err := row.Scan(&k.ID, &k.UserID, &k.Name, &k.TokenHash, &k.Prefix, &created, &lastUsed, &revoked); err != nil { + return nil, err + } + k.CreatedAt = decodeTime(created.Int64) + if lastUsed.Valid { + value := decodeTime(lastUsed.Int64) + k.LastUsedAt = &value + } + if revoked.Valid { + value := decodeTime(revoked.Int64) + k.RevokedAt = &value + } + return &k, nil +} + +// WorkerKeyRepo implements usecase.WorkerKeyRepository on SQLite. +type WorkerKeyRepo struct { + db *sql.DB +} + +func NewWorkerKeyRepo(db *sql.DB) *WorkerKeyRepo { + return &WorkerKeyRepo{db: db} +} + +func (r *WorkerKeyRepo) Insert(ctx context.Context, k *domain.WorkerKey) error { + _, err := r.db.ExecContext(ctx, ` +INSERT INTO worker_keys (id, user_id, name, token_hash, prefix, created_at, last_used_at, revoked_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + k.ID.String(), k.UserID.String(), k.Name, k.TokenHash, k.Prefix, + k.CreatedAt.UnixNano(), nullableTime(k.LastUsedAt), nullableTime(k.RevokedAt)) + return err +} + +func (r *WorkerKeyRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) { + rows, err := r.db.QueryContext(ctx, + "SELECT "+workerKeyColumns+" FROM worker_keys WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC", + userID.String()) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var keys []*domain.WorkerKey + for rows.Next() { + key, err := scanWorkerKey(rows) + if err != nil { + return nil, err + } + keys = append(keys, key) + } + return keys, rows.Err() +} + +func (r *WorkerKeyRepo) GetActiveByHash(ctx context.Context, tokenHash string) (*domain.WorkerKey, error) { + row := r.db.QueryRowContext(ctx, + "SELECT "+workerKeyColumns+" FROM worker_keys WHERE token_hash = ? AND revoked_at IS NULL", + tokenHash) + key, err := scanWorkerKey(row) + return key, mapErrNoRows(err, usecase.ErrWorkerKeyNotFound) +} + +func (r *WorkerKeyRepo) Revoke(ctx context.Context, id, userID uuid.UUID) error { + res, err := r.db.ExecContext(ctx, + "UPDATE worker_keys SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL", + time.Now().UnixNano(), id.String(), userID.String()) + if err != nil { + return err + } + return rowsAffectedOrNotFound(res, usecase.ErrWorkerKeyNotFound) +} + +func (r *WorkerKeyRepo) TouchLastUsed(ctx context.Context, id uuid.UUID) error { + _, err := r.db.ExecContext(ctx, + "UPDATE worker_keys SET last_used_at = ? WHERE id = ?", + time.Now().UnixNano(), id.String()) + return err +} + +// --- helpers --------------------------------------------------------------- + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func nullableTime(t *time.Time) any { + if t == nil { + return nil + } + return t.UnixNano() +} + +func decodeTime(raw any) time.Time { + switch v := raw.(type) { + case int64: + return time.Unix(0, v).UTC() + } + return time.Time{} +} + +func mapErrNoRows(err error, notFound error) error { + if errors.Is(err, sql.ErrNoRows) { + return notFound + } + return err +} + +func rowsAffectedOrNotFound(res sql.Result, notFound error) error { + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return notFound + } + return nil +} + +// isUnique reports whether the error is a SQLite unique-constraint violation. +func isUnique(err error) bool { + return err != nil && (contains(err.Error(), "UNIQUE constraint failed") || + contains(err.Error(), "constraint failed")) +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && (haystack == needle || len(haystack) > len(needle) && + (indexOf(haystack, needle) >= 0)) +} + +func indexOf(haystack, needle string) int { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return i + } + } + return -1 +} + +// Open opens (and creates when missing) the userservice database file. +func Open(path string) (*sql.DB, error) { + dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open userservice database: %w", err) + } + if err := db.PingContext(context.Background()); err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping userservice database: %w", err) + } + return db, nil +} diff --git a/coordinator/internal/userservice/storage/sqlite/sqlite_test.go b/coordinator/internal/userservice/storage/sqlite/sqlite_test.go new file mode 100644 index 0000000..c542c9c --- /dev/null +++ b/coordinator/internal/userservice/storage/sqlite/sqlite_test.go @@ -0,0 +1,137 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase" +) + +func newTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := Open(filepath.Join(t.TempDir(), "users.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := Migrate(context.Background(), db, nil); err != nil { + t.Fatalf("migrate: %v", err) + } + return db +} + +func TestMigrateIsIdempotent(t *testing.T) { + db := newTestDB(t) + if err := Migrate(context.Background(), db, nil); err != nil { + t.Fatalf("second migrate: %v", err) + } +} + +func TestUserRepoRoundTripAndSentinels(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + repo := NewUserRepo(db) + + user, err := domain.NewUser("root@scimesh.local", "hash", time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if err := repo.Insert(ctx, user); err != nil { + t.Fatal(err) + } + got, err := repo.GetByEmail(ctx, "root@scimesh.local") + if err != nil { + t.Fatal(err) + } + if got.ID != user.ID || got.Role != domain.RoleUser || got.Verified { + t.Errorf("user = %+v", got) + } + duplicate, err := domain.NewUser("ROOT@scimesh.local", "hash2", time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if err := repo.Insert(ctx, duplicate); !errors.Is(err, usecase.ErrEmailExists) { + t.Errorf("duplicate email err = %v, want ErrEmailExists", err) + } + if _, err := repo.GetByID(ctx, uuid.New()); !errors.Is(err, usecase.ErrUserNotFound) { + t.Errorf("missing user err = %v, want ErrUserNotFound", err) + } + if err := repo.SetVerified(ctx, user.ID, true); err != nil { + t.Fatal(err) + } + got, err = repo.GetByID(ctx, user.ID) + if err != nil { + t.Fatal(err) + } + if !got.Verified { + t.Error("verified flag did not persist") + } + if err := repo.SetRole(ctx, user.ID, domain.RoleAdmin); err != nil { + t.Fatal(err) + } + got, err = repo.GetByID(ctx, user.ID) + if err != nil { + t.Fatal(err) + } + if got.Role != domain.RoleAdmin { + t.Errorf("role = %q, want admin", got.Role) + } +} + +func TestWorkerKeyRepoLifecycle(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + repo := NewWorkerKeyRepo(db) + + user, err := domain.NewUser("worker@scimesh.local", "hash", time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if err := NewUserRepo(db).Insert(ctx, user); err != nil { + t.Fatal(err) + } + + key, plaintext, err := domain.NewWorkerKey(user.ID, "my machine", time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if plaintext == "" || key.TokenHash == "" { + t.Fatal("key must carry a hash and return a plaintext") + } + if err := repo.Insert(ctx, key); err != nil { + t.Fatal(err) + } + found, err := repo.GetActiveByHash(ctx, key.TokenHash) + if err != nil { + t.Fatal(err) + } + if found.ID != key.ID { + t.Errorf("key = %+v", found) + } + keys, err := repo.ListByUser(ctx, user.ID) + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 { + t.Errorf("keys = %d, want 1", len(keys)) + } + if err := repo.TouchLastUsed(ctx, key.ID); err != nil { + t.Fatal(err) + } + if err := repo.Revoke(ctx, key.ID, user.ID); err != nil { + t.Fatal(err) + } + if _, err := repo.GetActiveByHash(ctx, key.TokenHash); !errors.Is(err, usecase.ErrWorkerKeyNotFound) { + t.Errorf("revoked key err = %v, want ErrWorkerKeyNotFound", err) + } + if err := repo.Revoke(ctx, key.ID, user.ID); !errors.Is(err, usecase.ErrWorkerKeyNotFound) { + t.Errorf("double revoke err = %v, want ErrWorkerKeyNotFound", err) + } +} diff --git a/coordinator/internal/userservice/transport/http/dto.go b/coordinator/internal/userservice/transport/http/dto.go new file mode 100644 index 0000000..43cc750 --- /dev/null +++ b/coordinator/internal/userservice/transport/http/dto.go @@ -0,0 +1,93 @@ +package http + +import ( + "time" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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), + } +} + +// createWorkerKeyRequest is the body for minting a worker key. Name is an +// optional human label; the domain defaults it when blank. +type createWorkerKeyRequest struct { + Name string `json:"name"` +} + +// exchangeWorkerKeyRequest trades a worker key for a short-lived JWT. +type exchangeWorkerKeyRequest struct { + Key string `json:"key"` +} + +type exchangeWorkerKeyResponse struct { + Token string `json:"token"` + ExpiresIn int `json:"expires_in"` +} + +// workerKeyResponse is the public view of a key. It never carries the secret — +// only the non-secret prefix used to identify a row. +type workerKeyResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + CreatedAt string `json:"created_at"` + LastUsedAt string `json:"last_used_at,omitempty"` +} + +// createdWorkerKeyResponse extends the public view with the one-time plaintext, +// returned only from the create call and never again. +type createdWorkerKeyResponse struct { + workerKeyResponse + Key string `json:"key"` +} + +type workerKeysResponse struct { + WorkerKeys []workerKeyResponse `json:"worker_keys"` +} + +func toWorkerKeyResponse(k *domain.WorkerKey) workerKeyResponse { + resp := workerKeyResponse{ + ID: k.ID.String(), + Name: k.Name, + Prefix: k.Prefix, + CreatedAt: k.CreatedAt.UTC().Format(time.RFC3339), + } + if k.LastUsedAt != nil { + resp.LastUsedAt = k.LastUsedAt.UTC().Format(time.RFC3339) + } + return resp +} diff --git a/coordinator/internal/userservice/transport/http/errors.go b/coordinator/internal/userservice/transport/http/errors.go new file mode 100644 index 0000000..a00c155 --- /dev/null +++ b/coordinator/internal/userservice/transport/http/errors.go @@ -0,0 +1,69 @@ +package http + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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.ErrWorkerKeyNotFound): + return http.StatusNotFound, "worker key not found" + case errors.Is(err, usecase.ErrInvalidWorkerKey): + return http.StatusUnauthorized, "invalid worker key" + case errors.Is(err, domain.ErrWorkerKeyNameTooLong): + return http.StatusBadRequest, "worker key name is too long" + 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/coordinator/internal/userservice/transport/http/handlers.go b/coordinator/internal/userservice/transport/http/handlers.go new file mode 100644 index 0000000..5b70266 --- /dev/null +++ b/coordinator/internal/userservice/transport/http/handlers.go @@ -0,0 +1,231 @@ +package http + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase" +) + +// Handlers holds the use cases each endpoint drives. +type Handlers struct { + register *usecase.Register + login *usecase.Login + setVerified *usecase.SetVerified + setRole *usecase.SetRole + createWorkerKey *usecase.CreateWorkerKey + listWorkerKeys *usecase.ListWorkerKeys + revokeWorkerKey *usecase.RevokeWorkerKey + exchangeWorkerKey *usecase.ExchangeWorkerKey + 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) + } +} + +// handleCreateWorkerKey mints a long-lived worker key for the authenticated +// caller and returns it once, plaintext included. The user copies it into their +// worker's SCIMESH_WORKER_KEY; it is never retrievable again. +func (h *Handlers) handleCreateWorkerKey(w http.ResponseWriter, r *http.Request) { + id, ok := userIDFrom(r.Context()) + if !ok { + unauthorized(w, r) + return + } + // A signed JWT can outlive a demo reset or an account deletion. Check that + // its subject still exists before attempting the insert, otherwise the + // worker_keys foreign key would turn a stale session into an internal error. + if _, err := h.users.GetByID(r.Context(), id); err != nil { + if errors.Is(err, usecase.ErrUserNotFound) { + unauthorized(w, r) + return + } + writeError(w, r, h.log, err) + return + } + var req createWorkerKeyRequest + if !decodeJSON(w, r, &req) { + return + } + key, raw, err := h.createWorkerKey.Execute(r.Context(), id, req.Name) + if err != nil { + writeError(w, r, h.log, err) + return + } + writeJSON(w, http.StatusCreated, createdWorkerKeyResponse{ + workerKeyResponse: toWorkerKeyResponse(key), + Key: raw, + }) +} + +// handleListWorkerKeys returns the caller's live keys (no secrets) for display +// and revocation. +func (h *Handlers) handleListWorkerKeys(w http.ResponseWriter, r *http.Request) { + id, ok := userIDFrom(r.Context()) + if !ok { + unauthorized(w, r) + return + } + keys, err := h.listWorkerKeys.Execute(r.Context(), id) + if err != nil { + writeError(w, r, h.log, err) + return + } + out := make([]workerKeyResponse, 0, len(keys)) + for _, k := range keys { + out = append(out, toWorkerKeyResponse(k)) + } + writeJSON(w, http.StatusOK, workerKeysResponse{WorkerKeys: out}) +} + +// handleRevokeWorkerKey retires one of the caller's keys. The repository scopes +// the delete to the owner, so a mismatched id is a clean 404, not another user's +// key. +func (h *Handlers) handleRevokeWorkerKey(w http.ResponseWriter, r *http.Request) { + userID, ok := userIDFrom(r.Context()) + if !ok { + unauthorized(w, r) + return + } + keyID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "invalid worker key id", + RequestID: requestIDFrom(r.Context()), + }) + return + } + if err := h.revokeWorkerKey.Execute(r.Context(), userID, keyID); err != nil { + writeError(w, r, h.log, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleExchangeWorkerKey trades a worker key for a short-lived JWT. It is +// unauthenticated: the key itself is the credential. A worker calls this on +// startup and again to refresh before the JWT expires. +func (h *Handlers) handleExchangeWorkerKey(w http.ResponseWriter, r *http.Request) { + var req exchangeWorkerKeyRequest + if !decodeJSON(w, r, &req) { + return + } + token, expiresIn, err := h.exchangeWorkerKey.Execute(r.Context(), req.Key) + if err != nil { + writeError(w, r, h.log, err) + return + } + writeJSON(w, http.StatusOK, exchangeWorkerKeyResponse{Token: token, ExpiresIn: expiresIn}) +} + +// 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/coordinator/internal/userservice/transport/http/middleware.go b/coordinator/internal/userservice/transport/http/middleware.go new file mode 100644 index 0000000..238905b --- /dev/null +++ b/coordinator/internal/userservice/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/coordinator/internal/userservice/auth" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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/coordinator/internal/userservice/transport/http/server.go b/coordinator/internal/userservice/transport/http/server.go new file mode 100644 index 0000000..6f5df0b --- /dev/null +++ b/coordinator/internal/userservice/transport/http/server.go @@ -0,0 +1,73 @@ +// 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/coordinator/internal/userservice/auth" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase" +) + +// UseCases bundles the application services the handlers drive. +type UseCases struct { + Register *usecase.Register + Login *usecase.Login + SetVerified *usecase.SetVerified + SetRole *usecase.SetRole + CreateWorkerKey *usecase.CreateWorkerKey + ListWorkerKeys *usecase.ListWorkerKeys + RevokeWorkerKey *usecase.RevokeWorkerKey + ExchangeWorkerKey *usecase.ExchangeWorkerKey + 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, + createWorkerKey: uc.CreateWorkerKey, + listWorkerKeys: uc.ListWorkerKeys, + revokeWorkerKey: uc.RevokeWorkerKey, + exchangeWorkerKey: uc.ExchangeWorkerKey, + 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))) + + // Worker keys: a user mints a long-lived key (JWT-protected), and a worker + // trades it for a short-lived JWT on the public exchange endpoint — the key + // itself is the credential there, so no prior token is required. + mux.HandleFunc("POST /worker-tokens/exchange", h.handleExchangeWorkerKey) + mux.Handle("POST /worker-keys", chain(http.HandlerFunc(h.handleCreateWorkerKey), withJWT(issuer))) + mux.Handle("GET /worker-keys", chain(http.HandlerFunc(h.handleListWorkerKeys), withJWT(issuer))) + mux.Handle("DELETE /worker-keys/{id}", chain(http.HandlerFunc(h.handleRevokeWorkerKey), 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/coordinator/internal/userservice/transport/http/server_test.go b/coordinator/internal/userservice/transport/http/server_test.go new file mode 100644 index 0000000..19b6586 --- /dev/null +++ b/coordinator/internal/userservice/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/coordinator/internal/userservice/auth" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore" + apihttp "github.com/emil28092005/SciMesh/coordinator/internal/userservice/transport/http" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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/coordinator/internal/userservice/usecase/bootstrap.go b/coordinator/internal/userservice/usecase/bootstrap.go new file mode 100644 index 0000000..ec13232 --- /dev/null +++ b/coordinator/internal/userservice/usecase/bootstrap.go @@ -0,0 +1,65 @@ +package usecase + +import ( + "context" + "errors" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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/coordinator/internal/userservice/usecase/bootstrap_test.go b/coordinator/internal/userservice/usecase/bootstrap_test.go new file mode 100644 index 0000000..ca33fff --- /dev/null +++ b/coordinator/internal/userservice/usecase/bootstrap_test.go @@ -0,0 +1,71 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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/coordinator/internal/userservice/usecase/errorpaths_test.go b/coordinator/internal/userservice/usecase/errorpaths_test.go new file mode 100644 index 0000000..573d3ac --- /dev/null +++ b/coordinator/internal/userservice/usecase/errorpaths_test.go @@ -0,0 +1,109 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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/coordinator/internal/userservice/usecase/errors.go b/coordinator/internal/userservice/usecase/errors.go new file mode 100644 index 0000000..bc6978d --- /dev/null +++ b/coordinator/internal/userservice/usecase/errors.go @@ -0,0 +1,27 @@ +package usecase + +import "errors" + +var ( + // Repository-contract errors, returned by UserRepository implementations. + ErrEmailExists = errors.New("email already registered") + ErrUserNotFound = errors.New("user not found") + + // ErrWorkerKeyNotFound is returned by WorkerKeyRepository when no live key + // matches (by id for revoke, by hash for exchange). + ErrWorkerKeyNotFound = errors.New("worker key not found") + // ErrInvalidWorkerKey is surfaced to the transport layer for a key that does + // not exchange (unknown, revoked, or owner gone). Deliberately opaque so a + // caller cannot distinguish the cases while probing. + ErrInvalidWorkerKey = errors.New("invalid worker key") + + // 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/coordinator/internal/userservice/usecase/login.go b/coordinator/internal/userservice/usecase/login.go new file mode 100644 index 0000000..1d66ca1 --- /dev/null +++ b/coordinator/internal/userservice/usecase/login.go @@ -0,0 +1,42 @@ +package usecase + +import ( + "context" + "errors" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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/coordinator/internal/userservice/usecase/ports.go b/coordinator/internal/userservice/usecase/ports.go new file mode 100644 index 0000000..047e9cd --- /dev/null +++ b/coordinator/internal/userservice/usecase/ports.go @@ -0,0 +1,67 @@ +// 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/coordinator/internal/userservice/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 +} + +// WorkerKeyRepository persists and looks up the long-lived worker keys a user +// creates to run a worker bound to their account. Implementations return the +// sentinel errors in errors.go so the use cases stay free of SQL types. +type WorkerKeyRepository interface { + // Insert stores a freshly minted key. + Insert(ctx context.Context, k *domain.WorkerKey) error + // ListByUser returns a user's live (non-revoked) keys, newest first. + ListByUser(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) + // GetActiveByHash returns the non-revoked key with the given hash, or + // ErrWorkerKeyNotFound. + GetActiveByHash(ctx context.Context, tokenHash string) (*domain.WorkerKey, error) + // Revoke retires a key the user owns, returning ErrWorkerKeyNotFound when no + // live key with that id belongs to the user. + Revoke(ctx context.Context, id, userID uuid.UUID) error + // TouchLastUsed records a successful exchange. Best-effort: a failure here + // must not fail the exchange itself. + TouchLastUsed(ctx context.Context, id uuid.UUID) 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/coordinator/internal/userservice/usecase/register.go b/coordinator/internal/userservice/usecase/register.go new file mode 100644 index 0000000..0add811 --- /dev/null +++ b/coordinator/internal/userservice/usecase/register.go @@ -0,0 +1,56 @@ +package usecase + +import ( + "context" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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/coordinator/internal/userservice/usecase/role.go b/coordinator/internal/userservice/usecase/role.go new file mode 100644 index 0000000..ba0b3e1 --- /dev/null +++ b/coordinator/internal/userservice/usecase/role.go @@ -0,0 +1,28 @@ +package usecase + +import ( + "context" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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/coordinator/internal/userservice/usecase/usecase_test.go b/coordinator/internal/userservice/usecase/usecase_test.go new file mode 100644 index 0000000..1dfd44f --- /dev/null +++ b/coordinator/internal/userservice/usecase/usecase_test.go @@ -0,0 +1,133 @@ +package usecase_test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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/coordinator/internal/userservice/usecase/verify.go b/coordinator/internal/userservice/usecase/verify.go new file mode 100644 index 0000000..2a4fc94 --- /dev/null +++ b/coordinator/internal/userservice/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/coordinator/internal/userservice/usecase/verify_test.go b/coordinator/internal/userservice/usecase/verify_test.go new file mode 100644 index 0000000..ee58098 --- /dev/null +++ b/coordinator/internal/userservice/usecase/verify_test.go @@ -0,0 +1,73 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/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/coordinator/internal/userservice/usecase/workerkey.go b/coordinator/internal/userservice/usecase/workerkey.go new file mode 100644 index 0000000..1551203 --- /dev/null +++ b/coordinator/internal/userservice/usecase/workerkey.go @@ -0,0 +1,112 @@ +package usecase + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain" +) + +// CreateWorkerKey mints a long-lived worker key for a user and returns the +// one-time plaintext to show once. +type CreateWorkerKey struct { + keys WorkerKeyRepository + clock Clock +} + +func NewCreateWorkerKey(keys WorkerKeyRepository, clock Clock) *CreateWorkerKey { + return &CreateWorkerKey{keys: keys, clock: clock} +} + +// Execute returns the stored key (hash only) and the plaintext secret. The +// secret is never persisted, so this is the sole moment it can be surfaced. +func (uc *CreateWorkerKey) Execute(ctx context.Context, userID uuid.UUID, name string) (*domain.WorkerKey, string, error) { + key, raw, err := domain.NewWorkerKey(userID, name, uc.clock.Now()) + if err != nil { + return nil, "", err + } + if err := uc.keys.Insert(ctx, key); err != nil { + return nil, "", err + } + return key, raw, nil +} + +// ListWorkerKeys returns a user's live keys for display and management. +type ListWorkerKeys struct { + keys WorkerKeyRepository +} + +func NewListWorkerKeys(keys WorkerKeyRepository) *ListWorkerKeys { + return &ListWorkerKeys{keys: keys} +} + +func (uc *ListWorkerKeys) Execute(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) { + return uc.keys.ListByUser(ctx, userID) +} + +// RevokeWorkerKey retires one of the caller's keys. +type RevokeWorkerKey struct { + keys WorkerKeyRepository +} + +func NewRevokeWorkerKey(keys WorkerKeyRepository) *RevokeWorkerKey { + return &RevokeWorkerKey{keys: keys} +} + +func (uc *RevokeWorkerKey) Execute(ctx context.Context, userID, id uuid.UUID) error { + return uc.keys.Revoke(ctx, id, userID) +} + +// ExchangeWorkerKey trades a valid worker key for a short-lived JWT. The JWT +// carries the owner's current role and verified flag, so a worker that refreshes +// after an admin verifies the owner picks up the upgraded trust on its next +// registration. +type ExchangeWorkerKey struct { + keys WorkerKeyRepository + users UserRepository + tokens TokenIssuer + ttl time.Duration +} + +func NewExchangeWorkerKey(keys WorkerKeyRepository, users UserRepository, tokens TokenIssuer, ttl time.Duration) *ExchangeWorkerKey { + return &ExchangeWorkerKey{keys: keys, users: users, tokens: tokens, ttl: ttl} +} + +// Execute returns a signed token and its lifetime in seconds. Every failure to +// resolve the key to a usable owner collapses to ErrInvalidWorkerKey so a caller +// cannot tell an unknown key from a revoked one or a deleted owner. +func (uc *ExchangeWorkerKey) Execute(ctx context.Context, rawKey string) (string, int, error) { + if rawKey == "" { + return "", 0, ErrInvalidWorkerKey + } + + key, err := uc.keys.GetActiveByHash(ctx, domain.HashWorkerKey(rawKey)) + if err != nil { + if errors.Is(err, ErrWorkerKeyNotFound) { + return "", 0, ErrInvalidWorkerKey + } + return "", 0, err + } + + u, err := uc.users.GetByID(ctx, key.UserID) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return "", 0, ErrInvalidWorkerKey + } + return "", 0, err + } + + token, err := uc.tokens.Issue(u) + if err != nil { + return "", 0, err + } + + // Best-effort: a failed timestamp update must not sink an otherwise valid + // exchange the worker depends on to keep running. + _ = uc.keys.TouchLastUsed(ctx, key.ID) + + return token, int(uc.ttl.Seconds()), nil +} diff --git a/coordinator/internal/userservice/usecase/workerkey_test.go b/coordinator/internal/userservice/usecase/workerkey_test.go new file mode 100644 index 0000000..75ecfc2 --- /dev/null +++ b/coordinator/internal/userservice/usecase/workerkey_test.go @@ -0,0 +1,186 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore" + "github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase" +) + +// fakeKeyRepo is an in-memory WorkerKeyRepository for the use-case tests. +type fakeKeyRepo struct { + byHash map[string]*domain.WorkerKey + byID map[uuid.UUID]*domain.WorkerKey + touched []uuid.UUID +} + +func newFakeKeyRepo() *fakeKeyRepo { + return &fakeKeyRepo{byHash: map[string]*domain.WorkerKey{}, byID: map[uuid.UUID]*domain.WorkerKey{}} +} + +func (r *fakeKeyRepo) Insert(_ context.Context, k *domain.WorkerKey) error { + r.byHash[k.TokenHash] = k + r.byID[k.ID] = k + return nil +} + +func (r *fakeKeyRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) { + var out []*domain.WorkerKey + for _, k := range r.byID { + if k.UserID == userID && !k.Revoked() { + out = append(out, k) + } + } + return out, nil +} + +func (r *fakeKeyRepo) GetActiveByHash(_ context.Context, hash string) (*domain.WorkerKey, error) { + k, ok := r.byHash[hash] + if !ok || k.Revoked() { + return nil, usecase.ErrWorkerKeyNotFound + } + return k, nil +} + +func (r *fakeKeyRepo) Revoke(_ context.Context, id, userID uuid.UUID) error { + k, ok := r.byID[id] + if !ok || k.UserID != userID || k.Revoked() { + return usecase.ErrWorkerKeyNotFound + } + now := time.Now() + k.RevokedAt = &now + return nil +} + +func (r *fakeKeyRepo) TouchLastUsed(_ context.Context, id uuid.UUID) error { + r.touched = append(r.touched, id) + return nil +} + +func newKeyFixtures(t *testing.T) (*usecase.CreateWorkerKey, *usecase.ExchangeWorkerKey, *usecase.RevokeWorkerKey, *usecase.ListWorkerKeys, *fakeKeyRepo, *domain.User) { + t.Helper() + users := memstore.NewUserRepo() + 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) + keys := newFakeKeyRepo() + + u, err := usecase.NewRegister(users, hasher, clk).Execute(context.Background(), "worker@example.com", "password123") + if err != nil { + t.Fatalf("seed user: %v", err) + } + + return usecase.NewCreateWorkerKey(keys, clk), + usecase.NewExchangeWorkerKey(keys, users, issuer, time.Hour), + usecase.NewRevokeWorkerKey(keys), + usecase.NewListWorkerKeys(keys), + keys, u +} + +func TestCreateAndExchangeWorkerKey(t *testing.T) { + create, exchange, _, _, keys, u := newKeyFixtures(t) + ctx := context.Background() + + key, raw, err := create.Execute(ctx, u.ID, "home-desktop") + if err != nil { + t.Fatalf("create: %v", err) + } + if key.Name != "home-desktop" || raw == "" { + t.Fatalf("unexpected key %+v raw=%q", key, raw) + } + + token, expiresIn, err := exchange.Execute(ctx, raw) + if err != nil { + t.Fatalf("exchange: %v", err) + } + if expiresIn != int((time.Hour).Seconds()) { + t.Errorf("expires_in = %d, want 3600", expiresIn) + } + + 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 owner %q", claims.Subject, u.ID) + } + if len(keys.touched) != 1 || keys.touched[0] != key.ID { + t.Errorf("exchange did not record last-used, touched=%v", keys.touched) + } +} + +func TestExchangeUnknownKeyIsInvalid(t *testing.T) { + _, exchange, _, _, _, _ := newKeyFixtures(t) + if _, _, err := exchange.Execute(context.Background(), "scimesh_wk_live_nope"); !errors.Is(err, usecase.ErrInvalidWorkerKey) { + t.Errorf("got %v, want ErrInvalidWorkerKey", err) + } +} + +func TestExchangeEmptyKeyIsInvalid(t *testing.T) { + _, exchange, _, _, _, _ := newKeyFixtures(t) + if _, _, err := exchange.Execute(context.Background(), ""); !errors.Is(err, usecase.ErrInvalidWorkerKey) { + t.Errorf("got %v, want ErrInvalidWorkerKey", err) + } +} + +func TestExchangeRevokedKeyIsInvalid(t *testing.T) { + create, exchange, revoke, _, _, u := newKeyFixtures(t) + ctx := context.Background() + + key, raw, err := create.Execute(ctx, u.ID, "laptop") + if err != nil { + t.Fatal(err) + } + if err := revoke.Execute(ctx, u.ID, key.ID); err != nil { + t.Fatalf("revoke: %v", err) + } + if _, _, err := exchange.Execute(ctx, raw); !errors.Is(err, usecase.ErrInvalidWorkerKey) { + t.Errorf("revoked key still exchanges: %v", err) + } +} + +func TestRevokeIsScopedToOwner(t *testing.T) { + create, _, revoke, _, _, u := newKeyFixtures(t) + ctx := context.Background() + + key, _, err := create.Execute(ctx, u.ID, "laptop") + if err != nil { + t.Fatal(err) + } + // A different user must not be able to revoke this key. + if err := revoke.Execute(ctx, uuid.New(), key.ID); !errors.Is(err, usecase.ErrWorkerKeyNotFound) { + t.Errorf("cross-owner revoke returned %v, want ErrWorkerKeyNotFound", err) + } +} + +func TestListReturnsOnlyLiveKeys(t *testing.T) { + create, _, revoke, list, _, u := newKeyFixtures(t) + ctx := context.Background() + + live, _, err := create.Execute(ctx, u.ID, "keep") + if err != nil { + t.Fatal(err) + } + dead, _, err := create.Execute(ctx, u.ID, "drop") + if err != nil { + t.Fatal(err) + } + if err := revoke.Execute(ctx, u.ID, dead.ID); err != nil { + t.Fatal(err) + } + + got, err := list.Execute(ctx, u.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 1 || got[0].ID != live.ID { + t.Errorf("list = %d keys, want only the live one", len(got)) + } +}