Add admin console access and platform pages (trust, users, keys, workloads, settings)
This commit is contained in:
@@ -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, ©)
|
||||
}
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user