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:
Jakub Zych
2026-09-24 19:39:08 +02:00
parent 94814d980b
commit e3e1c2546e
2 changed files with 293 additions and 9 deletions

View File

@@ -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,12 +320,125 @@ 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 {

View File

@@ -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) {