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:
125
internal/api/handlers_apikeys.go
Normal file
125
internal/api/handlers_apikeys.go
Normal 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)
|
||||
}
|
||||
184
internal/api/handlers_auth.go
Normal file
184
internal/api/handlers_auth.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>`
|
||||
|
||||
38
internal/api/middleware_apikey.go
Normal file
38
internal/api/middleware_apikey.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
36
internal/api/middleware_jwt.go
Normal file
36
internal/api/middleware_jwt.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user