feat(12.1-01): cabana.ForbiddenError answers a refused write with 403

- hooks and bulk, record, toolbar and widget actions may return it
- 403 forbidden with the localized message and field details; the write's
  transaction is rolled back; other errors stay the opaque 500
- form shows a refused save as a persistent banner and keeps the values;
  a refused delete is a toast
- smoke tests, OpenAPI notes, dist, README, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:53:34 +02:00
parent 61d5fc72ad
commit 71073bc8a2
23 changed files with 486 additions and 48 deletions

View File

@@ -65,6 +65,72 @@ type ValidationError struct {
func (e *ValidationError) Error() string { return "validation_failed" }
// ForbiddenError is a write that controller code refuses (D-27): a lifecycle
// hook, a bulk action, a record action, a toolbar action or a widget action
// returns it, and the admin API answers 403 with code forbidden. Message is a
// phrase key or text shown to the administrator; it may be empty, and the
// admin then shows its own text. Details maps a field name to a list of
// messages (phrase keys or text) shown on that field. Both are localized in
// the request locale before the response is written. The surrounding
// transaction is rolled back, so a refused write changes nothing.
type ForbiddenError struct {
Message string
Details map[string]any
}
func (e *ForbiddenError) Error() string { return "forbidden" }
// localizeForbidden returns err with a *ForbiddenError's Message and Details
// strings translated in the request locale; any other error, and a nil one,
// is returned unchanged. The plugin's error value is never modified: it may
// be a shared variable.
func localizeForbidden(ctx context.Context, tr *phrasebook.Translator, err error) error {
var refused *ForbiddenError
if err == nil || !errors.As(err, &refused) || refused == nil {
return err
}
if ctx == nil {
ctx = context.Background()
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
out := &ForbiddenError{Message: translateKey(ctx, tr, refused.Message)}
if len(refused.Details) > 0 {
out.Details = make(map[string]any, len(refused.Details))
for field, value := range refused.Details {
switch messages := value.(type) {
case string:
out.Details[field] = []string{translateKey(ctx, tr, messages)}
case []string:
list := make([]string, len(messages))
for i, text := range messages {
list[i] = translateKey(ctx, tr, text)
}
out.Details[field] = list
case []any:
list := make([]any, len(messages))
for i, item := range messages {
if text, ok := item.(string); ok {
list[i] = translateKey(ctx, tr, text)
} else {
list[i] = item
}
}
out.Details[field] = list
default:
out.Details[field] = value
}
}
}
return out
}
// transaction runs fn in lagoon.Transaction on the service's database and
// localizes a *ForbiddenError that comes out of it, so a refusal leaves the
// service ready to be written.
func (s CRUDService) transaction(ctx context.Context, fn func(ctx context.Context, tx *gorm.DB) error) error {
return localizeForbidden(ctx, s.tr, lagoon.Transaction(ctx, s.DB, fn))
}
// CapabilityError is a fail-closed Fill/Validate failure with controller context.
type CapabilityError struct {
ControllerID string
@@ -172,7 +238,7 @@ func (s CRUDService) Delete(ctx context.Context, cc *CompiledController, id any)
return BulkResult{}, err
}
var result BulkResult
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err := s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
target, err := newWritableModel(cc)
if err != nil {
@@ -220,7 +286,7 @@ func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in
return BulkResult{}, err
}
var result BulkResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
if err := ctx.Err(); err != nil {
return lifecycleFailure(cc, err)
@@ -285,7 +351,7 @@ func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, nam
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
var result BulkActionResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
if err := ctx.Err(); err != nil {
return lifecycleFailure(cc, err)
@@ -430,7 +496,7 @@ func (s CRUDService) RecordAction(ctx context.Context, cc *CompiledController, i
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
result := AdminActionResult{Fill: map[string]any{}}
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err := s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
target, err := newWritableModel(cc)
if err != nil {
@@ -503,7 +569,7 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
return RecordResult{}, err
}
var result RecordResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
target, err := newWritableModel(cc)
if err != nil {
@@ -608,6 +674,13 @@ func writeCRUDError(w http.ResponseWriter, err error) {
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", ve.Details)
return
}
// Controller code refused the write (D-27): only the plugin-authored
// message and details are written, never another error's text.
var refused *ForbiddenError
if errors.As(err, &refused) && refused != nil {
WriteErrorDetails(w, http.StatusForbidden, "forbidden", refused.Message, refused.Details)
return
}
var missing recordNotFound
if errors.As(err, &missing) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
@@ -700,6 +773,11 @@ func lifecycleFailure(cc *CompiledController, err error) error {
if errors.As(err, &invalid) {
return err
}
// Without this a hook's refusal would become the opaque lifecycle error.
var refused *ForbiddenError
if errors.As(err, &refused) {
return err
}
var closed *CapabilityError
if errors.As(err, &closed) {
return err