docs: describe the Users admin screen, its permissions and the privileged-member rule

This commit is contained in:
Jakub Zych
2026-10-05 13:26:53 +02:00
parent c7d9d7d25d
commit 5805c6347f

View File

@@ -12,7 +12,7 @@ Module `git.golem15.com/golem15/sm-user-plugin`, package `user`, plugin ID `gole
The plugin is a compiled SummerCMS plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`.
It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_api_tokens`, `golem15_user_organisations`, `user_groups` and `users_groups`, and the JSON API under `/_user/api/v1`. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs and config keys are a contract: change them only in a backwards-compatible way.
It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_api_tokens`, `golem15_user_organisations`, `user_groups`, `users_groups` and `golem15_user_frontend_permissions`, the JSON API under `/_user/api/v1`, and the Users screen of the admin. It is a core plugin shared by several applications, so its routes, payloads, table names, migration IDs, permission codes and config keys are a contract: change them only in a backwards-compatible way.
## Features
@@ -24,9 +24,13 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
- **Rate limiting.** Every route runs behind the `user-api` bucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls.
- **Profile.** `POST /update`, `POST /marketing-consent`, `POST /avatar` and `POST /avatar/remove` (the avatar is stored through the framework's attachment storage).
- **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins.
- **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No route of this plugin reads or writes groups, and the user payload still answers `"groups":[]`.
- **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No user API route reads or writes groups, and the user payload still answers `"groups":[]`; the admin Users list can filter by group.
- **Admin screens.** The plugin registers the Users screen with the framework's admin (`pact.HasAdminControllers`, controller ID `golem15.user.users`), the port of the WinterCMS `Users` controller: a list with search, the group, registration date and activated filters and row states (deactivated, banned, not activated); a read-only preview with a status hint; and a form with name, email, a password with confirmation, an optional invitation mail, the organisation, the avatar and the user's frontend permissions. Bulk actions activate, deactivate, restore, ban and unban users; the preview offers activate, unban and unsuspend while they apply. Deactivating is the soft delete; the delete button and the bulk delete remove a user for good, together with the throttle rows, group memberships and attachments. A user created in the admin starts not activated. The User Groups and Organisations screens follow in the same release. There is no impersonation and no guest account.
- **Privileged group members.** A user who belongs to a group whose code is listed in `golem15.user.privileged_groups` (default `admin`) is protected against takeover: changing that user's email or password, and deleting them for good, needs the backend permission `golem15.users.manage_privileged_groups` on top of `golem15.users.access_users`. Without it the request answers 403 and nothing is saved; a bulk delete that contains such a user deletes none of the selected users. Everything else on such a user (name, organisation, avatar, frontend permissions, activate, deactivate, restore, ban, unban, unsuspend) needs only `golem15.users.access_users`. `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs` and `IsPrivilegedMember` expose the rule to other code.
- **Frontend permissions.** `golem15_user_frontend_permissions` is the catalogue of permissions a site defines for its users (rows come from the application's migrations or seeds). A user's own choices are stored in `users.permissions` and a group's in `user_groups.permissions`, as a JSON object of code to `1` (allow) or `-1` (deny). `MergedPermissions` combines them as PHP `getMergedPermissions` does: among a user's groups the most positive value wins, then the user's own value replaces the groups'. `HasPermission` and `UserHasPermission` answer whether a code is held: only at exactly `1`, with Winter's `*` wildcards. Neither value appears in the user payload, whose `permissions` key stays `null`.
- **Last seen.** `users.last_seen` is stamped by `POST /login` and `POST /refresh` through `TouchLastSeen`, at most once per five minutes per user. A failed write is logged and never fails authentication. It is shown in the admin list and is not part of any user payload.
- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:<scope>` limits delegated access and `user.permission:<code>` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created.
- **Mail templates.** Activation, reset and reactivation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`).
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never). `GetApiArrayEvent` lets other plugins merge keys into the user payload.
- **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload.
- **OAuth providers.** `GET /oauth-providers` lists configured social login providers (none yet: it returns an empty list).
@@ -68,6 +72,8 @@ plugins:
The plugin's own `go.mod` replaces the framework with `../../../../summercms.go`, so it expects the framework checkout next to the application checkout (`<dir>/summercms.go` and `<dir>/acme/plugins/golem15/user`).
Because the plugin registers an admin screen, an application that loads it mounts the framework's admin API and must set `admin.jwt.secret` (`SUMMER_ADMIN__JWT__SECRET`); start-up fails while it is empty. The admin screens need framework release v0.1.3 or later.
Another plugin that needs the signed-in user puts its routes behind `jwt.auth`. A machine API uses `user.api_token`, optionally followed by `user.scope:read`, `user.scope:write`, or `user.permission:vendor.plugin.code`. Both guards resolve the same user and current group permissions. Import `git.golem15.com/golem15/sm-user-plugin/models`, `.../classes`, or `.../middleware` for the shared types and helpers.
## API reference
@@ -95,16 +101,49 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle
| `GET /tokens` | `controllers.APITokenIndex` (`jwt.auth`) |
| `DELETE /tokens/{id}` | `controllers.APITokenDestroy` (`jwt.auth`) |
### Admin
The Users screen is served by the framework's generic admin API under `<admin prefix>/api/v1/golem15/user/users`; the plugin adds no admin route of its own.
| Item | Value |
|------|-------|
| Admin controller | `golem15.user.users`, from `controllers.AdminControllers` |
| Navigation | main item `user` (order 555) with the side item `users` |
| Bulk actions | `activate`, `deactivate`, `restore`, `ban`, `unban`, and the built-in delete |
| Record actions | `activate`, `unban`, `unsuspend` |
Backend permissions, all on the tab `golem15.user::lang.plugin.tab`:
| Code | Default role | Grants |
|------|--------------|--------|
| `golem15.users.access_users` | `developer` | The Users screen and every action on it. |
| `golem15.users.access_groups` | `developer` | The User Groups screen, which follows in the same release. |
| `golem15.users.access_settings` | `developer` | Registered for roles that already name it; no screen uses it yet. |
| `golem15.users.impersonate_user` | `developer` | Registered for roles that already name it; impersonation is not available. |
| `golem15.users.manage_privileged_groups` | none | Changing the email or password of a privileged-group member, and deleting one for good. Only a superuser or a role that was granted it holds it. |
Migrations added for the admin, all additive:
| ID | Adds |
|----|------|
| `202610040001_add_users_permissions` | `users.permissions` (text, nullable) |
| `202610040002_add_users_last_seen` | `users.last_seen` (timestamp, nullable) |
| `202610040003_create_frontend_permissions` | the table `golem15_user_frontend_permissions` (`code` unique, `label`, `tab`, `comment`) |
Packages:
- `controllers`: the route handlers above, plus the registration steps `POST /register` runs, for other plugins:
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list) and `PermissionAccessUsers`, plus the registration steps `POST /register` runs, for other plugins:
- `RegisterUser(ctx, app, db, fields, RegisterOptions)` validates `fields` against the user model's rules, hashes the password with `golem15.user.password.bcrypt_cost` and creates the user on `db` (which may be a transaction). It returns the validation errors instead of writing a response and never modifies `fields`. `RegisterOptions.Activate` creates the account activated; `RegisterOptions.IP` records the client IP.
- `FireRegisterEvent(ctx, app, user, fields)` fires `RegisterEvent` with `Payload` set to a copy of `fields` without `password` and `password_confirmation`, and returns the listeners' error (`/register` ignores it). Fire it after the user row is committed.
- `IssueToken(app, user, r)` mints the session JWT as `/register` and `/login` do.
- `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners.
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `HashAPIToken` and `RevokeAPIToken`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys.
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `HashAPIToken` and `RevokeAPIToken`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
- `MergedPermissions(ctx, db, userID)`, `HasPermission(merged, code)` and `UserHasPermission(ctx, db, userID, code)`, the frontend permission resolver, and `FrontendPermissionOptions(ctx, db)`, the catalogue by tab and code.
- `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule.
- `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction.
- `TouchLastSeen(ctx, db, userID)`.
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `APIToken`.
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `APIToken`, `FrontendPermission` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`).
- `updates`: the gormigrate migrations, returned by `updates.All()`.
- `console`: `RequirePasswordChange` and `APITokenCommands`.
@@ -132,6 +171,7 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
| `golem15.user.password.bcrypt_cost` | `10` | bcrypt cost for new password hashes. |
| `golem15.user.password.min_length` | `8` | Minimum password length. |
| `golem15.user.api_tokens.prefix` | `usr_` | Prefix for newly minted and accepted shared API-token secrets. Override an existing application's legacy prefix without creating a plugin-specific token table. |
| `golem15.user.privileged_groups` | `[admin]` | Codes of the user groups whose members are protected: their email, password and permanent delete need `golem15.users.manage_privileged_groups`. Read on every request. An empty list protects no group. |
## CLI commands
@@ -156,3 +196,5 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
```
`TestRegisterCORSPath` reads the host application's `config/http.yaml`, so run the tests inside an application checkout where the plugin is mounted at `plugins/golem15/user`.
The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`.