feat(09-05): enforce scoped record lifecycle on admin routes
- Mount show, create, update, and delete behind the backend permission check - Run controller and model hooks once per operation and roll back on failure - Treat missing and out-of-scope records the same, including idempotent delete
This commit is contained in:
205
cabana/crud.go
205
cabana/crud.go
@@ -126,9 +126,77 @@ func (s CRUDService) Update(ctx context.Context, cc *CompiledController, id any,
|
|||||||
return s.save(ctx, cc, id, in, true)
|
return s.save(ctx, cc, id, in, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes one scoped record. The lifecycle is added with the record routes.
|
// Delete removes one scoped record. A missing or out-of-scope id is a successful no-op.
|
||||||
func (s CRUDService) Delete(ctx context.Context, cc *CompiledController, id any) (BulkResult, error) {
|
func (s CRUDService) Delete(ctx context.Context, cc *CompiledController, id any) (BulkResult, error) {
|
||||||
return BulkResult{}, errors.New("cabana: delete is not implemented")
|
if s.DB == nil {
|
||||||
|
return BulkResult{}, errors.New("cabana: database is not configured")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if _, err := newWritableModel(cc); err != nil {
|
||||||
|
return BulkResult{}, err
|
||||||
|
}
|
||||||
|
var result BulkResult
|
||||||
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
tx = tx.WithContext(ctx)
|
||||||
|
target, err := newWritableModel(cc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pk, err := coercePK(target, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
|
||||||
|
var missing recordNotFound
|
||||||
|
if errors.As(err, &missing) {
|
||||||
|
result.Deleted = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := deleteRecord(ctx, tx, cc, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.Deleted = 1
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BulkResult{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loads one scoped record. Missing and out-of-scope ids are identical.
|
||||||
|
func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (map[string]any, error) {
|
||||||
|
if s.DB == nil {
|
||||||
|
return nil, errors.New("cabana: database is not configured")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
var result map[string]any
|
||||||
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
tx = tx.WithContext(ctx)
|
||||||
|
target, err := newWritableModel(cc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pk, err := coercePK(target, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result = projectRecord(cc, target)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, in RecordInput, update bool) (map[string]any, error) {
|
func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, in RecordInput, update bool) (map[string]any, error) {
|
||||||
@@ -177,10 +245,24 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
|||||||
return &ValidationError{Details: validationDetails(msgs)}
|
return &ValidationError{Details: validationDetails(msgs)}
|
||||||
}
|
}
|
||||||
if update {
|
if update {
|
||||||
|
if err := formBeforeUpdate(ctx, cc, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
err = tx.Save(target).Error
|
err = tx.Save(target).Error
|
||||||
} else {
|
} else {
|
||||||
|
if err := formBeforeCreate(ctx, cc, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
err = tx.Create(target).Error
|
err = tx.Create(target).Error
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
if update {
|
||||||
|
err = formAfterUpdate(ctx, cc, target)
|
||||||
|
} else {
|
||||||
|
err = formAfterCreate(ctx, cc, target)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -238,12 +320,125 @@ func controllerID(cc *CompiledController) string {
|
|||||||
|
|
||||||
func loadRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, dest any, pk any) error {
|
func loadRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, dest any, pk any) error {
|
||||||
col := primaryColumn(dest)
|
col := primaryColumn(dest)
|
||||||
q := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"})
|
q := tx.WithContext(ctx)
|
||||||
err := q.Where(clause.Eq{Column: clause.Column{Name: col}, Value: pk}).Take(dest).Error
|
if cc != nil {
|
||||||
|
if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil {
|
||||||
|
if next := ext.FormExtendQuery(ctx, q); next != nil {
|
||||||
|
q = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := q.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where(clause.Eq{Column: clause.Column{Name: col}, Value: pk}).
|
||||||
|
Take(dest).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return recordNotFound{}
|
return recordNotFound{}
|
||||||
}
|
}
|
||||||
return err
|
if err != nil {
|
||||||
|
return lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type lifecycleError struct {
|
||||||
|
ControllerID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *lifecycleError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return "cabana: lifecycle failed"
|
||||||
|
}
|
||||||
|
return "cabana: controller " + e.ControllerID + " failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func lifecycleFailure(cc *CompiledController, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var missing recordNotFound
|
||||||
|
if errors.As(err, &missing) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var invalid *ValidationError
|
||||||
|
if errors.As(err, &invalid) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var closed *CapabilityError
|
||||||
|
if errors.As(err, &closed) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return &lifecycleError{ControllerID: controllerID(cc)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formBeforeCreate(ctx context.Context, cc *CompiledController, model any) error {
|
||||||
|
hook, ok := cc.Controller.(pact.FormBeforeCreate)
|
||||||
|
if !ok || hook == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return lifecycleFailure(cc, hook.FormBeforeCreate(ctx, model))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formAfterCreate(ctx context.Context, cc *CompiledController, model any) error {
|
||||||
|
hook, ok := cc.Controller.(pact.FormAfterCreate)
|
||||||
|
if !ok || hook == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return lifecycleFailure(cc, hook.FormAfterCreate(ctx, model))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formBeforeUpdate(ctx context.Context, cc *CompiledController, model any) error {
|
||||||
|
hook, ok := cc.Controller.(pact.FormBeforeUpdate)
|
||||||
|
if !ok || hook == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return lifecycleFailure(cc, hook.FormBeforeUpdate(ctx, model))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formAfterUpdate(ctx context.Context, cc *CompiledController, model any) error {
|
||||||
|
hook, ok := cc.Controller.(pact.FormAfterUpdate)
|
||||||
|
if !ok || hook == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return lifecycleFailure(cc, hook.FormAfterUpdate(ctx, model))
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) error {
|
||||||
|
if hook, ok := cc.Controller.(pact.FormBeforeDelete); ok && hook != nil {
|
||||||
|
if err := lifecycleFailure(cc, hook.FormBeforeDelete(ctx, model)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.WithContext(ctx).Delete(model).Error; err != nil {
|
||||||
|
return lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
if hook, ok := cc.Controller.(pact.FormAfterDelete); ok && hook != nil {
|
||||||
|
if err := lifecycleFailure(cc, hook.FormAfterDelete(ctx, model)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeObject(r *http.Request) (map[string]any, error) {
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
dec.UseNumber()
|
||||||
|
var body map[string]any
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
return nil, &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}}
|
||||||
|
}
|
||||||
|
if body == nil {
|
||||||
|
body = map[string]any{}
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathID(r *http.Request) (uint, error) {
|
||||||
|
raw := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
n, err := strconv.ParseUint(raw, 10, 64)
|
||||||
|
if raw == "" || err != nil {
|
||||||
|
return 0, &ValidationError{Details: map[string]any{"id": []string{"The id field must be an integer."}}}
|
||||||
|
}
|
||||||
|
return uint(n), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func projectOperation(cc *CompiledController, body map[string]any, op string) map[string]any {
|
func projectOperation(cc *CompiledController, body map[string]any, op string) map[string]any {
|
||||||
|
|||||||
@@ -126,6 +126,14 @@ func (s *service) mount(r pact.Router) {
|
|||||||
constrainController(g)
|
constrainController(g)
|
||||||
g.Get("/{vendor}/{plugin}/{controller}", s.list)
|
g.Get("/{vendor}/{plugin}/{controller}", s.list)
|
||||||
constrainController(g)
|
constrainController(g)
|
||||||
|
g.Post("/{vendor}/{plugin}/{controller}", s.create)
|
||||||
|
constrainController(g)
|
||||||
|
g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show)
|
||||||
|
constrainController(g)
|
||||||
|
g.Put("/{vendor}/{plugin}/{controller}/{id}", s.update)
|
||||||
|
constrainController(g)
|
||||||
|
g.Delete("/{vendor}/{plugin}/{controller}/{id}", s.deleteRecord)
|
||||||
|
constrainController(g)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,19 +174,100 @@ func (s *service) translator() *phrasebook.Translator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) show(w http.ResponseWriter, r *http.Request) {
|
func (s *service) show(w http.ResponseWriter, r *http.Request) {
|
||||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
s.protect(w, r, func(cc *CompiledController) {
|
||||||
|
id, err := pathID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc, err := s.crud()
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec, err := svc.Show(r.Context(), cc, id)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusOK, rec, nil)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) create(w http.ResponseWriter, r *http.Request) {
|
func (s *service) create(w http.ResponseWriter, r *http.Request) {
|
||||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
s.protect(w, r, func(cc *CompiledController) {
|
||||||
|
body, err := decodeObject(r)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc, err := s.crud()
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec, err := svc.Create(r.Context(), cc, RecordInput{Body: body})
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusCreated, rec, nil)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) update(w http.ResponseWriter, r *http.Request) {
|
func (s *service) update(w http.ResponseWriter, r *http.Request) {
|
||||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
s.protect(w, r, func(cc *CompiledController) {
|
||||||
|
id, err := pathID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := decodeObject(r)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc, err := s.crud()
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec, err := svc.Update(r.Context(), cc, id, RecordInput{Body: body})
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusOK, rec, nil)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) deleteRecord(w http.ResponseWriter, r *http.Request) {
|
func (s *service) deleteRecord(w http.ResponseWriter, r *http.Request) {
|
||||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
s.protect(w, r, func(cc *CompiledController) {
|
||||||
|
id, err := pathID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc, err := s.crud()
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := svc.Delete(r.Context(), cc, id)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusOK, result, nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) crud() (CRUDService, error) {
|
||||||
|
db, err := s.db()
|
||||||
|
if err != nil {
|
||||||
|
return CRUDService{}, err
|
||||||
|
}
|
||||||
|
return CRUDService{DB: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) list(w http.ResponseWriter, r *http.Request) {
|
func (s *service) list(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
Reference in New Issue
Block a user