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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user