diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 771d7634..d5641c35 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -51,7 +51,7 @@ "type": "string" }, "error": { - "description": "Error is true when the section's status could not be loaded (the\nplugin's Status failed); the other sections are answered as usual.\nAbsent otherwise.", + "description": "Error is true when the section's status or one of its actions could\nnot be loaded (the plugin's Status or an action's Applies failed; that\naction is left out); the other sections are answered as usual. Absent\notherwise.", "type": "boolean" }, "label": { diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 801496e8..d425aa22 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -5522,9 +5522,10 @@ export interface components { actions: components["schemas"]["cabana.AccountContributionActionView"][]; description: string; /** - * @description Error is true when the section's status could not be loaded (the - * plugin's Status failed); the other sections are answered as usual. - * Absent otherwise. + * @description Error is true when the section's status or one of its actions could + * not be loaded (the plugin's Status or an action's Applies failed; that + * action is left out); the other sections are answered as usual. Absent + * otherwise. */ error?: boolean; label: string; diff --git a/docs/backend/users-and-permissions.md b/docs/backend/users-and-permissions.md index 7af02fa9..f235f28c 100644 --- a/docs/backend/users-and-permissions.md +++ b/docs/backend/users-and-permissions.md @@ -147,7 +147,7 @@ Every signed-in administrator can edit their own profile on the My account scree - **Preferences:** the theme (system, light or dark) and the admin language an administrator picks in the SPA are saved through `PUT /api/v1/account/preferences` and read with `GET /api/v1/account/preferences`; `GET .../auth/me` returns them as `preferences`. The body may hold only `theme` and `locale` (`cabana.AdminPreferencesRequest`); any other key, including `id`, answers 422 on that key and nothing is written. Each present key is saved and an absent key keeps its value, so `{}` answers the current preferences. `theme` must be `system`, `light` or `dark`; `locale` must be `""` (follow the request locale) or one of the admin locales `GET /lang` lists in `meta.locales`; another value or a non-string answers 422 on that field. They are stored under `admin_theme` and `admin_locale` in `backend_users.metadata`, next to the subtitle; the defaults (`system`, `""`) are stored as absent keys, every other key survives a save, and metadata that is not a JSON object is never overwritten (422). Like the rest of My account the routes act only on the token's own administrator and refuse personal API tokens. - **API tokens:** a card below the profile form (never inside it — the page Save does not touch tokens) lists, creates and revokes only the signed-in administrator's own tokens. Creating asks for a name, an expiry (`30d`, `90d`, `365d` or `never`) and scopes from `GET /account/tokens/scopes`; the plaintext is shown once in the dialog for copying, then cleared. Revoke asks for confirmation. A "Show revoked tokens" toggle reloads the list with revoked rows. See [API tokens](#api-tokens) for the wire contract and scopes. -- **Plugin sections:** a plugin can add its own card below the API tokens, for something only the signed-in administrator does for themselves, such as linking their site account. The plugin implements `pact.HasAccountContributions` and returns `pact.AccountContribution` entries: a name, a label and description, an optional status line computed for the administrator, and buttons (`pact.AccountAction`) with an optional confirmation (danger variant included), extra permissions and an `Applies` rule. The SPA loads them from `GET /api/v1/account/contributions` and runs a button through `POST /api/v1/account/contributions/{name}/actions/{action}`, which calls the action's `Run` with the signed-in administrator's id inside a transaction (`cabana.TxFromContext`) and shows the returned message; a returned URL is opened in a new tab and shown as a link. Only an absolute `http`/`https` URL or a same-origin path is passed on; the admin drops anything else. A section whose status fails to load is still listed, marked `error: true`, and My account shows it with an error line; the other sections are unaffected. Both routes refuse personal API tokens like the rest of My account, and a page without contributing plugins is unchanged. +- **Plugin sections:** a plugin can add its own card below the API tokens, for something only the signed-in administrator does for themselves, such as linking their site account. The plugin implements `pact.HasAccountContributions` and returns `pact.AccountContribution` entries: a name, a label and description, an optional status line computed for the administrator, and buttons (`pact.AccountAction`) with an optional confirmation (danger variant included), extra permissions and an `Applies` rule. The SPA loads them from `GET /api/v1/account/contributions` and runs a button through `POST /api/v1/account/contributions/{name}/actions/{action}`, which calls the action's `Run` with the signed-in administrator's id inside a transaction (`cabana.TxFromContext`) and shows the returned message; a returned URL is opened in a new tab and shown as a link. Only an absolute `http`/`https` URL or a same-origin path is passed on; the admin drops anything else. A section whose status fails to load is still listed, marked `error: true`, and My account shows it with an error line; an action whose `Applies` fails is left out and marks its section the same way, so a missing button is explained. The other sections are unaffected. Both routes refuse personal API tokens like the rest of My account, and a page without contributing plugins is unchanged. Plugins that show an administrator, such as an author box, read the subtitle with `cabana.BackendUser.Subtitle` and the picture with `cabana.BackendUserAvatar`, which returns the avatar file (or nil) for an administrator id. diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 49a94b22..e6b1024c 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -59,7 +59,7 @@ Use cases: - Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). Administrators are activated, deactivated and restored by record and bulk actions and deleted softly through `deleted_at` (never hard-deleted); the list shows deleted and deactivated rows with their row states and filters deleted ones, and the preview shows a status hint. Deactivating or deleting an administrator ends their sessions and stops their personal API tokens; the last active superuser cannot be demoted, deactivated or deleted on any of these paths (a transaction-scoped advisory lock serializes the check). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin. - Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant. - Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, `backend::lang.form.action_prompt_invalid` for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed. -- My account contributions: a plugin implementing `pact.HasAccountContributions` adds sections (`pact.AccountContribution`) to the administrator's own account page, each with a status line and buttons (`pact.AccountAction`). GET `/account/contributions` lists them for the signed-in administrator and POST `/account/contributions/{name}/actions/{action}` runs one for that administrator only, behind the interactive-only guard, the action's permissions and its `Applies` rule, inside a transaction; the answer may carry a URL the admin opens in a new tab. That URL is kept only when it is an absolute `http`/`https` URL with a host or a same-origin path (one leading `/`); anything else, `javascript:` and `data:` included, is dropped and logged without the URL. A section whose `Status` fails is listed with an empty status and `error: true`, and an action whose `Applies` fails is left out; both are logged and the other sections are answered as usual. A status is translated only when it is a phrase key. Names are generic: cabana never names the contributing plugin. The existing `/account*` and `/auth/me` payloads are unchanged. A non-identifier or duplicate section name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot. +- My account contributions: a plugin implementing `pact.HasAccountContributions` adds sections (`pact.AccountContribution`) to the administrator's own account page, each with a status line and buttons (`pact.AccountAction`). GET `/account/contributions` lists them for the signed-in administrator and POST `/account/contributions/{name}/actions/{action}` runs one for that administrator only, behind the interactive-only guard, the action's permissions and its `Applies` rule, inside a transaction; the answer may carry a URL the admin opens in a new tab. That URL is kept only when it is an absolute `http`/`https` URL with a host or a same-origin path (one leading `/`); anything else, `javascript:` and `data:` included, is dropped and logged without the URL. A section whose `Status` fails is listed with an empty status and `error: true`, and an action whose `Applies` fails is left out and marks its section `error: true` as well; both are logged and the other sections are answered as usual. A status is translated only when it is a phrase key. Names are generic: cabana never names the contributing plugin. The existing `/account*` and `/auth/me` payloads are unchanged. A non-identifier or duplicate section name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot. - Backend subject events: a save on the Administrators screen that changed an administrator's role, permission overrides or superuser flag, a Roles save that changed a role's grants (one event per administrator holding it), a deactivation or deletion (record and bulk forms) and a revoked personal API token fire `pact.MCPSubjectChanged` on the application's event bus with guard `backend`, so a module acting on behalf of an administrator elsewhere (the MCP endpoint) re-lists what they may do or ends their sessions. A revoked token's event carries its `TokenID`, so only the sessions opened with that token end; the administrator's other sessions stay open. A listener's error is logged and never fails the save. - Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code. - Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full. @@ -87,7 +87,7 @@ Guarded routes run surf's `locale.from-principal` after the backend guard: an in | GET `/account/tokens`, POST `/account/tokens` | List the caller's personal API tokens (newest first; revoked only with `?revoked=1`) and create one (`cabana.AdminAPITokenRequest`: `name`, `expires`, `scopes`). Create answers 201 with `cabana.AdminAPITokenCreated` (plaintext `token` once). Interactive session only. | | DELETE `/account/tokens/{id}` | Soft-revoke one of the caller's own active tokens (`cabana.AdminAPIToken`); 404 otherwise. Interactive session only. | | GET `/account/tokens/scopes` | Scopes the caller may assign (`cabana.AdminAPITokenScope`), including `*`, `admin:read` and overlapping plugin scopes. Interactive session only. | -| GET `/account/contributions` | The sections plugins contribute to My account (`cabana.AccountContributionView`): translated label, description and status for the signed-in administrator, and the actions (`cabana.AccountContributionActionView`) they hold the permissions for and whose `Applies` reports true. A section whose status failed carries `error: true`. An empty list when no plugin contributes. Interactive session only. | +| GET `/account/contributions` | The sections plugins contribute to My account (`cabana.AccountContributionView`): translated label, description and status for the signed-in administrator, and the actions (`cabana.AccountContributionActionView`) they hold the permissions for and whose `Applies` reports true. A section whose status or one of whose actions' `Applies` failed carries `error: true`. An empty list when no plugin contributes. Interactive session only. | | POST `/account/contributions/{name}/actions/{action}` | Run one contributed action for the signed-in administrator: 404 for an unknown section or action, 403 without the action's permissions, 409 when it no longer applies; `Run` gets the administrator's id inside a transaction and answers `cabana.AccountActionResultView` (translated `message`, optional `url`, only an `http`/`https` or same-origin URL). Interactive session only, AJAX header required. | | GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open and, for a navigation entry with a `Visible` func, that it answers true for the request; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. | | GET `/field-types` | The plugin field types (`pact.HasFormFieldTypes`) sorted by name, each with its `element`, `translatable` flag and the versioned URLs of its `scripts` and `styles` (`cabana.FieldTypesResponse`); an empty list when no plugin registers one. The SPA loads them after sign-in. Needs any backend session. | diff --git a/modules/cabana/account_contributions.go b/modules/cabana/account_contributions.go index 383c199d..9cc4dd65 100644 --- a/modules/cabana/account_contributions.go +++ b/modules/cabana/account_contributions.go @@ -92,8 +92,9 @@ func compileAccountContributions(reg *Registry, plugins []party.Plugin) error { // contributed section with its status for the signed-in administrator and // the actions they may run that apply to them, labels translated. A // section whose Status fails is sent with Error set and no status, and an -// action whose Applies fails is left out, both logged: one plugin's error -// never answers 500 for the list. +// action whose Applies fails is left out and sets Error on its section (the +// admin sees why a button is missing), both logged: one plugin's error never +// answers 500 for the list. func (s *service) accountContributionsList(w http.ResponseWriter, r *http.Request) { principal := accountPrincipal(w, r) if principal == nil { @@ -128,9 +129,10 @@ func (s *service) accountContributionsList(w http.ResponseWriter, r *http.Reques if action.Applies != nil { applies, err := action.Applies(ctx, principal.ID) if err != nil { - // The action is left out; the section and the - // other sections are still answered. + // The action is left out and the section says so + // (RB-IN-02); the other sections are still answered. moduleLog.ErrorContext(ctx, "cabana: account contribution applies failed", "section", item.section.Name, "action", action.Name, "plugin", item.pluginID, "error", err) + view.Error = true continue } if !applies { diff --git a/modules/cabana/account_contributions_hardening_test.go b/modules/cabana/account_contributions_hardening_test.go index a3b42108..8e91d1d9 100644 --- a/modules/cabana/account_contributions_hardening_test.go +++ b/modules/cabana/account_contributions_hardening_test.go @@ -186,3 +186,48 @@ func TestAccountContributionsIsolation(t *testing.T) { t.Fatalf("a dynamic status text was looked up as a phrase key: %s", logged) } } + +// TestAccountContributionsAppliesFailureFlagsSection pins RB-IN-02: an +// action whose Applies fails is left out and marks its section error: true +// even though the section's Status succeeded, so My account shows the +// section error line instead of a silently missing button. A section whose +// actions all answer is not marked. +func TestAccountContributionsAppliesFailureFlagsSection(t *testing.T) { + run := func(context.Context, pact.AccountActionInput) (pact.AccountActionResult, error) { + return pact.AccountActionResult{Message: "Done"}, nil + } + linked := func(context.Context, uint) (string, error) { return "Linked", nil } + plugin := sectionPlugin{id: "acme.gamma", sections: []pact.AccountContribution{ + { + Name: "gamma", Label: "Gamma", Status: linked, + Actions: []pact.AccountAction{ + {Name: "keep", Label: "Keep", Run: run}, + {Name: "unlink", Label: "Unlink", Run: run, Applies: func(context.Context, uint) (bool, error) { + return false, errors.New("gamma: applies failed") + }}, + }, + }, + { + Name: "delta", Label: "Delta", Status: linked, + Actions: []pact.AccountAction{{Name: "hidden", Label: "Hidden", Run: run, Applies: func(context.Context, uint) (bool, error) { + return false, nil + }}}, + }, + }} + h, err := assembleWithoutDB(t, plugin) + if err != nil { + t.Fatal(err) + } + rec := asAdmin(h, http.MethodGet, "/account/contributions", &bouncer.Principal{ID: 42, Backend: true}, nil) + var out contributionViews + if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil || len(out.Data) != 2 { + t.Fatalf("list = %d %s", rec.Code, rec.Body.String()) + } + gamma, delta := out.Data[0], out.Data[1] + if !gamma.Error || gamma.Status != "Linked" || len(gamma.Actions) != 1 || gamma.Actions[0].Name != "keep" { + t.Fatalf("section with a failing Applies = %+v, want error with its status and the other action kept", gamma) + } + if delta.Error || len(delta.Actions) != 0 { + t.Fatalf("section whose Applies answered false = %+v, want no error and no action", delta) + } +} diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index 049b2198..43ab3de9 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -341,9 +341,10 @@ type AccountContributionView struct { // section has none or its status could not be loaded. Status string `json:"status"` Actions []AccountContributionActionView `json:"actions"` - // Error is true when the section's status could not be loaded (the - // plugin's Status failed); the other sections are answered as usual. - // Absent otherwise. + // Error is true when the section's status or one of its actions could + // not be loaded (the plugin's Status or an action's Applies failed; that + // action is left out); the other sections are answered as usual. Absent + // otherwise. Error bool `json:"error,omitempty"` }