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

@ -23,3 +23,6 @@ S3_REGION=fsn1
S3_ENDPOINT=https://fsn1.your-objectstorage.com S3_ENDPOINT=https://fsn1.your-objectstorage.com
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
# Auth
JWT_SECRET=

View File

@ -24,6 +24,11 @@ func main() {
cfg := config.Load() cfg := config.Load()
if len(cfg.JWTSecret) < 32 {
slog.Error("JWT_SECRET must be at least 32 characters")
os.Exit(1)
}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -51,7 +56,7 @@ func main() {
) )
// API server. // API server.
srv := api.New(queries, agentClient) srv := api.New(queries, agentClient, pool, []byte(cfg.JWTSecret))
// Start reconciler. // Start reconciler.
reconciler := api.NewReconciler(queries, agentClient, "default", 30*time.Second) reconciler := api.NewReconciler(queries, agentClient, "default", 30*time.Second)

View File

@ -0,0 +1,46 @@
-- +goose Up
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE teams (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE users_teams (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
is_default BOOLEAN NOT NULL DEFAULT TRUE,
role TEXT NOT NULL DEFAULT 'owner',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (team_id, user_id)
);
CREATE INDEX idx_users_teams_user ON users_teams(user_id);
CREATE TABLE team_api_keys (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used TIMESTAMPTZ
);
CREATE INDEX idx_team_api_keys_team ON team_api_keys(team_id);
-- +goose Down
DROP TABLE team_api_keys;
DROP TABLE users_teams;
DROP TABLE teams;
DROP TABLE users;

View File

@ -0,0 +1,31 @@
-- +goose Up
ALTER TABLE sandboxes
ADD COLUMN team_id TEXT NOT NULL DEFAULT '';
UPDATE sandboxes SET team_id = owner_id WHERE owner_id != '';
ALTER TABLE sandboxes
DROP COLUMN owner_id;
ALTER TABLE templates
ADD COLUMN team_id TEXT NOT NULL DEFAULT '';
CREATE INDEX idx_sandboxes_team ON sandboxes(team_id);
CREATE INDEX idx_templates_team ON templates(team_id);
-- +goose Down
ALTER TABLE sandboxes
ADD COLUMN owner_id TEXT NOT NULL DEFAULT '';
UPDATE sandboxes SET owner_id = team_id WHERE team_id != '';
ALTER TABLE sandboxes
DROP COLUMN team_id;
ALTER TABLE templates
DROP COLUMN team_id;
DROP INDEX IF EXISTS idx_sandboxes_team;
DROP INDEX IF EXISTS idx_templates_team;

16
db/queries/api_keys.sql Normal file
View File

@ -0,0 +1,16 @@
-- name: InsertAPIKey :one
INSERT INTO team_api_keys (id, team_id, name, key_hash, key_prefix, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: GetAPIKeyByHash :one
SELECT * FROM team_api_keys WHERE key_hash = $1;
-- name: ListAPIKeysByTeam :many
SELECT * FROM team_api_keys WHERE team_id = $1 ORDER BY created_at DESC;
-- name: DeleteAPIKey :exec
DELETE FROM team_api_keys WHERE id = $1 AND team_id = $2;
-- name: UpdateAPIKeyLastUsed :exec
UPDATE team_api_keys SET last_used = NOW() WHERE id = $1;

View File

@ -1,14 +1,20 @@
-- name: InsertSandbox :one -- name: InsertSandbox :one
INSERT INTO sandboxes (id, owner_id, host_id, template, status, vcpus, memory_mb, timeout_sec) INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *; RETURNING *;
-- name: GetSandbox :one -- name: GetSandbox :one
SELECT * FROM sandboxes WHERE id = $1; SELECT * FROM sandboxes WHERE id = $1;
-- name: GetSandboxByTeam :one
SELECT * FROM sandboxes WHERE id = $1 AND team_id = $2;
-- name: ListSandboxes :many -- name: ListSandboxes :many
SELECT * FROM sandboxes ORDER BY created_at DESC; SELECT * FROM sandboxes ORDER BY created_at DESC;
-- name: ListSandboxesByTeam :many
SELECT * FROM sandboxes WHERE team_id = $1 ORDER BY created_at DESC;
-- name: ListSandboxesByHostAndStatus :many -- name: ListSandboxesByHostAndStatus :many
SELECT * FROM sandboxes SELECT * FROM sandboxes
WHERE host_id = $1 AND status = ANY($2::text[]) WHERE host_id = $1 AND status = ANY($2::text[])

17
db/queries/teams.sql Normal file
View File

@ -0,0 +1,17 @@
-- name: InsertTeam :one
INSERT INTO teams (id, name)
VALUES ($1, $2)
RETURNING *;
-- name: GetTeam :one
SELECT * FROM teams WHERE id = $1;
-- name: InsertTeamMember :exec
INSERT INTO users_teams (user_id, team_id, is_default, role)
VALUES ($1, $2, $3, $4);
-- name: GetDefaultTeamForUser :one
SELECT t.* FROM teams t
JOIN users_teams ut ON ut.team_id = t.id
WHERE ut.user_id = $1 AND ut.is_default = TRUE
LIMIT 1;

View File

@ -1,16 +1,28 @@
-- name: InsertTemplate :one -- name: InsertTemplate :one
INSERT INTO templates (name, type, vcpus, memory_mb, size_bytes) INSERT INTO templates (name, type, vcpus, memory_mb, size_bytes, team_id)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *; RETURNING *;
-- name: GetTemplate :one -- name: GetTemplate :one
SELECT * FROM templates WHERE name = $1; SELECT * FROM templates WHERE name = $1;
-- name: GetTemplateByTeam :one
SELECT * FROM templates WHERE name = $1 AND team_id = $2;
-- name: ListTemplates :many -- name: ListTemplates :many
SELECT * FROM templates ORDER BY created_at DESC; SELECT * FROM templates ORDER BY created_at DESC;
-- name: ListTemplatesByType :many -- name: ListTemplatesByType :many
SELECT * FROM templates WHERE type = $1 ORDER BY created_at DESC; SELECT * FROM templates WHERE type = $1 ORDER BY created_at DESC;
-- name: ListTemplatesByTeam :many
SELECT * FROM templates WHERE team_id = $1 ORDER BY created_at DESC;
-- name: ListTemplatesByTeamAndType :many
SELECT * FROM templates WHERE team_id = $1 AND type = $2 ORDER BY created_at DESC;
-- name: DeleteTemplate :exec -- name: DeleteTemplate :exec
DELETE FROM templates WHERE name = $1; DELETE FROM templates WHERE name = $1;
-- name: DeleteTemplateByTeam :exec
DELETE FROM templates WHERE name = $1 AND team_id = $2;

10
db/queries/users.sql Normal file
View File

@ -0,0 +1,10 @@
-- name: InsertUser :one
INSERT INTO users (id, email, password_hash)
VALUES ($1, $2, $3)
RETURNING *;
-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = $1;
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1;

7
go.mod
View File

@ -5,11 +5,13 @@ go 1.25.0
require ( require (
connectrpc.com/connect v1.19.1 connectrpc.com/connect v1.19.1
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.8.0 github.com/jackc/pgx/v5 v5.8.0
github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5 github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5
github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f
golang.org/x/crypto v0.49.0
golang.org/x/sys v0.42.0 golang.org/x/sys v0.42.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
) )
@ -18,6 +20,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect github.com/joho/godotenv v1.5.1 // indirect
golang.org/x/text v0.29.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
) )

