diff --git a/README.md b/README.md index b2dce7f..d6c0a15 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` 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:` 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. -- **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 (`/summercms.go` and `/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 `/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`.