Embed the userservice and add serve/agent subcommands for one-binary operation
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <your scimesh wheel or index> (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()
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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 <addr>" 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
|
||||
}
|
||||
@@ -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 <bob@x.com>", "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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 <data-dir>/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() }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user