feat: self-service worker enrollment bound to a user account

Let a signed-in user turn their own machine into a worker without the
shared token. The coordinator already binds a JWT-authenticated
registration to owner_id as untrusted; this adds the missing pieces.

userservice: long-lived worker keys (scimesh_wk_live_*, hash-at-rest)
with create/list/revoke and a public /worker-tokens/exchange that trades
a key for a short-lived JWT carrying the owner current role/verified.

python worker: SCIMESH_WORKER_KEY + SCIMESH_USERSERVICE_URL; a token
provider exchanges the key and refreshes the JWT proactively and on 401,
so a long-running worker survives token expiry. Static bearer token path
is unchanged.

coordinator UI: an "add your machine" page that mints a key and shows a
ready-to-run command, proxying key management to the userservice; the
dashboard gains an owner-scoped "my machines" section.

docs: how to run a worker from your account, plus the untrusted/quorum/
verified trust model.
This commit is contained in:
Efremenko Arhip
2026-07-27 16:11:07 +03:00
parent 172ff76fb8
commit 3a1461315f
34 changed files with 1829 additions and 78 deletions
+10 -5
View File
@@ -49,16 +49,21 @@ func run() error {
// Adapters implementing the usecase ports.
users := postgres.NewUserRepo(pool)
workerKeys := postgres.NewWorkerKeyRepo(pool)
hasher := auth.NewHasher(cfg.BcryptCost)
clock := infra.NewClock()
issuer := auth.NewIssuer(cfg.JWTSecret, cfg.TokenTTL, clock.Now)
uc := apihttp.UseCases{
Register: usecase.NewRegister(users, hasher, clock),
Login: usecase.NewLogin(users, hasher, issuer),
SetVerified: usecase.NewSetVerified(users),
SetRole: usecase.NewSetRole(users),
Users: users,
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, cfg.TokenTTL),
Users: users,
}
// Seed the first admin, if configured. Idempotent: a no-op once it exists.
+2
View File
@@ -8,4 +8,6 @@ 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")
)
+84
View File
@@ -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 }
+61
View File
@@ -0,0 +1,61 @@
package domain_test
import (
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/users/internal/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 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,123 @@
package postgres
import (
"context"
"errors"
sq "github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/emil28092005/SciMesh/users/internal/domain"
"github.com/emil28092005/SciMesh/users/internal/usecase"
)
var workerKeyColumns = []string{
"id", "user_id", "name", "token_hash", "prefix", "created_at", "last_used_at", "revoked_at",
}
// WorkerKeyRepo implements usecase.WorkerKeyRepository on PostgreSQL.
type WorkerKeyRepo struct {
pool *pgxpool.Pool
}
func NewWorkerKeyRepo(pool *pgxpool.Pool) *WorkerKeyRepo {
return &WorkerKeyRepo{pool: pool}
}
func (r *WorkerKeyRepo) Insert(ctx context.Context, k *domain.WorkerKey) error {
sql, args, err := psql.Insert("worker_keys").
Columns(workerKeyColumns...).
Values(k.ID, k.UserID, k.Name, k.TokenHash, k.Prefix, k.CreatedAt, k.LastUsedAt, k.RevokedAt).
ToSql()
if err != nil {
return err
}
_, err = conn(ctx, r.pool).Exec(ctx, sql, args...)
return err
}
func (r *WorkerKeyRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
sql, args, err := psql.Select(workerKeyColumns...).
From("worker_keys").
Where(sq.Eq{"user_id": userID, "revoked_at": nil}).
OrderBy("created_at DESC").
ToSql()
if err != nil {
return nil, err
}
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
if err != nil {
return nil, err
}
defer rows.Close()
keys := []*domain.WorkerKey{}
for rows.Next() {
k, err := scanWorkerKey(rows)
if err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
func (r *WorkerKeyRepo) GetActiveByHash(ctx context.Context, tokenHash string) (*domain.WorkerKey, error) {
sql, args, err := psql.Select(workerKeyColumns...).
From("worker_keys").
Where(sq.Eq{"token_hash": tokenHash, "revoked_at": nil}).
ToSql()
if err != nil {
return nil, err
}
return scanWorkerKey(conn(ctx, r.pool).QueryRow(ctx, sql, args...))
}
// Revoke retires a live key the user owns. Scoping the UPDATE to both id and
// user_id means one user can never revoke another's key, and the revoked_at IS
// NULL guard makes a double-revoke a clean 404 rather than a silent success.
func (r *WorkerKeyRepo) Revoke(ctx context.Context, id, userID uuid.UUID) error {
sql, args, err := psql.Update("worker_keys").
Set("revoked_at", sq.Expr("now()")).
Where(sq.Eq{"id": id, "user_id": userID, "revoked_at": nil}).
ToSql()
if err != nil {
return err
}
tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return usecase.ErrWorkerKeyNotFound
}
return nil
}
func (r *WorkerKeyRepo) TouchLastUsed(ctx context.Context, id uuid.UUID) error {
sql, args, err := psql.Update("worker_keys").
Set("last_used_at", sq.Expr("now()")).
Where(sq.Eq{"id": id}).
ToSql()
if err != nil {
return err
}
_, err = conn(ctx, r.pool).Exec(ctx, sql, args...)
return err
}
func scanWorkerKey(row pgx.Row) (*domain.WorkerKey, error) {
var k domain.WorkerKey
if err := row.Scan(
&k.ID, &k.UserID, &k.Name, &k.TokenHash, &k.Prefix,
&k.CreatedAt, &k.LastUsedAt, &k.RevokedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, usecase.ErrWorkerKeyNotFound
}
return nil, err
}
return &k, nil
}
+50
View File
@@ -41,3 +41,53 @@ func toUserResponse(u *domain.User) userResponse {
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
}
+6
View File
@@ -48,6 +48,12 @@ func statusForError(err error) (int, string) {
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):
+94 -6
View File
@@ -13,12 +13,16 @@ import (
// Handlers holds the use cases each endpoint drives.
type Handlers struct {
register *usecase.Register
login *usecase.Login
setVerified *usecase.SetVerified
setRole *usecase.SetRole
users usecase.UserRepository
log *slog.Logger
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
@@ -113,6 +117,90 @@ func (h *Handlers) handleSetRole(role domain.Role) http.HandlerFunc {
}
}
// 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
}
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 }`.
+27 -11
View File
@@ -14,23 +14,31 @@ import (
// UseCases bundles the application services the handlers drive.
type UseCases struct {
Register *usecase.Register
Login *usecase.Login
SetVerified *usecase.SetVerified
SetRole *usecase.SetRole
Users usecase.UserRepository
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,
users: uc.Users,
log: log,
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()
@@ -41,6 +49,14 @@ func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler {
// /me proves a token round-trips; it sits behind JWT auth.
mux.Handle("GET /me", chain(http.HandlerFunc(h.handleMe), withJWT(issuer)))
// 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",
+8
View File
@@ -7,6 +7,14 @@ var (
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
+19
View File
@@ -30,6 +30,25 @@ type UserRepository interface {
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)
+112
View File
@@ -0,0 +1,112 @@
package usecase
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/users/internal/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
}
+186
View File
@@ -0,0 +1,186 @@
package usecase_test
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/users/internal/auth"
"github.com/emil28092005/SciMesh/users/internal/domain"
"github.com/emil28092005/SciMesh/users/internal/memstore"
"github.com/emil28092005/SciMesh/users/internal/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))
}
}
@@ -0,0 +1,5 @@
BEGIN;
DROP TABLE IF EXISTS worker_keys;
COMMIT;
+31
View File
@@ -0,0 +1,31 @@
BEGIN;
-- A worker key is a long-lived credential a user creates to run a worker on
-- their own machine. Unlike the 24h login JWT, it does not expire on its own:
-- the worker presents it to /worker-tokens/exchange to mint a short-lived JWT
-- and refreshes as needed. Only a SHA-256 hash is stored, never the key itself,
-- so a database leak cannot be replayed as a credential.
CREATE TABLE worker_keys (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Human label so a user can tell their machines apart when revoking.
name text NOT NULL,
-- Hex SHA-256 of the presented key. The key is high-entropy, so a fast hash
-- is enough — no per-key salt or bcrypt cost is needed here.
token_hash text NOT NULL,
-- The leading, non-secret slice of the key, shown in the UI to identify a
-- row without ever revealing the secret again.
prefix text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
-- Last successful exchange; NULL until first use.
last_used_at timestamptz,
-- Set when the user revokes the key; a revoked key never exchanges again.
revoked_at timestamptz,
CONSTRAINT uq_worker_keys_token_hash UNIQUE (token_hash)
);
-- Listing and revoking are always scoped to one owner's live keys.
CREATE INDEX ix_worker_keys_user_active ON worker_keys (user_id) WHERE revoked_at IS NULL;
COMMIT;