mirror of
https://github.com/ferdzo/fs.git
synced 2026-04-05 14:26:26 +00:00
Compare commits
12 Commits
651413d494
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
| 46eb093d83 | |||
| 9abffd056e | |||
| cd7a1b4956 | |||
| cfec3afb49 | |||
| 66e3db44dc | |||
| 57951fda38 | |||
| 22cfb820f9 | |||
| 93a3aabf7d | |||
| 9b8d0b2b3e | |||
| 7a7f570882 | |||
| 828b7c7c34 | |||
| 96e3b0e042 |
@@ -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
|
||||
|
||||
13
README.md
13
README.md
@@ -34,14 +34,27 @@ 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}`
|
||||
- `PUT /_admin/v1/users/{accessKeyId}/policy`
|
||||
- `PUT /_admin/v1/users/{accessKeyId}/status`
|
||||
- `DELETE /_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`
|
||||
|
||||
Additional docs:
|
||||
- Admin OpenAPI spec: `docs/admin-api-openapi.yaml`
|
||||
- S3 compatibility matrix: `docs/s3-compatibility.md`
|
||||
|
||||
Health:
|
||||
- `GET /healthz`
|
||||
- `HEAD /healthz`
|
||||
|
||||
300
api/admin_api.go
Normal file
300
api/admin_api.go
Normal file
@@ -0,0 +1,300 @@
|
||||
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)
|
||||
}
|
||||
18
api/api.go
18
api/api.go
@@ -33,6 +33,7 @@ type Handler struct {
|
||||
logger *slog.Logger
|
||||
logConfig logging.Config
|
||||
authSvc *auth.Service
|
||||
adminAPI bool
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -47,7 +48,7 @@ const (
|
||||
serverMaxConnections = 1024
|
||||
)
|
||||
|
||||
func NewHandler(svc *service.ObjectService, logger *slog.Logger, logConfig logging.Config, authSvc *auth.Service) *Handler {
|
||||
func NewHandler(svc *service.ObjectService, logger *slog.Logger, logConfig logging.Config, authSvc *auth.Service, adminAPI bool) *Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Recoverer)
|
||||
@@ -61,6 +62,7 @@ func NewHandler(svc *service.ObjectService, logger *slog.Logger, logConfig loggi
|
||||
logger: logger,
|
||||
logConfig: logConfig,
|
||||
authSvc: authSvc,
|
||||
adminAPI: adminAPI,
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -74,6 +76,9 @@ func (h *Handler) setupRoutes() {
|
||||
h.router.Get("/metrics", h.handleMetrics)
|
||||
h.router.Head("/metrics", h.handleMetrics)
|
||||
h.router.Get("/", h.handleGetBuckets)
|
||||
if h.adminAPI {
|
||||
h.registerAdminRoutes()
|
||||
}
|
||||
|
||||
h.router.Get("/{bucket}/", h.handleGetBucket)
|
||||
h.router.Get("/{bucket}", h.handleGetBucket)
|
||||
@@ -735,8 +740,15 @@ func (h *Handler) handleGetBucket(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
|
||||
if query.Has("location") {
|
||||
xmlResponse := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">us-east-1</LocationConstraint>`
|
||||
region := "us-east-1"
|
||||
if h.authSvc != nil {
|
||||
candidate := strings.TrimSpace(h.authSvc.Config().Region)
|
||||
if candidate != "" {
|
||||
region = candidate
|
||||
}
|
||||
}
|
||||
xmlResponse := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">%s</LocationConstraint>`, region)
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(xmlResponse)))
|
||||
|
||||
@@ -5,6 +5,9 @@ import "errors"
|
||||
var (
|
||||
ErrAccessDenied = errors.New("access denied")
|
||||
ErrInvalidAccessKeyID = errors.New("invalid access key id")
|
||||
ErrUserAlreadyExists = errors.New("user already exists")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrInvalidUserInput = errors.New("invalid user input")
|
||||
ErrSignatureDoesNotMatch = errors.New("signature does not match")
|
||||
ErrAuthorizationHeaderMalformed = errors.New("authorization header malformed")
|
||||
ErrRequestTimeTooSkewed = errors.New("request time too skewed")
|
||||
|
||||
376
auth/service.go
376
auth/service.go
@@ -1,11 +1,15 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"fs/metadata"
|
||||
"fs/models"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -13,10 +17,42 @@ import (
|
||||
type Store interface {
|
||||
GetAuthIdentity(accessKeyID string) (*models.AuthIdentity, error)
|
||||
PutAuthIdentity(identity *models.AuthIdentity) error
|
||||
DeleteAuthIdentity(accessKeyID string) error
|
||||
ListAuthIdentities(limit int, after string) ([]models.AuthIdentity, string, error)
|
||||
GetAuthPolicy(accessKeyID string) (*models.AuthPolicy, error)
|
||||
PutAuthPolicy(policy *models.AuthPolicy) error
|
||||
DeleteAuthPolicy(accessKeyID string) error
|
||||
}
|
||||
|
||||
type CreateUserInput struct {
|
||||
AccessKeyID string
|
||||
SecretKey string
|
||||
Status string
|
||||
Policy models.AuthPolicy
|
||||
}
|
||||
|
||||
type UserSummary struct {
|
||||
AccessKeyID string
|
||||
Status string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
type UserDetails struct {
|
||||
AccessKeyID string
|
||||
Status string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
Policy models.AuthPolicy
|
||||
}
|
||||
|
||||
type CreateUserResult struct {
|
||||
UserDetails
|
||||
SecretKey string
|
||||
}
|
||||
|
||||
var validAccessKeyID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$`)
|
||||
|
||||
type Service struct {
|
||||
cfg Config
|
||||
store Store
|
||||
@@ -137,6 +173,19 @@ func (s *Service) AuthenticateRequest(r *http.Request) (RequestContext, error) {
|
||||
return RequestContext{}, ErrSignatureDoesNotMatch
|
||||
}
|
||||
|
||||
authType := "sigv4-header"
|
||||
if input.Presigned {
|
||||
authType = "sigv4-presign"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.URL.Path, "/_admin/") {
|
||||
return RequestContext{
|
||||
Authenticated: true,
|
||||
AccessKeyID: identity.AccessKeyID,
|
||||
AuthType: authType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
policy, err := s.store.GetAuthPolicy(identity.AccessKeyID)
|
||||
if err != nil {
|
||||
return RequestContext{}, ErrAccessDenied
|
||||
@@ -149,10 +198,6 @@ func (s *Service) AuthenticateRequest(r *http.Request) (RequestContext, error) {
|
||||
return RequestContext{}, ErrAccessDenied
|
||||
}
|
||||
|
||||
authType := "sigv4-header"
|
||||
if input.Presigned {
|
||||
authType = "sigv4-presign"
|
||||
}
|
||||
return RequestContext{
|
||||
Authenticated: true,
|
||||
AccessKeyID: identity.AccessKeyID,
|
||||
@@ -160,6 +205,261 @@ func (s *Service) AuthenticateRequest(r *http.Request) (RequestContext, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateUser(input CreateUserInput) (*CreateUserResult, error) {
|
||||
if !s.cfg.Enabled {
|
||||
return nil, ErrAuthNotEnabled
|
||||
}
|
||||
|
||||
accessKeyID := strings.TrimSpace(input.AccessKeyID)
|
||||
if !validAccessKeyID.MatchString(accessKeyID) {
|
||||
return nil, fmt.Errorf("%w: invalid access key id", ErrInvalidUserInput)
|
||||
}
|
||||
|
||||
secretKey := strings.TrimSpace(input.SecretKey)
|
||||
if secretKey == "" {
|
||||
generated, err := generateSecretKey(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secretKey = generated
|
||||
}
|
||||
if len(secretKey) < 8 {
|
||||
return nil, fmt.Errorf("%w: secret key must be at least 8 characters", ErrInvalidUserInput)
|
||||
}
|
||||
|
||||
status := normalizeUserStatus(input.Status)
|
||||
if status == "" {
|
||||
return nil, fmt.Errorf("%w: status must be active or disabled", ErrInvalidUserInput)
|
||||
}
|
||||
|
||||
policy, err := normalizePolicy(input.Policy, accessKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := s.store.GetAuthIdentity(accessKeyID)
|
||||
if err == nil && existing != nil {
|
||||
return nil, ErrUserAlreadyExists
|
||||
}
|
||||
if err != nil && !errors.Is(err, metadata.ErrAuthIdentityNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := s.now().Unix()
|
||||
ciphertext, nonce, err := encryptSecret(s.masterKey, accessKeyID, secretKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identity := &models.AuthIdentity{
|
||||
AccessKeyID: accessKeyID,
|
||||
SecretEnc: ciphertext,
|
||||
SecretNonce: nonce,
|
||||
EncAlg: "AES-256-GCM",
|
||||
KeyVersion: "v1",
|
||||
Status: status,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.store.PutAuthIdentity(identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.PutAuthPolicy(&policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &CreateUserResult{
|
||||
UserDetails: UserDetails{
|
||||
AccessKeyID: accessKeyID,
|
||||
Status: status,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Policy: policy,
|
||||
},
|
||||
SecretKey: secretKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListUsers(limit int, cursor string) ([]UserSummary, string, error) {
|
||||
if !s.cfg.Enabled {
|
||||
return nil, "", ErrAuthNotEnabled
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
identities, nextCursor, err := s.store.ListAuthIdentities(limit, cursor)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
users := make([]UserSummary, 0, len(identities))
|
||||
for _, identity := range identities {
|
||||
users = append(users, UserSummary{
|
||||
AccessKeyID: identity.AccessKeyID,
|
||||
Status: normalizeUserStatus(identity.Status),
|
||||
CreatedAt: identity.CreatedAt,
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return users, nextCursor, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetUser(accessKeyID string) (*UserDetails, error) {
|
||||
if !s.cfg.Enabled {
|
||||
return nil, ErrAuthNotEnabled
|
||||
}
|
||||
accessKeyID = strings.TrimSpace(accessKeyID)
|
||||
if accessKeyID == "" {
|
||||
return nil, fmt.Errorf("%w: access key id is required", ErrInvalidUserInput)
|
||||
}
|
||||
|
||||
identity, err := s.store.GetAuthIdentity(accessKeyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, metadata.ErrAuthIdentityNotFound) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
policy, err := s.store.GetAuthPolicy(accessKeyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, metadata.ErrAuthPolicyNotFound) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserDetails{
|
||||
AccessKeyID: identity.AccessKeyID,
|
||||
Status: normalizeUserStatus(identity.Status),
|
||||
CreatedAt: identity.CreatedAt,
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
Policy: *policy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteUser(accessKeyID string) error {
|
||||
if !s.cfg.Enabled {
|
||||
return ErrAuthNotEnabled
|
||||
}
|
||||
accessKeyID = strings.TrimSpace(accessKeyID)
|
||||
if !validAccessKeyID.MatchString(accessKeyID) {
|
||||
return fmt.Errorf("%w: invalid access key id", ErrInvalidUserInput)
|
||||
}
|
||||
|
||||
bootstrap := strings.TrimSpace(s.cfg.BootstrapAccessKey)
|
||||
if bootstrap != "" && accessKeyID == bootstrap {
|
||||
return fmt.Errorf("%w: bootstrap user cannot be deleted", ErrInvalidUserInput)
|
||||
}
|
||||
|
||||
if _, err := s.store.GetAuthIdentity(accessKeyID); err != nil {
|
||||
if errors.Is(err, metadata.ErrAuthIdentityNotFound) {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.store.DeleteAuthIdentity(accessKeyID); err != nil {
|
||||
if errors.Is(err, metadata.ErrAuthIdentityNotFound) {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.store.DeleteAuthPolicy(accessKeyID); err != nil && !errors.Is(err, metadata.ErrAuthPolicyNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) SetUserPolicy(accessKeyID string, policy models.AuthPolicy) (*UserDetails, error) {
|
||||
if !s.cfg.Enabled {
|
||||
return nil, ErrAuthNotEnabled
|
||||
}
|
||||
accessKeyID = strings.TrimSpace(accessKeyID)
|
||||
if !validAccessKeyID.MatchString(accessKeyID) {
|
||||
return nil, fmt.Errorf("%w: invalid access key id", ErrInvalidUserInput)
|
||||
}
|
||||
|
||||
identity, err := s.store.GetAuthIdentity(accessKeyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, metadata.ErrAuthIdentityNotFound) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalizedPolicy, err := normalizePolicy(policy, accessKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.PutAuthPolicy(&normalizedPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identity.UpdatedAt = s.now().Unix()
|
||||
if err := s.store.PutAuthIdentity(identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserDetails{
|
||||
AccessKeyID: identity.AccessKeyID,
|
||||
Status: normalizeUserStatus(identity.Status),
|
||||
CreatedAt: identity.CreatedAt,
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
Policy: normalizedPolicy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetUserStatus(accessKeyID, status string) (*UserDetails, error) {
|
||||
if !s.cfg.Enabled {
|
||||
return nil, ErrAuthNotEnabled
|
||||
}
|
||||
accessKeyID = strings.TrimSpace(accessKeyID)
|
||||
if !validAccessKeyID.MatchString(accessKeyID) {
|
||||
return nil, fmt.Errorf("%w: invalid access key id", ErrInvalidUserInput)
|
||||
}
|
||||
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
return nil, fmt.Errorf("%w: status is required", ErrInvalidUserInput)
|
||||
}
|
||||
normalizedStatus := normalizeUserStatus(status)
|
||||
if normalizedStatus == "" {
|
||||
return nil, fmt.Errorf("%w: status must be active or disabled", ErrInvalidUserInput)
|
||||
}
|
||||
|
||||
identity, err := s.store.GetAuthIdentity(accessKeyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, metadata.ErrAuthIdentityNotFound) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
identity.Status = normalizedStatus
|
||||
identity.UpdatedAt = s.now().Unix()
|
||||
if err := s.store.PutAuthIdentity(identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
policy, err := s.store.GetAuthPolicy(accessKeyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, metadata.ErrAuthPolicyNotFound) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserDetails{
|
||||
AccessKeyID: identity.AccessKeyID,
|
||||
Status: normalizeUserStatus(identity.Status),
|
||||
CreatedAt: identity.CreatedAt,
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
Policy: *policy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parsePolicyJSON(raw string) (*models.AuthPolicy, error) {
|
||||
policy := models.AuthPolicy{}
|
||||
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
|
||||
@@ -184,3 +484,71 @@ func defaultBootstrapPolicy(principal string) *models.AuthPolicy {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func generateSecretKey(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
length = 32
|
||||
}
|
||||
buf := make([]byte, length)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func normalizeUserStatus(raw string) string {
|
||||
status := strings.ToLower(strings.TrimSpace(raw))
|
||||
if status == "" {
|
||||
return "active"
|
||||
}
|
||||
if status != "active" && status != "disabled" {
|
||||
return ""
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func normalizePolicy(policy models.AuthPolicy, principal string) (models.AuthPolicy, error) {
|
||||
if len(policy.Statements) == 0 {
|
||||
return models.AuthPolicy{}, fmt.Errorf("%w: at least one policy statement is required", ErrInvalidUserInput)
|
||||
}
|
||||
|
||||
out := models.AuthPolicy{
|
||||
Principal: principal,
|
||||
Statements: make([]models.AuthPolicyStatement, 0, len(policy.Statements)),
|
||||
}
|
||||
for _, stmt := range policy.Statements {
|
||||
effect := strings.ToLower(strings.TrimSpace(stmt.Effect))
|
||||
if effect != "allow" && effect != "deny" {
|
||||
return models.AuthPolicy{}, fmt.Errorf("%w: invalid policy effect %q", ErrInvalidUserInput, stmt.Effect)
|
||||
}
|
||||
|
||||
actions := make([]string, 0, len(stmt.Actions))
|
||||
for _, action := range stmt.Actions {
|
||||
action = strings.TrimSpace(action)
|
||||
if action == "" {
|
||||
continue
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
if len(actions) == 0 {
|
||||
return models.AuthPolicy{}, fmt.Errorf("%w: policy statement must include at least one action", ErrInvalidUserInput)
|
||||
}
|
||||
|
||||
bucket := strings.TrimSpace(stmt.Bucket)
|
||||
if bucket == "" {
|
||||
bucket = "*"
|
||||
}
|
||||
prefix := strings.TrimSpace(stmt.Prefix)
|
||||
if prefix == "" {
|
||||
prefix = "*"
|
||||
}
|
||||
|
||||
out.Statements = append(out.Statements, models.AuthPolicyStatement{
|
||||
Effect: effect,
|
||||
Actions: actions,
|
||||
Bucket: bucket,
|
||||
Prefix: prefix,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
223
auth/service_admin_test.go
Normal file
223
auth/service_admin_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fs/metadata"
|
||||
"fs/models"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminCreateListGetUser(t *testing.T) {
|
||||
meta, svc := newTestAuthService(t)
|
||||
|
||||
created, err := svc.CreateUser(CreateUserInput{
|
||||
AccessKeyID: "backup-user",
|
||||
Policy: models.AuthPolicy{
|
||||
Statements: []models.AuthPolicyStatement{
|
||||
{
|
||||
Effect: "allow",
|
||||
Actions: []string{"s3:GetObject"},
|
||||
Bucket: "backup-bucket",
|
||||
Prefix: "restic/",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser returned error: %v", err)
|
||||
}
|
||||
if created.SecretKey == "" {
|
||||
t.Fatalf("CreateUser should return generated secret")
|
||||
}
|
||||
if created.AccessKeyID != "backup-user" {
|
||||
t.Fatalf("CreateUser access key mismatch: got %q", created.AccessKeyID)
|
||||
}
|
||||
if created.Policy.Principal != "backup-user" {
|
||||
t.Fatalf("policy principal mismatch: got %q", created.Policy.Principal)
|
||||
}
|
||||
|
||||
users, nextCursor, err := svc.ListUsers(100, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers returned error: %v", err)
|
||||
}
|
||||
if nextCursor != "" {
|
||||
t.Fatalf("unexpected next cursor: %q", nextCursor)
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("ListUsers returned %d users, want 1", len(users))
|
||||
}
|
||||
if users[0].AccessKeyID != "backup-user" {
|
||||
t.Fatalf("ListUsers returned wrong user: %q", users[0].AccessKeyID)
|
||||
}
|
||||
|
||||
got, err := svc.GetUser("backup-user")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser returned error: %v", err)
|
||||
}
|
||||
if got.AccessKeyID != "backup-user" {
|
||||
t.Fatalf("GetUser access key mismatch: got %q", got.AccessKeyID)
|
||||
}
|
||||
if got.Policy.Principal != "backup-user" {
|
||||
t.Fatalf("GetUser policy principal mismatch: got %q", got.Policy.Principal)
|
||||
}
|
||||
if len(got.Policy.Statements) != 1 {
|
||||
t.Fatalf("GetUser policy statement count = %d, want 1", len(got.Policy.Statements))
|
||||
}
|
||||
|
||||
_ = meta
|
||||
}
|
||||
|
||||
func TestCreateUserDuplicateFails(t *testing.T) {
|
||||
_, svc := newTestAuthService(t)
|
||||
|
||||
input := CreateUserInput{
|
||||
AccessKeyID: "duplicate-user",
|
||||
SecretKey: "super-secret-1",
|
||||
Policy: models.AuthPolicy{
|
||||
Statements: []models.AuthPolicyStatement{
|
||||
{Effect: "allow", Actions: []string{"s3:*"}, Bucket: "*", Prefix: "*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := svc.CreateUser(input); err != nil {
|
||||
t.Fatalf("first CreateUser returned error: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateUser(input); !errors.Is(err, ErrUserAlreadyExists) {
|
||||
t.Fatalf("second CreateUser error = %v, want ErrUserAlreadyExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserRejectsInvalidAccessKey(t *testing.T) {
|
||||
_, svc := newTestAuthService(t)
|
||||
|
||||
_, err := svc.CreateUser(CreateUserInput{
|
||||
AccessKeyID: "x",
|
||||
SecretKey: "super-secret-1",
|
||||
Policy: models.AuthPolicy{
|
||||
Statements: []models.AuthPolicyStatement{
|
||||
{Effect: "allow", Actions: []string{"s3:*"}, Bucket: "*", Prefix: "*"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidUserInput) {
|
||||
t.Fatalf("CreateUser error = %v, want ErrInvalidUserInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
_, svc := newTestAuthService(t)
|
||||
|
||||
_, err := svc.CreateUser(CreateUserInput{
|
||||
AccessKeyID: "delete-user",
|
||||
SecretKey: "super-secret-1",
|
||||
Policy: models.AuthPolicy{
|
||||
Statements: []models.AuthPolicyStatement{
|
||||
{Effect: "allow", Actions: []string{"s3:*"}, Bucket: "*", Prefix: "*"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser returned error: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.DeleteUser("delete-user"); err != nil {
|
||||
t.Fatalf("DeleteUser returned error: %v", err)
|
||||
}
|
||||
if _, err := svc.GetUser("delete-user"); !errors.Is(err, ErrUserNotFound) {
|
||||
t.Fatalf("GetUser after delete error = %v, want ErrUserNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBootstrapUserRejected(t *testing.T) {
|
||||
_, svc := newTestAuthService(t)
|
||||
|
||||
if err := svc.DeleteUser("root-user"); !errors.Is(err, ErrInvalidUserInput) {
|
||||
t.Fatalf("DeleteUser bootstrap error = %v, want ErrInvalidUserInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserPolicy(t *testing.T) {
|
||||
_, svc := newTestAuthService(t)
|
||||
|
||||
_, err := svc.CreateUser(CreateUserInput{
|
||||
AccessKeyID: "policy-user",
|
||||
SecretKey: "super-secret-1",
|
||||
Policy: models.AuthPolicy{
|
||||
Statements: []models.AuthPolicyStatement{
|
||||
{Effect: "allow", Actions: []string{"s3:GetObject"}, Bucket: "b1", Prefix: "*"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser returned error: %v", err)
|
||||
}
|
||||
|
||||
updated, err := svc.SetUserPolicy("policy-user", models.AuthPolicy{
|
||||
Statements: []models.AuthPolicyStatement{
|
||||
{Effect: "allow", Actions: []string{"s3:PutObject"}, Bucket: "b2", Prefix: "p/"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetUserPolicy returned error: %v", err)
|
||||
}
|
||||
if len(updated.Policy.Statements) != 1 || updated.Policy.Statements[0].Actions[0] != "s3:PutObject" {
|
||||
t.Fatalf("SetUserPolicy did not apply new policy: %+v", updated.Policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserStatus(t *testing.T) {
|
||||
_, svc := newTestAuthService(t)
|
||||
|
||||
_, err := svc.CreateUser(CreateUserInput{
|
||||
AccessKeyID: "status-user",
|
||||
SecretKey: "super-secret-1",
|
||||
Policy: models.AuthPolicy{
|
||||
Statements: []models.AuthPolicyStatement{
|
||||
{Effect: "allow", Actions: []string{"s3:*"}, Bucket: "*", Prefix: "*"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser returned error: %v", err)
|
||||
}
|
||||
|
||||
updated, err := svc.SetUserStatus("status-user", "disabled")
|
||||
if err != nil {
|
||||
t.Fatalf("SetUserStatus returned error: %v", err)
|
||||
}
|
||||
if updated.Status != "disabled" {
|
||||
t.Fatalf("SetUserStatus status = %q, want disabled", updated.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestAuthService(t *testing.T) (*metadata.MetadataHandler, *Service) {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "metadata.db")
|
||||
meta, err := metadata.NewMetadataHandler(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMetadataHandler returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = meta.Close()
|
||||
})
|
||||
|
||||
masterKey := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
cfg := ConfigFromValues(
|
||||
true,
|
||||
"us-east-1",
|
||||
0,
|
||||
0,
|
||||
masterKey,
|
||||
"root-user",
|
||||
"root-secret-123",
|
||||
"",
|
||||
)
|
||||
svc, err := NewService(cfg, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService returned error: %v", err)
|
||||
}
|
||||
return meta, svc
|
||||
}
|
||||
336
docs/admin-api-openapi.yaml
Normal file
336
docs/admin-api-openapi.yaml
Normal file
@@ -0,0 +1,336 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: fs Admin API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
JSON admin API for managing local users and policies.
|
||||
|
||||
Notes:
|
||||
- Base path is `/_admin/v1`.
|
||||
- Requests must be AWS SigV4 signed.
|
||||
- Only the bootstrap access key is authorized for admin endpoints.
|
||||
servers:
|
||||
- url: http://localhost:2600
|
||||
description: Local development
|
||||
security:
|
||||
- AwsSigV4: []
|
||||
paths:
|
||||
/_admin/v1/users:
|
||||
post:
|
||||
summary: Create user
|
||||
operationId: createUser
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateUserRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: User created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/InvalidRequest'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'409':
|
||||
$ref: '#/components/responses/UserAlreadyExists'
|
||||
'503':
|
||||
$ref: '#/components/responses/AuthDisabled'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
get:
|
||||
summary: List users
|
||||
operationId: listUsers
|
||||
parameters:
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
default: 100
|
||||
- name: cursor
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: User summaries
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserListResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/InvalidRequest'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'503':
|
||||
$ref: '#/components/responses/AuthDisabled'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
|
||||
/_admin/v1/users/{accessKeyId}:
|
||||
get:
|
||||
summary: Get user with policy
|
||||
operationId: getUser
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AccessKeyId'
|
||||
responses:
|
||||
'200':
|
||||
description: User details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/UserNotFound'
|
||||
'503':
|
||||
$ref: '#/components/responses/AuthDisabled'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
delete:
|
||||
summary: Delete user
|
||||
operationId: deleteUser
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AccessKeyId'
|
||||
responses:
|
||||
'204':
|
||||
description: User deleted
|
||||
'400':
|
||||
$ref: '#/components/responses/InvalidRequest'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/UserNotFound'
|
||||
'503':
|
||||
$ref: '#/components/responses/AuthDisabled'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
|
||||
/_admin/v1/users/{accessKeyId}/policy:
|
||||
put:
|
||||
summary: Replace user policy
|
||||
operationId: setUserPolicy
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AccessKeyId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SetPolicyRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: User details with updated policy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/InvalidRequest'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/UserNotFound'
|
||||
'503':
|
||||
$ref: '#/components/responses/AuthDisabled'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
|
||||
/_admin/v1/users/{accessKeyId}/status:
|
||||
put:
|
||||
summary: Set user status
|
||||
operationId: setUserStatus
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AccessKeyId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SetStatusRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: User details with updated status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/InvalidRequest'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/UserNotFound'
|
||||
'503':
|
||||
$ref: '#/components/responses/AuthDisabled'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
AwsSigV4:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: Authorization
|
||||
description: |
|
||||
AWS Signature Version 4 headers are required (`Authorization`, `x-amz-date`,
|
||||
and for payload-signed requests `x-amz-content-sha256`).
|
||||
Only bootstrap credential is authorized for admin endpoints.
|
||||
parameters:
|
||||
AccessKeyId:
|
||||
name: accessKeyId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: User access key ID
|
||||
responses:
|
||||
InvalidRequest:
|
||||
description: Invalid request input
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminError'
|
||||
Forbidden:
|
||||
description: Authenticated but not allowed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminError'
|
||||
UserAlreadyExists:
|
||||
description: User already exists
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminError'
|
||||
UserNotFound:
|
||||
description: User not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminError'
|
||||
AuthDisabled:
|
||||
description: Authentication subsystem disabled
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminError'
|
||||
InternalError:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminError'
|
||||
schemas:
|
||||
AdminError:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
requestId:
|
||||
type: string
|
||||
required: [code, message]
|
||||
PolicyStatement:
|
||||
type: object
|
||||
properties:
|
||||
effect:
|
||||
type: string
|
||||
enum: [allow, deny]
|
||||
actions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minItems: 1
|
||||
bucket:
|
||||
type: string
|
||||
default: "*"
|
||||
prefix:
|
||||
type: string
|
||||
default: "*"
|
||||
required: [effect, actions]
|
||||
Policy:
|
||||
type: object
|
||||
properties:
|
||||
principal:
|
||||
type: string
|
||||
description: Server-managed; overwritten with target access key ID.
|
||||
statements:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/PolicyStatement'
|
||||
minItems: 1
|
||||
required: [statements]
|
||||
CreateUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
accessKeyId:
|
||||
type: string
|
||||
secretKey:
|
||||
type: string
|
||||
description: If omitted, server generates one.
|
||||
status:
|
||||
type: string
|
||||
enum: [active, disabled]
|
||||
default: active
|
||||
policy:
|
||||
$ref: '#/components/schemas/Policy'
|
||||
required: [accessKeyId, policy]
|
||||
SetPolicyRequest:
|
||||
type: object
|
||||
properties:
|
||||
policy:
|
||||
$ref: '#/components/schemas/Policy'
|
||||
required: [policy]
|
||||
SetStatusRequest:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [active, disabled]
|
||||
required: [status]
|
||||
UserListItem:
|
||||
type: object
|
||||
properties:
|
||||
accessKeyId:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [active, disabled]
|
||||
createdAt:
|
||||
type: integer
|
||||
format: int64
|
||||
updatedAt:
|
||||
type: integer
|
||||
format: int64
|
||||
required: [accessKeyId, status, createdAt, updatedAt]
|
||||
UserListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UserListItem'
|
||||
nextCursor:
|
||||
type: string
|
||||
required: [items]
|
||||
UserResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/UserListItem'
|
||||
- type: object
|
||||
properties:
|
||||
policy:
|
||||
$ref: '#/components/schemas/Policy'
|
||||
secretKey:
|
||||
type: string
|
||||
description: Returned only on create.
|
||||
53
docs/s3-compatibility.md
Normal file
53
docs/s3-compatibility.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# S3 Compatibility Matrix
|
||||
|
||||
This project is S3-compatible for a focused subset of operations.
|
||||
|
||||
## Implemented
|
||||
|
||||
### Service and account
|
||||
- `GET /` list buckets
|
||||
|
||||
### Bucket
|
||||
- `PUT /{bucket}` create bucket
|
||||
- `HEAD /{bucket}` head bucket
|
||||
- `DELETE /{bucket}` delete bucket (must be empty)
|
||||
- `GET /{bucket}?list-type=2...` list objects v2
|
||||
- `GET /{bucket}?location` get bucket location
|
||||
- `POST /{bucket}?delete` delete multiple objects
|
||||
|
||||
### Object
|
||||
- `PUT /{bucket}/{key}` put object
|
||||
- `GET /{bucket}/{key}` get object
|
||||
- `HEAD /{bucket}/{key}` head object
|
||||
- `DELETE /{bucket}/{key}` delete object
|
||||
- `GET /{bucket}/{key}` supports single-range requests
|
||||
|
||||
### Multipart upload
|
||||
- `POST /{bucket}/{key}?uploads` initiate
|
||||
- `PUT /{bucket}/{key}?uploadId=...&partNumber=N` upload part
|
||||
- `GET /{bucket}/{key}?uploadId=...` list parts
|
||||
- `POST /{bucket}/{key}?uploadId=...` complete
|
||||
- `DELETE /{bucket}/{key}?uploadId=...` abort
|
||||
|
||||
### Authentication
|
||||
- AWS SigV4 header auth
|
||||
- AWS SigV4 presigned query auth
|
||||
- `aws-chunked` payload decode for streaming uploads
|
||||
|
||||
## Partially Implemented / Differences
|
||||
- Exact parity with AWS S3 error codes/headers is still evolving.
|
||||
- Some S3 edge-case behaviors may differ (especially uncommon query/header combinations).
|
||||
- Admin API is custom JSON (`/_admin/v1/*`).
|
||||
|
||||
## Not Implemented (Current)
|
||||
- Bucket versioning
|
||||
- Lifecycle rules
|
||||
- Replication
|
||||
- Object lock / legal hold / retention
|
||||
- SSE-S3 / SSE-KMS / SSE-C
|
||||
- ACL APIs and IAM-compatible policy APIs
|
||||
- STS / temporary credentials
|
||||
- Event notifications
|
||||
- Tagging APIs
|
||||
- CORS APIs
|
||||
- Website hosting APIs
|
||||
3
main.go
3
main.go
@@ -39,6 +39,7 @@ func main() {
|
||||
"multipart_retention_hours", int(config.MultipartCleanupRetention/time.Hour),
|
||||
"auth_enabled", authConfig.Enabled,
|
||||
"auth_region", authConfig.Region,
|
||||
"admin_api_enabled", config.AdminAPIEnabled,
|
||||
)
|
||||
|
||||
if err := os.MkdirAll(config.DataPath, 0o755); err != nil {
|
||||
@@ -72,7 +73,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
handler := api.NewHandler(objectService, logger, logConfig, authService)
|
||||
handler := api.NewHandler(objectService, logger, logConfig, authService, config.AdminAPIEnabled)
|
||||
addr := config.Address + ":" + strconv.Itoa(config.Port)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
@@ -154,6 +154,23 @@ func (h *MetadataHandler) PutAuthIdentity(identity *models.AuthIdentity) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MetadataHandler) DeleteAuthIdentity(accessKeyID string) error {
|
||||
accessKeyID = strings.TrimSpace(accessKeyID)
|
||||
if accessKeyID == "" {
|
||||
return errors.New("access key id is required")
|
||||
}
|
||||
return h.update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(authIdentitiesIndex)
|
||||
if bucket == nil {
|
||||
return errors.New("auth identities index not found")
|
||||
}
|
||||
if bucket.Get([]byte(accessKeyID)) == nil {
|
||||
return fmt.Errorf("%w: %s", ErrAuthIdentityNotFound, accessKeyID)
|
||||
}
|
||||
return bucket.Delete([]byte(accessKeyID))
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MetadataHandler) GetAuthIdentity(accessKeyID string) (*models.AuthIdentity, error) {
|
||||
accessKeyID = strings.TrimSpace(accessKeyID)
|
||||
if accessKeyID == "" {
|
||||
@@ -205,6 +222,23 @@ func (h *MetadataHandler) PutAuthPolicy(policy *models.AuthPolicy) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MetadataHandler) DeleteAuthPolicy(accessKeyID string) error {
|
||||
accessKeyID = strings.TrimSpace(accessKeyID)
|
||||
if accessKeyID == "" {
|
||||
return errors.New("access key id is required")
|
||||
}
|
||||
return h.update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(authPoliciesIndex)
|
||||
if bucket == nil {
|
||||
return errors.New("auth policies index not found")
|
||||
}
|
||||
if bucket.Get([]byte(accessKeyID)) == nil {
|
||||
return fmt.Errorf("%w: %s", ErrAuthPolicyNotFound, accessKeyID)
|
||||
}
|
||||
return bucket.Delete([]byte(accessKeyID))
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MetadataHandler) GetAuthPolicy(accessKeyID string) (*models.AuthPolicy, error) {
|
||||
accessKeyID = strings.TrimSpace(accessKeyID)
|
||||
if accessKeyID == "" {
|
||||
@@ -234,6 +268,53 @@ func (h *MetadataHandler) GetAuthPolicy(accessKeyID string) (*models.AuthPolicy,
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (h *MetadataHandler) ListAuthIdentities(limit int, after string) ([]models.AuthIdentity, string, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
after = strings.TrimSpace(after)
|
||||
|
||||
identities := make([]models.AuthIdentity, 0, limit)
|
||||
nextCursor := ""
|
||||
|
||||
err := h.view(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(authIdentitiesIndex)
|
||||
if bucket == nil {
|
||||
return errors.New("auth identities index not found")
|
||||
}
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
var k, v []byte
|
||||
if after == "" {
|
||||
k, v = cursor.First()
|
||||
} else {
|
||||
k, v = cursor.Seek([]byte(after))
|
||||
if k != nil && string(k) == after {
|
||||
k, v = cursor.Next()
|
||||
}
|
||||
}
|
||||
|
||||
count := 0
|
||||
for ; k != nil; k, v = cursor.Next() {
|
||||
if count >= limit {
|
||||
nextCursor = string(k)
|
||||
break
|
||||
}
|
||||
record := models.AuthIdentity{}
|
||||
if err := json.Unmarshal(v, &record); err != nil {
|
||||
return err
|
||||
}
|
||||
identities = append(identities, record)
|
||||
count++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return identities, nextCursor, nil
|
||||
}
|
||||
|
||||
func (h *MetadataHandler) CreateBucket(bucketName string) error {
|
||||
if !isValidBucketName(bucketName) {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidBucketName, bucketName)
|
||||
|
||||
@@ -763,8 +763,8 @@ func trimFloat(v float64) string {
|
||||
|
||||
func escapeLabelValue(value string) string {
|
||||
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||
value = strings.ReplaceAll(value, "\n", `\\n`)
|
||||
value = strings.ReplaceAll(value, `"`, `\\"`)
|
||||
value = strings.ReplaceAll(value, "\n", `\n`)
|
||||
value = strings.ReplaceAll(value, `"`, `\"`)
|
||||
return value
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ type Config struct {
|
||||
AuthBootstrapAccessKey string
|
||||
AuthBootstrapSecretKey string
|
||||
AuthBootstrapPolicy string
|
||||
AdminAPIEnabled bool
|
||||
}
|
||||
|
||||
func NewConfig() *Config {
|
||||
@@ -47,7 +48,7 @@ func NewConfig() *Config {
|
||||
MultipartCleanupRetention: time.Duration(
|
||||
envIntRange("MULTIPART_RETENTION_HOURS", 24, 1, 24*30),
|
||||
) * time.Hour,
|
||||
AuthEnabled: envBool("AUTH_ENABLED", true),
|
||||
AuthEnabled: envBool("AUTH_ENABLED", false),
|
||||
AuthRegion: firstNonEmpty(strings.TrimSpace(os.Getenv("AUTH_REGION")), "us-east-1"),
|
||||
AuthSkew: time.Duration(envIntRange("AUTH_SKEW_SECONDS", 300, 30, 3600)) * time.Second,
|
||||
AuthMaxPresign: time.Duration(envIntRange("AUTH_MAX_PRESIGN_SECONDS", 86400, 60, 86400)) * time.Second,
|
||||
@@ -55,6 +56,7 @@ func NewConfig() *Config {
|
||||
AuthBootstrapAccessKey: strings.TrimSpace(os.Getenv("AUTH_BOOTSTRAP_ACCESS_KEY")),
|
||||
AuthBootstrapSecretKey: strings.TrimSpace(os.Getenv("AUTH_BOOTSTRAP_SECRET_KEY")),
|
||||
AuthBootstrapPolicy: strings.TrimSpace(os.Getenv("AUTH_BOOTSTRAP_POLICY")),
|
||||
AdminAPIEnabled: envBool("ADMIN_API_ENABLED", true),
|
||||
}
|
||||
|
||||
if config.LogFormat != "json" && config.LogFormat != "text" {
|
||||
|
||||
Reference in New Issue
Block a user