diff --git a/.env.example b/.env.example index c79e887..a4dcab2 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,4 @@ AUTH_MASTER_KEY=REPLACE_WITH_SECURE_RANDOM_KEY AUTH_BOOTSTRAP_ACCESS_KEY= AUTH_BOOTSTRAP_SECRET_KEY= AUTH_BOOTSTRAP_POLICY= +ADMIN_API_ENABLED=true diff --git a/README.md b/README.md index 1e57191..aa3cc36 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,17 @@ Authentication: - Local credential/policy store in bbolt - Bootstrap access key/secret via environment variables +Admin API (JSON): +- `POST /_admin/v1/users` +- `GET /_admin/v1/users` +- `GET /_admin/v1/users/{accessKeyId}` + ## Auth Setup Required when `AUTH_ENABLED=true`: - `AUTH_MASTER_KEY` must be base64 for 32 decoded bytes (AES-256 key), e.g. `openssl rand -base64 32` - `AUTH_BOOTSTRAP_ACCESS_KEY` and `AUTH_BOOTSTRAP_SECRET_KEY` define initial credentials +- `ADMIN_API_ENABLED=true` enables `/_admin/v1/*` routes (bootstrap key only) Reference: `auth/README.md` diff --git a/api/admin_api.go b/api/admin_api.go new file mode 100644 index 0000000..9946791 --- /dev/null +++ b/api/admin_api.go @@ -0,0 +1,223 @@ +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 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) + }) +} + +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) 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) +} diff --git a/api/api.go b/api/api.go index 824b502..94b11c0 100644 --- a/api/api.go +++ b/api/api.go @@ -98,9 +98,6 @@ func (h *Handler) setupRoutes() { h.router.Delete("/{bucket}/*", h.handleDeleteObject) } -func (h *Handler) registerAdminRoutes() { -} - func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { if _, err := h.svc.ListBuckets(); err != nil { w.Header().Set("Content-Type", "text/plain; charset=utf-8")