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