Add authentication, authorization, and team-scoped access control

Implement email/password auth with JWT sessions and API key auth for
sandbox lifecycle. Users get a default team on signup; sandboxes,
snapshots, and API keys are scoped to teams.

- Add user, team, users_teams, and team_api_keys tables (goose migrations)
- Add JWT middleware (Bearer token) for user management endpoints
- Add API key middleware (X-API-Key header, SHA-256 hashed) for sandbox ops
- Add signup/login handlers with transactional user+team creation
- Add API key CRUD endpoints (create/list/delete)
- Replace owner_id with team_id on sandboxes and templates
- Update all handlers to use team-scoped queries
- Add godotenv for .env file loading
- Update OpenAPI spec and test UI with auth flows
This commit is contained in:
2026-03-14 03:57:06 +06:00
parent 712b77b01c
commit c92cc29b88
37 changed files with 1722 additions and 82 deletions

View File

@ -0,0 +1,125 @@
package api
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
)
type apiKeyHandler struct {
db *db.Queries
}
func newAPIKeyHandler(db *db.Queries) *apiKeyHandler {
return &apiKeyHandler{db: db}
}
type createAPIKeyRequest struct {
Name string `json:"name"`
}
type apiKeyResponse struct {
ID string `json:"id"`
TeamID string `json:"team_id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
CreatedAt string `json:"created_at"`
LastUsed *string `json:"last_used,omitempty"`
Key *string `json:"key,omitempty"` // only populated on Create
}
func apiKeyToResponse(k db.TeamApiKey) apiKeyResponse {
resp := apiKeyResponse{
ID: k.ID,
TeamID: k.TeamID,
Name: k.Name,
KeyPrefix: k.KeyPrefix,
}
if k.CreatedAt.Valid {
resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339)
}
if k.LastUsed.Valid {
s := k.LastUsed.Time.Format(time.RFC3339)
resp.LastUsed = &s
}
return resp
}
// Create handles POST /v1/api-keys.
func (h *apiKeyHandler) Create(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
var req createAPIKeyRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
if req.Name == "" {
req.Name = "Unnamed API Key"
}
plaintext, hash, err := auth.GenerateAPIKey()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate API key")
return
}
keyID := id.NewAPIKeyID()
row, err := h.db.InsertAPIKey(r.Context(), db.InsertAPIKeyParams{
ID: keyID,
TeamID: ac.TeamID,
Name: req.Name,
KeyHash: hash,
KeyPrefix: auth.APIKeyPrefix(plaintext),
CreatedBy: ac.UserID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to create API key")
return
}
resp := apiKeyToResponse(row)
resp.Key = &plaintext
writeJSON(w, http.StatusCreated, resp)
}
// List handles GET /v1/api-keys.
func (h *apiKeyHandler) List(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
keys, err := h.db.ListAPIKeysByTeam(r.Context(), ac.TeamID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list API keys")
return
}
resp := make([]apiKeyResponse, len(keys))
for i, k := range keys {
resp[i] = apiKeyToResponse(k)
}
writeJSON(w, http.StatusOK, resp)
}
// Delete handles DELETE /v1/api-keys/{id}.
func (h *apiKeyHandler) Delete(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
keyID := chi.URLParam(r, "id")
if err := h.db.DeleteAPIKey(r.Context(), db.DeleteAPIKeyParams{
ID: keyID,
TeamID: ac.TeamID,
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete API key")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -0,0 +1,184 @@
package api
import (
"errors"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
)
type authHandler struct {
db *db.Queries
pool *pgxpool.Pool
jwtSecret []byte
}
func newAuthHandler(db *db.Queries, pool *pgxpool.Pool, jwtSecret []byte) *authHandler {
return &authHandler{db: db, pool: pool, jwtSecret: jwtSecret}
}
type signupRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type authResponse struct {
Token string `json:"token"`
UserID string `json:"user_id"`
TeamID string `json:"team_id"`
Email string `json:"email"`
}
// Signup handles POST /v1/auth/signup.
func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
var req signupRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if !strings.Contains(req.Email, "@") || len(req.Email) < 3 {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid email address")
return
}
if len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
return
}
ctx := r.Context()
passwordHash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
return
}
// Use a transaction to atomically create user + team + membership.
tx, err := h.pool.Begin(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to begin transaction")
return
}
defer tx.Rollback(ctx) //nolint:errcheck
qtx := h.db.WithTx(tx)
userID := id.NewUserID()
_, err = qtx.InsertUser(ctx, db.InsertUserParams{
ID: userID,
Email: req.Email,
PasswordHash: passwordHash,
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
writeError(w, http.StatusConflict, "email_taken", "an account with this email already exists")
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to create user")
return
}
// Create default team.
teamID := id.NewTeamID()
if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{
ID: teamID,
Name: req.Email + "'s Team",
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to create team")
return
}
if err := qtx.InsertTeamMember(ctx, db.InsertTeamMemberParams{
UserID: userID,
TeamID: teamID,
IsDefault: true,
Role: "owner",
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to add user to team")
return
}
if err := tx.Commit(ctx); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to commit signup")
return
}
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return
}
writeJSON(w, http.StatusCreated, authResponse{
Token: token,
UserID: userID,
TeamID: teamID,
Email: req.Email,
})
}
// Login handles POST /v1/auth/login.
func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "email and password are required")
return
}
ctx := r.Context()
user, err := h.db.GetUserByEmail(ctx, req.Email)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
return
}
if err := auth.CheckPassword(user.PasswordHash, req.Password); err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
return
}
team, err := h.db.GetDefaultTeamForUser(ctx, user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
return
}
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return
}
writeJSON(w, http.StatusOK, authResponse{
Token: token,
UserID: user.ID,
TeamID: team.ID,
Email: user.Email,
})
}

View File

@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
@ -47,8 +48,9 @@ type execResponse struct {
func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id")
ctx := r.Context()
ac := auth.MustFromContext(ctx)
sb, err := h.db.GetSandbox(ctx, sandboxID)
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return

View File

@ -12,6 +12,7 @@ import (
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
@ -49,8 +50,9 @@ type wsOutMsg struct {
func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id")
ctx := r.Context()
ac := auth.MustFromContext(ctx)
sb, err := h.db.GetSandbox(ctx, sandboxID)
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return

View File

@ -9,6 +9,7 @@ import (
"connectrpc.com/connect"
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
@ -30,8 +31,9 @@ func newFilesHandler(db *db.Queries, agent hostagentv1connect.HostAgentServiceCl
func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id")
ctx := r.Context()
ac := auth.MustFromContext(ctx)
sb, err := h.db.GetSandbox(ctx, sandboxID)
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return
@ -95,8 +97,9 @@ type readFileRequest struct {
func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id")
ctx := r.Context()
ac := auth.MustFromContext(ctx)
sb, err := h.db.GetSandbox(ctx, sandboxID)
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return

View File

@ -10,6 +10,7 @@ import (
"connectrpc.com/connect"
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
@ -30,8 +31,9 @@ func newFilesStreamHandler(db *db.Queries, agent hostagentv1connect.HostAgentSer
func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id")
ctx := r.Context()
ac := auth.MustFromContext(ctx)
sb, err := h.db.GetSandbox(ctx, sandboxID)
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return
@ -140,8 +142,9 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id")
ctx := r.Context()
ac := auth.MustFromContext(ctx)
sb, err := h.db.GetSandbox(ctx, sandboxID)
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return

View File

@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/validate"
@ -103,10 +104,11 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
}
ctx := r.Context()
ac := auth.MustFromContext(ctx)
// If the template is a snapshot, use its baked-in vcpus/memory
// (they cannot be changed since the VM state is frozen).
if tmpl, err := h.db.GetTemplate(ctx, req.Template); err == nil && tmpl.Type == "snapshot" {
if tmpl, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: req.Template, TeamID: ac.TeamID}); err == nil && tmpl.Type == "snapshot" {
if tmpl.Vcpus.Valid {
req.VCPUs = tmpl.Vcpus.Int32
}
@ -119,7 +121,7 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
// Insert pending record.
_, err := h.db.InsertSandbox(ctx, db.InsertSandboxParams{
ID: sandboxID,
OwnerID: "",
TeamID: ac.TeamID,
HostID: "default",
Template: req.Template,
Status: "pending",
@ -173,7 +175,8 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
// List handles GET /v1/sandboxes.
func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) {
sandboxes, err := h.db.ListSandboxes(r.Context())
ac := auth.MustFromContext(r.Context())
sandboxes, err := h.db.ListSandboxesByTeam(r.Context(), ac.TeamID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list sandboxes")
return
@ -190,8 +193,9 @@ func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) {
// Get handles GET /v1/sandboxes/{id}.
func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context())
sb, err := h.db.GetSandbox(r.Context(), sandboxID)
sb, err := h.db.GetSandboxByTeam(r.Context(), db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return
@ -206,8 +210,9 @@ func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) {
func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id")
ctx := r.Context()
ac := auth.MustFromContext(ctx)
sb, err := h.db.GetSandbox(ctx, sandboxID)
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return
@ -241,8 +246,9 @@ func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) {
func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id")
ctx := r.Context()
ac := auth.MustFromContext(ctx)
sb, err := h.db.GetSandbox(ctx, sandboxID)
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return
@ -283,8 +289,9 @@ func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) {
func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id")
ctx := r.Context()
ac := auth.MustFromContext(ctx)
_, err := h.db.GetSandbox(ctx, sandboxID)
_, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return

View File

@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/validate"
@ -81,22 +82,23 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
}
ctx := r.Context()
ac := auth.MustFromContext(ctx)
overwrite := r.URL.Query().Get("overwrite") == "true"
// Check if name already exists.
if _, err := h.db.GetTemplate(ctx, req.Name); err == nil {
// Check if name already exists for this team.
if _, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: req.Name, TeamID: ac.TeamID}); err == nil {
if !overwrite {
writeError(w, http.StatusConflict, "already_exists", "snapshot name already exists; use ?overwrite=true to replace")
return
}
// Delete existing template record and files.
if err := h.db.DeleteTemplate(ctx, req.Name); err != nil {
if err := h.db.DeleteTemplateByTeam(ctx, db.DeleteTemplateByTeamParams{Name: req.Name, TeamID: ac.TeamID}); err != nil {
slog.Warn("failed to delete existing template", "name", req.Name, "error", err)
}
}
// Verify sandbox exists and is running or paused.
sb, err := h.db.GetSandbox(ctx, req.SandboxID)
// Verify sandbox exists, belongs to team, and is running or paused.
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: req.SandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return
@ -134,6 +136,7 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
Vcpus: pgtype.Int4{Int32: sb.Vcpus, Valid: true},
MemoryMb: pgtype.Int4{Int32: sb.MemoryMb, Valid: true},
SizeBytes: resp.Msg.SizeBytes,
TeamID: ac.TeamID,
})
if err != nil {
slog.Error("failed to insert template record", "name", req.Name, "error", err)
@ -147,14 +150,15 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
// List handles GET /v1/snapshots.
func (h *snapshotHandler) List(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ac := auth.MustFromContext(ctx)
typeFilter := r.URL.Query().Get("type")
var templates []db.Template
var err error
if typeFilter != "" {
templates, err = h.db.ListTemplatesByType(ctx, typeFilter)
templates, err = h.db.ListTemplatesByTeamAndType(ctx, db.ListTemplatesByTeamAndTypeParams{TeamID: ac.TeamID, Type: typeFilter})
} else {
templates, err = h.db.ListTemplates(ctx)
templates, err = h.db.ListTemplatesByTeam(ctx, ac.TeamID)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates")
@ -177,8 +181,9 @@ func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
ctx := r.Context()
ac := auth.MustFromContext(ctx)
if _, err := h.db.GetTemplate(ctx, name); err != nil {
if _, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: name, TeamID: ac.TeamID}); err != nil {
writeError(w, http.StatusNotFound, "not_found", "template not found")
return
}
@ -190,7 +195,7 @@ func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
slog.Warn("delete snapshot: agent RPC failed", "name", name, "error", err)
}
if err := h.db.DeleteTemplate(ctx, name); err != nil {
if err := h.db.DeleteTemplateByTeam(ctx, db.DeleteTemplateByTeamParams{Name: name, TeamID: ac.TeamID}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete template record")
return
}

View File

@ -109,13 +109,63 @@ const testUIHTML = `<!DOCTYPE html>
}
.clickable { cursor: pointer; color: #89a785; text-decoration: underline; }
.clickable:hover { color: #aacdaa; }
.auth-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
margin-left: 8px;
}
.auth-badge.authed { background: rgba(94,140,88,0.15); color: #89a785; }
.auth-badge.unauthed { background: rgba(179,85,68,0.15); color: #c27b6d; }
.key-display {
background: #1b201e;
border: 1px solid #5e8c58;
border-radius: 4px;
padding: 8px;
margin-top: 8px;
font-size: 12px;
word-break: break-all;
color: #89a785;
}
</style>
</head>
<body>
<h1>Wrenn Sandbox Test Console</h1>
<h1>Wrenn Sandbox Test Console <span id="auth-status" class="auth-badge unauthed">not authenticated</span></h1>
<div class="grid">
<!-- Auth Panel -->
<div class="panel">
<h2>Authentication</h2>
<label>Email</label>
<input type="email" id="auth-email" value="" placeholder="user@example.com">
<label>Password</label>
<input type="password" id="auth-password" value="" placeholder="min 8 characters">
<div class="btn-row">
<button class="btn-green" onclick="signup()">Sign Up</button>
<button class="btn-blue" onclick="login()">Log In</button>
<button class="btn-red" onclick="logout()">Log Out</button>
</div>
<div id="auth-info" style="margin-top:8px;font-size:12px;color:#5f5c57"></div>
</div>
<!-- API Keys Panel -->
<div class="panel">
<h2>API Keys</h2>
<label>Key Name</label>
<input type="text" id="key-name" value="" placeholder="my-api-key">
<div class="btn-row">
<button class="btn-green" onclick="createAPIKey()">Create Key</button>
<button onclick="listAPIKeys()">Refresh</button>
</div>
<div id="new-key-display" style="display:none" class="key-display"></div>
<div id="api-keys-table"></div>
<label style="margin-top:12px">Active API Key</label>
<input type="text" id="active-api-key" value="" placeholder="wrn_...">
</div>
<!-- Create Sandbox -->
<div class="panel">
<h2>Create Sandbox</h2>
@ -189,6 +239,8 @@ const testUIHTML = `<!DOCTYPE html>
<script>
const API = '';
let jwtToken = '';
let activeAPIKey = '';
function log(msg, level) {
const el = document.getElementById('log');
@ -203,8 +255,37 @@ function esc(s) {
return d.innerHTML;
}
async function api(method, path, body) {
function updateAuthStatus() {
const badge = document.getElementById('auth-status');
const info = document.getElementById('auth-info');
if (jwtToken) {
badge.textContent = 'authenticated';
badge.className = 'auth-badge authed';
try {
const payload = JSON.parse(atob(jwtToken.split('.')[1]));
info.textContent = 'User: ' + payload.email + ' | Team: ' + payload.team_id;
} catch(e) {
info.textContent = 'Token set';
}
} else {
badge.textContent = 'not authenticated';
badge.className = 'auth-badge unauthed';
info.textContent = '';
}
}
// API call with appropriate auth headers.
async function api(method, path, body, authType) {
const opts = { method, headers: {} };
if (authType === 'jwt' && jwtToken) {
opts.headers['Authorization'] = 'Bearer ' + jwtToken;
} else if (authType === 'apikey') {
const key = document.getElementById('active-api-key').value;
if (!key) {
throw new Error('No API key set. Create one first and paste it in the Active API Key field.');
}
opts.headers['X-API-Key'] = key;
}
if (body) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
@ -222,11 +303,109 @@ function statusBadge(s) {
return '<span class="status status-' + s + '">' + s + '</span>';
}
// --- Auth ---
async function signup() {
const email = document.getElementById('auth-email').value;
const password = document.getElementById('auth-password').value;
if (!email || !password) { log('Email and password required', 'err'); return; }
log('Signing up as ' + email + '...', 'info');
try {
const data = await api('POST', '/v1/auth/signup', { email, password });
jwtToken = data.token;
updateAuthStatus();
log('Signed up! User: ' + data.user_id + ', Team: ' + data.team_id, 'ok');
} catch (e) {
log('Signup failed: ' + e.message, 'err');
}
}
async function login() {
const email = document.getElementById('auth-email').value;
const password = document.getElementById('auth-password').value;
if (!email || !password) { log('Email and password required', 'err'); return; }
log('Logging in as ' + email + '...', 'info');
try {
const data = await api('POST', '/v1/auth/login', { email, password });
jwtToken = data.token;
updateAuthStatus();
log('Logged in! User: ' + data.user_id + ', Team: ' + data.team_id, 'ok');
listAPIKeys();
} catch (e) {
log('Login failed: ' + e.message, 'err');
}
}
function logout() {
jwtToken = '';
updateAuthStatus();
log('Logged out', 'info');
}
// --- API Keys ---
async function createAPIKey() {
if (!jwtToken) { log('Log in first to create API keys', 'err'); return; }
const name = document.getElementById('key-name').value || 'Unnamed API Key';
log('Creating API key "' + name + '"...', 'info');
try {
const data = await api('POST', '/v1/api-keys', { name }, 'jwt');
const display = document.getElementById('new-key-display');
display.style.display = 'block';
display.textContent = 'New key (copy now — shown only once): ' + data.key;
document.getElementById('active-api-key').value = data.key;
log('API key created: ' + data.key_prefix, 'ok');
listAPIKeys();
} catch (e) {
log('Create API key failed: ' + e.message, 'err');
}
}
async function listAPIKeys() {
if (!jwtToken) return;
try {
const data = await api('GET', '/v1/api-keys', null, 'jwt');
renderAPIKeys(data);
} catch (e) {
log('List API keys failed: ' + e.message, 'err');
}
}
function renderAPIKeys(keys) {
if (!keys || keys.length === 0) {
document.getElementById('api-keys-table').innerHTML = '<p style="color:#5f5c57;margin-top:8px">No API keys</p>';
return;
}
let html = '<table><thead><tr><th>Name</th><th>Prefix</th><th>Created</th><th>Last Used</th><th>Actions</th></tr></thead><tbody>';
for (const k of keys) {
html += '<tr>';
html += '<td>' + esc(k.name) + '</td>';
html += '<td style="font-size:11px">' + esc(k.key_prefix) + '</td>';
html += '<td>' + new Date(k.created_at).toLocaleString() + '</td>';
html += '<td>' + (k.last_used ? new Date(k.last_used).toLocaleString() : '-') + '</td>';
html += '<td><button class="btn-red" onclick="deleteAPIKey(\'' + k.id + '\')">Delete</button></td>';
html += '</tr>';
}
html += '</tbody></table>';
document.getElementById('api-keys-table').innerHTML = html;
}
async function deleteAPIKey(id) {
log('Deleting API key ' + id + '...', 'info');
try {
await api('DELETE', '/v1/api-keys/' + id, null, 'jwt');
log('Deleted API key ' + id, 'ok');
listAPIKeys();
} catch (e) {
log('Delete API key failed: ' + e.message, 'err');
}
}
// --- Sandboxes ---
async function listSandboxes() {
try {
const data = await api('GET', '/v1/sandboxes');
const data = await api('GET', '/v1/sandboxes', null, 'apikey');
renderSandboxes(data);
} catch (e) {
log('List sandboxes failed: ' + e.message, 'err');
@ -277,7 +456,7 @@ async function createSandbox() {
const timeout_sec = parseInt(document.getElementById('create-timeout').value);
log('Creating sandbox (template=' + template + ', vcpus=' + vcpus + ', mem=' + memory_mb + 'MB)...', 'info');
try {
const data = await api('POST', '/v1/sandboxes', { template, vcpus, memory_mb, timeout_sec });
const data = await api('POST', '/v1/sandboxes', { template, vcpus, memory_mb, timeout_sec }, 'apikey');
log('Created sandbox ' + data.id + ' [' + data.status + ']', 'ok');
listSandboxes();
} catch (e) {
@ -288,7 +467,7 @@ async function createSandbox() {
async function pauseSandbox(id) {
log('Pausing ' + id + '...', 'info');
try {
await api('POST', '/v1/sandboxes/' + id + '/pause');
await api('POST', '/v1/sandboxes/' + id + '/pause', null, 'apikey');
log('Paused ' + id, 'ok');
listSandboxes();
} catch (e) {
@ -299,7 +478,7 @@ async function pauseSandbox(id) {
async function resumeSandbox(id) {
log('Resuming ' + id + '...', 'info');
try {
await api('POST', '/v1/sandboxes/' + id + '/resume');
await api('POST', '/v1/sandboxes/' + id + '/resume', null, 'apikey');
log('Resumed ' + id, 'ok');
listSandboxes();
} catch (e) {
@ -310,7 +489,7 @@ async function resumeSandbox(id) {
async function destroySandbox(id) {
log('Destroying ' + id + '...', 'info');
try {
await api('DELETE', '/v1/sandboxes/' + id);
await api('DELETE', '/v1/sandboxes/' + id, null, 'apikey');
log('Destroyed ' + id, 'ok');
listSandboxes();
} catch (e) {
@ -334,7 +513,7 @@ async function execCmd() {
log('Exec on ' + sandboxId + ': ' + cmd + ' ' + args.join(' '), 'info');
try {
const data = await api('POST', '/v1/sandboxes/' + sandboxId + '/exec', { cmd, args });
const data = await api('POST', '/v1/sandboxes/' + sandboxId + '/exec', { cmd, args }, 'apikey');
let text = '';
if (data.stdout) text += data.stdout;
if (data.stderr) text += '\n[stderr]\n' + data.stderr;
@ -362,7 +541,7 @@ async function createSnapshot() {
const qs = overwrite ? '?overwrite=true' : '';
log('Creating snapshot from ' + sandbox_id + (name ? ' as "' + name + '"' : '') + '...', 'info');
try {
const data = await api('POST', '/v1/snapshots' + qs, body);
const data = await api('POST', '/v1/snapshots' + qs, body, 'apikey');
log('Snapshot created: ' + data.name + ' (' + (data.size_bytes / 1024 / 1024).toFixed(1) + 'MB)', 'ok');
listSnapshots();
listSandboxes();
@ -373,7 +552,7 @@ async function createSnapshot() {
async function listSnapshots() {
try {
const data = await api('GET', '/v1/snapshots');
const data = await api('GET', '/v1/snapshots', null, 'apikey');
renderSnapshots(data);
} catch (e) {
log('List snapshots failed: ' + e.message, 'err');
@ -408,7 +587,7 @@ function useTemplate(name) {
async function deleteSnapshot(name) {
log('Deleting snapshot "' + name + '"...', 'info');
try {
await api('DELETE', '/v1/snapshots/' + encodeURIComponent(name));
await api('DELETE', '/v1/snapshots/' + encodeURIComponent(name), null, 'apikey');
log('Deleted snapshot "' + name + '"', 'ok');
listSnapshots();
} catch (e) {
@ -428,8 +607,7 @@ document.getElementById('auto-refresh').addEventListener('change', function() {
});
// --- Init ---
listSandboxes();
listSnapshots();
updateAuthStatus();
</script>
</body>
</html>`

View File

@ -0,0 +1,38 @@
package api
import (
"log/slog"
"net/http"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
)
// requireAPIKey validates the X-API-Key header, looks up the SHA-256 hash in DB,
// and stamps TeamID into the request context.
func requireAPIKey(queries *db.Queries) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if key == "" {
writeError(w, http.StatusUnauthorized, "unauthorized", "X-API-Key header required")
return
}
hash := auth.HashAPIKey(key)
row, err := queries.GetAPIKeyByHash(r.Context(), hash)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid API key")
return
}
// Best-effort update of last_used timestamp.
if err := queries.UpdateAPIKeyLastUsed(r.Context(), row.ID); err != nil {
slog.Warn("failed to update api key last_used", "key_id", row.ID, "error", err)
}
ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{TeamID: row.TeamID})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@ -0,0 +1,36 @@
package api
import (
"net/http"
"strings"
"git.omukk.dev/wrenn/sandbox/internal/auth"
)
// requireJWT validates the Authorization: Bearer <token> header, verifies the JWT
// signature and expiry, and stamps UserID + TeamID + Email into the request context.
func requireJWT(secret []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authorization: Bearer <token> required")
return
}
tokenStr := strings.TrimPrefix(header, "Bearer ")
claims, err := auth.VerifyJWT(secret, tokenStr)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token")
return
}
ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{
TeamID: claims.TeamID,
UserID: claims.Subject,
Email: claims.Email,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@ -8,11 +8,133 @@ servers:
- url: http://localhost:8080
description: Local development
security: []
paths:
/v1/auth/signup:
post:
summary: Create a new account
operationId: signup
tags: [auth]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SignupRequest"
responses:
"201":
description: Account created
content:
application/json:
schema:
$ref: "#/components/schemas/AuthResponse"
"400":
description: Invalid request (bad email, short password)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: Email already registered
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/auth/login:
post:
summary: Log in with email and password
operationId: login
tags: [auth]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/LoginRequest"
responses:
"200":
description: Login successful
content:
application/json:
schema:
$ref: "#/components/schemas/AuthResponse"
"401":
description: Invalid credentials
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/api-keys:
post:
summary: Create an API key
operationId: createAPIKey
tags: [api-keys]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateAPIKeyRequest"
responses:
"201":
description: API key created (plaintext key only shown once)
content:
application/json:
schema:
$ref: "#/components/schemas/APIKeyResponse"
"401":
description: Unauthorized
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
get:
summary: List API keys for your team
operationId: listAPIKeys
tags: [api-keys]
security:
- bearerAuth: []
responses:
"200":
description: List of API keys (plaintext keys are never returned)
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/APIKeyResponse"
/v1/api-keys/{id}:
parameters:
- name: id
in: path
required: true
schema:
type: string
delete:
summary: Delete an API key
operationId: deleteAPIKey
tags: [api-keys]
security:
- bearerAuth: []
responses:
"204":
description: API key deleted
/v1/sandboxes:
post:
summary: Create a sandbox
operationId: createSandbox
tags: [sandboxes]
security:
- apiKeyAuth: []
requestBody:
required: true
content:
@ -34,8 +156,11 @@ paths:
$ref: "#/components/schemas/Error"
get:
summary: List all sandboxes
summary: List sandboxes for your team
operationId: listSandboxes
tags: [sandboxes]
security:
- apiKeyAuth: []
responses:
"200":
description: List of sandboxes
@ -57,6 +182,9 @@ paths:
get:
summary: Get sandbox details
operationId: getSandbox
tags: [sandboxes]
security:
- apiKeyAuth: []
responses:
"200":
description: Sandbox details
@ -74,6 +202,9 @@ paths:
delete:
summary: Destroy a sandbox
operationId: destroySandbox
tags: [sandboxes]
security:
- apiKeyAuth: []
responses:
"204":
description: Sandbox destroyed
@ -89,6 +220,9 @@ paths:
post:
summary: Execute a command
operationId: execCommand
tags: [sandboxes]
security:
- apiKeyAuth: []
requestBody:
required: true
content:
@ -126,6 +260,9 @@ paths:
post:
summary: Pause a running sandbox
operationId: pauseSandbox
tags: [sandboxes]
security:
- apiKeyAuth: []
description: |
Takes a snapshot of the sandbox (VM state + memory + rootfs), then
destroys all running resources. The sandbox exists only as files on
@ -155,6 +292,9 @@ paths:
post:
summary: Resume a paused sandbox
operationId: resumeSandbox
tags: [sandboxes]
security:
- apiKeyAuth: []
description: |
Restores a paused sandbox from its snapshot using UFFD for lazy
memory loading. Boots a fresh Firecracker process, sets up a new
@ -177,6 +317,9 @@ paths:
post:
summary: Create a snapshot template
operationId: createSnapshot
tags: [snapshots]
security:
- apiKeyAuth: []
description: |
Pauses a running sandbox, takes a full snapshot, copies the snapshot
files to the images directory as a reusable template, then destroys
@ -210,8 +353,11 @@ paths:
$ref: "#/components/schemas/Error"
get:
summary: List templates
summary: List templates for your team
operationId: listSnapshots
tags: [snapshots]
security:
- apiKeyAuth: []
parameters:
- name: type
in: query
@ -241,6 +387,9 @@ paths:
delete:
summary: Delete a snapshot template
operationId: deleteSnapshot
tags: [snapshots]
security:
- apiKeyAuth: []
description: Removes the snapshot files from disk and deletes the database record.
responses:
"204":
@ -263,6 +412,9 @@ paths:
post:
summary: Upload a file
operationId: uploadFile
tags: [sandboxes]
security:
- apiKeyAuth: []
requestBody:
required: true
content:
@ -305,6 +457,9 @@ paths:
post:
summary: Download a file
operationId: downloadFile
tags: [sandboxes]
security:
- apiKeyAuth: []
requestBody:
required: true
content:
@ -337,6 +492,9 @@ paths:
get:
summary: Stream command execution via WebSocket
operationId: execStream
tags: [sandboxes]
security:
- apiKeyAuth: []
description: |
Opens a WebSocket connection for streaming command execution.
@ -387,6 +545,9 @@ paths:
post:
summary: Upload a file (streaming)
operationId: streamUploadFile
tags: [sandboxes]
security:
- apiKeyAuth: []
description: |
Streams file content to the sandbox without buffering in memory.
Suitable for large files. Uses the same multipart/form-data format
@ -433,6 +594,9 @@ paths:
post:
summary: Download a file (streaming)
operationId: streamDownloadFile
tags: [sandboxes]
security:
- apiKeyAuth: []
description: |
Streams file content from the sandbox without buffering in memory.
Suitable for large files. Returns raw bytes with chunked transfer encoding.
@ -464,7 +628,85 @@ paths:
$ref: "#/components/schemas/Error"
components:
securitySchemes:
apiKeyAuth:
type: apiKey
in: header
name: X-API-Key
description: API key for sandbox lifecycle operations. Create via POST /v1/api-keys.
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: JWT token from /v1/auth/login or /v1/auth/signup. Valid for 6 hours.
schemas:
SignupRequest:
type: object
required: [email, password]
properties:
email:
type: string
format: email
password:
type: string
minLength: 8
LoginRequest:
type: object
required: [email, password]
properties:
email:
type: string
format: email
password:
type: string
AuthResponse:
type: object
properties:
token:
type: string
description: JWT token (valid for 6 hours)
user_id:
type: string
team_id:
type: string
email:
type: string
CreateAPIKeyRequest:
type: object
properties:
name:
type: string
default: Unnamed API Key
APIKeyResponse:
type: object
properties:
id:
type: string
team_id:
type: string
name:
type: string
key_prefix:
type: string
description: Display prefix (e.g. "wrn_ab12cd34...")
created_at:
type: string
format: date-time
last_used:
type: string
format: date-time
nullable: true
key:
type: string
description: Full plaintext key. Only returned on creation, never again.
nullable: true
CreateSandboxRequest:
type: object
properties:

View File

@ -6,6 +6,7 @@ import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
@ -20,7 +21,7 @@ type Server struct {
}
// New constructs the chi router and registers all routes.
func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *Server {
func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient, pool *pgxpool.Pool, jwtSecret []byte) *Server {
r := chi.NewRouter()
r.Use(requestLogger())
@ -30,6 +31,8 @@ func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *
files := newFilesHandler(queries, agent)
filesStream := newFilesStreamHandler(queries, agent)
snapshots := newSnapshotHandler(queries, agent)
authH := newAuthHandler(queries, pool, jwtSecret)
apiKeys := newAPIKeyHandler(queries)
// OpenAPI spec and docs.
r.Get("/openapi.yaml", serveOpenAPI)
@ -38,8 +41,21 @@ func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *
// Test UI for sandbox lifecycle management.
r.Get("/test", serveTestUI)
// Sandbox CRUD.
// Unauthenticated auth endpoints.
r.Post("/v1/auth/signup", authH.Signup)
r.Post("/v1/auth/login", authH.Login)
// JWT-authenticated: API key management.
r.Route("/v1/api-keys", func(r chi.Router) {
r.Use(requireJWT(jwtSecret))
r.Post("/", apiKeys.Create)
r.Get("/", apiKeys.List)
r.Delete("/{id}", apiKeys.Delete)
})
// API-key-authenticated: sandbox lifecycle.
r.Route("/v1/sandboxes", func(r chi.Router) {
r.Use(requireAPIKey(queries))
r.Post("/", sandbox.Create)
r.Get("/", sandbox.List)
@ -57,8 +73,9 @@ func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *
})
})
// Snapshot / template management.
// API-key-authenticated: snapshot / template management.
r.Route("/v1/snapshots", func(r chi.Router) {
r.Use(requireAPIKey(queries))
r.Post("/", snapshots.Create)
r.Get("/", snapshots.List)
r.Delete("/{name}", snapshots.Delete)