Files
fs/api/admin_api.go

301 lines
8.3 KiB
Go

package api
import (
"encoding/json"
"errors"
"fs/auth"
"fs/models"
"io"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
const (
maxAdminJSONBodyBytes = 1 << 20
defaultAdminPageSize = 100
maxAdminPageSize = 1000
)
type adminErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"requestId,omitempty"`
}
type adminCreateUserRequest struct {
AccessKeyID string `json:"accessKeyId"`
SecretKey string `json:"secretKey,omitempty"`
Status string `json:"status,omitempty"`
Policy models.AuthPolicy `json:"policy"`
}
type adminSetPolicyRequest struct {
Policy models.AuthPolicy `json:"policy"`
}
type adminSetStatusRequest struct {
Status string `json:"status"`
}
type adminUserListItem struct {
AccessKeyID string `json:"accessKeyId"`
Status string `json:"status"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
type adminUserListResponse struct {
Items []adminUserListItem `json:"items"`
NextCursor string `json:"nextCursor,omitempty"`
}
type adminUserResponse struct {
AccessKeyID string `json:"accessKeyId"`
Status string `json:"status"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
Policy *models.AuthPolicy `json:"policy,omitempty"`
SecretKey string `json:"secretKey,omitempty"`
}
func (h *Handler) registerAdminRoutes() {
h.router.Route("/_admin/v1", func(r chi.Router) {
r.Post("/users", h.handleAdminCreateUser)
r.Get("/users", h.handleAdminListUsers)
r.Get("/users/{accessKeyId}", h.handleAdminGetUser)
r.Put("/users/{accessKeyId}/policy", h.handleAdminSetUserPolicy)
r.Put("/users/{accessKeyId}/status", h.handleAdminSetUserStatus)
r.Delete("/users/{accessKeyId}", h.handleAdminDeleteUser)
})
}
func (h *Handler) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
if !h.requireBootstrapAdmin(w, r) {
return
}
var req adminCreateUserRequest
if err := decodeJSONBody(w, r, &req); err != nil {
writeAdminError(w, r, http.StatusBadRequest, "InvalidRequest", err.Error())
return
}
created, err := h.authSvc.CreateUser(auth.CreateUserInput{
AccessKeyID: req.AccessKeyID,
SecretKey: req.SecretKey,
Status: req.Status,
Policy: req.Policy,
})
if err != nil {
writeMappedAdminError(w, r, err)
return
}
resp := adminUserResponse{
AccessKeyID: created.AccessKeyID,
Status: created.Status,
CreatedAt: created.CreatedAt,
UpdatedAt: created.UpdatedAt,
Policy: &created.Policy,
SecretKey: created.SecretKey,
}
writeJSON(w, http.StatusCreated, resp)
}
func (h *Handler) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
if !h.requireBootstrapAdmin(w, r) {
return
}
limit := defaultAdminPageSize
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 || parsed > maxAdminPageSize {
writeAdminError(w, r, http.StatusBadRequest, "InvalidRequest", "limit must be between 1 and 1000")
return
}
limit = parsed
}
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
users, nextCursor, err := h.authSvc.ListUsers(limit, cursor)
if err != nil {
writeMappedAdminError(w, r, err)
return
}
items := make([]adminUserListItem, 0, len(users))
for _, user := range users {
items = append(items, adminUserListItem{
AccessKeyID: user.AccessKeyID,
Status: user.Status,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
})
}
writeJSON(w, http.StatusOK, adminUserListResponse{
Items: items,
NextCursor: nextCursor,
})
}
func (h *Handler) handleAdminGetUser(w http.ResponseWriter, r *http.Request) {
if !h.requireBootstrapAdmin(w, r) {
return
}
accessKeyID := chi.URLParam(r, "accessKeyId")
user, err := h.authSvc.GetUser(accessKeyID)
if err != nil {
writeMappedAdminError(w, r, err)
return
}
resp := adminUserResponse{
AccessKeyID: user.AccessKeyID,
Status: user.Status,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
Policy: &user.Policy,
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
if !h.requireBootstrapAdmin(w, r) {
return
}
accessKeyID := chi.URLParam(r, "accessKeyId")
if err := h.authSvc.DeleteUser(accessKeyID); err != nil {
writeMappedAdminError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) handleAdminSetUserPolicy(w http.ResponseWriter, r *http.Request) {
if !h.requireBootstrapAdmin(w, r) {
return
}
accessKeyID := chi.URLParam(r, "accessKeyId")
var req adminSetPolicyRequest
if err := decodeJSONBody(w, r, &req); err != nil {
writeAdminError(w, r, http.StatusBadRequest, "InvalidRequest", err.Error())
return
}
user, err := h.authSvc.SetUserPolicy(accessKeyID, req.Policy)
if err != nil {
writeMappedAdminError(w, r, err)
return
}
resp := adminUserResponse{
AccessKeyID: user.AccessKeyID,
Status: user.Status,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
Policy: &user.Policy,
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) handleAdminSetUserStatus(w http.ResponseWriter, r *http.Request) {
if !h.requireBootstrapAdmin(w, r) {
return
}
accessKeyID := chi.URLParam(r, "accessKeyId")
var req adminSetStatusRequest
if err := decodeJSONBody(w, r, &req); err != nil {
writeAdminError(w, r, http.StatusBadRequest, "InvalidRequest", err.Error())
return
}
user, err := h.authSvc.SetUserStatus(accessKeyID, req.Status)
if err != nil {
writeMappedAdminError(w, r, err)
return
}
resp := adminUserResponse{
AccessKeyID: user.AccessKeyID,
Status: user.Status,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
Policy: &user.Policy,
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) requireBootstrapAdmin(w http.ResponseWriter, r *http.Request) bool {
authCtx, ok := auth.GetRequestContext(r.Context())
if !ok || !authCtx.Authenticated {
writeAdminError(w, r, http.StatusForbidden, "Forbidden", "admin credentials are required")
return false
}
if h.authSvc == nil {
writeAdminError(w, r, http.StatusForbidden, "Forbidden", "admin access is not configured")
return false
}
bootstrap := strings.TrimSpace(h.authSvc.Config().BootstrapAccessKey)
if bootstrap == "" || authCtx.AccessKeyID != bootstrap {
writeAdminError(w, r, http.StatusForbidden, "Forbidden", "admin access denied")
return false
}
return true
}
func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, maxAdminJSONBodyBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return errors.New("request body must contain a single JSON object")
}
return nil
}
func writeMappedAdminError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, auth.ErrInvalidUserInput):
writeAdminError(w, r, http.StatusBadRequest, "InvalidRequest", err.Error())
case errors.Is(err, auth.ErrUserAlreadyExists):
writeAdminError(w, r, http.StatusConflict, "UserAlreadyExists", "user already exists")
case errors.Is(err, auth.ErrUserNotFound):
writeAdminError(w, r, http.StatusNotFound, "UserNotFound", "user was not found")
case errors.Is(err, auth.ErrAuthNotEnabled):
writeAdminError(w, r, http.StatusServiceUnavailable, "AuthDisabled", "authentication subsystem is disabled")
default:
writeAdminError(w, r, http.StatusInternalServerError, "InternalError", "internal server error")
}
}
func writeAdminError(w http.ResponseWriter, r *http.Request, status int, code string, message string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
requestID := middleware.GetReqID(r.Context())
if requestID != "" {
w.Header().Set("x-amz-request-id", requestID)
}
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(adminErrorResponse{
Code: code,
Message: message,
RequestID: requestID,
})
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}