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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 }`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user