14
go.sum
View File

@ -5,6 +5,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -19,6 +21,8 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -31,14 +35,16 @@ github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5/go.mod h1:tw
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f h1:p4VB7kIXpOQvVn1ZaTIVp+3vuYAXFe3OJEvjbUYJLaA= github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f h1:p4VB7kIXpOQvVn1ZaTIVp+3vuYAXFe3OJEvjbUYJLaA=
github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

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/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype" "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/db"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" "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) { func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxID := chi.URLParam(r, "id")
ctx := r.Context() 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 { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return return

View File

@ -12,6 +12,7 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype" "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/db"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" "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) { func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxID := chi.URLParam(r, "id")
ctx := r.Context() 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 { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return return

View File

@ -9,6 +9,7 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/go-chi/chi/v5" "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/db"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" "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) { func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxID := chi.URLParam(r, "id")
ctx := r.Context() 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 { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return return
@ -95,8 +97,9 @@ type readFileRequest struct {
func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) { func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxID := chi.URLParam(r, "id")
ctx := r.Context() 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 { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return return

View File

@ -10,6 +10,7 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"github.com/go-chi/chi/v5" "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/db"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" "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) { func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxID := chi.URLParam(r, "id")
ctx := r.Context() 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 { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return 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) { func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxID := chi.URLParam(r, "id")
ctx := r.Context() 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 { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return return

View File

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

View File

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

View File

@ -109,13 +109,63 @@ const testUIHTML = `<!DOCTYPE html>
} }
.clickable { cursor: pointer; color: #89a785; text-decoration: underline; } .clickable { cursor: pointer; color: #89a785; text-decoration: underline; }
.clickable:hover { color: #aacdaa; } .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> </style>
</head> </head>
<body> <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"> <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 --> <!-- Create Sandbox -->
<div class="panel"> <div class="panel">
<h2>Create Sandbox</h2> <h2>Create Sandbox</h2>
@ -189,6 +239,8 @@ const testUIHTML = `<!DOCTYPE html>
<script> <script>
const API = ''; const API = '';
let jwtToken = '';
let activeAPIKey = '';
function log(msg, level) { function log(msg, level) {
const el = document.getElementById('log'); const el = document.getElementById('log');
@ -203,8 +255,37 @@ function esc(s) {
return d.innerHTML; 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: {} }; 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) { if (body) {
opts.headers['Content-Type'] = 'application/json'; opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body); opts.body = JSON.stringify(body);
@ -222,11 +303,109 @@ function statusBadge(s) {
return '<span class="status status-' + s + '">' + s + '</span>'; 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 --- // --- Sandboxes ---
async function listSandboxes() { async function listSandboxes() {
try { try {
const data = await api('GET', '/v1/sandboxes'); const data = await api('GET', '/v1/sandboxes', null, 'apikey');
renderSandboxes(data); renderSandboxes(data);
} catch (e) { } catch (e) {
log('List sandboxes failed: ' + e.message, 'err'); log('List sandboxes failed: ' + e.message, 'err');
@ -277,7 +456,7 @@ async function createSandbox() {
const timeout_sec = parseInt(document.getElementById('create-timeout').value); const timeout_sec = parseInt(document.getElementById('create-timeout').value);
log('Creating sandbox (template=' + template + ', vcpus=' + vcpus + ', mem=' + memory_mb + 'MB)...', 'info'); log('Creating sandbox (template=' + template + ', vcpus=' + vcpus + ', mem=' + memory_mb + 'MB)...', 'info');
try { 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'); log('Created sandbox ' + data.id + ' [' + data.status + ']', 'ok');
listSandboxes(); listSandboxes();
} catch (e) { } catch (e) {
@ -288,7 +467,7 @@ async function createSandbox() {
async function pauseSandbox(id) { async function pauseSandbox(id) {
log('Pausing ' + id + '...', 'info'); log('Pausing ' + id + '...', 'info');
try { try {
await api('POST', '/v1/sandboxes/' + id + '/pause'); await api('POST', '/v1/sandboxes/' + id + '/pause', null, 'apikey');
log('Paused ' + id, 'ok'); log('Paused ' + id, 'ok');
listSandboxes(); listSandboxes();
} catch (e) { } catch (e) {
@ -299,7 +478,7 @@ async function pauseSandbox(id) {
async function resumeSandbox(id) { async function resumeSandbox(id) {
log('Resuming ' + id + '...', 'info'); log('Resuming ' + id + '...', 'info');
try { try {
await api('POST', '/v1/sandboxes/' + id + '/resume'); await api('POST', '/v1/sandboxes/' + id + '/resume', null, 'apikey');
log('Resumed ' + id, 'ok'); log('Resumed ' + id, 'ok');
listSandboxes(); listSandboxes();
} catch (e) { } catch (e) {
@ -310,7 +489,7 @@ async function resumeSandbox(id) {
async function destroySandbox(id) { async function destroySandbox(id) {
log('Destroying ' + id + '...', 'info'); log('Destroying ' + id + '...', 'info');
try { try {
await api('DELETE', '/v1/sandboxes/' + id); await api('DELETE', '/v1/sandboxes/' + id, null, 'apikey');
log('Destroyed ' + id, 'ok'); log('Destroyed ' + id, 'ok');
listSandboxes(); listSandboxes();
} catch (e) { } catch (e) {
@ -334,7 +513,7 @@ async function execCmd() {
log('Exec on ' + sandboxId + ': ' + cmd + ' ' + args.join(' '), 'info'); log('Exec on ' + sandboxId + ': ' + cmd + ' ' + args.join(' '), 'info');
try { 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 = ''; let text = '';
if (data.stdout) text += data.stdout; if (data.stdout) text += data.stdout;
if (data.stderr) text += '\n[stderr]\n' + data.stderr; if (data.stderr) text += '\n[stderr]\n' + data.stderr;
@ -362,7 +541,7 @@ async function createSnapshot() {
const qs = overwrite ? '?overwrite=true' : ''; const qs = overwrite ? '?overwrite=true' : '';
log('Creating snapshot from ' + sandbox_id + (name ? ' as "' + name + '"' : '') + '...', 'info'); log('Creating snapshot from ' + sandbox_id + (name ? ' as "' + name + '"' : '') + '...', 'info');
try { 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'); log('Snapshot created: ' + data.name + ' (' + (data.size_bytes / 1024 / 1024).toFixed(1) + 'MB)', 'ok');
listSnapshots(); listSnapshots();
listSandboxes(); listSandboxes();
@ -373,7 +552,7 @@ async function createSnapshot() {
async function listSnapshots() { async function listSnapshots() {
try { try {
const data = await api('GET', '/v1/snapshots'); const data = await api('GET', '/v1/snapshots', null, 'apikey');
renderSnapshots(data); renderSnapshots(data);
} catch (e) { } catch (e) {
log('List snapshots failed: ' + e.message, 'err'); log('List snapshots failed: ' + e.message, 'err');
@ -408,7 +587,7 @@ function useTemplate(name) {
async function deleteSnapshot(name) { async function deleteSnapshot(name) {
log('Deleting snapshot "' + name + '"...', 'info'); log('Deleting snapshot "' + name + '"...', 'info');
try { try {
await api('DELETE', '/v1/snapshots/' + encodeURIComponent(name)); await api('DELETE', '/v1/snapshots/' + encodeURIComponent(name), null, 'apikey');
log('Deleted snapshot "' + name + '"', 'ok'); log('Deleted snapshot "' + name + '"', 'ok');
listSnapshots(); listSnapshots();
} catch (e) { } catch (e) {
@ -428,8 +607,7 @@ document.getElementById('auto-refresh').addEventListener('change', function() {
}); });
// --- Init --- // --- Init ---
listSandboxes(); updateAuthStatus();
listSnapshots();
</script> </script>
</body> </body>
</html>` </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 - url: http://localhost:8080
description: Local development description: Local development
security: []
paths: 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: /v1/sandboxes:
post: post:
summary: Create a sandbox summary: Create a sandbox
operationId: createSandbox operationId: createSandbox
tags: [sandboxes]
security:
- apiKeyAuth: []
requestBody: requestBody:
required: true required: true
content: content:
@ -34,8 +156,11 @@ paths:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
get: get:
summary: List all sandboxes summary: List sandboxes for your team
operationId: listSandboxes operationId: listSandboxes
tags: [sandboxes]
security:
- apiKeyAuth: []
responses: responses:
"200": "200":
description: List of sandboxes description: List of sandboxes
@ -57,6 +182,9 @@ paths:
get: get:
summary: Get sandbox details summary: Get sandbox details
operationId: getSandbox operationId: getSandbox
tags: [sandboxes]
security:
- apiKeyAuth: []
responses: responses:
"200": "200":
description: Sandbox details description: Sandbox details
@ -74,6 +202,9 @@ paths:
delete: delete:
summary: Destroy a sandbox summary: Destroy a sandbox
operationId: destroySandbox operationId: destroySandbox
tags: [sandboxes]
security:
- apiKeyAuth: []
responses: responses:
"204": "204":
description: Sandbox destroyed description: Sandbox destroyed
@ -89,6 +220,9 @@ paths:
post: post:
summary: Execute a command summary: Execute a command
operationId: execCommand operationId: execCommand
tags: [sandboxes]
security:
- apiKeyAuth: []
requestBody: requestBody:
required: true required: true
content: content:
@ -126,6 +260,9 @@ paths:
post: post:
summary: Pause a running sandbox summary: Pause a running sandbox
operationId: pauseSandbox operationId: pauseSandbox
tags: [sandboxes]
security:
- apiKeyAuth: []
description: | description: |
Takes a snapshot of the sandbox (VM state + memory + rootfs), then Takes a snapshot of the sandbox (VM state + memory + rootfs), then
destroys all running resources. The sandbox exists only as files on destroys all running resources. The sandbox exists only as files on
@ -155,6 +292,9 @@ paths:
post: post:
summary: Resume a paused sandbox summary: Resume a paused sandbox
operationId: resumeSandbox operationId: resumeSandbox
tags: [sandboxes]
security:
- apiKeyAuth: []
description: | description: |
Restores a paused sandbox from its snapshot using UFFD for lazy Restores a paused sandbox from its snapshot using UFFD for lazy
memory loading. Boots a fresh Firecracker process, sets up a new memory loading. Boots a fresh Firecracker process, sets up a new
@ -177,6 +317,9 @@ paths:
post: post:
summary: Create a snapshot template summary: Create a snapshot template
operationId: createSnapshot operationId: createSnapshot
tags: [snapshots]
security:
- apiKeyAuth: []
description: | description: |
Pauses a running sandbox, takes a full snapshot, copies the snapshot Pauses a running sandbox, takes a full snapshot, copies the snapshot
files to the images directory as a reusable template, then destroys files to the images directory as a reusable template, then destroys
@ -210,8 +353,11 @@ paths:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
get: get:
summary: List templates summary: List templates for your team
operationId: listSnapshots operationId: listSnapshots
tags: [snapshots]
security:
- apiKeyAuth: []
parameters: parameters:
- name: type - name: type
in: query in: query
@ -241,6 +387,9 @@ paths:
delete: delete:
summary: Delete a snapshot template summary: Delete a snapshot template
operationId: deleteSnapshot operationId: deleteSnapshot
tags: [snapshots]
security:
- apiKeyAuth: []
description: Removes the snapshot files from disk and deletes the database record. description: Removes the snapshot files from disk and deletes the database record.
responses: responses:
"204": "204":
@ -263,6 +412,9 @@ paths:
post: post:
summary: Upload a file summary: Upload a file
operationId: uploadFile operationId: uploadFile
tags: [sandboxes]
security:
- apiKeyAuth: []
requestBody: requestBody:
required: true required: true
content: content:
@ -305,6 +457,9 @@ paths:
post: post:
summary: Download a file summary: Download a file
operationId: downloadFile operationId: downloadFile
tags: [sandboxes]
security:
- apiKeyAuth: []
requestBody: requestBody:
required: true required: true
content: content:
@ -337,6 +492,9 @@ paths:
get: get:
summary: Stream command execution via WebSocket summary: Stream command execution via WebSocket
operationId: execStream operationId: execStream
tags: [sandboxes]
security:
- apiKeyAuth: []
description: | description: |
Opens a WebSocket connection for streaming command execution. Opens a WebSocket connection for streaming command execution.
@ -387,6 +545,9 @@ paths:
post: post:
summary: Upload a file (streaming) summary: Upload a file (streaming)
operationId: streamUploadFile operationId: streamUploadFile
tags: [sandboxes]
security:
- apiKeyAuth: []
description: | description: |
Streams file content to the sandbox without buffering in memory. Streams file content to the sandbox without buffering in memory.
Suitable for large files. Uses the same multipart/form-data format Suitable for large files. Uses the same multipart/form-data format
@ -433,6 +594,9 @@ paths:
post: post:
summary: Download a file (streaming) summary: Download a file (streaming)
operationId: streamDownloadFile operationId: streamDownloadFile
tags: [sandboxes]
security:
- apiKeyAuth: []
description: | description: |
Streams file content from the sandbox without buffering in memory. Streams file content from the sandbox without buffering in memory.
Suitable for large files. Returns raw bytes with chunked transfer encoding. Suitable for large files. Returns raw bytes with chunked transfer encoding.
@ -464,7 +628,85 @@ paths:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
components: 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: 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: CreateSandboxRequest:
type: object type: object
properties: properties:

View File

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" "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. // 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 := chi.NewRouter()
r.Use(requestLogger()) r.Use(requestLogger())
@ -30,6 +31,8 @@ func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *
files := newFilesHandler(queries, agent) files := newFilesHandler(queries, agent)
filesStream := newFilesStreamHandler(queries, agent) filesStream := newFilesStreamHandler(queries, agent)
snapshots := newSnapshotHandler(queries, agent) snapshots := newSnapshotHandler(queries, agent)
authH := newAuthHandler(queries, pool, jwtSecret)
apiKeys := newAPIKeyHandler(queries)
// OpenAPI spec and docs. // OpenAPI spec and docs.
r.Get("/openapi.yaml", serveOpenAPI) r.Get("/openapi.yaml", serveOpenAPI)
@ -38,8 +41,21 @@ func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *
// Test UI for sandbox lifecycle management. // Test UI for sandbox lifecycle management.
r.Get("/test", serveTestUI) 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.Route("/v1/sandboxes", func(r chi.Router) {
r.Use(requireAPIKey(queries))
r.Post("/", sandbox.Create) r.Post("/", sandbox.Create)
r.Get("/", sandbox.List) 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.Route("/v1/snapshots", func(r chi.Router) {
r.Use(requireAPIKey(queries))
r.Post("/", snapshots.Create) r.Post("/", snapshots.Create)
r.Get("/", snapshots.List) r.Get("/", snapshots.List)
r.Delete("/{name}", snapshots.Delete) r.Delete("/{name}", snapshots.Delete)

View File

@ -1 +1,35 @@
package auth package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
)
// GenerateAPIKey returns a plaintext key in the form "wrn_" + 32 random hex chars
// and its SHA-256 hash. The caller must show the plaintext to the user exactly once;
// only the hash is stored.
func GenerateAPIKey() (plaintext, hash string, err error) {
b := make([]byte, 16) // 16 bytes → 32 hex chars
if _, err = rand.Read(b); err != nil {
return "", "", fmt.Errorf("generate api key: %w", err)
}
plaintext = "wrn_" + hex.EncodeToString(b)
hash = HashAPIKey(plaintext)
return plaintext, hash, nil
}
// HashAPIKey returns the hex-encoded SHA-256 hash of a plaintext API key.
func HashAPIKey(plaintext string) string {
sum := sha256.Sum256([]byte(plaintext))
return hex.EncodeToString(sum[:])
}
// APIKeyPrefix returns the displayable prefix of an API key (e.g. "wrn_ab12...").
func APIKeyPrefix(plaintext string) string {
if len(plaintext) > 12 {
return plaintext[:12] + "..."
}
return plaintext
}

35
internal/auth/context.go Normal file
View File

@ -0,0 +1,35 @@
package auth
import "context"
type contextKey int
const authCtxKey contextKey = 0
// AuthContext is stamped into request context by auth middleware.
type AuthContext struct {
TeamID string
UserID string // empty when authenticated via API key
Email string // empty when authenticated via API key
}
// WithAuthContext returns a new context with the given AuthContext.
func WithAuthContext(ctx context.Context, a AuthContext) context.Context {
return context.WithValue(ctx, authCtxKey, a)
}
// FromContext retrieves the AuthContext. Returns zero value and false if absent.
func FromContext(ctx context.Context) (AuthContext, bool) {
a, ok := ctx.Value(authCtxKey).(AuthContext)
return a, ok
}
// MustFromContext retrieves the AuthContext. Panics if absent — only call
// inside handlers behind auth middleware.
func MustFromContext(ctx context.Context) AuthContext {
a, ok := FromContext(ctx)
if !ok {
panic("auth: MustFromContext called on unauthenticated request")
}
return a
}

51
internal/auth/jwt.go Normal file
View File

@ -0,0 +1,51 @@
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
const jwtExpiry = 6 * time.Hour
// Claims are the JWT payload.
type Claims struct {
TeamID string `json:"team_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
// SignJWT signs a new 6-hour JWT for the given user.
func SignJWT(secret []byte, userID, teamID, email string) (string, error) {
now := time.Now()
claims := Claims{
TeamID: teamID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(jwtExpiry)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
// VerifyJWT parses and validates a JWT, returning the claims on success.
func VerifyJWT(secret []byte, tokenStr string) (Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
})
if err != nil {
return Claims{}, fmt.Errorf("invalid token: %w", err)
}
c, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return Claims{}, fmt.Errorf("invalid token claims")
}
return *c, nil
}

16
internal/auth/password.go Normal file
View File

@ -0,0 +1,16 @@
package auth
import "golang.org/x/crypto/bcrypt"
const bcryptCost = 12
// HashPassword returns the bcrypt hash of a plaintext password.
func HashPassword(plaintext string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcryptCost)
return string(b), err
}
// CheckPassword returns nil if plaintext matches the stored hash.
func CheckPassword(hash, plaintext string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext))
}

View File

@ -1 +0,0 @@
package auth

View File

@ -3,6 +3,8 @@ package config
import ( import (
"os" "os"
"strings" "strings"
"github.com/joho/godotenv"
) )
// Config holds the control plane configuration. // Config holds the control plane configuration.
@ -10,14 +12,20 @@ type Config struct {
DatabaseURL string DatabaseURL string
ListenAddr string ListenAddr string
HostAgentAddr string HostAgentAddr string
JWTSecret string
} }
// Load reads configuration from environment variables. // Load reads configuration from a .env file (if present) and environment variables.
// Real environment variables take precedence over .env values.
func Load() Config { func Load() Config {
// Best-effort load — missing .env file is fine.
_ = godotenv.Load()
cfg := Config{ cfg := Config{
DatabaseURL: envOrDefault("DATABASE_URL", "postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable"), DatabaseURL: envOrDefault("DATABASE_URL", "postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable"),
ListenAddr: envOrDefault("CP_LISTEN_ADDR", ":8080"), ListenAddr: envOrDefault("CP_LISTEN_ADDR", ":8080"),
HostAgentAddr: envOrDefault("CP_HOST_AGENT_ADDR", "http://localhost:50051"), HostAgentAddr: envOrDefault("CP_HOST_AGENT_ADDR", "http://localhost:50051"),
JWTSecret: os.Getenv("JWT_SECRET"),
} }
// Ensure the host agent address has a scheme. // Ensure the host agent address has a scheme.

124
internal/db/api_keys.sql.go Normal file
View File

@ -0,0 +1,124 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: api_keys.sql
package db
import (
"context"
)
const deleteAPIKey = `-- name: DeleteAPIKey :exec
DELETE FROM team_api_keys WHERE id = $1 AND team_id = $2
`
type DeleteAPIKeyParams struct {
ID string `json:"id"`
TeamID string `json:"team_id"`
}
func (q *Queries) DeleteAPIKey(ctx context.Context, arg DeleteAPIKeyParams) error {
_, err := q.db.Exec(ctx, deleteAPIKey, arg.ID, arg.TeamID)
return err
}
const getAPIKeyByHash = `-- name: GetAPIKeyByHash :one
SELECT id, team_id, name, key_hash, key_prefix, created_by, created_at, last_used FROM team_api_keys WHERE key_hash = $1
`
func (q *Queries) GetAPIKeyByHash(ctx context.Context, keyHash string) (TeamApiKey, error) {
row := q.db.QueryRow(ctx, getAPIKeyByHash, keyHash)
var i TeamApiKey
err := row.Scan(
&i.ID,
&i.TeamID,
&i.Name,
&i.KeyHash,
&i.KeyPrefix,
&i.CreatedBy,
&i.CreatedAt,
&i.LastUsed,
)
return i, err
}
const insertAPIKey = `-- name: InsertAPIKey :one
INSERT INTO team_api_keys (id, team_id, name, key_hash, key_prefix, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, team_id, name, key_hash, key_prefix, created_by, created_at, last_used
`
type InsertAPIKeyParams struct {
ID string `json:"id"`
TeamID string `json:"team_id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"`
CreatedBy string `json:"created_by"`
}
func (q *Queries) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (TeamApiKey, error) {
row := q.db.QueryRow(ctx, insertAPIKey,
arg.ID,
arg.TeamID,
arg.Name,
arg.KeyHash,
arg.KeyPrefix,
arg.CreatedBy,
)
var i TeamApiKey
err := row.Scan(
&i.ID,
&i.TeamID,
&i.Name,
&i.KeyHash,
&i.KeyPrefix,
&i.CreatedBy,
&i.CreatedAt,
&i.LastUsed,
)
return i, err
}
const listAPIKeysByTeam = `-- name: ListAPIKeysByTeam :many
SELECT id, team_id, name, key_hash, key_prefix, created_by, created_at, last_used FROM team_api_keys WHERE team_id = $1 ORDER BY created_at DESC
`
func (q *Queries) ListAPIKeysByTeam(ctx context.Context, teamID string) ([]TeamApiKey, error) {
rows, err := q.db.Query(ctx, listAPIKeysByTeam, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TeamApiKey
for rows.Next() {
var i TeamApiKey
if err := rows.Scan(
&i.ID,
&i.TeamID,
&i.Name,
&i.KeyHash,
&i.KeyPrefix,
&i.CreatedBy,
&i.CreatedAt,
&i.LastUsed,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateAPIKeyLastUsed = `-- name: UpdateAPIKeyLastUsed :exec
UPDATE team_api_keys SET last_used = NOW() WHERE id = $1
`
func (q *Queries) UpdateAPIKeyLastUsed(ctx context.Context, id string) error {
_, err := q.db.Exec(ctx, updateAPIKeyLastUsed, id)
return err
}

View File

@ -10,7 +10,6 @@ import (
type Sandbox struct { type Sandbox struct {
ID string `json:"id"` ID string `json:"id"`
OwnerID string `json:"owner_id"`
HostID string `json:"host_id"` HostID string `json:"host_id"`
Template string `json:"template"` Template string `json:"template"`
Status string `json:"status"` Status string `json:"status"`
@ -23,6 +22,24 @@ type Sandbox struct {
StartedAt pgtype.Timestamptz `json:"started_at"` StartedAt pgtype.Timestamptz `json:"started_at"`
LastActiveAt pgtype.Timestamptz `json:"last_active_at"` LastActiveAt pgtype.Timestamptz `json:"last_active_at"`
LastUpdated pgtype.Timestamptz `json:"last_updated"` LastUpdated pgtype.Timestamptz `json:"last_updated"`
TeamID string `json:"team_id"`
}
type Team struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type TeamApiKey struct {
ID string `json:"id"`
TeamID string `json:"team_id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"`
CreatedBy string `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
LastUsed pgtype.Timestamptz `json:"last_used"`
} }
type Template struct { type Template struct {
@ -32,4 +49,21 @@ type Template struct {
MemoryMb pgtype.Int4 `json:"memory_mb"` MemoryMb pgtype.Int4 `json:"memory_mb"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
TeamID string `json:"team_id"`
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type UsersTeam struct {
UserID string `json:"user_id"`
TeamID string `json:"team_id"`
IsDefault bool `json:"is_default"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
} }

View File

@ -29,7 +29,7 @@ func (q *Queries) BulkUpdateStatusByIDs(ctx context.Context, arg BulkUpdateStatu
} }
const getSandbox = `-- name: GetSandbox :one const getSandbox = `-- name: GetSandbox :one
SELECT id, owner_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes WHERE id = $1 SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes WHERE id = $1
` `
func (q *Queries) GetSandbox(ctx context.Context, id string) (Sandbox, error) { func (q *Queries) GetSandbox(ctx context.Context, id string) (Sandbox, error) {
@ -37,7 +37,6 @@ func (q *Queries) GetSandbox(ctx context.Context, id string) (Sandbox, error) {
var i Sandbox var i Sandbox
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.OwnerID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -50,19 +49,51 @@ func (q *Queries) GetSandbox(ctx context.Context, id string) (Sandbox, error) {
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
)
return i, err
}
const getSandboxByTeam = `-- name: GetSandboxByTeam :one
SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes WHERE id = $1 AND team_id = $2
`
type GetSandboxByTeamParams struct {
ID string `json:"id"`
TeamID string `json:"team_id"`
}
func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamParams) (Sandbox, error) {
row := q.db.QueryRow(ctx, getSandboxByTeam, arg.ID, arg.TeamID)
var i Sandbox
err := row.Scan(
&i.ID,
&i.HostID,
&i.Template,
&i.Status,
&i.Vcpus,
&i.MemoryMb,
&i.TimeoutSec,
&i.GuestIp,
&i.HostIp,
&i.CreatedAt,
&i.StartedAt,
&i.LastActiveAt,
&i.LastUpdated,
&i.TeamID,
) )
return i, err return i, err
} }
const insertSandbox = `-- name: InsertSandbox :one const insertSandbox = `-- name: InsertSandbox :one
INSERT INTO sandboxes (id, owner_id, host_id, template, status, vcpus, memory_mb, timeout_sec) INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, owner_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated RETURNING id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id
` `
type InsertSandboxParams struct { type InsertSandboxParams struct {
ID string `json:"id"` ID string `json:"id"`
OwnerID string `json:"owner_id"` TeamID string `json:"team_id"`
HostID string `json:"host_id"` HostID string `json:"host_id"`
Template string `json:"template"` Template string `json:"template"`
Status string `json:"status"` Status string `json:"status"`
@ -74,7 +105,7 @@ type InsertSandboxParams struct {
func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (Sandbox, error) { func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (Sandbox, error) {
row := q.db.QueryRow(ctx, insertSandbox, row := q.db.QueryRow(ctx, insertSandbox,
arg.ID, arg.ID,
arg.OwnerID, arg.TeamID,
arg.HostID, arg.HostID,
arg.Template, arg.Template,
arg.Status, arg.Status,
@ -85,7 +116,6 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
var i Sandbox var i Sandbox
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.OwnerID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -98,12 +128,13 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
) )
return i, err return i, err
} }
const listSandboxes = `-- name: ListSandboxes :many const listSandboxes = `-- name: ListSandboxes :many
SELECT id, owner_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes ORDER BY created_at DESC SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes ORDER BY created_at DESC
` `
func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) { func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
@ -117,7 +148,6 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
var i Sandbox var i Sandbox
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.OwnerID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -130,6 +160,7 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -142,7 +173,7 @@ func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) {
} }
const listSandboxesByHostAndStatus = `-- name: ListSandboxesByHostAndStatus :many const listSandboxesByHostAndStatus = `-- name: ListSandboxesByHostAndStatus :many
SELECT id, owner_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated FROM sandboxes SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes
WHERE host_id = $1 AND status = ANY($2::text[]) WHERE host_id = $1 AND status = ANY($2::text[])
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@ -163,7 +194,6 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand
var i Sandbox var i Sandbox
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.OwnerID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -176,6 +206,46 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSandboxesByTeam = `-- name: ListSandboxesByTeam :many
SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes WHERE team_id = $1 ORDER BY created_at DESC
`
func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID string) ([]Sandbox, error) {
rows, err := q.db.Query(ctx, listSandboxesByTeam, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Sandbox
for rows.Next() {
var i Sandbox
if err := rows.Scan(
&i.ID,
&i.HostID,
&i.Template,
&i.Status,
&i.Vcpus,
&i.MemoryMb,
&i.TimeoutSec,
&i.GuestIp,
&i.HostIp,
&i.CreatedAt,
&i.StartedAt,
&i.LastActiveAt,
&i.LastUpdated,
&i.TeamID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -213,7 +283,7 @@ SET status = 'running',
last_active_at = $4, last_active_at = $4,
last_updated = NOW() last_updated = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, owner_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated RETURNING id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id
` `
type UpdateSandboxRunningParams struct { type UpdateSandboxRunningParams struct {
@ -233,7 +303,6 @@ func (q *Queries) UpdateSandboxRunning(ctx context.Context, arg UpdateSandboxRun
var i Sandbox var i Sandbox
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.OwnerID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -246,6 +315,7 @@ func (q *Queries) UpdateSandboxRunning(ctx context.Context, arg UpdateSandboxRun
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
) )
return i, err return i, err
} }
@ -255,7 +325,7 @@ UPDATE sandboxes
SET status = $2, SET status = $2,
last_updated = NOW() last_updated = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, owner_id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated RETURNING id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id
` `
type UpdateSandboxStatusParams struct { type UpdateSandboxStatusParams struct {
@ -268,7 +338,6 @@ func (q *Queries) UpdateSandboxStatus(ctx context.Context, arg UpdateSandboxStat
var i Sandbox var i Sandbox
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.OwnerID,
&i.HostID, &i.HostID,
&i.Template, &i.Template,
&i.Status, &i.Status,
@ -281,6 +350,7 @@ func (q *Queries) UpdateSandboxStatus(ctx context.Context, arg UpdateSandboxStat
&i.StartedAt, &i.StartedAt,
&i.LastActiveAt, &i.LastActiveAt,
&i.LastUpdated, &i.LastUpdated,
&i.TeamID,
) )
return i, err return i, err
} }

75
internal/db/teams.sql.go Normal file
View File

@ -0,0 +1,75 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: teams.sql
package db
import (
"context"
)
const getDefaultTeamForUser = `-- name: GetDefaultTeamForUser :one
SELECT t.id, t.name, t.created_at FROM teams t
JOIN users_teams ut ON ut.team_id = t.id
WHERE ut.user_id = $1 AND ut.is_default = TRUE
LIMIT 1
`
func (q *Queries) GetDefaultTeamForUser(ctx context.Context, userID string) (Team, error) {
row := q.db.QueryRow(ctx, getDefaultTeamForUser, userID)
var i Team
err := row.Scan(&i.ID, &i.Name, &i.CreatedAt)
return i, err
}
const getTeam = `-- name: GetTeam :one
SELECT id, name, created_at FROM teams WHERE id = $1
`
func (q *Queries) GetTeam(ctx context.Context, id string) (Team, error) {
row := q.db.QueryRow(ctx, getTeam, id)
var i Team
err := row.Scan(&i.ID, &i.Name, &i.CreatedAt)
return i, err
}
const insertTeam = `-- name: InsertTeam :one
INSERT INTO teams (id, name)
VALUES ($1, $2)
RETURNING id, name, created_at
`
type InsertTeamParams struct {
ID string `json:"id"`
Name string `json:"name"`
}
func (q *Queries) InsertTeam(ctx context.Context, arg InsertTeamParams) (Team, error) {
row := q.db.QueryRow(ctx, insertTeam, arg.ID, arg.Name)
var i Team
err := row.Scan(&i.ID, &i.Name, &i.CreatedAt)
return i, err
}
const insertTeamMember = `-- name: InsertTeamMember :exec
INSERT INTO users_teams (user_id, team_id, is_default, role)
VALUES ($1, $2, $3, $4)
`
type InsertTeamMemberParams struct {
UserID string `json:"user_id"`
TeamID string `json:"team_id"`
IsDefault bool `json:"is_default"`
Role string `json:"role"`
}
func (q *Queries) InsertTeamMember(ctx context.Context, arg InsertTeamMemberParams) error {
_, err := q.db.Exec(ctx, insertTeamMember,
arg.UserID,
arg.TeamID,
arg.IsDefault,
arg.Role,
)
return err
}

View File

@ -20,8 +20,22 @@ func (q *Queries) DeleteTemplate(ctx context.Context, name string) error {
return err return err
} }
const deleteTemplateByTeam = `-- name: DeleteTemplateByTeam :exec
DELETE FROM templates WHERE name = $1 AND team_id = $2
`
type DeleteTemplateByTeamParams struct {
Name string `json:"name"`
TeamID string `json:"team_id"`
}
func (q *Queries) DeleteTemplateByTeam(ctx context.Context, arg DeleteTemplateByTeamParams) error {
_, err := q.db.Exec(ctx, deleteTemplateByTeam, arg.Name, arg.TeamID)
return err
}
const getTemplate = `-- name: GetTemplate :one const getTemplate = `-- name: GetTemplate :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at FROM templates WHERE name = $1 SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE name = $1
` `
func (q *Queries) GetTemplate(ctx context.Context, name string) (Template, error) { func (q *Queries) GetTemplate(ctx context.Context, name string) (Template, error) {
@ -34,14 +48,39 @@ func (q *Queries) GetTemplate(ctx context.Context, name string) (Template, error
&i.MemoryMb, &i.MemoryMb,
&i.SizeBytes, &i.SizeBytes,
&i.CreatedAt, &i.CreatedAt,
&i.TeamID,
)
return i, err
}
const getTemplateByTeam = `-- name: GetTemplateByTeam :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE name = $1 AND team_id = $2
`
type GetTemplateByTeamParams struct {
Name string `json:"name"`
TeamID string `json:"team_id"`
}
func (q *Queries) GetTemplateByTeam(ctx context.Context, arg GetTemplateByTeamParams) (Template, error) {
row := q.db.QueryRow(ctx, getTemplateByTeam, arg.Name, arg.TeamID)
var i Template
err := row.Scan(
&i.Name,
&i.Type,
&i.Vcpus,
&i.MemoryMb,
&i.SizeBytes,
&i.CreatedAt,
&i.TeamID,
) )
return i, err return i, err
} }
const insertTemplate = `-- name: InsertTemplate :one const insertTemplate = `-- name: InsertTemplate :one
INSERT INTO templates (name, type, vcpus, memory_mb, size_bytes) INSERT INTO templates (name, type, vcpus, memory_mb, size_bytes, team_id)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING name, type, vcpus, memory_mb, size_bytes, created_at RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id
` `
type InsertTemplateParams struct { type InsertTemplateParams struct {
@ -50,6 +89,7 @@ type InsertTemplateParams struct {
Vcpus pgtype.Int4 `json:"vcpus"` Vcpus pgtype.Int4 `json:"vcpus"`
MemoryMb pgtype.Int4 `json:"memory_mb"` MemoryMb pgtype.Int4 `json:"memory_mb"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
TeamID string `json:"team_id"`
} }
func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) { func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) {
@ -59,6 +99,7 @@ func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams)
arg.Vcpus, arg.Vcpus,
arg.MemoryMb, arg.MemoryMb,
arg.SizeBytes, arg.SizeBytes,
arg.TeamID,
) )
var i Template var i Template
err := row.Scan( err := row.Scan(
@ -68,12 +109,13 @@ func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams)
&i.MemoryMb, &i.MemoryMb,
&i.SizeBytes, &i.SizeBytes,
&i.CreatedAt, &i.CreatedAt,
&i.TeamID,
) )
return i, err return i, err
} }
const listTemplates = `-- name: ListTemplates :many const listTemplates = `-- name: ListTemplates :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at FROM templates ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates ORDER BY created_at DESC
` `
func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) { func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) {
@ -92,6 +134,76 @@ func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) {
&i.MemoryMb, &i.MemoryMb,
&i.SizeBytes, &i.SizeBytes,
&i.CreatedAt, &i.CreatedAt,
&i.TeamID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTemplatesByTeam = `-- name: ListTemplatesByTeam :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE team_id = $1 ORDER BY created_at DESC
`
func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID string) ([]Template, error) {
rows, err := q.db.Query(ctx, listTemplatesByTeam, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Template
for rows.Next() {
var i Template
if err := rows.Scan(
&i.Name,
&i.Type,
&i.Vcpus,
&i.MemoryMb,
&i.SizeBytes,
&i.CreatedAt,
&i.TeamID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTemplatesByTeamAndType = `-- name: ListTemplatesByTeamAndType :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE team_id = $1 AND type = $2 ORDER BY created_at DESC
`
type ListTemplatesByTeamAndTypeParams struct {
TeamID string `json:"team_id"`
Type string `json:"type"`
}
func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTemplatesByTeamAndTypeParams) ([]Template, error) {
rows, err := q.db.Query(ctx, listTemplatesByTeamAndType, arg.TeamID, arg.Type)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Template
for rows.Next() {
var i Template
if err := rows.Scan(
&i.Name,
&i.Type,
&i.Vcpus,
&i.MemoryMb,
&i.SizeBytes,
&i.CreatedAt,
&i.TeamID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -104,7 +216,7 @@ func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) {
} }
const listTemplatesByType = `-- name: ListTemplatesByType :many const listTemplatesByType = `-- name: ListTemplatesByType :many
SELECT name, type, vcpus, memory_mb, size_bytes, created_at FROM templates WHERE type = $1 ORDER BY created_at DESC SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE type = $1 ORDER BY created_at DESC
` `
func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Template, error) { func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Template, error) {
@ -123,6 +235,7 @@ func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Temp
&i.MemoryMb, &i.MemoryMb,
&i.SizeBytes, &i.SizeBytes,
&i.CreatedAt, &i.CreatedAt,
&i.TeamID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

69
internal/db/users.sql.go Normal file
View File

@ -0,0 +1,69 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: users.sql
package db
import (
"context"
)
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password_hash, created_at, updated_at FROM users WHERE email = $1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
row := q.db.QueryRow(ctx, getUserByEmail, email)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, password_hash, created_at, updated_at FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const insertUser = `-- name: InsertUser :one
INSERT INTO users (id, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, email, password_hash, created_at, updated_at
`
type InsertUserParams struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
}
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) {
row := q.db.QueryRow(ctx, insertUser, arg.ID, arg.Email, arg.PasswordHash)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -6,20 +6,35 @@ import (
"fmt" "fmt"
) )
// NewSandboxID generates a new sandbox ID in the format "sb-" + 8 hex chars. func hex8() string {
func NewSandboxID() string {
b := make([]byte, 4) b := make([]byte, 4)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("crypto/rand failed: %v", err)) panic(fmt.Sprintf("crypto/rand failed: %v", err))
} }
return "sb-" + hex.EncodeToString(b) return hex.EncodeToString(b)
}
// NewSandboxID generates a new sandbox ID in the format "sb-" + 8 hex chars.
func NewSandboxID() string {
return "sb-" + hex8()
} }
// NewSnapshotName generates a snapshot name in the format "template-" + 8 hex chars. // NewSnapshotName generates a snapshot name in the format "template-" + 8 hex chars.
func NewSnapshotName() string { func NewSnapshotName() string {
b := make([]byte, 4) return "template-" + hex8()
if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("crypto/rand failed: %v", err))
} }
return "template-" + hex.EncodeToString(b)
// NewUserID generates a new user ID in the format "usr-" + 8 hex chars.
func NewUserID() string {
return "usr-" + hex8()
}
// NewTeamID generates a new team ID in the format "team-" + 8 hex chars.
func NewTeamID() string {
return "team-" + hex8()
}
// NewAPIKeyID generates a new API key ID in the format "key-" + 8 hex chars.
func NewAPIKeyID() string {
return "key-" + hex8()
} }