diff --git a/README.md b/README.md index 67f5d2d..bae663e 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ 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 diff --git a/api/admin_api.go b/api/admin_api.go index c9c2a81..8130132 100644 --- a/api/admin_api.go +++ b/api/admin_api.go @@ -33,6 +33,14 @@ type adminCreateUserRequest struct { 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"` @@ -59,6 +67,8 @@ func (h *Handler) registerAdminRoutes() { 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) }) } @@ -168,6 +178,60 @@ func (h *Handler) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) 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 { diff --git a/auth/service.go b/auth/service.go index ef2987d..1f1ffb3 100644 --- a/auth/service.go +++ b/auth/service.go @@ -375,6 +375,93 @@ func (s *Service) DeleteUser(accessKeyID string) error { 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 { diff --git a/auth/service_admin_test.go b/auth/service_admin_test.go index c352473..bb167e8 100644 --- a/auth/service_admin_test.go +++ b/auth/service_admin_test.go @@ -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) { t.Helper()