From 469315510809f703fc315aceaa9ef902794a0237 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 13:58:54 +0200 Subject: [PATCH] docs: describe the User Groups and Organisations screens and the privileged-group rules --- README.md | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d6c0a15..afdf517 100644 --- a/README.md +++ b/README.md @@ -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`, `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. +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, User Groups and Organisations screens 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 @@ -23,10 +23,12 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap - **Login throttling.** Failed logins are counted per user and IP in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. - **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 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. +- **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins. An organisation has a name, a slug (filled from the name by `Slugify` when it is left empty), a description, an avatar and its members: the users whose `organisation_id` points at it. +- **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":[]`. Membership is written in one place only: the `groups` field of the admin user form. Apart from that, rows leave `users_groups` only when a user or a group is deleted for good. 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 user's groups, 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. There is no impersonation and no guest account. +- **User Groups screen.** Controller ID `golem15.user.usergroups`, the port of the WinterCMS `UserGroups` controller, behind `golem15.users.access_groups`: a list with the name, code, creation date and number of members, and a form with the name, the code (letters, digits, underscore and hyphen; unique), a description and the group's frontend permissions as checkboxes, stored in `user_groups.permissions` as a JSON object of code to `1`. A group can be deleted from its form; its memberships go with it and its members stay. The screen never changes who is in a group. +- **Organisations screen.** Controller ID `golem15.user.organisations`, the port of the WinterCMS `Organisations` controller, behind `golem15.users.access_users`: a list and a form with the name, slug, description and avatar, and on an existing organisation the Members tab. Adding a member sets that user's `organisation_id` and removing one clears it; a user is never deleted. Only users without an organisation can be added, so a user moves by being removed from one organisation and added to the other (or through the organisation field of the user form). Deleting an organisation clears `organisation_id` and `organisation_role` of its members first. +- **Privileged groups.** The groups whose code is listed in `golem15.user.privileged_groups` (default `admin`) are privileged: an application may treat their members as site administrators. Three things therefore need the backend permission `golem15.users.manage_privileged_groups` on top of the screen's own permission, and answer 403 without it, saving nothing. First, membership: adding a privileged group to a user or removing it, on create and on update of the user form. An administrator without the permission sees those groups locked in the field and can still change the user's other groups. Second, the code: creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group. Its name, description and frontend permissions need only `golem15.users.access_groups`. Codes compare case-sensitively, and a group without a code is never privileged. Third, the member's account, which 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:` limits delegated access and `user.permission:` 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. @@ -103,24 +105,25 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle ### Admin -The Users screen is served by the framework's generic admin API under `/api/v1/golem15/user/users`; the plugin adds no admin route of its own. +The three screens are served by the framework's generic admin API under `/api/v1/golem15/user/users`, `.../usergroups` and `.../organisations`; 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` | +| Admin controllers | `golem15.user.users`, `golem15.user.usergroups` and `golem15.user.organisations`, from `controllers.AdminControllers` | +| Navigation | main item `user` (order 555) with the side items `users`, `usergroups` and `organisations`, each shown to an administrator who holds its permission | +| Relation manager | `members` on `golem15.user.organisations` (link and unlink) | +| Bulk actions (Users) | `activate`, `deactivate`, `restore`, `ban`, `unban`, and the built-in delete | +| Record actions (Users) | `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_users` | `developer` | The Users screen and every action on it, including a user's membership of ordinary groups, and the Organisations screen with its members. | +| `golem15.users.access_groups` | `developer` | The User Groups screen. | | `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. | +| `golem15.users.manage_privileged_groups` | none | Adding a privileged group to a user or removing it; creating, re-coding or deleting a privileged group; 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: @@ -132,7 +135,7 @@ Migrations added for the admin, all additive: Packages: -- `controllers`: the route handlers above, `AdminControllers` (the admin controller list) and `PermissionAccessUsers`, plus the registration steps `POST /register` runs, for other plugins: +- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, 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. @@ -143,7 +146,7 @@ Packages: - `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`, `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`). +- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `FrontendPermission` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`), and `Slugify` is the slug rule. `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`. @@ -171,7 +174,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. | +| `golem15.user.privileged_groups` | `[admin]` | Codes of the privileged user groups. Membership of such a group, the group's code and its delete, and the email, password and permanent delete of its members need `golem15.users.manage_privileged_groups`. Read on every request. An empty list protects no group. | ## CLI commands