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:
@ -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=
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
46
db/migrations/20260313210608_auth.sql
Normal file
46
db/migrations/20260313210608_auth.sql
Normal 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;
|
||||||
31
db/migrations/20260313210611_team_ownership.sql
Normal file
31
db/migrations/20260313210611_team_ownership.sql
Normal 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
16
db/queries/api_keys.sql
Normal 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;
|
||||||
@ -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
17
db/queries/teams.sql
Normal 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;
|
||||||
@ -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
10
db/queries/users.sql
Normal 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
7
go.mod
@ -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
14
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/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=
|
||||||
|
|||||||
125
internal/api/handlers_apikeys.go
Normal file
125
internal/api/handlers_apikeys.go
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/auth"
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
type apiKeyHandler struct {
|
||||||
|
db *db.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAPIKeyHandler(db *db.Queries) *apiKeyHandler {
|
||||||
|
return &apiKeyHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createAPIKeyRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiKeyResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
KeyPrefix string `json:"key_prefix"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
LastUsed *string `json:"last_used,omitempty"`
|
||||||
|
Key *string `json:"key,omitempty"` // only populated on Create
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyToResponse(k db.TeamApiKey) apiKeyResponse {
|
||||||
|
resp := apiKeyResponse{
|
||||||
|
ID: k.ID,
|
||||||
|
TeamID: k.TeamID,
|
||||||
|
Name: k.Name,
|
||||||
|
KeyPrefix: k.KeyPrefix,
|
||||||
|
}
|
||||||
|
if k.CreatedAt.Valid {
|
||||||
|
resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if k.LastUsed.Valid {
|
||||||
|
s := k.LastUsed.Time.Format(time.RFC3339)
|
||||||
|
resp.LastUsed = &s
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create handles POST /v1/api-keys.
|
||||||
|
func (h *apiKeyHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ac := auth.MustFromContext(r.Context())
|
||||||
|
|
||||||
|
var req createAPIKeyRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" {
|
||||||
|
req.Name = "Unnamed API Key"
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, hash, err := auth.GenerateAPIKey()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keyID := id.NewAPIKeyID()
|
||||||
|
row, err := h.db.InsertAPIKey(r.Context(), db.InsertAPIKeyParams{
|
||||||
|
ID: keyID,
|
||||||
|
TeamID: ac.TeamID,
|
||||||
|
Name: req.Name,
|
||||||
|
KeyHash: hash,
|
||||||
|
KeyPrefix: auth.APIKeyPrefix(plaintext),
|
||||||
|
CreatedBy: ac.UserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to create API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := apiKeyToResponse(row)
|
||||||
|
resp.Key = &plaintext
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /v1/api-keys.
|
||||||
|
func (h *apiKeyHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ac := auth.MustFromContext(r.Context())
|
||||||
|
|
||||||
|
keys, err := h.db.ListAPIKeysByTeam(r.Context(), ac.TeamID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to list API keys")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := make([]apiKeyResponse, len(keys))
|
||||||
|
for i, k := range keys {
|
||||||
|
resp[i] = apiKeyToResponse(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /v1/api-keys/{id}.
|
||||||
|
func (h *apiKeyHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ac := auth.MustFromContext(r.Context())
|
||||||
|
keyID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
if err := h.db.DeleteAPIKey(r.Context(), db.DeleteAPIKeyParams{
|
||||||
|
ID: keyID,
|
||||||
|
TeamID: ac.TeamID,
|
||||||
|
}); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
184
internal/api/handlers_auth.go
Normal file
184
internal/api/handlers_auth.go
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/auth"
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
type authHandler struct {
|
||||||
|
db *db.Queries
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
jwtSecret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthHandler(db *db.Queries, pool *pgxpool.Pool, jwtSecret []byte) *authHandler {
|
||||||
|
return &authHandler{db: db, pool: pool, jwtSecret: jwtSecret}
|
||||||
|
}
|
||||||
|
|
||||||
|
type signupRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type authResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signup handles POST /v1/auth/signup.
|
||||||
|
func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req signupRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||||
|
if !strings.Contains(req.Email, "@") || len(req.Email) < 3 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid email address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Password) < 8 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
passwordHash, err := auth.HashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a transaction to atomically create user + team + membership.
|
||||||
|
tx, err := h.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to begin transaction")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx) //nolint:errcheck
|
||||||
|
|
||||||
|
qtx := h.db.WithTx(tx)
|
||||||
|
|
||||||
|
userID := id.NewUserID()
|
||||||
|
_, err = qtx.InsertUser(ctx, db.InsertUserParams{
|
||||||
|
ID: userID,
|
||||||
|
Email: req.Email,
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
writeError(w, http.StatusConflict, "email_taken", "an account with this email already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to create user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create default team.
|
||||||
|
teamID := id.NewTeamID()
|
||||||
|
if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{
|
||||||
|
ID: teamID,
|
||||||
|
Name: req.Email + "'s Team",
|
||||||
|
}); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to create team")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := qtx.InsertTeamMember(ctx, db.InsertTeamMemberParams{
|
||||||
|
UserID: userID,
|
||||||
|
TeamID: teamID,
|
||||||
|
IsDefault: true,
|
||||||
|
Role: "owner",
|
||||||
|
}); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to add user to team")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to commit signup")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, authResponse{
|
||||||
|
Token: token,
|
||||||
|
UserID: userID,
|
||||||
|
TeamID: teamID,
|
||||||
|
Email: req.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login handles POST /v1/auth/login.
|
||||||
|
func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req loginRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||||
|
if req.Email == "" || req.Password == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "email and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
user, err := h.db.GetUserByEmail(ctx, req.Email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.CheckPassword(user.PasswordHash, req.Password); err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
team, err := h.db.GetDefaultTeamForUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, authResponse{
|
||||||
|
Token: token,
|
||||||
|
UserID: user.ID,
|
||||||
|
TeamID: team.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>`
|
||||||
|
|||||||
38
internal/api/middleware_apikey.go
Normal file
38
internal/api/middleware_apikey.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/auth"
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requireAPIKey validates the X-API-Key header, looks up the SHA-256 hash in DB,
|
||||||
|
// and stamps TeamID into the request context.
|
||||||
|
func requireAPIKey(queries *db.Queries) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
key := r.Header.Get("X-API-Key")
|
||||||
|
if key == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "X-API-Key header required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := auth.HashAPIKey(key)
|
||||||
|
row, err := queries.GetAPIKeyByHash(r.Context(), hash)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort update of last_used timestamp.
|
||||||
|
if err := queries.UpdateAPIKeyLastUsed(r.Context(), row.ID); err != nil {
|
||||||
|
slog.Warn("failed to update api key last_used", "key_id", row.ID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{TeamID: row.TeamID})
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
36
internal/api/middleware_jwt.go
Normal file
36
internal/api/middleware_jwt.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.omukk.dev/wrenn/sandbox/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requireJWT validates the Authorization: Bearer <token> header, verifies the JWT
|
||||||
|
// signature and expiry, and stamps UserID + TeamID + Email into the request context.
|
||||||
|
func requireJWT(secret []byte) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
header := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(header, "Bearer ") {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authorization: Bearer <token> required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStr := strings.TrimPrefix(header, "Bearer ")
|
||||||
|
claims, err := auth.VerifyJWT(secret, tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{
|
||||||
|
TeamID: claims.TeamID,
|
||||||
|
UserID: claims.Subject,
|
||||||
|
Email: claims.Email,
|
||||||
|
})
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,11 +8,133 @@ servers:
|
|||||||
- url: http://localhost:8080
|
- 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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
35
internal/auth/context.go
Normal 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
51
internal/auth/jwt.go
Normal 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
16
internal/auth/password.go
Normal 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))
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
package auth
|
|
||||||
@ -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
124
internal/db/api_keys.sql.go
Normal 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
|
||||||
|
}
|
||||||
@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
75
internal/db/teams.sql.go
Normal 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
|
||||||
|
}
|
||||||
@ -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
69
internal/db/users.sql.go
Normal 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
|
||||||
|
}
|
||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user