mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
879 lines
29 KiB
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
|
|
})
|
|
}
|