Files
multica/server/internal/handler/admin.go

879 lines
29 KiB
Go

package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/issuestatus"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/marketcatalog"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Platform admin console (/api/admin/*). Everything here is platform-level:
// it is gated by the platform_admin table, never by workspace membership, and
// every write lands in admin_audit_log inside the same transaction.
const (
PlatformRoleSuperAdmin = "super_admin"
PlatformRoleOperator = "operator"
PlatformRoleViewer = "viewer"
adminReasonMaxLen = 500
adminPageSizeMax = 100
adminPageSizeDefault = 25
)
var platformRoleRank = map[string]int{
PlatformRoleViewer: 1,
PlatformRoleOperator: 2,
PlatformRoleSuperAdmin: 3,
}
type platformAdminCtxKey struct{}
// PlatformAdminFromContext returns the role RequirePlatformAdmin resolved.
func PlatformAdminFromContext(ctx context.Context) (db.PlatformAdmin, bool) {
pa, ok := ctx.Value(platformAdminCtxKey{}).(db.PlatformAdmin)
return pa, ok
}
// RequirePlatformAdmin gates a route on a platform role of at least minRole.
// It must sit behind middleware.Auth. A user listed in PLATFORM_ADMIN_EMAILS
// without a row is granted super_admin on first use, which is how the first
// administrator gets in before anyone can use the console to add others.
func (h *Handler) RequirePlatformAdmin(minRole string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isMachineCredentialActor(r) {
writeError(w, http.StatusForbidden, "this endpoint is only available to human actors")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
pa, err := h.resolvePlatformAdmin(r.Context(), userID)
if err != nil {
if isNotFound(err) {
writeError(w, http.StatusForbidden, "platform admin access required")
return
}
slog.Warn("platform admin lookup failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to resolve platform admin")
return
}
if platformRoleRank[pa.Role] < platformRoleRank[minRole] {
writeError(w, http.StatusForbidden, "insufficient platform role")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), platformAdminCtxKey{}, pa)))
})
}
}
func (h *Handler) resolvePlatformAdmin(ctx context.Context, userID string) (db.PlatformAdmin, error) {
uid, err := parseUUIDString(userID)
if err != nil {
return db.PlatformAdmin{}, err
}
pa, err := h.Queries.GetPlatformAdmin(ctx, uid)
if err == nil || !isNotFound(err) {
return pa, err
}
user, lookupErr := h.Queries.GetUser(ctx, uid)
if lookupErr != nil || !h.isBootstrapPlatformAdminEmail(user.Email) {
return db.PlatformAdmin{}, err
}
return h.Queries.UpsertPlatformAdmin(ctx, db.UpsertPlatformAdminParams{
UserID: uid,
Role: PlatformRoleSuperAdmin,
})
}
func (h *Handler) isBootstrapPlatformAdminEmail(email string) bool {
email = strings.ToLower(strings.TrimSpace(email))
for _, candidate := range h.cfg.PlatformAdminEmails {
if email == strings.ToLower(strings.TrimSpace(candidate)) {
return true
}
}
return false
}
// adminAction is one audited write. The business write and the audit row
// commit together, so an action can never happen unrecorded.
type adminAction struct {
Action string
TargetType string
TargetID string
Reason string
Payload map[string]any
}
type adminReasonRequest struct {
Reason string `json:"reason"`
}
func readAdminReason(w http.ResponseWriter, r *http.Request, dest any) (string, bool) {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024)).Decode(dest); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return "", false
}
var reason string
switch v := dest.(type) {
case *adminReasonRequest:
reason = v.Reason
case interface{ reasonValue() string }:
reason = v.reasonValue()
}
reason = strings.TrimSpace(reason)
if reason == "" {
writeError(w, http.StatusBadRequest, "reason is required")
return "", false
}
if len(reason) > adminReasonMaxLen {
writeError(w, http.StatusBadRequest, "reason is too long")
return "", false
}
return reason, true
}
// runAdminAction executes write inside a transaction and records the audit
// row in the same transaction. write returns the HTTP status and body to
// send; a non-2xx status rolls everything back.
func (h *Handler) runAdminAction(w http.ResponseWriter, r *http.Request, action adminAction, write func(q *db.Queries) (int, any, error)) {
actor, _ := PlatformAdminFromContext(r.Context())
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
status, body, err := write(qtx)
if err != nil {
slog.Warn("admin action failed", append(logger.RequestAttrs(r), "action", action.Action, "error", err)...)
writeError(w, http.StatusInternalServerError, "admin action failed")
return
}
if status < 200 || status >= 300 {
writeJSON(w, status, body)
return
}
payload, _ := json.Marshal(action.Payload)
if action.Payload == nil {
payload = []byte("{}")
}
if _, err := qtx.InsertAdminAuditLog(r.Context(), db.InsertAdminAuditLogParams{
ActorUserID: actor.UserID,
Action: action.Action,
TargetType: action.TargetType,
TargetID: action.TargetID,
Reason: action.Reason,
Payload: payload,
Ip: h.clientIPForRateLimit(r),
UserAgent: truncateString(r.UserAgent(), 512),
}); err != nil {
slog.Warn("admin audit insert failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to record audit log")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to commit")
return
}
writeJSON(w, status, body)
}
func adminPaging(r *http.Request) (limit, offset int32, page int) {
page, _ = strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
size, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
if size < 1 {
size = adminPageSizeDefault
}
if size > adminPageSizeMax {
size = adminPageSizeMax
}
return int32(size), int32((page - 1) * size), page
}
func optionalText(s string) pgtype.Text {
s = strings.TrimSpace(s)
if s == "" {
return pgtype.Text{}
}
return pgtype.Text{String: s, Valid: true}
}
func optionalUUID(w http.ResponseWriter, s, field string) (pgtype.UUID, bool) {
s = strings.TrimSpace(s)
if s == "" {
return pgtype.UUID{}, true
}
return parseUUIDOrBadRequest(w, s, field)
}
type AdminPage[T any] struct {
Items []T `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// --- me ---
type AdminMeResponse struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
}
func (h *Handler) AdminMe(w http.ResponseWriter, r *http.Request) {
pa, _ := PlatformAdminFromContext(r.Context())
user, err := h.Queries.GetUser(r.Context(), pa.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
writeJSON(w, http.StatusOK, AdminMeResponse{
UserID: uuidToString(pa.UserID),
Email: user.Email,
Name: user.Name,
Role: pa.Role,
})
}
// --- overview ---
type AdminOverviewResponse struct {
UsersTotal int64 `json:"users_total"`
UsersNew7d int64 `json:"users_new_7d"`
UsersDisabled int64 `json:"users_disabled"`
WorkspacesTotal int64 `json:"workspaces_total"`
RuntimesTotal int64 `json:"runtimes_total"`
RuntimesOnline int64 `json:"runtimes_online"`
Tasks24h map[string]int64 `json:"tasks_24h"`
}
func (h *Handler) AdminOverview(w http.ResponseWriter, r *http.Request) {
counts, err := h.Queries.AdminOverviewCounts(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load overview")
return
}
tasks, err := h.Queries.AdminTaskCounts24h(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load task counts")
return
}
byStatus := map[string]int64{}
for _, t := range tasks {
byStatus[t.Status] = t.Total
}
writeJSON(w, http.StatusOK, AdminOverviewResponse{
UsersTotal: counts.UsersTotal,
UsersNew7d: counts.UsersNew7d,
UsersDisabled: counts.UsersDisabled,
WorkspacesTotal: counts.WorkspacesTotal,
RuntimesTotal: counts.RuntimesTotal,
RuntimesOnline: counts.RuntimesOnline,
Tasks24h: byStatus,
})
}
// --- users ---
type AdminUserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL *string `json:"avatar_url"`
CreatedAt string `json:"created_at"`
DisabledAt *string `json:"disabled_at"`
DisabledReason string `json:"disabled_reason"`
WorkspaceCount int64 `json:"workspace_count"`
}
type AdminUserWorkspaceResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Role string `json:"role"`
JoinedAt string `json:"joined_at"`
}
// AdminUserTokenResponse deliberately carries no hash: the prefix is the only
// token material an operator ever sees.
type AdminUserTokenResponse struct {
ID string `json:"id"`
Name string `json:"name"`
TokenPrefix string `json:"token_prefix"`
CreatedAt string `json:"created_at"`
LastUsedAt *string `json:"last_used_at"`
ExpiresAt *string `json:"expires_at"`
}
type AdminUserDetailResponse struct {
AdminUserResponse
Workspaces []AdminUserWorkspaceResponse `json:"workspaces"`
Tokens []AdminUserTokenResponse `json:"tokens"`
}
func adminUserFromRow(id pgtype.UUID, name, email string, avatar pgtype.Text, createdAt, disabledAt pgtype.Timestamptz, reason string, wsCount int64) AdminUserResponse {
return AdminUserResponse{
ID: uuidToString(id),
Name: name,
Email: email,
AvatarURL: textToPtr(avatar),
CreatedAt: timestampToString(createdAt),
DisabledAt: timestampToPtr(disabledAt),
DisabledReason: reason,
WorkspaceCount: wsCount,
}
}
func (h *Handler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
limit, offset, page := adminPaging(r)
q := optionalText(r.URL.Query().Get("q"))
disabledOnly := r.URL.Query().Get("disabled") == "true"
rows, err := h.Queries.AdminListUsers(r.Context(), db.AdminListUsersParams{
Q: q, DisabledOnly: disabledOnly, PageLimit: limit, PageOffset: offset,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list users")
return
}
total, err := h.Queries.AdminCountUsers(r.Context(), db.AdminCountUsersParams{Q: q, DisabledOnly: disabledOnly})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to count users")
return
}
items := make([]AdminUserResponse, 0, len(rows))
for _, u := range rows {
items = append(items, adminUserFromRow(u.ID, u.Name, u.Email, u.AvatarUrl, u.CreatedAt, u.DisabledAt, u.DisabledReason, u.WorkspaceCount))
}
writeJSON(w, http.StatusOK, AdminPage[AdminUserResponse]{Items: items, Total: total, Page: page, PageSize: int(limit)})
}
func (h *Handler) AdminGetUser(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "user id")
if !ok {
return
}
u, err := h.Queries.AdminGetUser(r.Context(), id)
if err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "user not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
workspaces, err := h.Queries.AdminListUserWorkspaces(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load workspaces")
return
}
tokens, err := h.Queries.ListPersonalAccessTokensByUser(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load tokens")
return
}
resp := AdminUserDetailResponse{
AdminUserResponse: adminUserFromRow(u.ID, u.Name, u.Email, u.AvatarUrl, u.CreatedAt, u.DisabledAt, u.DisabledReason, u.WorkspaceCount),
Workspaces: make([]AdminUserWorkspaceResponse, 0, len(workspaces)),
Tokens: make([]AdminUserTokenResponse, 0, len(tokens)),
}
for _, ws := range workspaces {
resp.Workspaces = append(resp.Workspaces, AdminUserWorkspaceResponse{
ID: uuidToString(ws.ID), Name: ws.Name, Slug: ws.Slug, Role: ws.Role, JoinedAt: timestampToString(ws.JoinedAt),
})
}
for _, t := range tokens {
resp.Tokens = append(resp.Tokens, AdminUserTokenResponse{
ID: uuidToString(t.ID), Name: t.Name, TokenPrefix: t.TokenPrefix,
CreatedAt: timestampToString(t.CreatedAt), LastUsedAt: timestampToPtr(t.LastUsedAt), ExpiresAt: timestampToPtr(t.ExpiresAt),
})
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) AdminDisableUser(w http.ResponseWriter, r *http.Request) {
h.adminSetUserDisabled(w, r, true)
}
func (h *Handler) AdminEnableUser(w http.ResponseWriter, r *http.Request) {
h.adminSetUserDisabled(w, r, false)
}
func (h *Handler) adminSetUserDisabled(w http.ResponseWriter, r *http.Request, disable bool) {
id, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "user id")
if !ok {
return
}
var req adminReasonRequest
reason, ok := readAdminReason(w, r, &req)
if !ok {
return
}
actor, _ := PlatformAdminFromContext(r.Context())
if disable && actor.UserID == id {
writeError(w, http.StatusBadRequest, "you cannot disable your own account")
return
}
user, err := h.Queries.GetUser(r.Context(), id)
if err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "user not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
if disable {
if _, err := h.Queries.GetPlatformAdmin(r.Context(), id); err == nil {
writeError(w, http.StatusConflict, "remove the platform admin role before disabling this account")
return
} else if !isNotFound(err) {
writeError(w, http.StatusInternalServerError, "failed to check platform admin")
return
}
}
action := "user.enable"
if disable {
action = "user.disable"
}
h.runAdminAction(w, r, adminAction{
Action: action, TargetType: "user", TargetID: uuidToString(id), Reason: reason,
Payload: map[string]any{"email": user.Email},
}, func(q *db.Queries) (int, any, error) {
var n int64
var err error
if disable {
n, err = q.AdminDisableUser(r.Context(), db.AdminDisableUserParams{ID: id, DisabledReason: reason})
} else {
n, err = q.AdminEnableUser(r.Context(), id)
}
if err != nil {
return 0, nil, err
}
if n == 0 {
if disable {
return http.StatusConflict, map[string]string{"error": "user is already disabled"}, nil
}
return http.StatusConflict, map[string]string{"error": "user is not disabled"}, nil
}
// Applied after the DB write succeeds; the transaction commits right
// after the audit row, and a commit failure leaves this replica
// stricter than the database until the next refresh, never looser.
if disable {
auth.MarkUserDisabled(uuidToString(id), user.Email)
} else {
auth.MarkUserEnabled(uuidToString(id), user.Email)
}
u, err := q.AdminGetUser(r.Context(), id)
if err != nil {
return 0, nil, err
}
return http.StatusOK, adminUserFromRow(u.ID, u.Name, u.Email, u.AvatarUrl, u.CreatedAt, u.DisabledAt, u.DisabledReason, u.WorkspaceCount), nil
})
}
func (h *Handler) AdminRevokeUserToken(w http.ResponseWriter, r *http.Request) {
userID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "user id")
if !ok {
return
}
tokenID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "tokenId"), "token id")
if !ok {
return
}
var req adminReasonRequest
reason, ok := readAdminReason(w, r, &req)
if !ok {
return
}
h.runAdminAction(w, r, adminAction{
Action: "user.token.revoke", TargetType: "personal_access_token", TargetID: uuidToString(tokenID), Reason: reason,
Payload: map[string]any{"user_id": uuidToString(userID)},
}, func(q *db.Queries) (int, any, error) {
hash, err := q.RevokePersonalAccessToken(r.Context(), db.RevokePersonalAccessTokenParams{ID: tokenID, UserID: userID})
if err != nil {
if isNotFound(err) {
return http.StatusNotFound, map[string]string{"error": "token not found"}, nil
}
return 0, nil, err
}
h.PATCache.Invalidate(r.Context(), hash)
return http.StatusOK, map[string]string{"status": "revoked"}, nil
})
}
// --- workspaces ---
type AdminWorkspaceResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
CreatedAt string `json:"created_at"`
MemberCount int64 `json:"member_count"`
AgentCount int64 `json:"agent_count"`
}
type AdminWorkspaceMemberResponse struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
JoinedAt string `json:"joined_at"`
DisabledAt *string `json:"disabled_at"`
}
type AdminWorkspaceDetailResponse struct {
AdminWorkspaceResponse
Members []AdminWorkspaceMemberResponse `json:"members"`
}
func (h *Handler) AdminListWorkspaces(w http.ResponseWriter, r *http.Request) {
limit, offset, page := adminPaging(r)
q := optionalText(r.URL.Query().Get("q"))
rows, err := h.Queries.AdminListWorkspaces(r.Context(), db.AdminListWorkspacesParams{Q: q, PageLimit: limit, PageOffset: offset})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list workspaces")
return
}
total, err := h.Queries.AdminCountWorkspaces(r.Context(), q)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to count workspaces")
return
}
items := make([]AdminWorkspaceResponse, 0, len(rows))
for _, ws := range rows {
items = append(items, AdminWorkspaceResponse{
ID: uuidToString(ws.ID), Name: ws.Name, Slug: ws.Slug, CreatedAt: timestampToString(ws.CreatedAt),
MemberCount: ws.MemberCount, AgentCount: ws.AgentCount,
})
}
writeJSON(w, http.StatusOK, AdminPage[AdminWorkspaceResponse]{Items: items, Total: total, Page: page, PageSize: int(limit)})
}
func (h *Handler) AdminGetWorkspace(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
ws, err := h.Queries.AdminGetWorkspace(r.Context(), id)
if err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "workspace not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load workspace")
return
}
members, err := h.Queries.AdminListWorkspaceMembers(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load members")
return
}
resp := AdminWorkspaceDetailResponse{
AdminWorkspaceResponse: AdminWorkspaceResponse{
ID: uuidToString(ws.ID), Name: ws.Name, Slug: ws.Slug, CreatedAt: timestampToString(ws.CreatedAt),
MemberCount: ws.MemberCount, AgentCount: ws.AgentCount,
},
Members: make([]AdminWorkspaceMemberResponse, 0, len(members)),
}
for _, m := range members {
resp.Members = append(resp.Members, AdminWorkspaceMemberResponse{
UserID: uuidToString(m.UserID), Name: m.Name, Email: m.Email, Role: m.Role,
JoinedAt: timestampToString(m.JoinedAt), DisabledAt: timestampToPtr(m.DisabledAt),
})
}
writeJSON(w, http.StatusOK, resp)
}
// AdminMarketListings exposes the catalog to platform operators without
// requiring a workspace membership. It is read-only: catalog publication and
// release policy remain owned by the catalog service.
func (h *Handler) AdminMarketListings(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
pageSize, _ := strconv.Atoi(q.Get("page_size"))
result, err := h.MarketCatalog.Browse(r.Context(), marketcatalog.BrowseQuery{Text: q.Get("q"), Page: page, PageSize: pageSize})
if err != nil {
writeCatalogError(w, r, err)
return
}
writeJSON(w, http.StatusOK, listingPageResponseFrom(result))
}
func (h *Handler) AdminMarketListing(w http.ResponseWriter, r *http.Request) {
id, err := marketcatalog.ParseListingID(chi.URLParam(r, "listingId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid listing id")
return
}
listing, err := h.MarketCatalog.Listing(r.Context(), id, "")
if err != nil {
writeCatalogError(w, r, err)
return
}
writeJSON(w, http.StatusOK, listingResponseFrom(listing))
}
// AdminCreateWorkspace creates a fully initialized workspace for the platform
// admin's account. It deliberately uses the same status seeding and owner
// membership rules as the normal workspace creation flow, while keeping the
// action in the platform audit log.
func (h *Handler) AdminCreateWorkspace(w http.ResponseWriter, r *http.Request) {
actor, ok := PlatformAdminFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "platform admin access required")
return
}
var req CreateWorkspaceRequest
var reason string
var body struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description *string `json:"description"`
Context *string `json:"context"`
IssuePrefix *string `json:"issue_prefix"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
reason = strings.TrimSpace(body.Reason)
if reason == "" || len(reason) > adminReasonMaxLen {
writeError(w, http.StatusBadRequest, "reason is required and must be at most 500 characters")
return
}
req = CreateWorkspaceRequest{Name: body.Name, Slug: body.Slug, Description: body.Description, Context: body.Context, IssuePrefix: body.IssuePrefix}
req.Name = strings.TrimSpace(req.Name)
req.Slug = strings.ToLower(strings.TrimSpace(req.Slug))
if req.Name == "" || req.Slug == "" {
writeError(w, http.StatusBadRequest, "name and slug are required")
return
}
if !workspaceSlugPattern.MatchString(req.Slug) || isReservedSlug(req.Slug) {
writeError(w, http.StatusBadRequest, "slug is invalid or reserved")
return
}
issuePrefix := ""
if req.IssuePrefix != nil {
prefix, valid := normalizeIssuePrefix(*req.IssuePrefix)
if !valid {
writeError(w, http.StatusBadRequest, issuePrefixFormatError)
return
}
issuePrefix = prefix
}
if issuePrefix == "" {
issuePrefix = defaultIssuePrefixFromSlug(req.Slug)
}
h.runAdminAction(w, r, adminAction{
Action: "workspace.create", TargetType: "workspace", TargetID: req.Slug, Reason: reason,
Payload: map[string]any{"name": req.Name, "slug": req.Slug, "owner_user_id": uuidToString(actor.UserID)},
}, func(q *db.Queries) (int, any, error) {
ws, err := q.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{
Name: req.Name, Slug: req.Slug, Description: ptrToText(req.Description), Context: ptrToText(req.Context), IssuePrefix: issuePrefix,
})
if err != nil {
if isUniqueViolation(err) {
return http.StatusConflict, map[string]string{"error": "workspace slug already exists"}, nil
}
return 0, nil, err
}
if _, err := q.CreateMember(r.Context(), db.CreateMemberParams{WorkspaceID: ws.ID, UserID: actor.UserID, Role: "owner"}); err != nil {
return 0, nil, err
}
if err := issuestatus.Ensure(r.Context(), q, ws.ID); err != nil {
return 0, nil, err
}
return http.StatusCreated, h.workspaceToResponse(ws), nil
})
}
// --- audit ---
type AdminAuditEntryResponse struct {
ID string `json:"id"`
ActorUserID string `json:"actor_user_id"`
ActorEmail string `json:"actor_email"`
Action string `json:"action"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
Reason string `json:"reason"`
Payload json.RawMessage `json:"payload"`
IP string `json:"ip"`
CreatedAt string `json:"created_at"`
}
func (h *Handler) AdminListAudit(w http.ResponseWriter, r *http.Request) {
limit, offset, page := adminPaging(r)
actor, ok := optionalUUID(w, r.URL.Query().Get("actor"), "actor")
if !ok {
return
}
action := optionalText(r.URL.Query().Get("action"))
targetID := optionalText(r.URL.Query().Get("target_id"))
rows, err := h.Queries.ListAdminAuditLog(r.Context(), db.ListAdminAuditLogParams{
ActorUserID: actor, Action: action, TargetID: targetID, PageLimit: limit, PageOffset: offset,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list audit log")
return
}
total, err := h.Queries.CountAdminAuditLog(r.Context(), db.CountAdminAuditLogParams{ActorUserID: actor, Action: action, TargetID: targetID})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to count audit log")
return
}
items := make([]AdminAuditEntryResponse, 0, len(rows))
for _, e := range rows {
payload := json.RawMessage(e.Payload)
if len(payload) == 0 {
payload = json.RawMessage("{}")
}
items = append(items, AdminAuditEntryResponse{
ID: uuidToString(e.ID), ActorUserID: uuidToString(e.ActorUserID), ActorEmail: e.ActorEmail,
Action: e.Action, TargetType: e.TargetType, TargetID: e.TargetID, Reason: e.Reason,
Payload: payload, IP: e.Ip, CreatedAt: timestampToString(e.CreatedAt),
})
}
writeJSON(w, http.StatusOK, AdminPage[AdminAuditEntryResponse]{Items: items, Total: total, Page: page, PageSize: int(limit)})
}
// --- platform admins ---
type PlatformAdminResponse struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
GrantedBy *string `json:"granted_by"`
CreatedAt string `json:"created_at"`
}
func (h *Handler) AdminListPlatformAdmins(w http.ResponseWriter, r *http.Request) {
rows, err := h.Queries.ListPlatformAdmins(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list platform admins")
return
}
items := make([]PlatformAdminResponse, 0, len(rows))
for _, pa := range rows {
var grantedBy *string
if pa.GrantedBy.Valid {
s := uuidToString(pa.GrantedBy)
grantedBy = &s
}
items = append(items, PlatformAdminResponse{
UserID: uuidToString(pa.UserID), Name: pa.Name, Email: pa.Email, Role: pa.Role,
GrantedBy: grantedBy, CreatedAt: timestampToString(pa.CreatedAt),
})
}
writeJSON(w, http.StatusOK, items)
}
type setPlatformAdminRequest struct {
Email string `json:"email"`
Role string `json:"role"`
Reason string `json:"reason"`
}
func (r *setPlatformAdminRequest) reasonValue() string { return r.Reason }
// AdminSetPlatformAdmin grants or changes a platform role by email, so an
// operator never has to look up a user id to add a colleague.
func (h *Handler) AdminSetPlatformAdmin(w http.ResponseWriter, r *http.Request) {
var req setPlatformAdminRequest
reason, ok := readAdminReason(w, r, &req)
if !ok {
return
}
if _, valid := platformRoleRank[req.Role]; !valid {
writeError(w, http.StatusBadRequest, "role must be one of super_admin, operator, viewer")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
user, err := h.Queries.GetUserByEmail(r.Context(), email)
if err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "no user with that email; they must sign in once first")
return
}
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
if user.DisabledAt.Valid {
writeError(w, http.StatusConflict, "enable the account before granting a platform role")
return
}
actor, _ := PlatformAdminFromContext(r.Context())
if actor.UserID == user.ID && req.Role != PlatformRoleSuperAdmin {
writeError(w, http.StatusBadRequest, "you cannot lower your own role")
return
}
h.runAdminAction(w, r, adminAction{
Action: "platform_admin.set", TargetType: "user", TargetID: uuidToString(user.ID), Reason: reason,
Payload: map[string]any{"email": user.Email, "role": req.Role},
}, func(q *db.Queries) (int, any, error) {
pa, err := q.UpsertPlatformAdmin(r.Context(), db.UpsertPlatformAdminParams{
UserID: user.ID, Role: req.Role, GrantedBy: actor.UserID,
})
if err != nil {
return 0, nil, err
}
return http.StatusOK, PlatformAdminResponse{
UserID: uuidToString(pa.UserID), Name: user.Name, Email: user.Email, Role: pa.Role,
CreatedAt: timestampToString(pa.CreatedAt),
}, nil
})
}
func (h *Handler) AdminRemovePlatformAdmin(w http.ResponseWriter, r *http.Request) {
userID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "userId"), "user id")
if !ok {
return
}
var req adminReasonRequest
reason, ok := readAdminReason(w, r, &req)
if !ok {
return
}
actor, _ := PlatformAdminFromContext(r.Context())
if actor.UserID == userID {
writeError(w, http.StatusBadRequest, "you cannot remove your own platform role")
return
}
h.runAdminAction(w, r, adminAction{
Action: "platform_admin.remove", TargetType: "user", TargetID: uuidToString(userID), Reason: reason,
}, func(q *db.Queries) (int, any, error) {
n, err := q.DeletePlatformAdmin(r.Context(), userID)
if err != nil {
return 0, nil, err
}
if n == 0 {
return http.StatusNotFound, map[string]string{"error": "platform admin not found"}, nil
}
return http.StatusOK, map[string]string{"status": "removed"}, nil
})
}