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:
203
cabana/crud.go
203
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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) {
|
||||
@@ -177,10 +245,24 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
return &ValidationError{Details: validationDetails(msgs)}
|
||||
}
|
||||
if update {
|
||||
if err := formBeforeUpdate(ctx, cc, target); err != nil {
|
||||
return err
|
||||
}
|
||||
err = tx.Save(target).Error
|
||||
} else {
|
||||
if err := formBeforeCreate(ctx, cc, target); err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -238,13 +320,126 @@ func controllerID(cc *CompiledController) string {
|
||||
|
||||
func loadRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, dest any, pk any) error {
|
||||
col := primaryColumn(dest)
|
||||
q := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
err := q.Where(clause.Eq{Column: clause.Column{Name: col}, Value: pk}).Take(dest).Error
|
||||
q := tx.WithContext(ctx)
|
||||
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) {
|
||||
return recordNotFound{}
|
||||
}
|
||||
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 {
|
||||
projected := ProjectWritableFields(cc, body)
|
||||
|
||||
@@ -126,6 +126,14 @@ func (s *service) mount(r pact.Router) {
|
||||
constrainController(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}", s.list)
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user