From 8e5d426638b6d0f9e7109953f6c259aede010c2c Mon Sep 17 00:00:00 2001 From: pptx704 Date: Tue, 24 Mar 2026 13:29:54 +0600 Subject: [PATCH 1/6] Add team management endpoints - Three-role model (owner/admin/member) with owner protection invariants - Team CRUD: create, rename (admin+), soft-delete with VM cleanup (owner only) - Member management: add by email, remove, role updates (admin+), leave - Switch-team endpoint re-issues JWT after DB membership verification - User email prefix search for add-member UI autocomplete - JWT carries role as a hint; all authorization decisions verified from DB - Team slug: immutable 12-char hex (e.g. a1b2c3-d1e2f3), reserved on soft-delete - Migration adds slug + deleted_at to teams; backfills existing rows --- .../20260324071453_team_management.sql | 17 + db/queries/sandboxes.sql | 5 + db/queries/teams.sql | 33 +- db/queries/users.sql | 3 + internal/api/handlers_auth.go | 77 +++- internal/api/handlers_oauth.go | 19 +- internal/api/handlers_team.go | 321 +++++++++++++ internal/api/handlers_users.go | 47 ++ internal/api/middleware_apikey.go | 38 -- internal/api/middleware_auth.go | 1 + internal/api/middleware_jwt.go | 1 + internal/api/openapi.yaml | 430 ++++++++++++++++++ internal/api/server.go | 26 ++ internal/auth/context.go | 1 + internal/auth/jwt.go | 4 +- internal/db/models.go | 2 + internal/db/sandboxes.sql.go | 41 ++ internal/db/teams.sql.go | 181 +++++++- internal/db/users.sql.go | 29 ++ internal/id/id.go | 10 + internal/service/team.go | 368 +++++++++++++++ 21 files changed, 1601 insertions(+), 53 deletions(-) create mode 100644 db/migrations/20260324071453_team_management.sql create mode 100644 internal/api/handlers_team.go create mode 100644 internal/api/handlers_users.go delete mode 100644 internal/api/middleware_apikey.go create mode 100644 internal/service/team.go diff --git a/db/migrations/20260324071453_team_management.sql b/db/migrations/20260324071453_team_management.sql new file mode 100644 index 0000000..1495d6d --- /dev/null +++ b/db/migrations/20260324071453_team_management.sql @@ -0,0 +1,17 @@ +-- +goose Up + +ALTER TABLE teams ADD COLUMN slug TEXT; +ALTER TABLE teams ADD COLUMN deleted_at TIMESTAMPTZ; + +-- Backfill slugs for existing teams using MD5 of their ID. +-- MD5 returns 32 hex chars; take chars 1-6 and 7-12 to form a 6-6 slug. +UPDATE teams SET slug = LEFT(MD5(id), 6) || '-' || SUBSTRING(MD5(id), 7, 6); + +ALTER TABLE teams ALTER COLUMN slug SET NOT NULL; +CREATE UNIQUE INDEX idx_teams_slug ON teams(slug); + +-- +goose Down + +DROP INDEX idx_teams_slug; +ALTER TABLE teams DROP COLUMN deleted_at; +ALTER TABLE teams DROP COLUMN slug; diff --git a/db/queries/sandboxes.sql b/db/queries/sandboxes.sql index f2a5d51..d897bff 100644 --- a/db/queries/sandboxes.sql +++ b/db/queries/sandboxes.sql @@ -51,3 +51,8 @@ UPDATE sandboxes SET status = $2, last_updated = NOW() WHERE id = ANY($1::text[]); + +-- name: ListActiveSandboxesByTeam :many +SELECT * FROM sandboxes +WHERE team_id = $1 AND status IN ('running', 'paused', 'starting') +ORDER BY created_at DESC; diff --git a/db/queries/teams.sql b/db/queries/teams.sql index 58985ab..935c4dd 100644 --- a/db/queries/teams.sql +++ b/db/queries/teams.sql @@ -1,6 +1,6 @@ -- name: InsertTeam :one -INSERT INTO teams (id, name) -VALUES ($1, $2) +INSERT INTO teams (id, name, slug) +VALUES ($1, $2, $3) RETURNING *; -- name: GetTeam :one @@ -24,3 +24,32 @@ SELECT * FROM teams WHERE is_byoc = TRUE ORDER BY created_at; -- name: GetTeamMembership :one SELECT * FROM users_teams WHERE user_id = $1 AND team_id = $2; + +-- name: UpdateTeamName :exec +UPDATE teams SET name = $2 WHERE id = $1 AND deleted_at IS NULL; + +-- name: SoftDeleteTeam :exec +UPDATE teams SET deleted_at = NOW() WHERE id = $1; + +-- name: GetTeamBySlug :one +SELECT * FROM teams WHERE slug = $1 AND deleted_at IS NULL; + +-- name: GetTeamsForUser :many +SELECT t.id, t.name, t.slug, t.is_byoc, t.created_at, t.deleted_at, ut.role +FROM teams t +JOIN users_teams ut ON ut.team_id = t.id +WHERE ut.user_id = $1 AND t.deleted_at IS NULL +ORDER BY ut.created_at; + +-- name: GetTeamMembers :many +SELECT u.id, u.email, ut.role, ut.created_at AS joined_at +FROM users_teams ut +JOIN users u ON u.id = ut.user_id +WHERE ut.team_id = $1 +ORDER BY ut.created_at; + +-- name: UpdateMemberRole :exec +UPDATE users_teams SET role = $3 WHERE team_id = $1 AND user_id = $2; + +-- name: DeleteTeamMember :exec +DELETE FROM users_teams WHERE team_id = $1 AND user_id = $2; diff --git a/db/queries/users.sql b/db/queries/users.sql index 3c2f4f0..171ef70 100644 --- a/db/queries/users.sql +++ b/db/queries/users.sql @@ -34,3 +34,6 @@ SELECT * FROM admin_permissions WHERE user_id = $1 ORDER BY permission; SELECT EXISTS( SELECT 1 FROM admin_permissions WHERE user_id = $1 AND permission = $2 ) AS has_permission; + +-- name: SearchUsersByEmailPrefix :many +SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10; diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index ba90982..d8d9443 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -15,6 +15,10 @@ import ( "git.omukk.dev/wrenn/sandbox/internal/id" ) +type switchTeamRequest struct { + TeamID string `json:"team_id"` +} + type authHandler struct { db *db.Queries pool *pgxpool.Pool @@ -99,6 +103,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) { if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{ ID: teamID, Name: req.Email + "'s Team", + Slug: id.NewTeamSlug(), }); err != nil { writeError(w, http.StatusInternalServerError, "db_error", "failed to create team") return @@ -119,7 +124,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) { return } - token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email) + token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email, "owner") if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token") return @@ -174,7 +179,13 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) { return } - token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email) + membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: user.ID, TeamID: team.ID}) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to look up membership") + return + } + + token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, membership.Role) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token") return @@ -187,3 +198,65 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) { Email: user.Email, }) } + +// SwitchTeam handles POST /v1/auth/switch-team. +// Verifies from DB that the user is a member of the target team, then re-issues +// a JWT scoped to that team. The JWT's team_id is used as a pre-filter on all +// subsequent team-scoped requests; DB is the source of truth for actual permissions. +func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + + var req switchTeamRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + if req.TeamID == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "team_id is required") + return + } + + ctx := r.Context() + + // Verify team exists and is not deleted. + team, err := h.db.GetTeam(ctx, req.TeamID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "not_found", "team not found") + return + } + writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team") + return + } + if team.DeletedAt.Valid { + writeError(w, http.StatusNotFound, "not_found", "team not found") + return + } + + // Verify membership from DB — JWT role is not trusted here. + membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{ + UserID: ac.UserID, + TeamID: req.TeamID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusForbidden, "forbidden", "not a member of this team") + return + } + writeError(w, http.StatusInternalServerError, "db_error", "failed to look up membership") + return + } + + token, err := auth.SignJWT(h.jwtSecret, ac.UserID, req.TeamID, ac.Email, membership.Role) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token") + return + } + + writeJSON(w, http.StatusOK, authResponse{ + Token: token, + UserID: ac.UserID, + TeamID: req.TeamID, + Email: ac.Email, + }) +} diff --git a/internal/api/handlers_oauth.go b/internal/api/handlers_oauth.go index ab30617..e52bb6f 100644 --- a/internal/api/handlers_oauth.go +++ b/internal/api/handlers_oauth.go @@ -156,7 +156,13 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) { redirectWithError(w, r, redirectBase, "db_error") return } - token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email) + membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: user.ID, TeamID: team.ID}) + if err != nil { + slog.Error("oauth login: failed to get membership", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, membership.Role) if err != nil { slog.Error("oauth login: failed to sign jwt", "error", err) redirectWithError(w, r, redirectBase, "internal_error") @@ -219,6 +225,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) { if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{ ID: teamID, Name: teamName, + Slug: id.NewTeamSlug(), }); err != nil { slog.Error("oauth: failed to create team", "error", err) redirectWithError(w, r, redirectBase, "db_error") @@ -253,7 +260,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) { return } - token, err := auth.SignJWT(h.jwtSecret, userID, teamID, email) + token, err := auth.SignJWT(h.jwtSecret, userID, teamID, email, "owner") if err != nil { slog.Error("oauth: failed to sign jwt", "error", err) redirectWithError(w, r, redirectBase, "internal_error") @@ -288,7 +295,13 @@ func (h *oauthHandler) retryAsLogin(w http.ResponseWriter, r *http.Request, prov redirectWithError(w, r, redirectBase, "db_error") return } - token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email) + membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: user.ID, TeamID: team.ID}) + if err != nil { + slog.Error("oauth: retry login: failed to get membership", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, membership.Role) if err != nil { slog.Error("oauth: retry login: failed to sign jwt", "error", err) redirectWithError(w, r, redirectBase, "internal_error") diff --git a/internal/api/handlers_team.go b/internal/api/handlers_team.go new file mode 100644 index 0000000..f1df8f8 --- /dev/null +++ b/internal/api/handlers_team.go @@ -0,0 +1,321 @@ +package api + +import ( + "net/http" + "strings" + "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/service" +) + +type teamHandler struct { + svc *service.TeamService +} + +func newTeamHandler(svc *service.TeamService) *teamHandler { + return &teamHandler{svc: svc} +} + +// teamResponse is the JSON shape for a team. +type teamResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + CreatedAt string `json:"created_at"` +} + +// teamWithRoleResponse includes the calling user's role. +type teamWithRoleResponse struct { + teamResponse + Role string `json:"role"` +} + +type memberResponse struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + JoinedAt string `json:"joined_at,omitempty"` +} + +func teamToResponse(t db.Team) teamResponse { + resp := teamResponse{ + ID: t.ID, + Name: t.Name, + Slug: t.Slug, + } + if t.CreatedAt.Valid { + resp.CreatedAt = t.CreatedAt.Time.Format(time.RFC3339) + } + return resp +} + +func memberInfoToResponse(m service.MemberInfo) memberResponse { + return memberResponse{ + UserID: m.UserID, + Email: m.Email, + Role: m.Role, + JoinedAt: m.JoinedAt.Format(time.RFC3339), + } +} + +// requireTeamAccess is an inline check used by every team-scoped handler: +// the JWT team_id must match the URL {id} before any DB call is made. +// Returns false and writes 403 if they don't match. +func requireTeamAccess(w http.ResponseWriter, r *http.Request, ac auth.AuthContext) (string, bool) { + teamID := chi.URLParam(r, "id") + if ac.TeamID != teamID { + writeError(w, http.StatusForbidden, "forbidden", "JWT team does not match requested team; use switch-team first") + return "", false + } + return teamID, true +} + +// List handles GET /v1/teams +// Returns all teams the authenticated user belongs to. +func (h *teamHandler) List(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + + teams, err := h.svc.ListTeamsForUser(r.Context(), ac.UserID) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + resp := make([]teamWithRoleResponse, len(teams)) + for i, t := range teams { + resp[i] = teamWithRoleResponse{ + teamResponse: teamToResponse(t.Team), + Role: t.Role, + } + } + writeJSON(w, http.StatusOK, resp) +} + +// Create handles POST /v1/teams +// Creates a new team owned by the authenticated user. +func (h *teamHandler) Create(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + + var req struct { + Name string `json:"name"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + req.Name = strings.TrimSpace(req.Name) + + team, err := h.svc.CreateTeam(r.Context(), ac.UserID, req.Name) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + writeJSON(w, http.StatusCreated, teamWithRoleResponse{ + teamResponse: teamToResponse(team.Team), + Role: team.Role, + }) +} + +// Get handles GET /v1/teams/{id} +// Returns team info and member list. +func (h *teamHandler) Get(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + teamID, ok := requireTeamAccess(w, r, ac) + if !ok { + return + } + + team, err := h.svc.GetTeam(r.Context(), teamID) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + members, err := h.svc.GetMembers(r.Context(), teamID) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + memberResp := make([]memberResponse, len(members)) + for i, m := range members { + memberResp[i] = memberInfoToResponse(m) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "team": teamToResponse(team), + "members": memberResp, + }) +} + +// Rename handles PATCH /v1/teams/{id} +// Renames the team. Requires admin or owner role (verified from DB). +func (h *teamHandler) Rename(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + teamID, ok := requireTeamAccess(w, r, ac) + if !ok { + return + } + + var req struct { + Name string `json:"name"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + req.Name = strings.TrimSpace(req.Name) + + if err := h.svc.RenameTeam(r.Context(), teamID, ac.UserID, req.Name); err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// Delete handles DELETE /v1/teams/{id} +// Soft-deletes the team and destroys active sandboxes. Owner only. +func (h *teamHandler) Delete(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + teamID, ok := requireTeamAccess(w, r, ac) + if !ok { + return + } + + if err := h.svc.DeleteTeam(r.Context(), teamID, ac.UserID); err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// ListMembers handles GET /v1/teams/{id}/members +func (h *teamHandler) ListMembers(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + teamID, ok := requireTeamAccess(w, r, ac) + if !ok { + return + } + + members, err := h.svc.GetMembers(r.Context(), teamID) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + resp := make([]memberResponse, len(members)) + for i, m := range members { + resp[i] = memberInfoToResponse(m) + } + writeJSON(w, http.StatusOK, resp) +} + +// AddMember handles POST /v1/teams/{id}/members +// Adds a user by email. Requires admin or owner (verified from DB). +func (h *teamHandler) AddMember(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + teamID, ok := requireTeamAccess(w, r, ac) + if !ok { + return + } + + var req struct { + Email string `json:"email"` + } + 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 == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "email is required") + return + } + + member, err := h.svc.AddMember(r.Context(), teamID, ac.UserID, req.Email) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + writeJSON(w, http.StatusCreated, memberInfoToResponse(member)) +} + +// RemoveMember handles DELETE /v1/teams/{id}/members/{uid} +// Removes a member. Requires admin or owner (verified from DB). Owner cannot be removed. +func (h *teamHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + teamID, ok := requireTeamAccess(w, r, ac) + if !ok { + return + } + targetUserID := chi.URLParam(r, "uid") + + if err := h.svc.RemoveMember(r.Context(), teamID, ac.UserID, targetUserID); err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// UpdateMemberRole handles PATCH /v1/teams/{id}/members/{uid} +// Changes a member's role (admin or member). Owner's role cannot be changed. +func (h *teamHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + teamID, ok := requireTeamAccess(w, r, ac) + if !ok { + return + } + targetUserID := chi.URLParam(r, "uid") + + var req struct { + Role string `json:"role"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + if err := h.svc.UpdateMemberRole(r.Context(), teamID, ac.UserID, targetUserID, req.Role); err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// Leave handles POST /v1/teams/{id}/leave +// Removes the calling user from the team. Owner cannot leave. +func (h *teamHandler) Leave(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + teamID, ok := requireTeamAccess(w, r, ac) + if !ok { + return + } + + if err := h.svc.LeaveTeam(r.Context(), teamID, ac.UserID); err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go new file mode 100644 index 0000000..8beaee9 --- /dev/null +++ b/internal/api/handlers_users.go @@ -0,0 +1,47 @@ +package api + +import ( + "net/http" + "strings" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/service" +) + +type usersHandler struct { + svc *service.TeamService +} + +func newUsersHandler(svc *service.TeamService) *usersHandler { + return &usersHandler{svc: svc} +} + +// Search handles GET /v1/users/search?email= +// Returns up to 10 users whose email starts with the given prefix. +// The prefix must contain "@" to scope searches and prevent broad enumeration. +func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) { + auth.MustFromContext(r.Context()) // ensure authenticated + + prefix := strings.TrimSpace(r.URL.Query().Get("email")) + if !strings.Contains(prefix, "@") { + writeError(w, http.StatusBadRequest, "invalid_request", "email prefix must contain '@'") + return + } + + results, err := h.svc.SearchUsersByEmailPrefix(r.Context(), prefix) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + type userResult struct { + UserID string `json:"user_id"` + Email string `json:"email"` + } + resp := make([]userResult, len(results)) + for i, u := range results { + resp[i] = userResult{UserID: u.ID, Email: u.Email} + } + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/middleware_apikey.go b/internal/api/middleware_apikey.go deleted file mode 100644 index 8a53506..0000000 --- a/internal/api/middleware_apikey.go +++ /dev/null @@ -1,38 +0,0 @@ -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)) - }) - } -} diff --git a/internal/api/middleware_auth.go b/internal/api/middleware_auth.go index f850344..4cde817 100644 --- a/internal/api/middleware_auth.go +++ b/internal/api/middleware_auth.go @@ -45,6 +45,7 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler TeamID: claims.TeamID, UserID: claims.Subject, Email: claims.Email, + Role: claims.Role, }) next.ServeHTTP(w, r.WithContext(ctx)) return diff --git a/internal/api/middleware_jwt.go b/internal/api/middleware_jwt.go index c071064..bfe1438 100644 --- a/internal/api/middleware_jwt.go +++ b/internal/api/middleware_jwt.go @@ -29,6 +29,7 @@ func requireJWT(secret []byte) func(http.Handler) http.Handler { TeamID: claims.TeamID, UserID: claims.Subject, Email: claims.Email, + Role: claims.Role, }) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index f4c8f66..02bcd77 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -42,6 +42,47 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/auth/switch-team: + post: + summary: Switch active team + operationId: switchTeam + tags: [auth] + security: + - bearerAuth: [] + description: | + Re-issues a JWT scoped to a different team. The user must be a member of + the target team (verified from DB). Use the returned token for subsequent + requests to that team's resources. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [team_id] + properties: + team_id: + type: string + responses: + "200": + description: New JWT issued for the target team + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + "403": + description: Not a member of this team + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Team not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/auth/login: post: summary: Log in with email and password @@ -195,6 +236,340 @@ paths: "204": description: API key deleted + /v1/users/search: + get: + summary: Search users by email prefix + operationId: searchUsers + tags: [users] + security: + - bearerAuth: [] + description: | + Returns up to 10 users whose email starts with the given prefix. + The prefix must contain "@". Intended for the add-member UI autocomplete. + parameters: + - name: email + in: query + required: true + schema: + type: string + description: Email prefix (must contain "@", e.g. "alice@") + responses: + "200": + description: Matching users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UserSearchResult" + "400": + description: Prefix does not contain "@" + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams: + get: + summary: List teams for the authenticated user + operationId: listTeams + tags: [teams] + security: + - bearerAuth: [] + responses: + "200": + description: Teams the user belongs to, each with their role + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TeamWithRole" + + post: + summary: Create a new team + operationId: createTeam + tags: [teams] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + description: 1-128 chars; A-Z a-z 0-9 space _ + responses: + "201": + description: Team created (caller is owner) + content: + application/json: + schema: + $ref: "#/components/schemas/TeamWithRole" + "400": + description: Invalid team name + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Team ID (must match the JWT's team_id) + + get: + summary: Get team info and member list + operationId: getTeam + tags: [teams] + security: + - bearerAuth: [] + responses: + "200": + description: Team details with members + content: + application/json: + schema: + $ref: "#/components/schemas/TeamDetail" + "403": + description: JWT team does not match requested team + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Team not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + patch: + summary: Rename the team + operationId: renameTeam + tags: [teams] + security: + - bearerAuth: [] + description: Admin or owner role required (verified from DB). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + responses: + "204": + description: Renamed + "400": + description: Invalid team name + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Insufficient role + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + delete: + summary: Delete the team + operationId: deleteTeam + tags: [teams] + security: + - bearerAuth: [] + description: | + Owner only. Soft-deletes the team and destroys all running/paused/starting + sandboxes. All DB records are preserved. The team slug is permanently reserved. + responses: + "204": + description: Team deleted + "403": + description: Caller is not the owner + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams/{id}/members: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: List team members + operationId: listTeamMembers + tags: [teams] + security: + - bearerAuth: [] + responses: + "200": + description: Members with roles + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TeamMember" + + post: + summary: Add a member by email + operationId: addTeamMember + tags: [teams] + security: + - bearerAuth: [] + description: Admin or owner role required. User is added instantly as a member. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: + type: string + format: email + responses: + "201": + description: Member added + content: + application/json: + schema: + $ref: "#/components/schemas/TeamMember" + "403": + description: Insufficient role + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: No account with that email + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "400": + description: User is already a member + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams/{id}/members/{uid}: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: uid + in: path + required: true + schema: + type: string + description: Target user ID + + patch: + summary: Update member role + operationId: updateMemberRole + tags: [teams] + security: + - bearerAuth: [] + description: | + Admin or owner required. Valid target roles: admin, member. + The owner's role cannot be changed. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role] + properties: + role: + type: string + enum: [admin, member] + responses: + "204": + description: Role updated + "403": + description: Insufficient role or attempt to modify owner + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: User is not a member + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + delete: + summary: Remove a member + operationId: removeTeamMember + tags: [teams] + security: + - bearerAuth: [] + description: Admin or owner required. Owner cannot be removed. + responses: + "204": + description: Member removed + "403": + description: Insufficient role or attempt to remove owner + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: User is not a member + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/teams/{id}/leave: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Leave the team + operationId: leaveTeam + tags: [teams] + security: + - bearerAuth: [] + description: The owner cannot leave; they must delete the team instead. + responses: + "204": + description: Left the team + "403": + description: Owner cannot leave + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/sandboxes: post: summary: Create a sandbox @@ -1338,6 +1713,61 @@ components: tag: type: string + UserSearchResult: + type: object + properties: + user_id: + type: string + email: + type: string + + Team: + type: object + properties: + id: + type: string + name: + type: string + slug: + type: string + description: Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3) + created_at: + type: string + format: date-time + + TeamWithRole: + allOf: + - $ref: "#/components/schemas/Team" + - type: object + properties: + role: + type: string + enum: [owner, admin, member] + + TeamMember: + type: object + properties: + user_id: + type: string + email: + type: string + role: + type: string + enum: [owner, admin, member] + joined_at: + type: string + format: date-time + + TeamDetail: + type: object + properties: + team: + $ref: "#/components/schemas/Team" + members: + type: array + items: + $ref: "#/components/schemas/TeamMember" + Error: type: object properties: diff --git a/internal/api/server.go b/internal/api/server.go index 3760167..46759a1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -33,6 +33,7 @@ func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient, p apiKeySvc := &service.APIKeyService{DB: queries} templateSvc := &service.TemplateService{DB: queries} hostSvc := &service.HostService{DB: queries, Redis: rdb, JWT: jwtSecret} + teamSvc := &service.TeamService{DB: queries, Pool: pool, Agent: agent} sandbox := newSandboxHandler(sandboxSvc) exec := newExecHandler(queries, agent) @@ -44,6 +45,8 @@ func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient, p oauthH := newOAuthHandler(queries, pool, jwtSecret, oauthRegistry, oauthRedirectURL) apiKeys := newAPIKeyHandler(apiKeySvc) hostH := newHostHandler(hostSvc, queries) + teamH := newTeamHandler(teamSvc) + usersH := newUsersHandler(teamSvc) // OpenAPI spec and docs. r.Get("/openapi.yaml", serveOpenAPI) @@ -55,6 +58,9 @@ func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient, p r.Get("/auth/oauth/{provider}", oauthH.Redirect) r.Get("/auth/oauth/{provider}/callback", oauthH.Callback) + // JWT-authenticated: switch active team. + r.With(requireJWT(jwtSecret)).Post("/v1/auth/switch-team", authH.SwitchTeam) + // JWT-authenticated: API key management. r.Route("/v1/api-keys", func(r chi.Router) { r.Use(requireJWT(jwtSecret)) @@ -63,6 +69,26 @@ func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient, p r.Delete("/{id}", apiKeys.Delete) }) + // JWT-authenticated: team management. + r.Route("/v1/teams", func(r chi.Router) { + r.Use(requireJWT(jwtSecret)) + r.Get("/", teamH.List) + r.Post("/", teamH.Create) + r.Route("/{id}", func(r chi.Router) { + r.Get("/", teamH.Get) + r.Patch("/", teamH.Rename) + r.Delete("/", teamH.Delete) + r.Get("/members", teamH.ListMembers) + r.Post("/members", teamH.AddMember) + r.Patch("/members/{uid}", teamH.UpdateMemberRole) + r.Delete("/members/{uid}", teamH.RemoveMember) + r.Post("/leave", teamH.Leave) + }) + }) + + // JWT-authenticated: user search (for add-member UI). + r.With(requireJWT(jwtSecret)).Get("/v1/users/search", usersH.Search) + // Sandbox lifecycle: accepts API key or JWT bearer token. r.Route("/v1/sandboxes", func(r chi.Router) { r.Use(requireAPIKeyOrJWT(queries, jwtSecret)) diff --git a/internal/auth/context.go b/internal/auth/context.go index a1ebf69..e5ae893 100644 --- a/internal/auth/context.go +++ b/internal/auth/context.go @@ -11,6 +11,7 @@ type AuthContext struct { TeamID string UserID string // empty when authenticated via API key Email string // empty when authenticated via API key + Role string // owner, admin, or member; empty when authenticated via API key } // WithAuthContext returns a new context with the given AuthContext. diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 45818ff..331cae2 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -14,15 +14,17 @@ const hostJWTExpiry = 8760 * time.Hour // 1 year type Claims struct { Type string `json:"typ,omitempty"` // empty for user tokens; used to reject host tokens TeamID string `json:"team_id"` + Role string `json:"role"` // owner, admin, or member within TeamID 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) { +func SignJWT(secret []byte, userID, teamID, email, role string) (string, error) { now := time.Now() claims := Claims{ TeamID: teamID, + Role: role, Email: email, RegisteredClaims: jwt.RegisteredClaims{ Subject: userID, diff --git a/internal/db/models.go b/internal/db/models.go index 663a37b..6e29928 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -80,6 +80,8 @@ type Team struct { Name string `json:"name"` CreatedAt pgtype.Timestamptz `json:"created_at"` IsByoc bool `json:"is_byoc"` + Slug string `json:"slug"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` } type TeamApiKey struct { diff --git a/internal/db/sandboxes.sql.go b/internal/db/sandboxes.sql.go index 2bc9481..cf39a14 100644 --- a/internal/db/sandboxes.sql.go +++ b/internal/db/sandboxes.sql.go @@ -133,6 +133,47 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S return i, err } +const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :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 AND status IN ('running', 'paused', 'starting') +ORDER BY created_at DESC +` + +func (q *Queries) ListActiveSandboxesByTeam(ctx context.Context, teamID string) ([]Sandbox, error) { + rows, err := q.db.Query(ctx, listActiveSandboxesByTeam, 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 { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listSandboxes = `-- name: ListSandboxes :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 ORDER BY created_at DESC ` diff --git a/internal/db/teams.sql.go b/internal/db/teams.sql.go index c135bf1..7324763 100644 --- a/internal/db/teams.sql.go +++ b/internal/db/teams.sql.go @@ -7,10 +7,26 @@ package db import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) +const deleteTeamMember = `-- name: DeleteTeamMember :exec +DELETE FROM users_teams WHERE team_id = $1 AND user_id = $2 +` + +type DeleteTeamMemberParams struct { + TeamID string `json:"team_id"` + UserID string `json:"user_id"` +} + +func (q *Queries) DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberParams) error { + _, err := q.db.Exec(ctx, deleteTeamMember, arg.TeamID, arg.UserID) + return err +} + const getBYOCTeams = `-- name: GetBYOCTeams :many -SELECT id, name, created_at, is_byoc FROM teams WHERE is_byoc = TRUE ORDER BY created_at +SELECT id, name, created_at, is_byoc, slug, deleted_at FROM teams WHERE is_byoc = TRUE ORDER BY created_at ` func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) { @@ -27,6 +43,8 @@ func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) { &i.Name, &i.CreatedAt, &i.IsByoc, + &i.Slug, + &i.DeletedAt, ); err != nil { return nil, err } @@ -39,7 +57,7 @@ func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) { } const getDefaultTeamForUser = `-- name: GetDefaultTeamForUser :one -SELECT t.id, t.name, t.created_at, t.is_byoc FROM teams t +SELECT t.id, t.name, t.created_at, t.is_byoc, t.slug, t.deleted_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 @@ -53,12 +71,14 @@ func (q *Queries) GetDefaultTeamForUser(ctx context.Context, userID string) (Tea &i.Name, &i.CreatedAt, &i.IsByoc, + &i.Slug, + &i.DeletedAt, ) return i, err } const getTeam = `-- name: GetTeam :one -SELECT id, name, created_at, is_byoc FROM teams WHERE id = $1 +SELECT id, name, created_at, is_byoc, slug, deleted_at FROM teams WHERE id = $1 ` func (q *Queries) GetTeam(ctx context.Context, id string) (Team, error) { @@ -69,10 +89,70 @@ func (q *Queries) GetTeam(ctx context.Context, id string) (Team, error) { &i.Name, &i.CreatedAt, &i.IsByoc, + &i.Slug, + &i.DeletedAt, ) return i, err } +const getTeamBySlug = `-- name: GetTeamBySlug :one +SELECT id, name, created_at, is_byoc, slug, deleted_at FROM teams WHERE slug = $1 AND deleted_at IS NULL +` + +func (q *Queries) GetTeamBySlug(ctx context.Context, slug string) (Team, error) { + row := q.db.QueryRow(ctx, getTeamBySlug, slug) + var i Team + err := row.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.IsByoc, + &i.Slug, + &i.DeletedAt, + ) + return i, err +} + +const getTeamMembers = `-- name: GetTeamMembers :many +SELECT u.id, u.email, ut.role, ut.created_at AS joined_at +FROM users_teams ut +JOIN users u ON u.id = ut.user_id +WHERE ut.team_id = $1 +ORDER BY ut.created_at +` + +type GetTeamMembersRow struct { + ID string `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + JoinedAt pgtype.Timestamptz `json:"joined_at"` +} + +func (q *Queries) GetTeamMembers(ctx context.Context, teamID string) ([]GetTeamMembersRow, error) { + rows, err := q.db.Query(ctx, getTeamMembers, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTeamMembersRow + for rows.Next() { + var i GetTeamMembersRow + if err := rows.Scan( + &i.ID, + &i.Email, + &i.Role, + &i.JoinedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getTeamMembership = `-- name: GetTeamMembership :one SELECT user_id, team_id, is_default, role, created_at FROM users_teams WHERE user_id = $1 AND team_id = $2 ` @@ -95,25 +175,74 @@ func (q *Queries) GetTeamMembership(ctx context.Context, arg GetTeamMembershipPa return i, err } +const getTeamsForUser = `-- name: GetTeamsForUser :many +SELECT t.id, t.name, t.slug, t.is_byoc, t.created_at, t.deleted_at, ut.role +FROM teams t +JOIN users_teams ut ON ut.team_id = t.id +WHERE ut.user_id = $1 AND t.deleted_at IS NULL +ORDER BY ut.created_at +` + +type GetTeamsForUserRow struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + IsByoc bool `json:"is_byoc"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + Role string `json:"role"` +} + +func (q *Queries) GetTeamsForUser(ctx context.Context, userID string) ([]GetTeamsForUserRow, error) { + rows, err := q.db.Query(ctx, getTeamsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTeamsForUserRow + for rows.Next() { + var i GetTeamsForUserRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.IsByoc, + &i.CreatedAt, + &i.DeletedAt, + &i.Role, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertTeam = `-- name: InsertTeam :one -INSERT INTO teams (id, name) -VALUES ($1, $2) -RETURNING id, name, created_at, is_byoc +INSERT INTO teams (id, name, slug) +VALUES ($1, $2, $3) +RETURNING id, name, created_at, is_byoc, slug, deleted_at ` type InsertTeamParams struct { ID string `json:"id"` Name string `json:"name"` + Slug string `json:"slug"` } func (q *Queries) InsertTeam(ctx context.Context, arg InsertTeamParams) (Team, error) { - row := q.db.QueryRow(ctx, insertTeam, arg.ID, arg.Name) + row := q.db.QueryRow(ctx, insertTeam, arg.ID, arg.Name, arg.Slug) var i Team err := row.Scan( &i.ID, &i.Name, &i.CreatedAt, &i.IsByoc, + &i.Slug, + &i.DeletedAt, ) return i, err } @@ -153,3 +282,41 @@ func (q *Queries) SetTeamBYOC(ctx context.Context, arg SetTeamBYOCParams) error _, err := q.db.Exec(ctx, setTeamBYOC, arg.ID, arg.IsByoc) return err } + +const softDeleteTeam = `-- name: SoftDeleteTeam :exec +UPDATE teams SET deleted_at = NOW() WHERE id = $1 +` + +func (q *Queries) SoftDeleteTeam(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, softDeleteTeam, id) + return err +} + +const updateMemberRole = `-- name: UpdateMemberRole :exec +UPDATE users_teams SET role = $3 WHERE team_id = $1 AND user_id = $2 +` + +type UpdateMemberRoleParams struct { + TeamID string `json:"team_id"` + UserID string `json:"user_id"` + Role string `json:"role"` +} + +func (q *Queries) UpdateMemberRole(ctx context.Context, arg UpdateMemberRoleParams) error { + _, err := q.db.Exec(ctx, updateMemberRole, arg.TeamID, arg.UserID, arg.Role) + return err +} + +const updateTeamName = `-- name: UpdateTeamName :exec +UPDATE teams SET name = $2 WHERE id = $1 AND deleted_at IS NULL +` + +type UpdateTeamNameParams struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func (q *Queries) UpdateTeamName(ctx context.Context, arg UpdateTeamNameParams) error { + _, err := q.db.Exec(ctx, updateTeamName, arg.ID, arg.Name) + return err +} diff --git a/internal/db/users.sql.go b/internal/db/users.sql.go index dd975e7..82c7c4b 100644 --- a/internal/db/users.sql.go +++ b/internal/db/users.sql.go @@ -206,6 +206,35 @@ func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams return i, err } +const searchUsersByEmailPrefix = `-- name: SearchUsersByEmailPrefix :many +SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10 +` + +type SearchUsersByEmailPrefixRow struct { + ID string `json:"id"` + Email string `json:"email"` +} + +func (q *Queries) SearchUsersByEmailPrefix(ctx context.Context, dollar_1 pgtype.Text) ([]SearchUsersByEmailPrefixRow, error) { + rows, err := q.db.Query(ctx, searchUsersByEmailPrefix, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchUsersByEmailPrefixRow + for rows.Next() { + var i SearchUsersByEmailPrefixRow + if err := rows.Scan(&i.ID, &i.Email); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const setUserAdmin = `-- name: SetUserAdmin :exec UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1 ` diff --git a/internal/id/id.go b/internal/id/id.go index 62cb682..362096f 100644 --- a/internal/id/id.go +++ b/internal/id/id.go @@ -34,6 +34,16 @@ func NewTeamID() string { return "team-" + hex8() } +// NewTeamSlug generates a unique team slug in the format "xxxxxx-yyyyyy" +// where each part is 3 random bytes encoded as hex (6 hex chars each). +func NewTeamSlug() string { + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + panic(fmt.Sprintf("crypto/rand failed: %v", err)) + } + return hex.EncodeToString(b[:3]) + "-" + hex.EncodeToString(b[3:]) +} + // NewAPIKeyID generates a new API key ID in the format "key-" + 8 hex chars. func NewAPIKeyID() string { return "key-" + hex8() diff --git a/internal/service/team.go b/internal/service/team.go new file mode 100644 index 0000000..0a8fa26 --- /dev/null +++ b/internal/service/team.go @@ -0,0 +1,368 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "regexp" + "time" + + "connectrpc.com/connect" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" + pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" + "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" +) + +var teamNameRE = regexp.MustCompile(`^[A-Za-z0-9 _]{1,128}$`) + +// TeamService provides team management operations. +type TeamService struct { + DB *db.Queries + Pool *pgxpool.Pool + Agent hostagentv1connect.HostAgentServiceClient +} + +// TeamWithRole pairs a team with the calling user's role in it. +type TeamWithRole struct { + db.Team + Role string `json:"role"` +} + +// MemberInfo is a team member with resolved email. +type MemberInfo struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + JoinedAt time.Time `json:"joined_at"` +} + +// callerRole fetches the calling user's role in the given team from DB. +// Returns an error wrapping "forbidden" if the caller is not a member. +func (s *TeamService) callerRole(ctx context.Context, teamID, callerUserID string) (string, error) { + m, err := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{ + UserID: callerUserID, + TeamID: teamID, + }) + if err != nil { + if err == pgx.ErrNoRows { + return "", fmt.Errorf("forbidden: not a member of this team") + } + return "", fmt.Errorf("get membership: %w", err) + } + return m.Role, nil +} + +// requireAdmin returns an error if the caller is not an admin or owner. +func requireAdmin(role string) error { + if role != "owner" && role != "admin" { + return fmt.Errorf("forbidden: admin or owner role required") + } + return nil +} + +// GetTeam returns the team by ID. Returns an error if the team is deleted or not found. +func (s *TeamService) GetTeam(ctx context.Context, teamID string) (db.Team, error) { + team, err := s.DB.GetTeam(ctx, teamID) + if err != nil { + if err == pgx.ErrNoRows { + return db.Team{}, fmt.Errorf("team not found") + } + return db.Team{}, fmt.Errorf("get team: %w", err) + } + if team.DeletedAt.Valid { + return db.Team{}, fmt.Errorf("team not found") + } + return team, nil +} + +// ListTeamsForUser returns all active teams the user belongs to, with their role in each. +func (s *TeamService) ListTeamsForUser(ctx context.Context, userID string) ([]TeamWithRole, error) { + rows, err := s.DB.GetTeamsForUser(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list teams: %w", err) + } + result := make([]TeamWithRole, len(rows)) + for i, r := range rows { + result[i] = TeamWithRole{ + Team: db.Team{ID: r.ID, Name: r.Name, CreatedAt: r.CreatedAt, IsByoc: r.IsByoc, Slug: r.Slug, DeletedAt: r.DeletedAt}, + Role: r.Role, + } + } + return result, nil +} + +// CreateTeam creates a new team owned by the given user. +func (s *TeamService) CreateTeam(ctx context.Context, ownerUserID, name string) (TeamWithRole, error) { + if !teamNameRE.MatchString(name) { + return TeamWithRole{}, fmt.Errorf("invalid team name: must be 1-128 characters, A-Z a-z 0-9 space _") + } + + tx, err := s.Pool.Begin(ctx) + if err != nil { + return TeamWithRole{}, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + qtx := s.DB.WithTx(tx) + + teamID := id.NewTeamID() + team, err := qtx.InsertTeam(ctx, db.InsertTeamParams{ + ID: teamID, + Name: name, + Slug: id.NewTeamSlug(), + }) + if err != nil { + return TeamWithRole{}, fmt.Errorf("insert team: %w", err) + } + + if err := qtx.InsertTeamMember(ctx, db.InsertTeamMemberParams{ + UserID: ownerUserID, + TeamID: teamID, + IsDefault: false, + Role: "owner", + }); err != nil { + return TeamWithRole{}, fmt.Errorf("insert owner: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return TeamWithRole{}, fmt.Errorf("commit: %w", err) + } + + return TeamWithRole{Team: team, Role: "owner"}, nil +} + +// RenameTeam updates the team name. Caller must be admin or owner (verified from DB). +func (s *TeamService) RenameTeam(ctx context.Context, teamID, callerUserID, newName string) error { + if !teamNameRE.MatchString(newName) { + return fmt.Errorf("invalid team name: must be 1-128 characters, A-Z a-z 0-9 space _") + } + + role, err := s.callerRole(ctx, teamID, callerUserID) + if err != nil { + return err + } + if err := requireAdmin(role); err != nil { + return err + } + + if err := s.DB.UpdateTeamName(ctx, db.UpdateTeamNameParams{ID: teamID, Name: newName}); err != nil { + return fmt.Errorf("update name: %w", err) + } + return nil +} + +// DeleteTeam soft-deletes the team and destroys all running/paused/starting sandboxes. +// Caller must be owner (verified from DB). All DB records (sandboxes, keys, templates) +// are preserved; only the team's deleted_at is set and active VMs are stopped. +func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID string) error { + role, err := s.callerRole(ctx, teamID, callerUserID) + if err != nil { + return err + } + if role != "owner" { + return fmt.Errorf("forbidden: only the owner can delete a team") + } + + // Collect active sandboxes and stop them. + sandboxes, err := s.DB.ListActiveSandboxesByTeam(ctx, teamID) + if err != nil { + return fmt.Errorf("list active sandboxes: %w", err) + } + + var stopIDs []string + for _, sb := range sandboxes { + if _, err := s.Agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{ + SandboxId: sb.ID, + })); err != nil && connect.CodeOf(err) != connect.CodeNotFound { + slog.Warn("team delete: failed to destroy sandbox", "sandbox_id", sb.ID, "error", err) + } + stopIDs = append(stopIDs, sb.ID) + } + + if len(stopIDs) > 0 { + if err := s.DB.BulkUpdateStatusByIDs(ctx, db.BulkUpdateStatusByIDsParams{ + Column1: stopIDs, + Status: "stopped", + }); err != nil { + // Do not proceed to soft-delete if sandbox statuses couldn't be updated, + // as that would leave orphaned "running" records for a deleted team. + return fmt.Errorf("update sandbox statuses: %w", err) + } + } + + if err := s.DB.SoftDeleteTeam(ctx, teamID); err != nil { + return fmt.Errorf("soft delete team: %w", err) + } + return nil +} + +// GetMembers returns all members of the team with their emails and roles. +func (s *TeamService) GetMembers(ctx context.Context, teamID string) ([]MemberInfo, error) { + rows, err := s.DB.GetTeamMembers(ctx, teamID) + if err != nil { + return nil, fmt.Errorf("get members: %w", err) + } + members := make([]MemberInfo, len(rows)) + for i, r := range rows { + var joinedAt time.Time + if r.JoinedAt.Valid { + joinedAt = r.JoinedAt.Time + } + members[i] = MemberInfo{ + UserID: r.ID, + Email: r.Email, + Role: r.Role, + JoinedAt: joinedAt, + } + } + return members, nil +} + +// AddMember adds an existing user (looked up by email) to the team as a member. +// Caller must be admin or owner (verified from DB). +func (s *TeamService) AddMember(ctx context.Context, teamID, callerUserID, email string) (MemberInfo, error) { + role, err := s.callerRole(ctx, teamID, callerUserID) + if err != nil { + return MemberInfo{}, err + } + if err := requireAdmin(role); err != nil { + return MemberInfo{}, err + } + + target, err := s.DB.GetUserByEmail(ctx, email) + if err != nil { + if err == pgx.ErrNoRows { + return MemberInfo{}, fmt.Errorf("user not found: no account with that email") + } + return MemberInfo{}, fmt.Errorf("look up user: %w", err) + } + + // Check if already a member. + _, memberCheckErr := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{ + UserID: target.ID, + TeamID: teamID, + }) + if memberCheckErr == nil { + return MemberInfo{}, fmt.Errorf("invalid: user is already a member of this team") + } else if memberCheckErr != pgx.ErrNoRows { + return MemberInfo{}, fmt.Errorf("check membership: %w", memberCheckErr) + } + + if err := s.DB.InsertTeamMember(ctx, db.InsertTeamMemberParams{ + UserID: target.ID, + TeamID: teamID, + IsDefault: false, + Role: "member", + }); err != nil { + return MemberInfo{}, fmt.Errorf("insert member: %w", err) + } + + return MemberInfo{UserID: target.ID, Email: target.Email, Role: "member"}, nil +} + +// RemoveMember removes a user from the team. +// Caller must be admin or owner (verified from DB). Owner cannot be removed. +func (s *TeamService) RemoveMember(ctx context.Context, teamID, callerUserID, targetUserID string) error { + callerRole, err := s.callerRole(ctx, teamID, callerUserID) + if err != nil { + return err + } + if err := requireAdmin(callerRole); err != nil { + return err + } + + targetMembership, err := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{ + UserID: targetUserID, + TeamID: teamID, + }) + if err != nil { + if err == pgx.ErrNoRows { + return fmt.Errorf("not found: user is not a member of this team") + } + return fmt.Errorf("get target membership: %w", err) + } + + if targetMembership.Role == "owner" { + return fmt.Errorf("forbidden: the owner cannot be removed from the team") + } + + if err := s.DB.DeleteTeamMember(ctx, db.DeleteTeamMemberParams{ + TeamID: teamID, + UserID: targetUserID, + }); err != nil { + return fmt.Errorf("delete member: %w", err) + } + return nil +} + +// UpdateMemberRole changes a member's role to admin or member. +// Caller must be admin or owner (verified from DB). Owner's role cannot be changed. +// Valid target roles: "admin", "member". +func (s *TeamService) UpdateMemberRole(ctx context.Context, teamID, callerUserID, targetUserID, newRole string) error { + if newRole != "admin" && newRole != "member" { + return fmt.Errorf("invalid: role must be admin or member") + } + + callerRole, err := s.callerRole(ctx, teamID, callerUserID) + if err != nil { + return err + } + if err := requireAdmin(callerRole); err != nil { + return err + } + + targetMembership, err := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{ + UserID: targetUserID, + TeamID: teamID, + }) + if err != nil { + if err == pgx.ErrNoRows { + return fmt.Errorf("not found: user is not a member of this team") + } + return fmt.Errorf("get target membership: %w", err) + } + + if targetMembership.Role == "owner" { + return fmt.Errorf("forbidden: the owner's role cannot be changed") + } + + if err := s.DB.UpdateMemberRole(ctx, db.UpdateMemberRoleParams{ + TeamID: teamID, + UserID: targetUserID, + Role: newRole, + }); err != nil { + return fmt.Errorf("update role: %w", err) + } + return nil +} + +// LeaveTeam removes the calling user from the team. +// The owner cannot leave; they must delete the team instead. +func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID string) error { + role, err := s.callerRole(ctx, teamID, callerUserID) + if err != nil { + return err + } + if role == "owner" { + return fmt.Errorf("forbidden: the owner cannot leave the team; delete the team instead") + } + + if err := s.DB.DeleteTeamMember(ctx, db.DeleteTeamMemberParams{ + TeamID: teamID, + UserID: callerUserID, + }); err != nil { + return fmt.Errorf("leave team: %w", err) + } + return nil +} + +// SearchUsersByEmailPrefix returns up to 10 users whose email starts with the given prefix. +// The prefix must contain "@" to prevent broad enumeration. +func (s *TeamService) SearchUsersByEmailPrefix(ctx context.Context, prefix string) ([]db.SearchUsersByEmailPrefixRow, error) { + return s.DB.SearchUsersByEmailPrefix(ctx, pgtype.Text{String: prefix, Valid: true}) +} From 1e681da738de4eb082ed81e802ee953feb30fbb8 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Tue, 24 Mar 2026 14:21:53 +0600 Subject: [PATCH 2/6] Add team management frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New /dashboard/team page with inline team name editing, slug/ID copy, members table with split-button (remove + make admin/member), add member typeahead, and danger zone (delete/leave) with confirmation dialogs - Sidebar now fetches real teams from API, supports team switching and team creation via dialog - Rename nav item Members → Team, route /dashboard/members → /dashboard/team - New src/lib/api/team.ts with typed functions for all team endpoints --- frontend/src/lib/api/team.ts | 83 ++ frontend/src/lib/components/Sidebar.svelte | 161 ++- .../src/routes/dashboard/team/+page.svelte | 1196 +++++++++++++++++ 3 files changed, 1424 insertions(+), 16 deletions(-) create mode 100644 frontend/src/lib/api/team.ts create mode 100644 frontend/src/routes/dashboard/team/+page.svelte diff --git a/frontend/src/lib/api/team.ts b/frontend/src/lib/api/team.ts new file mode 100644 index 0000000..0fae217 --- /dev/null +++ b/frontend/src/lib/api/team.ts @@ -0,0 +1,83 @@ +import { apiFetch, type ApiResult } from '$lib/api/client'; + +export type TeamMember = { + user_id: string; + email: string; + role: 'owner' | 'admin' | 'member'; + joined_at: string; +}; + +export type TeamInfo = { + id: string; + name: string; + slug: string; + created_at: string; +}; + +export type TeamDetail = { + team: TeamInfo; + members: TeamMember[]; +}; + +export type UserSearchResult = { + user_id: string; + email: string; +}; + +export type TeamWithRole = { + id: string; + name: string; + slug: string; + created_at: string; + role: string; +}; + +export async function listTeams(): Promise> { + return apiFetch('GET', '/api/v1/teams'); +} + +export async function createTeam(name: string): Promise> { + return apiFetch('POST', '/api/v1/teams', { name }); +} + +export async function switchTeam( + teamId: string +): Promise> { + return apiFetch('POST', '/api/v1/auth/switch-team', { team_id: teamId }); +} + +export async function getTeam(id: string): Promise> { + return apiFetch('GET', `/api/v1/teams/${id}`); +} + +export async function updateTeam(id: string, name: string): Promise> { + return apiFetch('PATCH', `/api/v1/teams/${id}`, { name }); +} + +export async function addMember(id: string, email: string): Promise> { + return apiFetch('POST', `/api/v1/teams/${id}/members`, { email }); +} + +export async function removeMember(id: string, userId: string): Promise> { + return apiFetch('DELETE', `/api/v1/teams/${id}/members/${userId}`); +} + +export async function updateMemberRole( + id: string, + userId: string, + role: 'admin' | 'member' +): Promise> { + return apiFetch('PATCH', `/api/v1/teams/${id}/members/${userId}`, { role }); +} + +export async function deleteTeam(id: string): Promise> { + return apiFetch('DELETE', `/api/v1/teams/${id}`); +} + +export async function leaveTeam(id: string): Promise> { + return apiFetch('POST', `/api/v1/teams/${id}/leave`); +} + +export async function searchUsers(email: string): Promise> { + return apiFetch('GET', `/api/v1/users/search?email=${encodeURIComponent(email)}`); +} diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 7102b11..a4eabb2 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -1,7 +1,9 @@