diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go index b11051e..a7d2371 100644 --- a/cmd/control-plane/main.go +++ b/cmd/control-plane/main.go @@ -14,6 +14,7 @@ import ( "github.com/redis/go-redis/v9" "git.omukk.dev/wrenn/sandbox/internal/api" + "git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/auth/oauth" "git.omukk.dev/wrenn/sandbox/internal/config" "git.omukk.dev/wrenn/sandbox/internal/db" @@ -90,7 +91,7 @@ func main() { srv := api.New(queries, hostPool, hostScheduler, pool, rdb, []byte(cfg.JWTSecret), oauthRegistry, cfg.OAuthRedirectURL) // Start host monitor (passive + active reconciliation every 30s). - monitor := api.NewHostMonitor(queries, hostPool, 30*time.Second) + monitor := api.NewHostMonitor(queries, hostPool, audit.New(queries), 30*time.Second) monitor.Start(ctx) httpServer := &http.Server{ diff --git a/db/migrations/20260324220743_audit_logs.sql b/db/migrations/20260324220743_audit_logs.sql new file mode 100644 index 0000000..91b7375 --- /dev/null +++ b/db/migrations/20260324220743_audit_logs.sql @@ -0,0 +1,28 @@ +-- +goose Up + +CREATE TABLE audit_logs ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL, + actor_type TEXT NOT NULL, -- 'user', 'api_key', 'system' + actor_id TEXT, -- user_id or api_key_id; NULL for system + actor_name TEXT, -- display name snapshotted at write time; NULL for system + resource_type TEXT NOT NULL, -- 'sandbox', 'snapshot', 'team', 'api_key', 'member', 'host' + resource_id TEXT, -- primary ID of the affected resource; NULL when not applicable + action TEXT NOT NULL, -- 'create', 'pause', 'resume', 'destroy', 'delete', 'rename', + -- 'revoke', 'add', 'remove', 'leave', 'role_update', + -- 'marked_down', 'marked_up' + scope TEXT NOT NULL, -- 'team' or 'admin' + status TEXT NOT NULL, -- 'success', 'info', 'warning', 'error' + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Primary access pattern: team feed sorted newest-first with cursor pagination. +CREATE INDEX idx_audit_logs_team_time ON audit_logs (team_id, created_at DESC); + +-- Secondary index: filtered by resource_type and action within a team. +CREATE INDEX idx_audit_logs_team_resource ON audit_logs (team_id, resource_type, action, created_at DESC); + +-- +goose Down + +DROP TABLE audit_logs; diff --git a/db/queries/audit.sql b/db/queries/audit.sql index e69de29..9250db7 100644 --- a/db/queries/audit.sql +++ b/db/queries/audit.sql @@ -0,0 +1,14 @@ +-- name: InsertAuditLog :exec +INSERT INTO audit_logs (id, team_id, actor_type, actor_id, actor_name, resource_type, resource_id, action, scope, status, metadata) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); + +-- name: ListAuditLogs :many +SELECT * FROM audit_logs +WHERE team_id = $1 + AND scope = ANY($2::text[]) + AND (cardinality($3::text[]) = 0 OR resource_type = ANY($3::text[])) + AND (cardinality($4::text[]) = 0 OR action = ANY($4::text[])) + AND ($5::timestamptz IS NULL OR created_at < $5 + OR (created_at = $5 AND id < $6)) +ORDER BY created_at DESC, id DESC +LIMIT $7; diff --git a/internal/api/handlers_apikeys.go b/internal/api/handlers_apikeys.go index 47f65ed..2637181 100644 --- a/internal/api/handlers_apikeys.go +++ b/internal/api/handlers_apikeys.go @@ -6,17 +6,19 @@ import ( "github.com/go-chi/chi/v5" + "git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/service" ) type apiKeyHandler struct { - svc *service.APIKeyService + svc *service.APIKeyService + audit *audit.AuditLogger } -func newAPIKeyHandler(svc *service.APIKeyService) *apiKeyHandler { - return &apiKeyHandler{svc: svc} +func newAPIKeyHandler(svc *service.APIKeyService, al *audit.AuditLogger) *apiKeyHandler { + return &apiKeyHandler{svc: svc, audit: al} } type createAPIKeyRequest struct { @@ -91,6 +93,7 @@ func (h *apiKeyHandler) Create(w http.ResponseWriter, r *http.Request) { resp := apiKeyToResponse(result.Row) resp.Key = &result.Plaintext + h.audit.LogAPIKeyCreate(r.Context(), ac, result.Row.ID, result.Row.Name) writeJSON(w, http.StatusCreated, resp) } @@ -122,5 +125,6 @@ func (h *apiKeyHandler) Delete(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogAPIKeyRevoke(r.Context(), ac, keyID) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/handlers_audit.go b/internal/api/handlers_audit.go new file mode 100644 index 0000000..7812309 --- /dev/null +++ b/internal/api/handlers_audit.go @@ -0,0 +1,134 @@ +package api + +import ( + "net/http" + "strconv" + "strings" + "time" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/service" +) + +type auditHandler struct { + svc *service.AuditService +} + +func newAuditHandler(svc *service.AuditService) *auditHandler { + return &auditHandler{svc: svc} +} + +type auditLogResponse struct { + ID string `json:"id"` + ActorType string `json:"actor_type"` + ActorID string `json:"actor_id,omitempty"` + ActorName string `json:"actor_name,omitempty"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id,omitempty"` + Action string `json:"action"` + Scope string `json:"scope"` + Status string `json:"status"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt string `json:"created_at"` +} + +// List handles GET /v1/audit-logs. +// Query params: +// - before: RFC3339 timestamp cursor (exclusive); omit to start from latest +// - limit: page size, default 50, max 200 +// - resource_type: filter by resource type (sandbox, snapshot, team, api_key, member, host) +// - action: filter by action verb +// +// Members see only team-scoped events; admins/owners see all. +func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + + // Parse ?before cursor. + var before time.Time + if s := r.URL.Query().Get("before"); s != "" { + var err error + before, err = time.Parse(time.RFC3339, s) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "before must be an RFC3339 timestamp") + return + } + } + + // Parse ?limit. + limit := 50 + if s := r.URL.Query().Get("limit"); s != "" { + n, err := strconv.Atoi(s) + if err != nil || n < 1 { + writeError(w, http.StatusBadRequest, "invalid_request", "limit must be a positive integer") + return + } + limit = n + } + + entries, err := h.svc.List(r.Context(), service.AuditListParams{ + TeamID: ac.TeamID, + AdminScoped: ac.Role == "owner" || ac.Role == "admin", + ResourceTypes: parseMultiParam(r.URL.Query()["resource_type"]), + Actions: parseMultiParam(r.URL.Query()["action"]), + Before: before, + BeforeID: r.URL.Query().Get("before_id"), + Limit: limit, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs") + return + } + + items := make([]auditLogResponse, len(entries)) + for i, e := range entries { + items[i] = auditLogResponse{ + ID: e.ID, + ActorType: e.ActorType, + ActorID: e.ActorID, + ActorName: e.ActorName, + ResourceType: e.ResourceType, + ResourceID: e.ResourceID, + Action: e.Action, + Scope: e.Scope, + Status: e.Status, + Metadata: e.Metadata, + CreatedAt: e.CreatedAt.UTC().Format(time.RFC3339), + } + } + + resp := map[string]any{"items": items} + if len(items) > 0 { + last := entries[len(entries)-1] + resp["next_before"] = last.CreatedAt.UTC().Format(time.RFC3339) + resp["next_before_id"] = last.ID + } + + writeJSON(w, http.StatusOK, resp) +} + +// parseMultiParam flattens repeated params and comma-separated values into a +// single deduplicated slice. Empty strings are dropped. Returns nil (no filter) +// when no values are present. +// +// Both ?resource_type=sandbox&resource_type=snapshot +// and ?resource_type=sandbox,snapshot are accepted. +func parseMultiParam(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}) + var out []string + for _, v := range values { + for _, part := range strings.Split(v, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, ok := seen[part]; !ok { + seen[part] = struct{}{} + out = append(out, part) + } + } + } + return out +} diff --git a/internal/api/handlers_hosts.go b/internal/api/handlers_hosts.go index 762fc91..f4f7917 100644 --- a/internal/api/handlers_hosts.go +++ b/internal/api/handlers_hosts.go @@ -2,11 +2,13 @@ package api import ( "errors" + "log/slog" "net/http" "time" "github.com/go-chi/chi/v5" + "git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/service" @@ -15,10 +17,11 @@ import ( type hostHandler struct { svc *service.HostService queries *db.Queries + audit *audit.AuditLogger } -func newHostHandler(svc *service.HostService, queries *db.Queries) *hostHandler { - return &hostHandler{svc: svc, queries: queries} +func newHostHandler(svc *service.HostService, queries *db.Queries, al *audit.AuditLogger) *hostHandler { + return &hostHandler{svc: svc, queries: queries, audit: al} } // Request/response types. @@ -50,10 +53,6 @@ type deletePreviewResponse struct { SandboxIDs []string `json:"sandbox_ids"` } -type hasSandboxesErrorResponse struct { - SandboxIDs []string `json:"sandbox_ids"` -} - type registerHostRequest struct { Token string `json:"token"` Arch string `json:"arch,omitempty"` @@ -166,6 +165,10 @@ func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) { return } + // Log audit for the owning team (BYOC hosts have a team; shared hosts use caller's team). + hostTeamID := result.Host.TeamID.String + h.audit.LogHostCreate(r.Context(), ac, result.Host.ID, hostTeamID) + writeJSON(w, http.StatusCreated, createHostResponse{ Host: hostToResponse(result.Host), RegistrationToken: result.RegistrationToken, @@ -257,8 +260,15 @@ func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) { ac := auth.MustFromContext(r.Context()) force := r.URL.Query().Get("force") == "true" + // Fetch host before deletion to capture team_id for audit. + deletedHost, hostErr := h.queries.GetHost(r.Context(), hostID) + if hostErr != nil { + slog.Warn("audit: could not fetch host before delete", "host_id", hostID, "error", hostErr) + } + err := h.svc.Delete(r.Context(), hostID, ac.UserID, ac.TeamID, h.isAdmin(r, ac.UserID), force) if err == nil { + h.audit.LogHostDelete(r.Context(), ac, hostID, deletedHost.TeamID.String) w.WriteHeader(http.StatusNoContent) return } @@ -347,12 +357,20 @@ func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) { return } + // Capture pre-heartbeat status to detect unreachable → online transition. + prevHost, _ := h.queries.GetHost(r.Context(), hc.HostID) + if err := h.svc.Heartbeat(r.Context(), hc.HostID); err != nil { status, code, msg := serviceErrToHTTP(err) writeError(w, status, code, msg) return } + // Log marked_up if the host just recovered from unreachable. + if prevHost.Status == "unreachable" { + h.audit.LogHostMarkedUp(r.Context(), prevHost.TeamID.String, hc.HostID) + } + w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/handlers_sandbox.go b/internal/api/handlers_sandbox.go index 08f99b0..b2709a5 100644 --- a/internal/api/handlers_sandbox.go +++ b/internal/api/handlers_sandbox.go @@ -7,17 +7,19 @@ import ( "github.com/go-chi/chi/v5" + "git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/service" ) type sandboxHandler struct { - svc *service.SandboxService + svc *service.SandboxService + audit *audit.AuditLogger } -func newSandboxHandler(svc *service.SandboxService) *sandboxHandler { - return &sandboxHandler{svc: svc} +func newSandboxHandler(svc *service.SandboxService, al *audit.AuditLogger) *sandboxHandler { + return &sandboxHandler{svc: svc, audit: al} } type createSandboxRequest struct { @@ -97,6 +99,7 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogSandboxCreate(r.Context(), ac, sb.ID, sb.Template) writeJSON(w, http.StatusCreated, sandboxToResponse(sb)) } @@ -143,6 +146,7 @@ func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogSandboxPause(r.Context(), ac, sandboxID) writeJSON(w, http.StatusOK, sandboxToResponse(sb)) } @@ -158,6 +162,7 @@ func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogSandboxResume(r.Context(), ac, sandboxID) writeJSON(w, http.StatusOK, sandboxToResponse(sb)) } @@ -186,5 +191,6 @@ func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogSandboxDestroy(r.Context(), ac, sandboxID) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/handlers_snapshots.go b/internal/api/handlers_snapshots.go index 7d3e7fa..fbfcdc1 100644 --- a/internal/api/handlers_snapshots.go +++ b/internal/api/handlers_snapshots.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" + "git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/id" @@ -22,13 +23,14 @@ import ( ) type snapshotHandler struct { - svc *service.TemplateService - db *db.Queries - pool *lifecycle.HostClientPool + svc *service.TemplateService + db *db.Queries + pool *lifecycle.HostClientPool + audit *audit.AuditLogger } -func newSnapshotHandler(svc *service.TemplateService, db *db.Queries, pool *lifecycle.HostClientPool) *snapshotHandler { - return &snapshotHandler{svc: svc, db: db, pool: pool} +func newSnapshotHandler(svc *service.TemplateService, db *db.Queries, pool *lifecycle.HostClientPool, al *audit.AuditLogger) *snapshotHandler { + return &snapshotHandler{svc: svc, db: db, pool: pool, audit: al} } // deleteSnapshotBroadcast attempts to delete snapshot files on all online hosts. @@ -180,6 +182,7 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogSnapshotCreate(r.Context(), ac, req.Name) writeJSON(w, http.StatusCreated, templateToResponse(tmpl)) } @@ -227,5 +230,6 @@ func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogSnapshotDelete(r.Context(), ac, name) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/handlers_team.go b/internal/api/handlers_team.go index fcb5564..3950ab7 100644 --- a/internal/api/handlers_team.go +++ b/internal/api/handlers_team.go @@ -1,23 +1,26 @@ package api import ( + "log/slog" "net/http" "strings" "time" "github.com/go-chi/chi/v5" + "git.omukk.dev/wrenn/sandbox/internal/audit" "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 + svc *service.TeamService + audit *audit.AuditLogger } -func newTeamHandler(svc *service.TeamService) *teamHandler { - return &teamHandler{svc: svc} +func newTeamHandler(svc *service.TeamService, al *audit.AuditLogger) *teamHandler { + return &teamHandler{svc: svc, audit: al} } // teamResponse is the JSON shape for a team. @@ -179,12 +182,19 @@ func (h *teamHandler) Rename(w http.ResponseWriter, r *http.Request) { } req.Name = strings.TrimSpace(req.Name) + // Fetch old name for audit log before renaming. + oldTeam, err := h.svc.GetTeam(r.Context(), teamID) + if err != nil { + slog.Warn("audit: could not fetch old team name for rename log", "team_id", teamID, "error", err) + } + 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 } + h.audit.LogTeamRename(r.Context(), ac, teamID, oldTeam.Name, req.Name) w.WriteHeader(http.StatusNoContent) } @@ -257,6 +267,7 @@ func (h *teamHandler) AddMember(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogMemberAdd(r.Context(), ac, member.UserID, member.Email, member.Role) writeJSON(w, http.StatusCreated, memberInfoToResponse(member)) } @@ -276,6 +287,7 @@ func (h *teamHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogMemberRemove(r.Context(), ac, targetUserID) w.WriteHeader(http.StatusNoContent) } @@ -303,6 +315,7 @@ func (h *teamHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogMemberRoleUpdate(r.Context(), ac, targetUserID, req.Role) w.WriteHeader(http.StatusNoContent) } @@ -321,6 +334,7 @@ func (h *teamHandler) Leave(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogMemberLeave(r.Context(), ac) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/host_monitor.go b/internal/api/host_monitor.go index e2afca1..4bf19d8 100644 --- a/internal/api/host_monitor.go +++ b/internal/api/host_monitor.go @@ -5,11 +5,12 @@ import ( "log/slog" "time" + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/lifecycle" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" - - "connectrpc.com/connect" ) // unreachableThreshold is how long a host can go without a heartbeat before @@ -27,14 +28,16 @@ const unreachableThreshold = 90 * time.Second type HostMonitor struct { db *db.Queries pool *lifecycle.HostClientPool + audit *audit.AuditLogger interval time.Duration } // NewHostMonitor creates a HostMonitor. -func NewHostMonitor(queries *db.Queries, pool *lifecycle.HostClientPool, interval time.Duration) *HostMonitor { +func NewHostMonitor(queries *db.Queries, pool *lifecycle.HostClientPool, al *audit.AuditLogger, interval time.Duration) *HostMonitor { return &HostMonitor{ db: queries, pool: pool, + audit: al, interval: interval, } } @@ -87,6 +90,7 @@ func (m *HostMonitor) checkHost(ctx context.Context, host db.Host) { if err := m.db.MarkSandboxesMissingByHost(ctx, host.ID); err != nil { slog.Warn("host monitor: failed to mark sandboxes missing", "host_id", host.ID, "error", err) } + m.audit.LogHostMarkedDown(ctx, host.TeamID.String, host.ID) return } @@ -170,7 +174,9 @@ func (m *HostMonitor) checkHost(ctx context.Context, host db.Host) { } var toPause, toStop []string + sbTeamID := make(map[string]string, len(runningSandboxes)) for _, sb := range runningSandboxes { + sbTeamID[sb.ID] = sb.TeamID if _, ok := alive[sb.ID]; ok { continue } @@ -189,6 +195,9 @@ func (m *HostMonitor) checkHost(ctx context.Context, host db.Host) { }); err != nil { slog.Warn("host monitor: failed to mark paused", "host_id", host.ID, "error", err) } + for _, sbID := range toPause { + m.audit.LogSandboxAutoPause(ctx, sbTeamID[sbID], sbID) + } } if len(toStop) > 0 { slog.Info("host monitor: marking orphaned sandboxes stopped", "host_id", host.ID, "count", len(toStop)) diff --git a/internal/api/middleware_auth.go b/internal/api/middleware_auth.go index 41daf36..dee4240 100644 --- a/internal/api/middleware_auth.go +++ b/internal/api/middleware_auth.go @@ -27,7 +27,11 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler 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}) + ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{ + TeamID: row.TeamID, + APIKeyID: row.ID, + APIKeyName: row.Name, + }) next.ServeHTTP(w, r.WithContext(ctx)) return } diff --git a/internal/api/server.go b/internal/api/server.go index 302aee3..366a122 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" + "git.omukk.dev/wrenn/sandbox/internal/audit" "git.omukk.dev/wrenn/sandbox/internal/auth/oauth" "git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/lifecycle" @@ -44,19 +45,23 @@ func New( templateSvc := &service.TemplateService{DB: queries} hostSvc := &service.HostService{DB: queries, Redis: rdb, JWT: jwtSecret, Pool: pool} teamSvc := &service.TeamService{DB: queries, Pool: pgPool, HostPool: pool} + auditSvc := &service.AuditService{DB: queries} - sandbox := newSandboxHandler(sandboxSvc) + al := audit.New(queries) + + sandbox := newSandboxHandler(sandboxSvc, al) exec := newExecHandler(queries, pool) execStream := newExecStreamHandler(queries, pool) files := newFilesHandler(queries, pool) filesStream := newFilesStreamHandler(queries, pool) - snapshots := newSnapshotHandler(templateSvc, queries, pool) + snapshots := newSnapshotHandler(templateSvc, queries, pool, al) authH := newAuthHandler(queries, pgPool, jwtSecret) oauthH := newOAuthHandler(queries, pgPool, jwtSecret, oauthRegistry, oauthRedirectURL) - apiKeys := newAPIKeyHandler(apiKeySvc) - hostH := newHostHandler(hostSvc, queries) - teamH := newTeamHandler(teamSvc) + apiKeys := newAPIKeyHandler(apiKeySvc, al) + hostH := newHostHandler(hostSvc, queries, al) + teamH := newTeamHandler(teamSvc, al) usersH := newUsersHandler(teamSvc) + auditH := newAuditHandler(auditSvc) // OpenAPI spec and docs. r.Get("/openapi.yaml", serveOpenAPI) @@ -156,6 +161,9 @@ func New( }) }) + // JWT-authenticated: audit log. + r.With(requireJWT(jwtSecret)).Get("/v1/audit-logs", auditH.List) + // Platform admin routes — require JWT + DB-validated admin status. r.Route("/v1/admin", func(r chi.Router) { r.Use(requireJWT(jwtSecret)) diff --git a/internal/audit/logger.go b/internal/audit/logger.go new file mode 100644 index 0000000..8f44059 --- /dev/null +++ b/internal/audit/logger.go @@ -0,0 +1,403 @@ +package audit + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/jackc/pgx/v5/pgtype" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" +) + +// AuditLogger writes audit log entries for user-initiated and system events. +// All methods are fire-and-forget: failures are logged via slog and never +// propagated to the caller. +type AuditLogger struct { + db *db.Queries +} + +// New constructs an AuditLogger. +func New(queries *db.Queries) *AuditLogger { + return &AuditLogger{db: queries} +} + +// actorFields extracts actor_type, actor_id, and actor_name from an AuthContext. +func actorFields(ac auth.AuthContext) (actorType string, actorID pgtype.Text, actorName pgtype.Text) { + if ac.UserID != "" { + return "user", + pgtype.Text{String: ac.UserID, Valid: true}, + pgtype.Text{String: ac.Name, Valid: ac.Name != ""} + } + if ac.APIKeyID != "" { + return "api_key", + pgtype.Text{String: ac.APIKeyID, Valid: true}, + pgtype.Text{String: ac.APIKeyName, Valid: true} + } + return "system", pgtype.Text{}, pgtype.Text{} +} + +func (l *AuditLogger) write(ctx context.Context, p db.InsertAuditLogParams) { + if err := l.db.InsertAuditLog(ctx, p); err != nil { + slog.Warn("audit: failed to write log entry", + "action", p.Action, + "resource_type", p.ResourceType, + "team_id", p.TeamID, + "error", err, + ) + } +} + +func marshalMeta(meta map[string]any) []byte { + if len(meta) == 0 { + return []byte("{}") + } + b, err := json.Marshal(meta) + if err != nil { + return []byte("{}") + } + return b +} + +// --- Sandbox events (scope: team) --- + +func (l *AuditLogger) LogSandboxCreate(ctx context.Context, ac auth.AuthContext, sandboxID, template string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "sandbox", + ResourceID: pgtype.Text{String: sandboxID, Valid: true}, + Action: "create", + Scope: "team", + Status: "success", + Metadata: marshalMeta(map[string]any{"template": template}), + }) +} + +func (l *AuditLogger) LogSandboxPause(ctx context.Context, ac auth.AuthContext, sandboxID string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "sandbox", + ResourceID: pgtype.Text{String: sandboxID, Valid: true}, + Action: "pause", + Scope: "team", + Status: "success", + Metadata: []byte("{}"), + }) +} + +// LogSandboxAutoPause records a system-initiated auto-pause (TTL or host reconciler). +func (l *AuditLogger) LogSandboxAutoPause(ctx context.Context, teamID, sandboxID string) { + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: teamID, + ActorType: "system", + ActorID: pgtype.Text{}, + ActorName: pgtype.Text{}, + ResourceType: "sandbox", + ResourceID: pgtype.Text{String: sandboxID, Valid: true}, + Action: "pause", + Scope: "team", + Status: "info", + Metadata: []byte("{}"), + }) +} + +func (l *AuditLogger) LogSandboxResume(ctx context.Context, ac auth.AuthContext, sandboxID string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "sandbox", + ResourceID: pgtype.Text{String: sandboxID, Valid: true}, + Action: "resume", + Scope: "team", + Status: "success", + Metadata: []byte("{}"), + }) +} + +func (l *AuditLogger) LogSandboxDestroy(ctx context.Context, ac auth.AuthContext, sandboxID string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "sandbox", + ResourceID: pgtype.Text{String: sandboxID, Valid: true}, + Action: "destroy", + Scope: "team", + Status: "warning", + Metadata: []byte("{}"), + }) +} + +// --- Snapshot events (scope: team) --- + +func (l *AuditLogger) LogSnapshotCreate(ctx context.Context, ac auth.AuthContext, name string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "snapshot", + ResourceID: pgtype.Text{String: name, Valid: true}, + Action: "create", + Scope: "team", + Status: "success", + Metadata: []byte("{}"), + }) +} + +func (l *AuditLogger) LogSnapshotDelete(ctx context.Context, ac auth.AuthContext, name string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "snapshot", + ResourceID: pgtype.Text{String: name, Valid: true}, + Action: "delete", + Scope: "team", + Status: "warning", + Metadata: []byte("{}"), + }) +} + +// --- Team events (scope: team) --- + +func (l *AuditLogger) LogTeamRename(ctx context.Context, ac auth.AuthContext, teamID, oldName, newName string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "team", + ResourceID: pgtype.Text{String: teamID, Valid: true}, + Action: "rename", + Scope: "team", + Status: "info", + Metadata: marshalMeta(map[string]any{"old_name": oldName, "new_name": newName}), + }) +} + +// --- API key events (scope: team) --- + +func (l *AuditLogger) LogAPIKeyCreate(ctx context.Context, ac auth.AuthContext, keyID, keyName string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "api_key", + ResourceID: pgtype.Text{String: keyID, Valid: true}, + Action: "create", + Scope: "team", + Status: "success", + Metadata: marshalMeta(map[string]any{"name": keyName}), + }) +} + +func (l *AuditLogger) LogAPIKeyRevoke(ctx context.Context, ac auth.AuthContext, keyID string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "api_key", + ResourceID: pgtype.Text{String: keyID, Valid: true}, + Action: "revoke", + Scope: "team", + Status: "warning", + Metadata: []byte("{}"), + }) +} + +// --- Member events (scope: admin) --- + +func (l *AuditLogger) LogMemberAdd(ctx context.Context, ac auth.AuthContext, targetUserID, targetEmail, role string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "member", + ResourceID: pgtype.Text{String: targetUserID, Valid: true}, + Action: "add", + Scope: "admin", + Status: "success", + Metadata: marshalMeta(map[string]any{"email": targetEmail, "role": role}), + }) +} + +func (l *AuditLogger) LogMemberRemove(ctx context.Context, ac auth.AuthContext, targetUserID string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "member", + ResourceID: pgtype.Text{String: targetUserID, Valid: true}, + Action: "remove", + Scope: "admin", + Status: "warning", + Metadata: []byte("{}"), + }) +} + +func (l *AuditLogger) LogMemberLeave(ctx context.Context, ac auth.AuthContext) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "member", + ResourceID: pgtype.Text{String: ac.UserID, Valid: ac.UserID != ""}, + Action: "leave", + Scope: "admin", + Status: "info", + Metadata: []byte("{}"), + }) +} + +func (l *AuditLogger) LogMemberRoleUpdate(ctx context.Context, ac auth.AuthContext, targetUserID, newRole string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: ac.TeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "member", + ResourceID: pgtype.Text{String: targetUserID, Valid: true}, + Action: "role_update", + Scope: "admin", + Status: "info", + Metadata: marshalMeta(map[string]any{"new_role": newRole}), + }) +} + +// --- Host events (scope: admin) --- + +func (l *AuditLogger) LogHostCreate(ctx context.Context, ac auth.AuthContext, hostID, teamID string) { + actorType, actorID, actorName := actorFields(ac) + // For shared hosts with no owning team, use the caller's team. + logTeamID := teamID + if logTeamID == "" { + logTeamID = ac.TeamID + } + if logTeamID == "" { + return + } + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: logTeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "host", + ResourceID: pgtype.Text{String: hostID, Valid: true}, + Action: "create", + Scope: "admin", + Status: "success", + Metadata: []byte("{}"), + }) +} + +func (l *AuditLogger) LogHostDelete(ctx context.Context, ac auth.AuthContext, hostID, teamID string) { + actorType, actorID, actorName := actorFields(ac) + logTeamID := teamID + if logTeamID == "" { + logTeamID = ac.TeamID + } + if logTeamID == "" { + return + } + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: logTeamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: "host", + ResourceID: pgtype.Text{String: hostID, Valid: true}, + Action: "delete", + Scope: "admin", + Status: "warning", + Metadata: []byte("{}"), + }) +} + +// LogHostMarkedDown records a system-initiated host status transition to unreachable. +// teamID must be non-empty (BYOC hosts only); shared hosts are not logged. +func (l *AuditLogger) LogHostMarkedDown(ctx context.Context, teamID, hostID string) { + if teamID == "" { + return + } + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: teamID, + ActorType: "system", + ActorID: pgtype.Text{}, + ActorName: pgtype.Text{}, + ResourceType: "host", + ResourceID: pgtype.Text{String: hostID, Valid: true}, + Action: "marked_down", + Scope: "admin", + Status: "error", + Metadata: []byte("{}"), + }) +} + +// LogHostMarkedUp records a system-initiated host status transition back to online. +// teamID must be non-empty (BYOC hosts only); shared hosts are not logged. +func (l *AuditLogger) LogHostMarkedUp(ctx context.Context, teamID, hostID string) { + if teamID == "" { + return + } + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: teamID, + ActorType: "system", + ActorID: pgtype.Text{}, + ActorName: pgtype.Text{}, + ResourceType: "host", + ResourceID: pgtype.Text{String: hostID, Valid: true}, + Action: "marked_up", + Scope: "admin", + Status: "success", + Metadata: []byte("{}"), + }) +} diff --git a/internal/auth/context.go b/internal/auth/context.go index 98db360..22bf795 100644 --- a/internal/auth/context.go +++ b/internal/auth/context.go @@ -8,12 +8,14 @@ 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 - Name string // empty when authenticated via API key - Role string // owner, admin, or member; empty when authenticated via API key - IsAdmin bool // platform-level admin; always false when authenticated via API key + TeamID string + UserID string // empty when authenticated via API key + Email string // empty when authenticated via API key + Name string // empty when authenticated via API key + Role string // owner, admin, or member; empty when authenticated via API key + IsAdmin bool // platform-level admin; always false when authenticated via API key + APIKeyID string // populated when authenticated via API key; empty for JWT auth + APIKeyName string // display name of the key, snapshotted at auth time; empty for JWT auth } // WithAuthContext returns a new context with the given AuthContext. diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index a40a032..fd1bc02 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -8,14 +8,14 @@ import ( ) const jwtExpiry = 6 * time.Hour -const hostJWTExpiry = 7 * 24 * time.Hour // 7 days; host refreshes via refresh token +const hostJWTExpiry = 7 * 24 * time.Hour // 7 days; host refreshes via refresh token const HostRefreshTokenExpiry = 60 * 24 * time.Hour // 60 days; exported for service layer // Claims are the JWT payload for user tokens. 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 + Role string `json:"role"` // owner, admin, or member within TeamID Email string `json:"email"` Name string `json:"name"` IsAdmin bool `json:"is_admin,omitempty"` // platform-level admin flag diff --git a/internal/db/audit.sql.go b/internal/db/audit.sql.go new file mode 100644 index 0000000..9370eca --- /dev/null +++ b/internal/db/audit.sql.go @@ -0,0 +1,111 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: audit.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const insertAuditLog = `-- name: InsertAuditLog :exec +INSERT INTO audit_logs (id, team_id, actor_type, actor_id, actor_name, resource_type, resource_id, action, scope, status, metadata) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +` + +type InsertAuditLogParams struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + ActorType string `json:"actor_type"` + ActorID pgtype.Text `json:"actor_id"` + ActorName pgtype.Text `json:"actor_name"` + ResourceType string `json:"resource_type"` + ResourceID pgtype.Text `json:"resource_id"` + Action string `json:"action"` + Scope string `json:"scope"` + Status string `json:"status"` + Metadata []byte `json:"metadata"` +} + +func (q *Queries) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error { + _, err := q.db.Exec(ctx, insertAuditLog, + arg.ID, + arg.TeamID, + arg.ActorType, + arg.ActorID, + arg.ActorName, + arg.ResourceType, + arg.ResourceID, + arg.Action, + arg.Scope, + arg.Status, + arg.Metadata, + ) + return err +} + +const listAuditLogs = `-- name: ListAuditLogs :many +SELECT id, team_id, actor_type, actor_id, actor_name, resource_type, resource_id, action, scope, status, metadata, created_at FROM audit_logs +WHERE team_id = $1 + AND scope = ANY($2::text[]) + AND (cardinality($3::text[]) = 0 OR resource_type = ANY($3::text[])) + AND (cardinality($4::text[]) = 0 OR action = ANY($4::text[])) + AND ($5::timestamptz IS NULL OR created_at < $5 + OR (created_at = $5 AND id < $6)) +ORDER BY created_at DESC, id DESC +LIMIT $7 +` + +type ListAuditLogsParams struct { + TeamID string `json:"team_id"` + Column2 []string `json:"column_2"` + Column3 []string `json:"column_3"` + Column4 []string `json:"column_4"` + Column5 pgtype.Timestamptz `json:"column_5"` + ID string `json:"id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListAuditLogs(ctx context.Context, arg ListAuditLogsParams) ([]AuditLog, error) { + rows, err := q.db.Query(ctx, listAuditLogs, + arg.TeamID, + arg.Column2, + arg.Column3, + arg.Column4, + arg.Column5, + arg.ID, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AuditLog + for rows.Next() { + var i AuditLog + if err := rows.Scan( + &i.ID, + &i.TeamID, + &i.ActorType, + &i.ActorID, + &i.ActorName, + &i.ResourceType, + &i.ResourceID, + &i.Action, + &i.Scope, + &i.Status, + &i.Metadata, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/hosts.sql.go b/internal/db/hosts.sql.go index 90d97ca..2d7b8e0 100644 --- a/internal/db/hosts.sql.go +++ b/internal/db/hosts.sql.go @@ -583,10 +583,13 @@ WHERE id = $1 ` // Updates last_heartbeat_at and transitions unreachable hosts back to online. -// Returns 0 if no host was found (deleted). +// Returns 0 if no host was found (deleted), which the caller treats as 404. func (q *Queries) UpdateHostHeartbeatAndStatus(ctx context.Context, id string) (int64, error) { result, err := q.db.Exec(ctx, updateHostHeartbeatAndStatus, id) - return result.RowsAffected(), err + if err != nil { + return 0, err + } + return result.RowsAffected(), nil } const updateHostStatus = `-- name: UpdateHostStatus :exec diff --git a/internal/db/models.go b/internal/db/models.go index 546c1da..00cbf70 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -15,6 +15,21 @@ type AdminPermission struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type AuditLog struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + ActorType string `json:"actor_type"` + ActorID pgtype.Text `json:"actor_id"` + ActorName pgtype.Text `json:"actor_name"` + ResourceType string `json:"resource_type"` + ResourceID pgtype.Text `json:"resource_id"` + Action string `json:"action"` + Scope string `json:"scope"` + Status string `json:"status"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Host struct { ID string `json:"id"` Type string `json:"type"` diff --git a/internal/id/id.go b/internal/id/id.go index 04a2506..bbda47c 100644 --- a/internal/id/id.go +++ b/internal/id/id.go @@ -73,6 +73,11 @@ func NewRefreshTokenID() string { return "hrt-" + hex8() } +// NewAuditLogID generates a new audit log ID in the format "log-" + 8 hex chars. +func NewAuditLogID() string { + return "log-" + hex8() +} + // NewRefreshToken generates a 64-char hex token (32 bytes of entropy) for use as a host refresh token. func NewRefreshToken() string { b := make([]byte, 32) diff --git a/internal/service/audit.go b/internal/service/audit.go new file mode 100644 index 0000000..5306142 --- /dev/null +++ b/internal/service/audit.go @@ -0,0 +1,112 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.omukk.dev/wrenn/sandbox/internal/db" +) + +const auditMaxLimit = 200 + +// AuditEntry is a single audit log record returned by List. +type AuditEntry struct { + ID string + TeamID string + ActorType string + ActorID string // empty for system + ActorName string // empty for system + ResourceType string + ResourceID string // empty when not applicable + Action string + Scope string + Status string // 'success', 'info', 'warning', 'error' + Metadata map[string]any + CreatedAt time.Time +} + +// AuditListParams controls the ListAuditLogs query. +type AuditListParams struct { + TeamID string + AdminScoped bool // true → include admin-scoped events; false → team-scoped only + ResourceTypes []string // empty = no filter; multiple values = OR match + Actions []string // empty = no filter; multiple values = OR match + Before time.Time // zero = no cursor (start from latest) + BeforeID string // tie-breaker: id of the last item at the Before timestamp; empty = no tie-break + Limit int // clamped to auditMaxLimit by the handler +} + +// AuditService provides the read side of the audit log. +type AuditService struct { + DB *db.Queries +} + +// List returns a page of audit log entries for the given team. +func (s *AuditService) List(ctx context.Context, p AuditListParams) ([]AuditEntry, error) { + limit := p.Limit + if limit <= 0 { + limit = 50 + } + if limit > auditMaxLimit { + limit = auditMaxLimit + } + + scopes := []string{"team"} + if p.AdminScoped { + scopes = append(scopes, "admin") + } + + var before pgtype.Timestamptz + if !p.Before.IsZero() { + before = pgtype.Timestamptz{Time: p.Before, Valid: true} + } + + resourceTypes := p.ResourceTypes + if resourceTypes == nil { + resourceTypes = []string{} + } + actions := p.Actions + if actions == nil { + actions = []string{} + } + + rows, err := s.DB.ListAuditLogs(ctx, db.ListAuditLogsParams{ + TeamID: p.TeamID, + Column2: scopes, + Column3: resourceTypes, + Column4: actions, + Column5: before, + ID: p.BeforeID, + Limit: int32(limit), + }) + if err != nil { + return nil, fmt.Errorf("list audit logs: %w", err) + } + + entries := make([]AuditEntry, len(rows)) + for i, row := range rows { + var meta map[string]any + if len(row.Metadata) > 0 { + _ = json.Unmarshal(row.Metadata, &meta) + } + entries[i] = AuditEntry{ + ID: row.ID, + TeamID: row.TeamID, + ActorType: row.ActorType, + ActorID: row.ActorID.String, + ActorName: row.ActorName.String, + ResourceType: row.ResourceType, + ResourceID: row.ResourceID.String, + Action: row.Action, + Scope: row.Scope, + Status: row.Status, + Metadata: meta, + CreatedAt: row.CreatedAt.Time, + } + } + return entries, nil +} diff --git a/internal/service/team.go b/internal/service/team.go index 859441e..0c1739e 100644 --- a/internal/service/team.go +++ b/internal/service/team.go @@ -22,9 +22,9 @@ var teamNameRE = regexp.MustCompile(`^[A-Za-z0-9 _\-@']{1,128}$`) // TeamService provides team management operations. type TeamService struct { - DB *db.Queries - Pool *pgxpool.Pool - HostPool *lifecycle.HostClientPool + DB *db.Queries + Pool *pgxpool.Pool + HostPool *lifecycle.HostClientPool } // TeamWithRole pairs a team with the calling user's role in it.