Add admin console access and platform pages (trust, users, keys, workloads, settings)

This commit is contained in:
Emil
2026-08-03 00:11:47 +03:00
parent 7fb1059401
commit e923627ce0
36 changed files with 1562 additions and 86 deletions
+12 -9
View File
@@ -51,15 +51,18 @@ func Serve(ctx context.Context, cfg Config) (string, func() error, error) {
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,
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),
ListWorkerKeysAll: usecase.NewListWorkerKeysAll(workerKeys),
RevokeWorkerKey: usecase.NewRevokeWorkerKey(workerKeys),
RevokeWorkerKeyAdmin: usecase.NewRevokeWorkerKeyAdmin(workerKeys),
ExchangeWorkerKey: usecase.NewExchangeWorkerKey(workerKeys, users, issuer, 24*time.Hour),
ListUsers: usecase.NewListUsers(users),
Users: users,
}
if cfg.AdminEmail != "" && cfg.AdminPassword != "" {
@@ -4,6 +4,7 @@ package memstore
import (
"context"
"sort"
"sync"
"time"
@@ -84,7 +85,105 @@ func (r *UserRepo) SetRole(_ context.Context, id uuid.UUID, role domain.Role) er
return nil
}
// ListUsers returns every account, oldest first. It copies, so callers cannot
// corrupt the store through the returned slice.
func (r *UserRepo) ListUsers(_ context.Context) ([]*domain.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
users := make([]*domain.User, 0, len(r.byID))
for _, u := range r.byID {
copy := u
users = append(users, &copy)
}
sort.Slice(users, func(i, j int) bool { return users[i].CreatedAt.Before(users[j].CreatedAt) })
return users, 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 }
// WorkerKeyRepo is an in-memory usecase.WorkerKeyRepository.
type WorkerKeyRepo struct {
mu sync.Mutex
keys map[uuid.UUID]*domain.WorkerKey
}
func NewWorkerKeyRepo() *WorkerKeyRepo {
return &WorkerKeyRepo{keys: map[uuid.UUID]*domain.WorkerKey{}}
}
func (r *WorkerKeyRepo) Insert(_ context.Context, k *domain.WorkerKey) error {
r.mu.Lock()
defer r.mu.Unlock()
r.keys[k.ID] = k
return nil
}
func (r *WorkerKeyRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
r.mu.Lock()
defer r.mu.Unlock()
var out []*domain.WorkerKey
for _, k := range r.keys {
if k.UserID == userID && !k.Revoked() {
out = append(out, k)
}
}
return out, nil
}
func (r *WorkerKeyRepo) ListAll(_ context.Context) ([]*domain.WorkerKey, error) {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]*domain.WorkerKey, 0, len(r.keys))
for _, k := range r.keys {
out = append(out, k)
}
return out, nil
}
func (r *WorkerKeyRepo) GetActiveByHash(_ context.Context, tokenHash string) (*domain.WorkerKey, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, k := range r.keys {
if k.TokenHash == tokenHash && !k.Revoked() {
return k, nil
}
}
return nil, usecase.ErrWorkerKeyNotFound
}
func (r *WorkerKeyRepo) Revoke(_ context.Context, id, userID uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
k, ok := r.keys[id]
if !ok || k.UserID != userID || k.Revoked() {
return usecase.ErrWorkerKeyNotFound
}
now := time.Now()
k.RevokedAt = &now
return nil
}
func (r *WorkerKeyRepo) RevokeAny(_ context.Context, id uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
k, ok := r.keys[id]
if !ok || k.Revoked() {
return usecase.ErrWorkerKeyNotFound
}
now := time.Now()
k.RevokedAt = &now
return nil
}
func (r *WorkerKeyRepo) TouchLastUsed(_ context.Context, id uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
if k, ok := r.keys[id]; ok {
now := time.Now()
k.LastUsedAt = &now
}
return nil
}
@@ -93,6 +93,24 @@ func (r *UserRepo) SetRole(ctx context.Context, id uuid.UUID, role domain.Role)
return rowsAffectedOrNotFound(res, usecase.ErrUserNotFound)
}
// ListUsers returns every account, oldest first.
func (r *UserRepo) ListUsers(ctx context.Context) ([]*domain.User, error) {
rows, err := r.db.QueryContext(ctx, "SELECT "+userColumns+" FROM users ORDER BY created_at ASC, id ASC")
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var users []*domain.User
for rows.Next() {
user, err := scanUser(rows)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
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) {
@@ -154,6 +172,25 @@ func (r *WorkerKeyRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]*do
return keys, rows.Err()
}
// ListAll returns every key, revoked included, newest first. Admin-only.
func (r *WorkerKeyRepo) ListAll(ctx context.Context) ([]*domain.WorkerKey, error) {
rows, err := r.db.QueryContext(ctx,
"SELECT "+workerKeyColumns+" FROM worker_keys ORDER BY created_at DESC, id DESC")
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",
@@ -172,6 +209,17 @@ func (r *WorkerKeyRepo) Revoke(ctx context.Context, id, userID uuid.UUID) error
return rowsAffectedOrNotFound(res, usecase.ErrWorkerKeyNotFound)
}
// RevokeAny retires a key by id regardless of its owner.
func (r *WorkerKeyRepo) RevokeAny(ctx context.Context, id uuid.UUID) error {
res, err := r.db.ExecContext(ctx,
"UPDATE worker_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
time.Now().UnixNano(), id.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 = ?",
@@ -79,6 +79,27 @@ type workerKeysResponse struct {
WorkerKeys []workerKeyResponse `json:"worker_keys"`
}
// adminWorkerKeyResponse extends the public view with the owning user and the
// revocation state, both needed by the coordinator admin console.
type adminWorkerKeyResponse struct {
workerKeyResponse
UserID string `json:"user_id"`
RevokedAt string `json:"revoked_at,omitempty"`
}
func toAdminWorkerKeyResponse(k *domain.WorkerKey) adminWorkerKeyResponse {
resp := adminWorkerKeyResponse{workerKeyResponse: toWorkerKeyResponse(k), UserID: k.UserID.String()}
if k.RevokedAt != nil {
resp.RevokedAt = k.RevokedAt.UTC().Format(time.RFC3339)
}
return resp
}
// usersResponse is the admin list of accounts, password hashes excluded.
type usersResponse struct {
Users []userResponse `json:"users"`
}
func toWorkerKeyResponse(k *domain.WorkerKey) workerKeyResponse {
resp := workerKeyResponse{
ID: k.ID.String(),
@@ -14,16 +14,19 @@ import (
// 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
register *usecase.Register
login *usecase.Login
setVerified *usecase.SetVerified
setRole *usecase.SetRole
createWorkerKey *usecase.CreateWorkerKey
listWorkerKeys *usecase.ListWorkerKeys
listWorkerKeysAll *usecase.ListWorkerKeysAll
revokeWorkerKey *usecase.RevokeWorkerKey
revokeWorkerKeyAdmin *usecase.RevokeWorkerKeyAdmin
exchangeWorkerKey *usecase.ExchangeWorkerKey
listUsers *usecase.ListUsers
users usecase.UserRepository
log *slog.Logger
}
// handleHealth is an unauthenticated liveness probe for the container and load
@@ -173,15 +176,42 @@ func (h *Handlers) handleListWorkerKeys(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, workerKeysResponse{WorkerKeys: out})
}
// handleListWorkerKeysAll returns every key in the service — revoked included,
// with the owning user id — for the coordinator admin console. Admin-only.
func (h *Handlers) handleListWorkerKeysAll(w http.ResponseWriter, r *http.Request) {
keys, err := h.listWorkerKeysAll.Execute(r.Context())
if err != nil {
writeError(w, r, h.log, err)
return
}
out := make([]adminWorkerKeyResponse, 0, len(keys))
for _, k := range keys {
out = append(out, toAdminWorkerKeyResponse(k))
}
writeJSON(w, http.StatusOK, struct {
WorkerKeys []adminWorkerKeyResponse `json:"worker_keys"`
}{WorkerKeys: out})
}
// handleListUsers returns every account for the coordinator admin console.
// Password hashes never leave the service: only the public projection is sent.
func (h *Handlers) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.listUsers.Execute(r.Context())
if err != nil {
writeError(w, r, h.log, err)
return
}
out := make([]userResponse, 0, len(users))
for _, u := range users {
out = append(out, toUserResponse(u))
}
writeJSON(w, http.StatusOK, usersResponse{Users: 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{
@@ -190,6 +220,20 @@ func (h *Handlers) handleRevokeWorkerKey(w http.ResponseWriter, r *http.Request)
})
return
}
// An admin may revoke any key; a plain user only their own.
if role, ok := r.Context().Value(roleKey).(domain.Role); ok && role == domain.RoleAdmin {
if err := h.revokeWorkerKeyAdmin.Execute(r.Context(), keyID); err != nil {
writeError(w, r, h.log, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
userID, ok := userIDFrom(r.Context())
if !ok {
unauthorized(w, r)
return
}
if err := h.revokeWorkerKey.Execute(r.Context(), userID, keyID); err != nil {
writeError(w, r, h.log, err)
return
@@ -14,31 +14,37 @@ import (
// 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
Register *usecase.Register
Login *usecase.Login
SetVerified *usecase.SetVerified
SetRole *usecase.SetRole
CreateWorkerKey *usecase.CreateWorkerKey
ListWorkerKeys *usecase.ListWorkerKeys
ListWorkerKeysAll *usecase.ListWorkerKeysAll
RevokeWorkerKey *usecase.RevokeWorkerKey
RevokeWorkerKeyAdmin *usecase.RevokeWorkerKeyAdmin
ExchangeWorkerKey *usecase.ExchangeWorkerKey
ListUsers *usecase.ListUsers
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,
register: uc.Register,
login: uc.Login,
setVerified: uc.SetVerified,
setRole: uc.SetRole,
createWorkerKey: uc.CreateWorkerKey,
listWorkerKeys: uc.ListWorkerKeys,
listWorkerKeysAll: uc.ListWorkerKeysAll,
revokeWorkerKey: uc.RevokeWorkerKey,
revokeWorkerKeyAdmin: uc.RevokeWorkerKeyAdmin,
exchangeWorkerKey: uc.ExchangeWorkerKey,
listUsers: uc.ListUsers,
users: uc.Users,
log: log,
}
mux := http.NewServeMux()
@@ -68,6 +74,12 @@ func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler {
mux.Handle("POST /users/{id}/demote",
chain(h.handleSetRole(domain.RoleUser), withJWT(issuer), withAdmin))
// Admin console: lists of every account and every worker key, and the key
// revoke path the admin console calls (the same DELETE endpoint already
// lets an admin revoke any key).
mux.Handle("GET /users", chain(http.HandlerFunc(h.handleListUsers), withJWT(issuer), withAdmin))
mux.Handle("GET /worker-keys/all", chain(http.HandlerFunc(h.handleListWorkerKeysAll), withJWT(issuer), withAdmin))
// Outermost first: every request gets an ID and an access-log line.
return chain(mux, withRequestID, withAccessLog(log))
}
@@ -25,17 +25,25 @@ const secret = "server-test-secret-32-bytes-long!!!!"
func newTestServer() http.Handler {
users := memstore.NewUserRepo()
keys := memstore.NewWorkerKeyRepo()
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,
Register: usecase.NewRegister(users, hasher, clk),
Login: usecase.NewLogin(users, hasher, issuer),
SetVerified: usecase.NewSetVerified(users),
SetRole: usecase.NewSetRole(users),
CreateWorkerKey: usecase.NewCreateWorkerKey(keys, clk),
ListWorkerKeys: usecase.NewListWorkerKeys(keys),
ListWorkerKeysAll: usecase.NewListWorkerKeysAll(keys),
RevokeWorkerKey: usecase.NewRevokeWorkerKey(keys),
RevokeWorkerKeyAdmin: usecase.NewRevokeWorkerKeyAdmin(keys),
ExchangeWorkerKey: usecase.NewExchangeWorkerKey(keys, users, issuer, time.Hour),
ListUsers: usecase.NewListUsers(users),
Users: users,
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
return apihttp.NewServer(log, uc, issuer)
@@ -216,10 +224,16 @@ func TestMeInternalError(t *testing.T) {
}
// mintToken issues a token with the package secret for a synthetic caller of the
// given role — enough to drive the admin-gated endpoints.
// given role — enough to drive the admin-gated endpoints. userID defaults to a
// fresh random id; pass one to act as an existing account.
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})
return mintTokenFor(t, role, uuid.New())
}
func mintTokenFor(t *testing.T, role domain.Role, userID uuid.UUID) string {
t.Helper()
token, err := auth.NewIssuer(secret, time.Hour, nil).Issue(&domain.User{ID: userID, Role: role})
if err != nil {
t.Fatal(err)
}
@@ -370,3 +384,98 @@ func TestUnverifyRevokes(t *testing.T) {
t.Error("verified should be false after unverify")
}
}
func TestAdminListsUsersAndKeys(t *testing.T) {
h := newTestServer()
userID, _ := uuid.Parse(registerUser(t, h, "listed@example.com"))
userToken := mintTokenFor(t, domain.RoleUser, userID)
// Mint a worker key as the plain user.
keyRec := do(t, h, http.MethodPost, "/worker-keys", userToken, map[string]string{"name": "lab-node"})
if keyRec.Code != http.StatusCreated {
t.Fatalf("create key: got %d, body %s", keyRec.Code, keyRec.Body)
}
var created struct {
ID string `json:"id"`
}
_ = json.Unmarshal(keyRec.Body.Bytes(), &created)
// Admin lists users: emails present, password hashes absent.
rec := do(t, h, http.MethodGet, "/users", mintToken(t, domain.RoleAdmin), nil)
if rec.Code != http.StatusOK {
t.Fatalf("list users: got %d, body %s", rec.Code, rec.Body)
}
var users struct {
Users []struct {
Email string `json:"email"`
Role string `json:"role"`
PasswordHash string `json:"password_hash"`
} `json:"users"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &users); err != nil {
t.Fatal(err)
}
found := false
for _, u := range users.Users {
if u.PasswordHash != "" {
t.Error("password hash leaked through the admin users list")
}
if u.Email == "listed@example.com" {
found = true
}
}
if !found {
t.Error("listed user missing from the admin list")
}
// Admin lists all keys: the owner is attached, no secret.
rec = do(t, h, http.MethodGet, "/worker-keys/all", mintToken(t, domain.RoleAdmin), nil)
if rec.Code != http.StatusOK {
t.Fatalf("list all keys: got %d", rec.Code)
}
var keys struct {
WorkerKeys []struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
} `json:"worker_keys"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &keys); err != nil {
t.Fatal(err)
}
if len(keys.WorkerKeys) != 1 || keys.WorkerKeys[0].UserID != userID.String() {
t.Errorf("all keys = %+v, want the one key owned by %s", keys.WorkerKeys, userID)
}
// Plain users cannot see either list.
for _, path := range []string{"/users", "/worker-keys/all"} {
if rec := do(t, h, http.MethodGet, path, mintToken(t, domain.RoleUser), nil); rec.Code != http.StatusForbidden {
t.Errorf("%s as user: got %d, want 403", path, rec.Code)
}
}
// An admin revokes a key that belongs to another user; the plain owner of
// that key could not (it would be a 404, scoped to their own keys).
// The owner of the key revokes it themselves: 204.
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+created.ID, userToken, nil); rec.Code != http.StatusNoContent {
t.Errorf("user revoke own key: got %d, want 204", rec.Code)
}
// Another plain user cannot revoke it: scoped to their own keys, so a
// mismatch reads as 404.
otherID, _ := uuid.Parse(registerUser(t, h, "other@example.com"))
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+created.ID, mintTokenFor(t, domain.RoleUser, otherID), nil); rec.Code != http.StatusNotFound {
t.Errorf("other user revoke: got %d, want 404", rec.Code)
}
// An admin revokes a key that belongs to someone else: 204.
keyRec = do(t, h, http.MethodPost, "/worker-keys", userToken, map[string]string{"name": "lab-node-2"})
var second struct {
ID string `json:"id"`
}
_ = json.Unmarshal(keyRec.Body.Bytes(), &second)
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+second.ID, mintToken(t, domain.RoleAdmin), nil); rec.Code != http.StatusNoContent {
t.Errorf("admin revoke other's key: got %d, want 204", rec.Code)
}
// Admin cannot revoke an unknown key.
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+uuid.NewString(), mintToken(t, domain.RoleAdmin), nil); rec.Code != http.StatusNotFound {
t.Errorf("admin revoke unknown key: got %d, want 404", rec.Code)
}
}
@@ -35,6 +35,7 @@ func (s stubRepo) SetVerified(context.Context, uuid.UUID, bool) error {
func (s stubRepo) SetRole(context.Context, uuid.UUID, domain.Role) error {
return usecase.ErrUserNotFound
}
func (s stubRepo) ListUsers(context.Context) ([]*domain.User, error) { return nil, nil }
type stubHasher struct {
hashErr error
@@ -28,6 +28,10 @@ type UserRepository interface {
// SetRole changes a user's role, returning ErrUserNotFound if no such user
// exists.
SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error
// ListUsers returns every account, oldest first. Admin-only: used by the
// coordinator admin console; the response must never carry password hashes
// (the caller projects the entity).
ListUsers(ctx context.Context) ([]*domain.User, error)
}
// WorkerKeyRepository persists and looks up the long-lived worker keys a user
@@ -38,12 +42,18 @@ type WorkerKeyRepository interface {
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)
// ListAll returns every key (revoked included), newest first. Admin-only:
// backs the coordinator admin console's key table.
ListAll(ctx context.Context) ([]*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
// RevokeAny retires a key by id regardless of its owner. Admin-only; the
// coordinator admin console uses it to cut a key immediately.
RevokeAny(ctx context.Context, id 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
@@ -0,0 +1,21 @@
package usecase
import (
"context"
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
)
// ListUsers returns every account for the coordinator admin console. The
// handler must project the entities so password hashes never leave the service.
type ListUsers struct {
users UserRepository
}
func NewListUsers(users UserRepository) *ListUsers {
return &ListUsers{users: users}
}
func (uc *ListUsers) Execute(ctx context.Context) ([]*domain.User, error) {
return uc.users.ListUsers(ctx)
}
@@ -47,6 +47,34 @@ func (uc *ListWorkerKeys) Execute(ctx context.Context, userID uuid.UUID) ([]*dom
return uc.keys.ListByUser(ctx, userID)
}
// ListWorkerKeysAll returns every key in the service, revoked included, for
// the coordinator admin console. Admin-only.
type ListWorkerKeysAll struct {
keys WorkerKeyRepository
}
func NewListWorkerKeysAll(keys WorkerKeyRepository) *ListWorkerKeysAll {
return &ListWorkerKeysAll{keys: keys}
}
func (uc *ListWorkerKeysAll) Execute(ctx context.Context) ([]*domain.WorkerKey, error) {
return uc.keys.ListAll(ctx)
}
// RevokeWorkerKeyAdmin retires any key, regardless of owner. Admin-only; used
// by the coordinator admin console when a key must be cut immediately.
type RevokeWorkerKeyAdmin struct {
keys WorkerKeyRepository
}
func NewRevokeWorkerKeyAdmin(keys WorkerKeyRepository) *RevokeWorkerKeyAdmin {
return &RevokeWorkerKeyAdmin{keys: keys}
}
func (uc *RevokeWorkerKeyAdmin) Execute(ctx context.Context, id uuid.UUID) error {
return uc.keys.RevokeAny(ctx, id)
}
// RevokeWorkerKey retires one of the caller's keys.
type RevokeWorkerKey struct {
keys WorkerKeyRepository
@@ -64,6 +64,24 @@ func (r *fakeKeyRepo) TouchLastUsed(_ context.Context, id uuid.UUID) error {
return nil
}
func (r *fakeKeyRepo) ListAll(_ context.Context) ([]*domain.WorkerKey, error) {
out := make([]*domain.WorkerKey, 0, len(r.byID))
for _, k := range r.byID {
out = append(out, k)
}
return out, nil
}
func (r *fakeKeyRepo) RevokeAny(_ context.Context, id uuid.UUID) error {
k, ok := r.byID[id]
if !ok || k.Revoked() {
return usecase.ErrWorkerKeyNotFound
}
now := time.Now()
k.RevokedAt = &now
return nil
}
func newKeyFixtures(t *testing.T) (*usecase.CreateWorkerKey, *usecase.ExchangeWorkerKey, *usecase.RevokeWorkerKey, *usecase.ListWorkerKeys, *fakeKeyRepo, *domain.User) {
t.Helper()
users := memstore.NewUserRepo()