diff --git a/README.md b/README.md index 766dc3a..0177ca7 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap - **Login throttling.** Failed logins are counted per user and client address in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. The client address is `surf.ClientIP` with the framework's `http.trusted_proxies`, as for the `/register` IP limit and the `user-api` rate limit: behind a listed reverse proxy it is the `X-Forwarded-For` client, otherwise the connection address, and an `X-Forwarded-For` header from any other peer is ignored. A deployment behind a proxy lists the proxy's address in `http.trusted_proxies`; otherwise every visitor shares the proxy's counter. - **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). -- **Deactivation and bans.** Deactivation is self-service and reversible: a password login or a `golem15.sociallogin` sign-in restores a deactivated account and sends the reactivate mail, and an administrator can restore it too. It ends every earlier session JWT and personal API token of the user (`RevokeUserAccess` stamps `tokens_valid_after` and revokes the `user_api_tokens` rows), so the user signs in again and gets a fresh token, while the old API tokens stay revoked. A ban is the lock: password login, social sign-in and `POST /refresh` refuse a banned user, and banning revokes the user's sessions and API tokens. Neither unban nor restore revives a token. Activation gates login, deactivation does not: while `require_activation` is on, an account that is both deactivated and not activated is refused and stays deactivated, with no reactivate mail. Upgrade note: accounts deactivated or banned before this version keep their API tokens until they are deactivated or banned again (both actions are idempotent) or `RevokeUserAccess` runs for them. +- **Deactivation and bans.** Deactivation is self-service and reversible: a password login or a `golem15.sociallogin` sign-in restores a deactivated account and sends the reactivate mail, and an administrator can restore it too. It ends every earlier session JWT and personal API token of the user (`RevokeUserAccess` stamps `tokens_valid_after` and revokes the `user_api_tokens` rows), so the user signs in again and gets a fresh token, while the old API tokens stay revoked. A ban is the lock: password login, social sign-in and `POST /refresh` refuse a banned user, and banning revokes the user's sessions and API tokens. A banned user's account link answers no administrator (`LinkedBackendUser`). Neither unban nor restore revives a token. Activation gates login, deactivation does not: while `require_activation` is on, an account that is both deactivated and not activated is refused and stays deactivated, with no reactivate mail. Upgrade note: accounts deactivated or banned before this version keep their API tokens until they are deactivated or banned again (both actions are idempotent) or `RevokeUserAccess` runs for them. - **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. `organisation_role` belongs to that membership: the admin clears it whenever a user's organisation changes (see the Organisations screen), and an application that sets `organisation_id` itself sets or clears the role with 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, and deactivate and ban both end the user's earlier sessions and personal API tokens (see Deactivation and bans); 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. @@ -42,7 +42,8 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap - **OAuth providers.** `GET /oauth-providers` lists the social sign-in providers a social-login plugin has enabled, read on every request from its published `classes.OAuthProviderSource`. Without one it answers the empty list, as before. - **Frontend account link for administrators.** A backend administrator's account can be linked to one frontend user, and a frontend user to one administrator (`golem15_user_backend_links`, unique on both sides). The administrator starts it on My account (section `frontend_account`, action `link`): the plugin stores a one-shot request (32 random bytes as hex, kept only as its SHA-256, valid `golem15.user.backend_links.request_ttl`) and answers the site URL `golem15.user.frontend_url` (else `app.url`) + `golem15.user.backend_links.page_path` (default `/link-admin`, `classes.BackendLinkPagePath`) + `#code=`, which the admin opens in a new tab. The code travels in the URL fragment, which browsers never send to a server, so it stays out of access logs. The site page reads the fragment, keeps the code in the browser (for example `sessionStorage`) across the sign-in, shows the request through `POST /backend-link/show` with `{"code":…}` in the body (the administrator's login and display name and the expiry, nothing else), shows which frontend account is signed in, and posts the code to `POST /backend-link/confirm` behind `jwt.auth`. `GET /backend-link/{code}` answers the same as the POST show route and stays for compatibility; it is deprecated, because its path puts the code in access logs, so pages use POST. The link is written only by the confirm, in one transaction with the request row locked, and only when the session was signed in at or after the request was created: the proof is the session token's `auth_time` claim (`bouncer.VerifyAuthTime`), the time of the sign-in itself, compared in whole seconds with the request. `POST /refresh` copies `auth_time` and never renews it, so a refreshed old session does not pass, and a browser that was already signed in must sign in again; password, social login and any login gate apply as usual. Upgrade note: session tokens issued before the framework stamped `auth_time` (SummerCMS v0.5.0) carry no `auth_time`, and a refresh carries that absence forward, so a frontend user signed in before the upgrade gets `stale_session` and must sign in again before linking. A code works once; a used, expired or unknown code answers 404. No link is ever made by matching emails. The administrator removes it on My account (`unlink`), and an administrator holding `golem15.users.manage_backend_links` links or unlinks another administrator on the Administrators screen (see Admin). Deleting or deactivating the frontend user removes the link in the same transaction; deleting an administrator removes it through the foreign key, and a deactivated or soft-deleted administrator is not answered by the resolver. Boot publishes `*classes.BackendLinks` and the same value as `pact.MCPLinkedBackendResolver` (`LinkedBackendUser`, one query per call), which the framework's MCP endpoint uses to give a frontend user the linked administrator's backend tools. `BackendLinkedEvent` and `BackendUnlinkedEvent` fire inside the write transaction. - **Frontend permission codes from plugins.** Another plugin declares the frontend permission codes its routes check (`user.permission:`) with `pact.HasFrontendPermissions`. The Users and User Groups permission editors offer the rows of `golem15_user_frontend_permissions` first, unchanged, then the declared codes of the activated plugins in activation order (`classes.FrontendPermissionCatalog`); a seeded row wins over a declared code with the same code, and the first declaring plugin wins over a later one. Nothing is written to the table; the stored permissions keep their shape. -- **MCP guard and the `user_me` tool.** Boot registers `classes.MCPUserGuard` as the MCP guard provider `user` in the application's `*pact.MCPGuards` and publishes it as `pact.MCPPersonalTokenResolver`, so the framework's MCP endpoint (built with `-tags summercms_mcp`) can bind tools to routes behind `user.api_token`, `user.scope:` and `user.permission:` and accept the plugin's personal API tokens (the `golem15.user.api_tokens.prefix`). The guard decides with the routes' own checks: every scope of the route must be granted to the MCP session, and every permission code must be granted by one of the user's groups, as `user.permission` checks it (a user's own permission override does not grant). A tool call runs the route with the user and an in-memory token credential carrying the session's scopes (ID 0, never saved). The plugin's one tool, `me` (exposed as `user_me`), runs `GET /token/me` and answers the signed-in user's payload. Revoking a personal token, saving a user on the Users screen, saving or deleting a group and the link changes fire `pact.MCPSubjectChanged` after they commit, so open MCP sessions re-list their tools or end. +- **Access revocation.** Banning a user (the admin `ban` bulk action, for the users it newly bans), a password reset (`POST /reset-password`), an administrator setting a password on the Users screen and the user's own `POST /change-password` fire `classes.AccessRevokedEvent` (`user.AccessRevoked`: `UserIDs`, `Before`, `Reason`, `Tx`) through `classes.FireAccessRevoked`, inside the write's transaction (a listener error rolls the write back), and a "revoked" `pact.MCPSubjectChanged` per user after it commits, so the users' MCP sessions end. `Before` is the untruncated instant of the revocation (for the self-service change, the start of the current session's sign-in second, so tokens issued since that sign-in survive, as the session does); `tokens_valid_after` keeps its whole-second value. `Reason` is `ban`, `password_reset`, `password_set_by_admin` or `password_changed` (`AccessRevokedBan`, `AccessRevokedPasswordReset`, `AccessRevokedPasswordSetByAdmin`, `AccessRevokedPasswordChanged`). Listeners revoke what such a user still holds elsewhere: `golem15.oauthserver` revokes the users' OAuth access and refresh tokens created before `Before`. `RevokeUserAccess`, `BanUsers` and `DeactivateUsers` keep their behaviour and fire nothing: an application that calls `RevokeUserAccess` or `BanUsers` directly calls `FireAccessRevoked` in the same transaction too. Deactivation and deletion keep their own events (`DeactivatingEvent`, `DeletingEvent`). +- **MCP guard and the `user_me` tool.** Boot registers `classes.MCPUserGuard` as the MCP guard provider `user` in the application's `*pact.MCPGuards` and publishes it as `pact.MCPPersonalTokenResolver`, so the framework's MCP endpoint (built with `-tags summercms_mcp`) can bind tools to routes behind `user.api_token`, `user.scope:` and `user.permission:` and accept the plugin's personal API tokens (the `golem15.user.api_tokens.prefix`). The guard decides with the routes' own checks: every scope of the route must be granted to the MCP session, and every permission code must be granted by one of the user's groups, as `user.permission` checks it (a user's own permission override does not grant). A tool call runs the route with the user and an in-memory token credential carrying the session's scopes (ID 0, never saved). The plugin's one tool, `me` (exposed as `user_me`), runs `GET /token/me` and answers the signed-in user's payload. A personal token used at the MCP endpoint gets `last_used_at` stamped, and `last_used_ip` when the endpoint passes the client address (`towel.ClientIP`), as the `user.api_token` guard does. Revoking a personal token, saving a user on the Users screen, saving or deleting a group and the link changes fire `pact.MCPSubjectChanged` after they commit, so open MCP sessions re-list their tools or end; revoking a personal token (`DELETE /tokens/{id}`) carries the token's id (`TokenID`), so only the sessions that token opened end. `BackendLinkedEvent` and `BackendUnlinkedEvent`, with `Link.UserID` set, fire on every link and unlink: the confirmation, the Administrators screen, My account and the deletion or deactivation of the frontend user. Deleting an administrator removes the link through the foreign key and fires nothing. - **Sign-in helpers for social login.** `RegisterOptions.PasswordLess`, `FinishLogin`, `RegistrationSettings`, `RegistrationsFromIP`, `RefreshTTL` and `RemoteIP` let a social-login plugin create accounts and finish logins with the user plugin's own rules instead of copies. ## Usage @@ -199,7 +200,7 @@ Packages: - `FireDeletingEvent(ctx, app, user, tx)` fires `DeletingEvent` (`user.Deleting`) inside the delete transaction. - `FireDeactivatingEvent(ctx, app, user, tx)` fires `DeactivatingEvent` (`user.Deactivating`) inside the deactivation transaction. The admin deactivate bulk action fires it after `DeactivateUsers` (so the user's sessions and API tokens are already revoked) for every selected user that was not deactivated yet; a listener error rolls the action back. - `IssueToken(app, user, r)` mints the session JWT as `/register` and `/login` do. - - `BackendLinkShow`, `BackendLinkConfirm` and `TokenMe`, the handlers of the account link and `/token/me` routes. + - `BackendLinkShow`, `BackendLinkShowPost`, `BackendLinkConfirm` and `TokenMe`, the handlers of the account link and `/token/me` routes. - `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners. - `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks. - `RegistrationSettings(app)` returns `allow_registration`, `use_register_throttle`, `activate_mode` and `require_activation` with their defaults (`true`, `true`, `auto`, `true`), and `RegistrationsFromIP(ctx, db, ip)` counts the accounts created from an IP in the last 60 minutes (`/register` refuses at 3 with the throttle on). @@ -208,15 +209,16 @@ Packages: - `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. - - `RevokeUserAccess(ctx, db, userIDs)` ends every session JWT issued before the current second (it stamps `users.tokens_valid_after`, deactivated rows included) and revokes every personal API token of the users. `DeactivateUsers` and `BanUsers` call it; an application may call it for its own offboarding and after writing a ban directly to `user_throttle`. + - `RevokeUserAccess(ctx, db, userIDs)` ends every session JWT issued before the current second (it stamps `users.tokens_valid_after`, deactivated rows included) and revokes every personal API token of the users. `DeactivateUsers` and `BanUsers` call it; an application may call it for its own offboarding and after writing a ban directly to `user_throttle`. Neither fires `AccessRevokedEvent`: such an application calls `FireAccessRevoked` in the same transaction, so OAuth grants are revoked too. - `TouchLastSeen(ctx, db, userID)`. - `OAuthProvider` (`Name`, `Label`) and `OAuthProviderSource` (`EnabledOAuthProviders(ctx)`, `KnownOAuthProviders()`), the seam a social-login plugin publishes with an explicit type argument, `app.Publish[classes.OAuthProviderSource](src)`, so the lookup finds it. `GET /oauth-providers` lists its enabled providers in order; the unlink routes accept its known names. - `EndSession(ctx, app, w, r)` is what `POST /logout` runs: it blacklists every session JWT the request carries (bearer header, `token` and `auth_token` cookies) that verifies, an expired one too while its refresh window is open, until that window closes, writes the forget cookies described under JWT sessions, and returns the subject of the token the guard would read (`""` when there is none or it does not verify); only a failing blacklist write is an error. - `SessionToken(r)`, the session JWT the `jwt` guard reads from a request (bearer header, then the `token` and `auth_token` cookies; `""` when there is none); `SessionTokenCookie(r)`, the name of the session cookie that supplied that token (`""` when the bearer header did or there is none), which `POST /refresh` uses to rewrite the `auth.cookie` cookie; and `SessionCookieNames()`, a copy of that cookie list to pass to `bouncer.NewJWTGuard`, so a handler's own guard reads the same token. - `RequestUser(ctx, app, r)` and `EndRequestSession(ctx, app, w, r)`, for handlers that serve signed-in and anonymous browsers on one route (an OAuth provider's authorize page). `RequestUser` reads and checks the session JWT exactly as the `jwt` guard does (bearer header, then the `token` and `auth_token` cookies, the blacklist, `tokens_valid_after`) and returns the user, or `nil` for an anonymous, invalid, expired or blacklisted request and for a soft-deleted user; only an infrastructure failure is an error. `EndRequestSession` is the best-effort `EndSession`: it revokes and forgets the same way (the forget cookie of `bouncer.LoadCookieConfig`), but a blacklist failure is logged, never returned. - `AvatarURL(ctx, app, user, size)`, the display picture rule of the user payload at any size: the avatar thumbnail (size by size, mode auto) when the user has an avatar and a file bucket is published, the file path when the thumbnail fails, otherwise the gravatar URL. The URL may be protocol-relative or relative. The payload's `avatar_url` comes from it at 128. - - `BackendLinks` (`CreateLinkRequest`, `LinkRequestByCode`, `ConfirmLinkRequest`, `SetLink`, `RemoveLink`, `LinkFor`, `LinkOf`, `LinkedBackendUser`, `RegisterBackendLinkListeners`), its errors `ErrLinkRequestNotFound`, `ErrLinkRequestUsed`, `ErrLinkRequestExpired`, `ErrStaleSession`, `ErrAlreadyLinked`, `ErrBackendUserUnavailable` and `ErrFrontendUserUnavailable`, the events `BackendLinkedEvent` / `BackendLinkedEventName` and `BackendUnlinkedEvent` / `BackendUnlinkedEventName`, `BackendLinkRequestTTL(app)`, `DefaultBackendLinkRequestTTL`, `FrontendURL(app)` and `BackendDisplayName(admin)`. - - `PermissionManageBackendLinks`, `FrontendPermissionCatalog(ctx, app, db)`, `MCPUserGuard`, `FireSubjectChanged(ctx, app, guard, userID, kind)` and `FireSubjectChangedAfterCommit(ctx, app, tx, guard, userID, kind)` with the guard names `SubjectGuardUser` and `SubjectGuardBackend`. + - `BackendLinks` (`CreateLinkRequest`, `LinkRequestByCode`, `ConfirmLinkRequest`, `SetLink`, `RemoveLink`, `LinkFor`, `LinkOf`, `LinkedBackendUser`, `RegisterBackendLinkListeners`), its errors `ErrLinkRequestNotFound`, `ErrLinkRequestUsed`, `ErrLinkRequestExpired`, `ErrStaleSession`, `ErrAlreadyLinked`, `ErrBackendUserUnavailable` and `ErrFrontendUserUnavailable`, the events `BackendLinkedEvent` / `BackendLinkedEventName` and `BackendUnlinkedEvent` / `BackendUnlinkedEventName`, `BackendLinkRequestTTL(app)`, `DefaultBackendLinkRequestTTL`, `FrontendURL(app)`, `BackendLinkPagePath(app)`, `DefaultBackendLinkPagePath`, `BackendLinksFor(app)` (the published service, or one over the app) and `BackendDisplayName(admin)`. + - `PermissionManageBackendLinks`, `FrontendPermissionCatalog(ctx, app, db)`, `MCPUserGuard`, `FireSubjectChanged(ctx, app, guard, userID, kind)` and `FireSubjectChangedAfterCommit(ctx, app, tx, guard, userID, kind)` with the guard names `SubjectGuardUser` and `SubjectGuardBackend`, and `FireSubjectEvent(ctx, app, ev)` and `FireSubjectEventAfterCommit(ctx, app, tx, ev)`, which fire a whole `pact.MCPSubjectChanged` (with `TokenID` or `ClientID` to end only one token's or one client's sessions). + - `AccessRevokedEvent` / `AccessRevokedEventName` and `FireAccessRevoked(ctx, app, tx, userIDs, before, reason)` (see Access revocation). - `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`. - `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`. - `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `BackendLink`, `BackendLinkRequest` 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`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code. @@ -307,6 +309,6 @@ go test ./... # full run, starts Postgres with testcontainers (needs D `TestAdminOrganisationMembershipClearsRole` checks that the members manager's unlink and link and a Users form save that changes the organisation clear `organisation_role`, that a save keeping the organisation keeps it, and that deleting an organisation clears both columns. -`TestBackendLinkSmoke` runs the account link end to end: My account offers `link`, the action answers the site URL with a 64-hex code, the show route answers the administrator's login and expiry, a session older than the request gets `stale_session`, a fresh one links (the published resolver answers the administrator), the code is one-shot, a second frontend user gets `already_linked`, an expired code is 404, unlink and deleting the frontend user remove the link, and the subject events fire. `TestBackendLinkAdminSmoke` covers the Administrators screen actions: offered only as they apply and only with `golem15.users.manage_backend_links`, the case-insensitive email match, `linked_by`, `BackendLinkedEvent`, the 422 refusals and the unlink. `TestUserMCPGuardSmoke` covers the `user` guard provider (requirements, the scope and group-permission checks, the dispatch credential, personal token resolution), `GET /token/me` and the revoke, group and user subject events. `TestFrontendPermissionCatalogSmoke` boots a test plugin that declares frontend permission codes and checks both editors' options. All four need Docker. +`TestBackendLinkSmoke` runs the account link end to end: My account offers `link`, the action answers the site URL with a 64-hex code, the show route answers the administrator's login and expiry, a session older than the request gets `stale_session`, a fresh one links (the published resolver answers the administrator), the code is one-shot, a second frontend user gets `already_linked`, an expired code is 404, unlink and deleting the frontend user remove the link, and the subject events fire. `TestBackendLinkAuthTimeSmoke` checks the `auth_time` proof: a session signed in before the request and refreshed after it gets `stale_session`, a sign-in after the request links, `POST /backend-link/show` answers for the code in the body, and My account's URL carries the code in the fragment. `TestBackendLinkAdminSmoke` covers the Administrators screen actions: offered only as they apply and only with `golem15.users.manage_backend_links`, the case-insensitive email match, `linked_by`, `BackendLinkedEvent`, the 422 refusals and the unlink. `TestUserMCPGuardSmoke` covers the `user` guard provider (requirements, the scope and group-permission checks, the dispatch credential, personal token resolution), `GET /token/me` and the revoke, group and user subject events. `TestFrontendPermissionCatalogSmoke` boots a test plugin that declares frontend permission codes and checks both editors' options. All five need Docker. 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`. diff --git a/account_contributions.go b/account_contributions.go index 68e48f3..91b4853 100644 --- a/account_contributions.go +++ b/account_contributions.go @@ -55,12 +55,7 @@ func (p *Plugin) AccountContributions() []pact.AccountContribution { // backendLinks returns the published link service, or one over the app. func (p *Plugin) backendLinks() *classes.BackendLinks { - if p.app != nil { - if links, ok := p.app.Lookup[*classes.BackendLinks](); ok && links != nil { - return links - } - } - return &classes.BackendLinks{App: p.app} + return classes.BackendLinksFor(p.app) } // frontendAccountStatus answers "Linked to " or "Not linked" in the diff --git a/backend_link_smoke_test.go b/backend_link_smoke_test.go index 510ea82..35ab460 100644 --- a/backend_link_smoke_test.go +++ b/backend_link_smoke_test.go @@ -30,6 +30,13 @@ func (s *subjectSpy) has(guard string, id uint, kind string) bool { return slices.Contains(s.events, pact.MCPSubjectChanged{Guard: guard, UserID: id, Kind: kind}) } +// hasEvent reports whether ev, with its TokenID and ClientID, was fired. +func (s *subjectSpy) hasEvent(ev pact.MCPSubjectChanged) bool { + s.mu.Lock() + defer s.mu.Unlock() + return slices.Contains(s.events, ev) +} + func (s *subjectSpy) reset() { s.mu.Lock() s.events = nil diff --git a/classes/access_revoked.go b/classes/access_revoked.go new file mode 100644 index 0000000..82767cc --- /dev/null +++ b/classes/access_revoked.go @@ -0,0 +1,57 @@ +package classes + +import ( + "context" + "fmt" + "time" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" +) + +// Reasons FireAccessRevoked is called with by this plugin. +const ( + AccessRevokedBan = "ban" + AccessRevokedPasswordReset = "password_reset" + AccessRevokedPasswordSetByAdmin = "password_set_by_admin" + AccessRevokedPasswordChanged = "password_changed" +) + +// FireAccessRevoked tells the application that userIDs lost every +// credential issued before before (reason names why: a ban, a password reset +// or change). It fires *AccessRevokedEvent in tx at once, so a listener +// error is returned and the caller's transaction rolls back, and then one +// "revoked" *pact.MCPSubjectChanged per user once tx commits, so MCP +// sessions of those users end on every instance. A nil app or bus, or an +// empty id list, does nothing. A nil tx fires the subject events at once. +// +// The plugin calls it from the ban action, the password reset, an +// administrator setting a password and the self-service password change. +// RevokeUserAccess, BanUsers and DeactivateUsers do not fire it: an +// application that calls RevokeUserAccess or BanUsers directly calls +// FireAccessRevoked too, in the same transaction. +func FireAccessRevoked(ctx context.Context, app *backpack.App, tx *gorm.DB, userIDs []uint, before time.Time, reason string) error { + if app == nil || app.Events == nil || len(userIDs) == 0 { + return nil + } + if ctx == nil { + ctx = context.Background() + } + ids := make([]uint, 0, len(userIDs)) + for _, id := range userIDs { + if id != 0 { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return nil + } + if err := app.Events.Fire(ctx, &AccessRevokedEvent{UserIDs: ids, Before: before, Reason: reason, Tx: tx}); err != nil { + return fmt.Errorf("%s: %w", AccessRevokedEventName, err) + } + for _, id := range ids { + FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardUser, id, pact.MCPSubjectRevoked) + } + return nil +} diff --git a/classes/backend_links.go b/classes/backend_links.go index 545a894..974df15 100644 --- a/classes/backend_links.go +++ b/classes/backend_links.go @@ -52,6 +52,17 @@ type BackendLinks struct { App *backpack.App } +// BackendLinksFor returns the link service Boot published on app, or one +// over app when none is published. +func BackendLinksFor(app *backpack.App) *BackendLinks { + if app != nil { + if links, ok := app.Lookup[*BackendLinks](); ok && links != nil { + return links + } + } + return &BackendLinks{App: app} +} + // BackendLinkRequestTTL reads golem15.user.backend_links.request_ttl, a Go // duration (10m by default). A value that does not parse or is not positive // is an error, so a typo fails the boot instead of silently changing the @@ -211,14 +222,16 @@ func (s *BackendLinks) LinkRequestByCode(ctx context.Context, plain string) (*mo return req, admin, nil } +// requestUsable answers why a request cannot be used (used or expired), or +// nil when req.IsUsableAt(now). func requestUsable(req *models.BackendLinkRequest, now time.Time) error { + if req.IsUsableAt(now) { + return nil + } if req.UsedAt != nil { return ErrLinkRequestUsed } - if !req.ExpiresAt.After(now) { - return ErrLinkRequestExpired - } - return nil + return ErrLinkRequestExpired } // ConfirmLinkRequest stores the link between the signed-in frontend user and @@ -313,7 +326,9 @@ func (s *BackendLinks) SetLink(ctx context.Context, tx *gorm.DB, frontendUserID, // insertLink writes one link in tx after checking both sides are free; a // concurrent writer that wins the unique index also ends in -// ErrAlreadyLinked. +// ErrAlreadyLinked. It is the one write path of a link (ConfirmLinkRequest +// and SetLink), so BackendLinkedEvent, with Link.UserID set, fires on every +// link; RemoveLink is the one removal path and fires BackendUnlinkedEvent. func (s *BackendLinks) insertLink(ctx context.Context, tx *gorm.DB, frontendUserID, backendUserID uint, linkedBy *uint) (*models.BackendLink, error) { var taken int64 if err := fresh(ctx, tx).Model(&models.BackendLink{}). @@ -343,7 +358,8 @@ func (s *BackendLinks) insertLink(ctx context.Context, tx *gorm.DB, frontendUser return link, nil } -// RemoveLink deletes link in tx, fires BackendUnlinkedEvent there and the +// RemoveLink deletes link in tx, fires BackendUnlinkedEvent (Link.UserID +// set) there and the // two pact.MCPSubjectChanged link events after tx commits. A link that is // already gone is not an error and fires nothing. func (s *BackendLinks) RemoveLink(ctx context.Context, tx *gorm.DB, link *models.BackendLink) error { @@ -402,7 +418,8 @@ func (s *BackendLinks) linkWhere(ctx context.Context, where string, id uint) (*m // LinkedBackendUser implements pact.MCPLinkedBackendResolver with one // indexed query per call (no cache): the administrator linked to the // frontend user, false when there is no link, when the frontend user is -// deactivated, or when the administrator is deactivated or deleted. +// deactivated or banned (user_throttle.is_banned on any of their rows), or +// when the administrator is deactivated or deleted. func (s *BackendLinks) LinkedBackendUser(ctx context.Context, frontendUserID uint) (pact.LinkedBackendUser, bool, error) { if frontendUserID == 0 { return pact.LinkedBackendUser{}, false, nil @@ -422,6 +439,7 @@ FROM golem15_user_backend_links l JOIN backend_users b ON b.id = l.backend_user_id JOIN users u ON u.id = l.user_id WHERE l.user_id = ? AND b.deleted_at IS NULL AND b.is_activated = TRUE AND u.deleted_at IS NULL +AND NOT EXISTS (SELECT 1 FROM user_throttle t WHERE t.user_id = l.user_id AND t.is_banned) LIMIT 1`, frontendUserID).Scan(&rows).Error if err != nil { return pact.LinkedBackendUser{}, false, err diff --git a/classes/events.go b/classes/events.go index 5c3a3bc..548be3a 100644 --- a/classes/events.go +++ b/classes/events.go @@ -1,6 +1,8 @@ package classes import ( + "time" + "git.golem15.com/golem15/sm-user-plugin/models" "gorm.io/gorm" ) @@ -20,6 +22,9 @@ const BackendLinkedEventName = "user.BackendLinked" // BackendUnlinkedEventName is the festival name of BackendUnlinkedEvent. const BackendUnlinkedEventName = "user.BackendUnlinked" +// AccessRevokedEventName is the festival name of AccessRevokedEvent. +const AccessRevokedEventName = "user.AccessRevoked" + // GetApiArrayEvent is the fire-and-collect payload other plugins extend. type GetApiArrayEvent struct { User *models.User @@ -90,3 +95,19 @@ type BackendUnlinkedEvent struct { Link *models.BackendLink Tx *gorm.DB } + +// AccessRevokedEvent fires inside the write transaction when users lose +// every credential issued before Before: a ban, a password reset, a password +// an administrator set, or the user's own password change (FireAccessRevoked; +// Reason names which). Listeners revoke what such a credential still holds +// elsewhere, for example OAuth access and refresh tokens created before +// Before. Before is the untruncated instant of the revocation, while the +// users' tokens_valid_after keeps its whole-second value. A listener error +// rolls the write back. Deactivation and deletion keep their own events +// (DeactivatingEvent, DeletingEvent). +type AccessRevokedEvent struct { + UserIDs []uint + Before time.Time + Reason string + Tx *gorm.DB +} diff --git a/classes/mcp_guard.go b/classes/mcp_guard.go index baac739..e667e76 100644 --- a/classes/mcp_guard.go +++ b/classes/mcp_guard.go @@ -12,6 +12,7 @@ import ( "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/towel" "gorm.io/gorm" ) @@ -139,7 +140,9 @@ func (g *MCPUserGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject, // an MCP subject with the token's own scopes. A token without the configured // prefix answers false; one with it that is unknown, revoked, expired or // whose user is gone answers pact.ErrMCPTokenInvalid. A usable token gets -// its last_used_at stamped, as the api_token guard does. +// its last_used_at stamped, and its last_used_ip when the context carries +// the client address (towel.ClientIP, which the MCP endpoint sets), as the +// api_token guard does. func (g *MCPUserGuard) MCPResolvePersonalToken(ctx context.Context, raw string) (pact.MCPSubject, bool, error) { prefix, err := APITokenPrefix(g.app()) if err != nil || !strings.HasPrefix(raw, prefix) { @@ -166,7 +169,11 @@ func (g *MCPUserGuard) MCPResolvePersonalToken(ctx context.Context, raw string) if principal == nil { return pact.MCPSubject{}, true, pact.ErrMCPTokenInvalid } - if err := db.Model(&models.APIToken{}).Where("id = ?", token.ID).UpdateColumn("last_used_at", now).Error; err != nil { + used := map[string]any{"last_used_at": now} + if ip, ok := towel.ClientIP(ctx); ok && strings.TrimSpace(ip) != "" { + used["last_used_ip"] = strings.TrimSpace(ip) + } + if err := db.Model(&models.APIToken{}).Where("id = ?", token.ID).UpdateColumns(used).Error; err != nil { return pact.MCPSubject{}, true, err } return pact.MCPSubject{Guard: SubjectGuardUser, UserID: token.UserID, Scopes: slices.Clone(token.Scopes.Get()), TokenID: token.ID}, true, nil diff --git a/classes/subject_events.go b/classes/subject_events.go index 4820691..9227145 100644 --- a/classes/subject_events.go +++ b/classes/subject_events.go @@ -17,39 +17,53 @@ const ( SubjectGuardBackend = "backend" ) -// FireSubjectChanged fires one *pact.MCPSubjectChanged (guard, user id, -// kind) through the application's event bus at once, so the MCP endpoint -// re-lists the subject's tools or ends its sessions. A missing application -// or bus, or a zero id, is a no-op; a listener's error is logged and never -// returned, so the write that caused the event never fails because of it -// (every MCP request re-checks anyway). -func FireSubjectChanged(ctx context.Context, app *backpack.App, guard string, userID uint, kind string) { - if app == nil || app.Events == nil || userID == 0 || kind == "" { +// FireSubjectEvent fires ev through the application's event bus at once, so +// the MCP endpoint re-lists the subject's tools or ends its sessions. A +// revoked event may carry TokenID (only the sessions one personal token +// opened end) or ClientID with UserID (only that OAuth client's sessions +// end). A missing application or bus, a zero UserID or an empty Kind is a +// no-op; a listener's error is logged and never returned, so the write that +// caused the event never fails because of it (every MCP request re-checks +// anyway). +func FireSubjectEvent(ctx context.Context, app *backpack.App, ev pact.MCPSubjectChanged) { + if app == nil || app.Events == nil || ev.UserID == 0 || ev.Kind == "" { return } if ctx == nil { ctx = context.Background() } - event := &pact.MCPSubjectChanged{Guard: guard, UserID: userID, Kind: kind} - if err := app.Events.Fire(ctx, event); err != nil { - pluginLog.WarnContext(ctx, "golem15.user: subject changed listener failed", "guard", guard, "user_id", userID, "kind", kind, "err", err) + event := ev + if err := app.Events.Fire(ctx, &event); err != nil { + pluginLog.WarnContext(ctx, "golem15.user: subject changed listener failed", "guard", ev.Guard, "user_id", ev.UserID, "kind", ev.Kind, "token_id", ev.TokenID, "client_id", ev.ClientID, "err", err) } } -// FireSubjectChangedAfterCommit is FireSubjectChanged once the write +// FireSubjectEventAfterCommit is FireSubjectEvent once the write // transaction tx commits (lagoon.AfterCommit), so a listener that reads the // database sees the change; nothing is fired when the transaction rolls // back. A nil tx fires at once. ctx must be the context the transaction // runs under (the one cabana hands a hook or an action). -func FireSubjectChangedAfterCommit(ctx context.Context, app *backpack.App, tx *gorm.DB, guard string, userID uint, kind string) { - if app == nil || app.Events == nil || userID == 0 || kind == "" { +func FireSubjectEventAfterCommit(ctx context.Context, app *backpack.App, tx *gorm.DB, ev pact.MCPSubjectChanged) { + if app == nil || app.Events == nil || ev.UserID == 0 || ev.Kind == "" { return } if tx == nil { - FireSubjectChanged(ctx, app, guard, userID, kind) + FireSubjectEvent(ctx, app, ev) return } lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { - FireSubjectChanged(ctx, app, guard, userID, kind) + FireSubjectEvent(ctx, app, ev) }) } + +// FireSubjectChanged fires one *pact.MCPSubjectChanged (guard, user id, +// kind) at once: FireSubjectEvent for the whole subject. +func FireSubjectChanged(ctx context.Context, app *backpack.App, guard string, userID uint, kind string) { + FireSubjectEvent(ctx, app, pact.MCPSubjectChanged{Guard: guard, UserID: userID, Kind: kind}) +} + +// FireSubjectChangedAfterCommit is FireSubjectChanged once the write +// transaction tx commits: FireSubjectEventAfterCommit for the whole subject. +func FireSubjectChangedAfterCommit(ctx context.Context, app *backpack.App, tx *gorm.DB, guard string, userID uint, kind string) { + FireSubjectEventAfterCommit(ctx, app, tx, pact.MCPSubjectChanged{Guard: guard, UserID: userID, Kind: kind}) +} diff --git a/controllers/api_controller.go b/controllers/api_controller.go index b1800ff..18ca254 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -447,13 +447,21 @@ func ResetPassword(app *backpack.App) http.HandlerFunc { // address (D-27). A reset that fails here records nothing. The // session cutoff is truncated to whole seconds, as in // RevokeUserAccess, because JWT iat has whole-second precision, so - // the login that follows the reset is not refused. + // the login that follows the reset is not refused. The same + // transaction fires the access-revoked event with the untruncated + // instant, so OAuth grants created before the reset are revoked + // with the password change or not at all. now := time.Now() - if err := gdb.WithContext(r.Context()).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{ - "password": hashed, - "tokens_valid_after": now.Truncate(time.Second), - "email_verified_at": now, - }).Error; err != nil { + if err := lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error { + if err := tx.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).Updates(map[string]any{ + "password": hashed, + "tokens_valid_after": now.Truncate(time.Second), + "email_verified_at": now, + }).Error; err != nil { + return err + } + return classes.FireAccessRevoked(ctx, app, tx, []uint{user.ID}, now, classes.AccessRevokedPasswordReset) + }); err != nil { writeOpaque500(w) return } @@ -707,13 +715,22 @@ func ChangePassword(app *backpack.App) http.HandlerFunc { writeOpaque500(w) return } + // The cutoff keeps the current session (issued at iat) and every + // token issued since its sign-in second; the access-revoked event + // revokes OAuth grants created before that second, in the same + // transaction. cutoff := iat.Add(-time.Second) - if err := gdb.WithContext(r.Context()).Model(&user).Updates(map[string]any{ - "password": hashed, - "must_change_password": false, - "has_self_set_password": true, - "tokens_valid_after": cutoff, - }).Error; err != nil { + if err := lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error { + if err := tx.WithContext(ctx).Model(&user).Updates(map[string]any{ + "password": hashed, + "must_change_password": false, + "has_self_set_password": true, + "tokens_valid_after": cutoff, + }).Error; err != nil { + return err + } + return classes.FireAccessRevoked(ctx, app, tx, []uint{user.ID}, cutoff.Add(time.Second), classes.AccessRevokedPasswordChanged) + }); err != nil { writeOpaque500(w) return } diff --git a/controllers/api_tokens.go b/controllers/api_tokens.go index b9ee93c..7ff3f3e 100644 --- a/controllers/api_tokens.go +++ b/controllers/api_tokens.go @@ -107,8 +107,8 @@ func APITokenDestroy(app *backpack.App) http.HandlerFunc { writeOpaque500(w) return } - // An MCP session opened with this token ends. - classes.FireSubjectChanged(r.Context(), app, classes.SubjectGuardUser, user.ID, pact.MCPSubjectRevoked) + // Only the MCP sessions opened with this token end. + classes.FireSubjectEvent(r.Context(), app, pact.MCPSubjectChanged{Guard: classes.SubjectGuardUser, UserID: user.ID, Kind: pact.MCPSubjectRevoked, TokenID: token.ID}) writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"revoked": true}}) } } diff --git a/controllers/backend_link_controller.go b/controllers/backend_link_controller.go index 483c592..9b4e978 100644 --- a/controllers/backend_link_controller.go +++ b/controllers/backend_link_controller.go @@ -14,12 +14,7 @@ import ( // backendLinks returns the published link service, or one over app. func backendLinks(app *backpack.App) *classes.BackendLinks { - if app != nil { - if links, ok := app.Lookup[*classes.BackendLinks](); ok && links != nil { - return links - } - } - return &classes.BackendLinks{App: app} + return classes.BackendLinksFor(app) } // linkNotFound is the one answer for an unknown, used or expired code and diff --git a/controllers/users_admin_controller.go b/controllers/users_admin_controller.go index ae27012..2c34001 100644 --- a/controllers/users_admin_controller.go +++ b/controllers/users_admin_controller.go @@ -244,8 +244,15 @@ func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) e // user's own change-password does. The cutoff is truncated to whole // seconds, as in RevokeUserAccess, because JWT iat has whole-second // precision, so the user's next login in that second is not refused. - now := time.Now().Truncate(time.Second) + // OAuth grants created before the untruncated instant are revoked in + // the save's transaction (the access-revoked event). + at := time.Now() + now := at.Truncate(time.Second) user.TokensValidAfter = &now + tx, _ := cabana.TxFromContext(ctx) + if err := classes.FireAccessRevoked(ctx, c.application(), tx, []uint{user.ID}, at, classes.AccessRevokedPasswordSetByAdmin); err != nil { + return err + } } return nil } @@ -631,11 +638,50 @@ func (c usersAdminController) AdminBulkActions() []pact.AdminBulkAction { bulk("activate", classes.ActivateUsers), bulk("deactivate", c.deactivateUsers), bulk("restore", classes.RestoreUsers), - bulk("ban", classes.BanUsers), + bulk("ban", c.banUsers), bulk("unban", classes.UnbanUsers), } } +// banUsers runs classes.BanUsers unchanged in the action's transaction and +// fires the access-revoked event there for the users it newly banned (banned +// after, not before), so their OAuth grants are revoked with the ban and +// their MCP sessions end once it commits. A listener error rolls the action +// back. +func (c usersAdminController) banUsers(ctx context.Context, tx *gorm.DB, users []*models.User) (int, error) { + ids := make([]uint, 0, len(users)) + for _, user := range users { + if user != nil && user.ID != 0 { + ids = append(ids, user.ID) + } + } + before, _, err := classes.ThrottleStates(ctx, tx, ids) + if err != nil { + return 0, err + } + at := time.Now() + changed, err := classes.BanUsers(ctx, tx, users) + if err != nil { + return 0, err + } + after, _, err := classes.ThrottleStates(ctx, tx, ids) + if err != nil { + return 0, err + } + newly := make([]uint, 0, len(ids)) + for _, id := range ids { + if after[id] && !before[id] { + newly = append(newly, id) + } + } + if len(newly) > 0 { + if err := classes.FireAccessRevoked(ctx, c.application(), tx, newly, at, classes.AccessRevokedBan); err != nil { + return 0, err + } + } + return changed, nil +} + // deactivateUsers runs classes.DeactivateUsers unchanged and then fires // user.Deactivating, in the action's transaction, for every selected user // that was not deactivated before. A listener error rolls the action back. diff --git a/mcp_guard_smoke_test.go b/mcp_guard_smoke_test.go index a5ebee8..6efa6dc 100644 --- a/mcp_guard_smoke_test.go +++ b/mcp_guard_smoke_test.go @@ -153,11 +153,11 @@ func TestUserMCPGuardSmoke(t *testing.T) { } env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/token/me", freshUserToken(t, user), nil) - // Revoking the token through the API fires revoked; the resolver then - // refuses it. + // Revoking the token through the API fires revoked for that token only + // (TokenID); the resolver then refuses it. spy.reset() env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/tokens/"+strconv.FormatUint(uint64(readToken.ID), 10), freshUserToken(t, user), nil) - if !spy.has("user", user.ID, pact.MCPSubjectRevoked) { + if !spy.hasEvent(pact.MCPSubjectChanged{Guard: "user", UserID: user.ID, Kind: pact.MCPSubjectRevoked, TokenID: readToken.ID}) { t.Fatalf("revoke fired %+v", spy.events) } if _, matched, err := resolver.MCPResolvePersonalToken(ctx, readSecret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {