mirror of
https://github.com/ferdzo/fs.git
synced 2026-04-05 01:36:25 +00:00
add admin endpoints for user policy and status updates
This commit is contained in:
@@ -38,6 +38,8 @@ Admin API (JSON):
|
|||||||
- `POST /_admin/v1/users`
|
- `POST /_admin/v1/users`
|
||||||
- `GET /_admin/v1/users`
|
- `GET /_admin/v1/users`
|
||||||
- `GET /_admin/v1/users/{accessKeyId}`
|
- `GET /_admin/v1/users/{accessKeyId}`
|
||||||
|
- `PUT /_admin/v1/users/{accessKeyId}/policy`
|
||||||
|
- `PUT /_admin/v1/users/{accessKeyId}/status`
|
||||||
- `DELETE /_admin/v1/users/{accessKeyId}`
|
- `DELETE /_admin/v1/users/{accessKeyId}`
|
||||||
|
|
||||||
## Auth Setup
|
## Auth Setup
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ type adminCreateUserRequest struct {
|
|||||||
Policy models.AuthPolicy `json:"policy"`
|
Policy models.AuthPolicy `json:"policy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adminSetPolicyRequest struct {
|
||||||
|
Policy models.AuthPolicy `json:"policy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSetStatusRequest struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
type adminUserListItem struct {
|
type adminUserListItem struct {
|
||||||
AccessKeyID string `json:"accessKeyId"`
|
AccessKeyID string `json:"accessKeyId"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -59,6 +67,8 @@ func (h *Handler) registerAdminRoutes() {
|
|||||||
r.Post("/users", h.handleAdminCreateUser)
|
r.Post("/users", h.handleAdminCreateUser)
|
||||||
r.Get("/users", h.handleAdminListUsers)
|
r.Get("/users", h.handleAdminListUsers)
|
||||||
r.Get("/users/{accessKeyId}", h.handleAdminGetUser)
|
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)
|
r.Delete("/users/{accessKeyId}", h.handleAdminDeleteUser)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -168,6 +178,60 @@ func (h *Handler) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request)
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
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 {
|
func (h *Handler) requireBootstrapAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||||
authCtx, ok := auth.GetRequestContext(r.Context())
|
authCtx, ok := auth.GetRequestContext(r.Context())
|
||||||
if !ok || !authCtx.Authenticated {
|
if !ok || !authCtx.Authenticated {
|
||||||
|
|||||||
@@ -375,6 +375,93 @@ func (s *Service) DeleteUser(accessKeyID string) error {
|
|||||||
return nil
|
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) {
|
func parsePolicyJSON(raw string) (*models.AuthPolicy, error) {
|
||||||
policy := models.AuthPolicy{}
|
policy := models.AuthPolicy{}
|
||||||
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
|
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
|
||||||
|
|||||||
@@ -138,6 +138,60 @@ func TestDeleteBootstrapUserRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func newTestAuthService(t *testing.T) (*metadata.MetadataHandler, *Service) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user