diff --git a/README.md b/README.md index aa3cc36..67f5d2d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Admin API (JSON): - `POST /_admin/v1/users` - `GET /_admin/v1/users` - `GET /_admin/v1/users/{accessKeyId}` +- `DELETE /_admin/v1/users/{accessKeyId}` ## Auth Setup diff --git a/api/admin_api.go b/api/admin_api.go index 9946791..c9c2a81 100644 --- a/api/admin_api.go +++ b/api/admin_api.go @@ -59,6 +59,7 @@ func (h *Handler) registerAdminRoutes() { r.Post("/users", h.handleAdminCreateUser) r.Get("/users", h.handleAdminListUsers) r.Get("/users/{accessKeyId}", h.handleAdminGetUser) + r.Delete("/users/{accessKeyId}", h.handleAdminDeleteUser) }) } @@ -155,6 +156,18 @@ func (h *Handler) handleAdminGetUser(w http.ResponseWriter, r *http.Request) { 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) 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 6ea1b44..ef2987d 100644 --- a/auth/service.go +++ b/auth/service.go @@ -17,9 +17,11 @@ 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 { @@ -339,6 +341,40 @@ func (s *Service) GetUser(accessKeyID string) (*UserDetails, error) { }, 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 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 c190e11..c352473 100644 --- a/auth/service_admin_test.go +++ b/auth/service_admin_test.go @@ -106,6 +106,38 @@ func TestCreateUserRejectsInvalidAccessKey(t *testing.T) { } } +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 newTestAuthService(t *testing.T) (*metadata.MetadataHandler, *Service) { t.Helper() diff --git a/metadata/metadata.go b/metadata/metadata.go index 452f125..ed84c55 100644 --- a/metadata/metadata.go +++ b/metadata/metadata.go @@ -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 == "" {