diff --git a/.env.example b/.env.example index cce316d..c52e46f 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,6 @@ S3_REGION=fsn1 S3_ENDPOINT=https://fsn1.your-objectstorage.com AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= + +# Auth +JWT_SECRET= diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go index 80638bb..2b22e65 100644 --- a/cmd/control-plane/main.go +++ b/cmd/control-plane/main.go @@ -24,6 +24,11 @@ func main() { 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()) defer cancel() @@ -51,7 +56,7 @@ func main() { ) // API server. - srv := api.New(queries, agentClient) + srv := api.New(queries, agentClient, pool, []byte(cfg.JWTSecret)) // Start reconciler. reconciler := api.NewReconciler(queries, agentClient, "default", 30*time.Second) diff --git a/db/migrations/20260313210608_auth.sql b/db/migrations/20260313210608_auth.sql new file mode 100644 index 0000000..03970a8 --- /dev/null +++ b/db/migrations/20260313210608_auth.sql @@ -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; diff --git a/db/migrations/20260313210611_team_ownership.sql b/db/migrations/20260313210611_team_ownership.sql new file mode 100644 index 0000000..849e781 --- /dev/null +++ b/db/migrations/20260313210611_team_ownership.sql @@ -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; diff --git a/db/queries/api_keys.sql b/db/queries/api_keys.sql new file mode 100644 index 0000000..0580518 --- /dev/null +++ b/db/queries/api_keys.sql @@ -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; diff --git a/db/queries/sandboxes.sql b/db/queries/sandboxes.sql index 7a964a7..33203f6 100644 --- a/db/queries/sandboxes.sql +++ b/db/queries/sandboxes.sql @@ -1,14 +1,20 @@ -- 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) RETURNING *; -- name: GetSandbox :one SELECT * FROM sandboxes WHERE id = $1; +-- name: GetSandboxByTeam :one +SELECT * FROM sandboxes WHERE id = $1 AND team_id = $2; + -- name: ListSandboxes :many 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 SELECT * FROM sandboxes WHERE host_id = $1 AND status = ANY($2::text[]) diff --git a/db/queries/teams.sql b/db/queries/teams.sql new file mode 100644 index 0000000..f4c4633 --- /dev/null +++ b/db/queries/teams.sql @@ -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; diff --git a/db/queries/templates.sql b/db/queries/templates.sql index 4a438d7..b17abc3 100644 --- a/db/queries/templates.sql +++ b/db/queries/templates.sql @@ -1,16 +1,28 @@ -- name: InsertTemplate :one -INSERT INTO templates (name, type, vcpus, memory_mb, size_bytes) -VALUES ($1, $2, $3, $4, $5) +INSERT INTO templates (name, type, vcpus, memory_mb, size_bytes, team_id) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; -- name: GetTemplate :one SELECT * FROM templates WHERE name = $1; +-- name: GetTemplateByTeam :one +SELECT * FROM templates WHERE name = $1 AND team_id = $2; + -- name: ListTemplates :many SELECT * FROM templates ORDER BY created_at DESC; -- name: ListTemplatesByType :many 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 DELETE FROM templates WHERE name = $1; + +-- name: DeleteTemplateByTeam :exec +DELETE FROM templates WHERE name = $1 AND team_id = $2; diff --git a/db/queries/users.sql b/db/queries/users.sql new file mode 100644 index 0000000..c1f61f0 --- /dev/null +++ b/db/queries/users.sql @@ -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; diff --git a/go.mod b/go.mod index 11c8bcb..82bd17a 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,13 @@ go 1.25.0 require ( connectrpc.com/connect v1.19.1 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/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.8.0 github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5 github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f + golang.org/x/crypto v0.49.0 golang.org/x/sys v0.42.0 google.golang.org/protobuf v1.36.11 ) @@ -18,6 +20,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.29.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 4997587..d8ac123 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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-20210104183010-2eb08e3e575f h1:p4VB7kIXpOQvVn1ZaTIVp+3vuYAXFe3OJEvjbUYJLaA= 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/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +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-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/api/handlers_apikeys.go b/internal/api/handlers_apikeys.go new file mode 100644 index 0000000..b8a5ead --- /dev/null +++ b/internal/api/handlers_apikeys.go @@ -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) +} diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go new file mode 100644 index 0000000..2fbe1db --- /dev/null +++ b/internal/api/handlers_auth.go @@ -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, + }) +} diff --git a/internal/api/handlers_exec.go b/internal/api/handlers_exec.go index 8323df4..9307a67 100644 --- a/internal/api/handlers_exec.go +++ b/internal/api/handlers_exec.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" + "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" @@ -47,8 +48,9 @@ type execResponse struct { func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) { sandboxID := chi.URLParam(r, "id") ctx := r.Context() + ac := auth.MustFromContext(ctx) - sb, err := h.db.GetSandbox(ctx, sandboxID) + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) if err != nil { writeError(w, http.StatusNotFound, "not_found", "sandbox not found") return diff --git a/internal/api/handlers_exec_stream.go b/internal/api/handlers_exec_stream.go index a2be27d..009f41b 100644 --- a/internal/api/handlers_exec_stream.go +++ b/internal/api/handlers_exec_stream.go @@ -12,6 +12,7 @@ import ( "github.com/gorilla/websocket" "github.com/jackc/pgx/v5/pgtype" + "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" @@ -49,8 +50,9 @@ type wsOutMsg struct { func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) { sandboxID := chi.URLParam(r, "id") ctx := r.Context() + ac := auth.MustFromContext(ctx) - sb, err := h.db.GetSandbox(ctx, sandboxID) + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) if err != nil { writeError(w, http.StatusNotFound, "not_found", "sandbox not found") return diff --git a/internal/api/handlers_files.go b/internal/api/handlers_files.go index 71a3aea..c1c0291 100644 --- a/internal/api/handlers_files.go +++ b/internal/api/handlers_files.go @@ -9,6 +9,7 @@ import ( "connectrpc.com/connect" "github.com/go-chi/chi/v5" + "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" @@ -30,8 +31,9 @@ func newFilesHandler(db *db.Queries, agent hostagentv1connect.HostAgentServiceCl func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) { sandboxID := chi.URLParam(r, "id") ctx := r.Context() + ac := auth.MustFromContext(ctx) - sb, err := h.db.GetSandbox(ctx, sandboxID) + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) if err != nil { writeError(w, http.StatusNotFound, "not_found", "sandbox not found") return @@ -95,8 +97,9 @@ type readFileRequest struct { func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) { sandboxID := chi.URLParam(r, "id") ctx := r.Context() + ac := auth.MustFromContext(ctx) - sb, err := h.db.GetSandbox(ctx, sandboxID) + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) if err != nil { writeError(w, http.StatusNotFound, "not_found", "sandbox not found") return diff --git a/internal/api/handlers_files_stream.go b/internal/api/handlers_files_stream.go index 7999a2f..66a3c5b 100644 --- a/internal/api/handlers_files_stream.go +++ b/internal/api/handlers_files_stream.go @@ -10,6 +10,7 @@ import ( "connectrpc.com/connect" "github.com/go-chi/chi/v5" + "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" @@ -30,8 +31,9 @@ func newFilesStreamHandler(db *db.Queries, agent hostagentv1connect.HostAgentSer func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request) { sandboxID := chi.URLParam(r, "id") ctx := r.Context() + ac := auth.MustFromContext(ctx) - sb, err := h.db.GetSandbox(ctx, sandboxID) + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) if err != nil { writeError(w, http.StatusNotFound, "not_found", "sandbox not found") return @@ -140,8 +142,9 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Request) { sandboxID := chi.URLParam(r, "id") ctx := r.Context() + ac := auth.MustFromContext(ctx) - sb, err := h.db.GetSandbox(ctx, sandboxID) + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) if err != nil { writeError(w, http.StatusNotFound, "not_found", "sandbox not found") return diff --git a/internal/api/handlers_sandbox.go b/internal/api/handlers_sandbox.go index bb06a5f..5ffd008 100644 --- a/internal/api/handlers_sandbox.go +++ b/internal/api/handlers_sandbox.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" + "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/validate" @@ -103,10 +104,11 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() + ac := auth.MustFromContext(ctx) // If the template is a snapshot, use its baked-in vcpus/memory // (they cannot be changed since the VM state is frozen). - if tmpl, err := h.db.GetTemplate(ctx, req.Template); err == nil && tmpl.Type == "snapshot" { + if tmpl, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: req.Template, TeamID: ac.TeamID}); err == nil && tmpl.Type == "snapshot" { if tmpl.Vcpus.Valid { req.VCPUs = tmpl.Vcpus.Int32 } @@ -119,7 +121,7 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) { // Insert pending record. _, err := h.db.InsertSandbox(ctx, db.InsertSandboxParams{ ID: sandboxID, - OwnerID: "", + TeamID: ac.TeamID, HostID: "default", Template: req.Template, Status: "pending", @@ -173,7 +175,8 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) { // List handles GET /v1/sandboxes. func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) { - sandboxes, err := h.db.ListSandboxes(r.Context()) + ac := auth.MustFromContext(r.Context()) + sandboxes, err := h.db.ListSandboxesByTeam(r.Context(), ac.TeamID) if err != nil { writeError(w, http.StatusInternalServerError, "db_error", "failed to list sandboxes") return @@ -190,8 +193,9 @@ func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) { // Get handles GET /v1/sandboxes/{id}. func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) { sandboxID := chi.URLParam(r, "id") + ac := auth.MustFromContext(r.Context()) - sb, err := h.db.GetSandbox(r.Context(), sandboxID) + sb, err := h.db.GetSandboxByTeam(r.Context(), db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) if err != nil { writeError(w, http.StatusNotFound, "not_found", "sandbox not found") return @@ -206,8 +210,9 @@ func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) { sandboxID := chi.URLParam(r, "id") ctx := r.Context() + ac := auth.MustFromContext(ctx) - sb, err := h.db.GetSandbox(ctx, sandboxID) + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) if err != nil { writeError(w, http.StatusNotFound, "not_found", "sandbox not found") return @@ -241,8 +246,9 @@ func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) { sandboxID := chi.URLParam(r, "id") ctx := r.Context() + ac := auth.MustFromContext(ctx) - sb, err := h.db.GetSandbox(ctx, sandboxID) + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) if err != nil { writeError(w, http.StatusNotFound, "not_found", "sandbox not found") return @@ -283,8 +289,9 @@ func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) { sandboxID := chi.URLParam(r, "id") ctx := r.Context() + ac := auth.MustFromContext(ctx) - _, err := h.db.GetSandbox(ctx, sandboxID) + _, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) if err != nil { writeError(w, http.StatusNotFound, "not_found", "sandbox not found") return diff --git a/internal/api/handlers_snapshots.go b/internal/api/handlers_snapshots.go index 8e6b36f..20cd99f 100644 --- a/internal/api/handlers_snapshots.go +++ b/internal/api/handlers_snapshots.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" + "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/validate" @@ -81,22 +82,23 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() + ac := auth.MustFromContext(ctx) overwrite := r.URL.Query().Get("overwrite") == "true" - // Check if name already exists. - if _, err := h.db.GetTemplate(ctx, req.Name); err == nil { + // Check if name already exists for this team. + if _, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: req.Name, TeamID: ac.TeamID}); err == nil { if !overwrite { writeError(w, http.StatusConflict, "already_exists", "snapshot name already exists; use ?overwrite=true to replace") return } // Delete existing template record and files. - if err := h.db.DeleteTemplate(ctx, req.Name); err != nil { + if err := h.db.DeleteTemplateByTeam(ctx, db.DeleteTemplateByTeamParams{Name: req.Name, TeamID: ac.TeamID}); err != nil { slog.Warn("failed to delete existing template", "name", req.Name, "error", err) } } - // Verify sandbox exists and is running or paused. - sb, err := h.db.GetSandbox(ctx, req.SandboxID) + // Verify sandbox exists, belongs to team, and is running or paused. + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: req.SandboxID, TeamID: ac.TeamID}) if err != nil { writeError(w, http.StatusNotFound, "not_found", "sandbox not found") return @@ -134,6 +136,7 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) { Vcpus: pgtype.Int4{Int32: sb.Vcpus, Valid: true}, MemoryMb: pgtype.Int4{Int32: sb.MemoryMb, Valid: true}, SizeBytes: resp.Msg.SizeBytes, + TeamID: ac.TeamID, }) if err != nil { slog.Error("failed to insert template record", "name", req.Name, "error", err) @@ -147,14 +150,15 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) { // List handles GET /v1/snapshots. func (h *snapshotHandler) List(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + ac := auth.MustFromContext(ctx) typeFilter := r.URL.Query().Get("type") var templates []db.Template var err error if typeFilter != "" { - templates, err = h.db.ListTemplatesByType(ctx, typeFilter) + templates, err = h.db.ListTemplatesByTeamAndType(ctx, db.ListTemplatesByTeamAndTypeParams{TeamID: ac.TeamID, Type: typeFilter}) } else { - templates, err = h.db.ListTemplates(ctx) + templates, err = h.db.ListTemplatesByTeam(ctx, ac.TeamID) } if err != nil { writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates") @@ -177,8 +181,9 @@ func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) { return } ctx := r.Context() + ac := auth.MustFromContext(ctx) - if _, err := h.db.GetTemplate(ctx, name); err != nil { + if _, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: name, TeamID: ac.TeamID}); err != nil { writeError(w, http.StatusNotFound, "not_found", "template not found") return } @@ -190,7 +195,7 @@ func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) { slog.Warn("delete snapshot: agent RPC failed", "name", name, "error", err) } - if err := h.db.DeleteTemplate(ctx, name); err != nil { + if err := h.db.DeleteTemplateByTeam(ctx, db.DeleteTemplateByTeamParams{Name: name, TeamID: ac.TeamID}); err != nil { writeError(w, http.StatusInternalServerError, "db_error", "failed to delete template record") return } diff --git a/internal/api/handlers_test_ui.go b/internal/api/handlers_test_ui.go index 7ac3c89..1161866 100644 --- a/internal/api/handlers_test_ui.go +++ b/internal/api/handlers_test_ui.go @@ -109,13 +109,63 @@ const testUIHTML = ` } .clickable { cursor: pointer; color: #89a785; text-decoration: underline; } .clickable:hover { color: #aacdaa; } + .auth-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + margin-left: 8px; + } + .auth-badge.authed { background: rgba(94,140,88,0.15); color: #89a785; } + .auth-badge.unauthed { background: rgba(179,85,68,0.15); color: #c27b6d; } + .key-display { + background: #1b201e; + border: 1px solid #5e8c58; + border-radius: 4px; + padding: 8px; + margin-top: 8px; + font-size: 12px; + word-break: break-all; + color: #89a785; + }
-