Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfcfea4ed7 | ||
|
|
59cc3bde2e | ||
|
|
dbb3d33b48 | ||
|
|
6f1cb1a774 | ||
|
|
0f545f5c81 | ||
|
|
7ac80c9aea |
32
README.md
32
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_oauth_identities`, `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.
|
||||
It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_api_tokens`, `golem15_user_oauth_identities`, `golem15_user_organisations`, `user_groups`, `users_groups`, `golem15_user_frontend_permissions`, `golem15_user_backend_links` and `golem15_user_backend_link_requests`, 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
|
||||
|
||||
@@ -40,6 +40,9 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
||||
- **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), so it is untrusted client input. `CreatedEvent` (`classes.CreatedEventName` `user.Created`) fires from API register and admin create. `/register` logs a failing `RegisterEvent` or `CreatedEvent` listener at Error level and continues: a committed registration is never undone or failed by a listener. `DeletingEvent` (`classes.DeletingEventName` `user.Deleting`) fires from `FormAfterDelete` before `ForceDeleteCleanup` / unscoped delete, inside the same transaction. `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 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`) + `/link-admin?code=<code>`, which the admin opens in a new tab. The site page reads `GET /backend-link/{code}` (the administrator's login and display name and the expiry, nothing else), has the person sign in on the site as the frontend user, and posts the code to `POST /backend-link/confirm` behind `jwt.auth`. The link is written only there, in one transaction with the request row locked, and only when the session token was issued at or after the request was created (compared in whole seconds), so a browser that was already signed in must sign in again; password, social login and any login gate apply as usual. 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:<code>`) 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:<scope>` and `user.permission:<code>` 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.
|
||||
- **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
|
||||
@@ -107,11 +110,16 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle
|
||||
| `POST /tokens` | `controllers.APITokenStore` (`jwt.auth`) |
|
||||
| `GET /tokens` | `controllers.APITokenIndex` (`jwt.auth`) |
|
||||
| `DELETE /tokens/{id}` | `controllers.APITokenDestroy` (`jwt.auth`) |
|
||||
| `GET /backend-link/{code}` | `controllers.BackendLinkShow` |
|
||||
| `POST /backend-link/confirm` | `controllers.BackendLinkConfirm` (`jwt.auth`) |
|
||||
| `GET /token/me` | `controllers.TokenMe` (`user.api_token`, `user.scope:read`) |
|
||||
|
||||
`/fetch`, `/refresh`, `/update`, `/change-password`, `/activate`, `/marketing-consent`, `/avatar`, `/avatar/remove` and the `jwt.auth` routes read the session token from the bearer header, then the `token` and `auth_token` cookies.
|
||||
|
||||
With `golem15.user.password.public_routes` false the `/login`, `/forgot-password` and `/reset-password` rows are not registered.
|
||||
|
||||
`GET /backend-link/{code}` (`code` is 64 lower-case hex characters) answers `{"data":{"admin":{"login":…,"name":…},"expires_at":"<RFC 3339>"}}` for a usable request and `404 {"error":"not_found"}` for an unknown, used or expired one or when the administrator was deactivated since. `POST /backend-link/confirm` takes `{"code":…}` from the signed-in frontend user and answers `200 {"data":{"backend_user":{"login":…,"name":…}}}`; `409 {"error":"stale_session"}` when the session token was issued before the request (sign in again), `409 {"error":"already_linked"}` when the user or the administrator is already linked, and `404 {"error":"not_found"}` as the show route. `GET /token/me` answers the `{"user": …}` body of `/fetch` to a personal API token with the `read` scope (401 `{"error":true,"message":"Invalid token."}` from the guard, 403 `Missing required scope: read` from the scope check); it is the target of the MCP tool `user_me`.
|
||||
|
||||
### Host-mounted OAuth identities
|
||||
|
||||
The host application mounts these constructors on a path it chooses. They are not registered under `/_user/api/v1`. `OAuthIdentitiesOptions.WriteNotFound` is the host's 404 writer so missing, foreign and unknown providers share one body. `OAuthIdentitiesOptions.Providers` is the unlink allow-list; when empty it is read on every request from a published `classes.OAuthProviderSource` (its `KnownOAuthProviders`), else it is `google`, `facebook`, `github` and `microsoft`. Unlinking follows the PHP rule: it succeeds when the user has a self-set password (`has_self_set_password`) or another identity remains, and otherwise answers 409 with the phrase `golem15.user::lang.oauth.last_method_blocked` (body unchanged). The guarded delete runs in a transaction that first locks the user's `users` row, so two concurrent unlinks never remove the last sign-in method.
|
||||
@@ -133,6 +141,10 @@ The three screens are served by the framework's generic admin API under `<admin
|
||||
| Relation manager | `members` on `golem15.user.organisations` (link and unlink) |
|
||||
| Bulk actions (Users) | `activate`, `deactivate`, `restore`, `ban`, `unban`, and the built-in delete; `deactivate` and `ban` also revoke the selected users' sessions and personal API tokens |
|
||||
| Record actions (Users) | `activate`, `unban`, `unsuspend` |
|
||||
| Record actions (Administrators, `summercms.cabana.users`) | `link_frontend_account` (asks for the frontend user's email) and `unlink_frontend_account` (danger), both behind `golem15.users.manage_backend_links`, from `AdminActionExtensions` |
|
||||
| My account section | `frontend_account` with the actions `link` and `unlink`, from `AccountContributions` |
|
||||
|
||||
On the Administrators screen, `link_frontend_account` links the activated, not deactivated frontend user whose email equals the typed value (letter case and surrounding spaces ignored; no search and no list) to the administrator shown, and records the caller in `linked_by`. An unknown email or an account that is already linked answers 422 on the input. It is offered only while the administrator has no link, and `unlink_frontend_account` only while it has one. The admin's own rule that an administrator cannot act on one whose grants exceed theirs runs first. These two actions skip the frontend user's sign-in proof, which is why their permission has no default role; the My account path always requires it.
|
||||
|
||||
Backend permissions, all on the tab `golem15.user::lang.plugin.tab`:
|
||||
|
||||
@@ -143,6 +155,7 @@ Backend permissions, all on the tab `golem15.user::lang.plugin.tab`:
|
||||
| `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 | 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. |
|
||||
| `golem15.users.manage_backend_links` | none | Linking a frontend account to another administrator, and unlinking one, on the Administrators screen. Only a superuser or a role that was granted it holds it. |
|
||||
|
||||
Migrations added for the admin, all additive:
|
||||
|
||||
@@ -170,6 +183,12 @@ Migrations added for address proof, all additive:
|
||||
|----|------|
|
||||
| `202610210001_add_users_email_verified_at` | `users.email_verified_at` (`TIMESTAMPTZ` NULL, `ADD COLUMN IF NOT EXISTS`, no backfill; rollback is a no-op) |
|
||||
|
||||
Migrations added for the frontend account link, all additive:
|
||||
|
||||
| ID | Adds |
|
||||
|----|------|
|
||||
| `202610220001_create_golem15_user_backend_links` | the tables `golem15_user_backend_links` (`user_id` unique, cascade FK to `users`; `backend_user_id` unique, cascade FK to `backend_users`; `linked_by` FK to `backend_users`, set null on delete; `created_at`) and `golem15_user_backend_link_requests` (`code_hash` `CHAR(64)` unique, `backend_user_id` cascade FK, `expires_at`, `used_at`, `created_at`). The `backend_users` keys are added when that table exists, which it does whenever the application migrates through `lagoon.Migrate`. |
|
||||
|
||||
Packages:
|
||||
|
||||
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins:
|
||||
@@ -179,6 +198,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.
|
||||
- `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).
|
||||
@@ -194,9 +214,11 @@ Packages:
|
||||
- `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`.
|
||||
- `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` 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.
|
||||
- `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.
|
||||
- `updates`: the gormigrate migrations, returned by `updates.All()`.
|
||||
- `console`: `RequirePasswordChange`, `BackfillHandles` and `APITokenCommands`.
|
||||
|
||||
@@ -227,6 +249,8 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
|
||||
| `golem15.user.password.public_routes` | `true` | When `false`, `POST /login`, `POST /forgot-password` and `POST /reset-password` are not registered and answer 404 like any unknown path; `POST /register` (still gated by `allow_registration` and guarded by `golem15.user.turnstile`) and every other route stay. It is read when the routes are built, so a change needs a restart. It takes `true` or `false`, also as a string (`SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false`); an empty value means `true`; any other value fails boot. |
|
||||
| `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 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. |
|
||||
| `golem15.user.frontend_url` | `""` | The site the plugin's own links point to: the account link page is this URL + `/link-admin?code=<code>`. Empty falls back to `app.url`; a trailing slash is dropped. |
|
||||
| `golem15.user.backend_links.request_ttl` | `10m` | How long a link request an administrator starts on My account stays usable (a Go duration). A value that does not parse or is not positive fails boot. |
|
||||
| `golem15.user.turnstile` | `""` | Cloudflare Turnstile on `POST /login` and `POST /register` (only `/register` while `golem15.user.password.public_routes` is false). Empty adds nothing; `open` or `closed` appends the framework's `turnstile:open` or `turnstile:closed` middleware to those two routes only (summercms v0.3.0 and later; secret in `turnstile.secret`, token in the `turnstile_token` field). `open` lets requests pass when Cloudflare is unreachable, `closed` refuses them. Any other value fails boot. |
|
||||
|
||||
## CLI commands
|
||||
@@ -243,7 +267,7 @@ Each command uses the database the serve command published or, run from the cons
|
||||
|
||||
## Dependencies
|
||||
|
||||
- The SummerCMS framework v0.4.3, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `diary`, `pact`, `party`, `surf` and others).
|
||||
- The SummerCMS framework v0.4.4, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `diary`, `pact`, `party`, `surf` and others).
|
||||
- The organisation role cleanup of the admin (members manager and user form) uses cabana's `ClearOnChange`, added in SummerCMS v0.4.3.
|
||||
- gormigrate, GORM, golang-jwt and gocloud.dev, as pinned in `go.mod`.
|
||||
- testcontainers-go for the Postgres-backed tests.
|
||||
@@ -281,4 +305,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.
|
||||
|
||||
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`.
|
||||
|
||||
142
account_contributions.go
Normal file
142
account_contributions.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var _ pact.HasAccountContributions = (*Plugin)(nil)
|
||||
|
||||
// backendLinkLang prefixes the phrase keys of the account link.
|
||||
const backendLinkLang = "golem15.user::lang.backend_link."
|
||||
|
||||
// AccountContributions adds the "Frontend account" section to the admin's
|
||||
// My account page (D-04): its status names the linked frontend account,
|
||||
// "link" starts a link request and answers the site URL the administrator
|
||||
// opens to sign in there as the frontend user and confirm (D-05; the link is
|
||||
// written only by that confirmation), and "unlink" removes the link.
|
||||
func (p *Plugin) AccountContributions() []pact.AccountContribution {
|
||||
return []pact.AccountContribution{{
|
||||
Name: "frontend_account",
|
||||
Label: backendLinkLang + "section",
|
||||
Description: backendLinkLang + "section_comment",
|
||||
Status: p.frontendAccountStatus,
|
||||
Actions: []pact.AccountAction{
|
||||
{
|
||||
Name: "link",
|
||||
Label: backendLinkLang + "link",
|
||||
Applies: func(ctx context.Context, adminID uint) (bool, error) {
|
||||
link, err := p.backendLinks().LinkFor(ctx, adminID)
|
||||
return link == nil, err
|
||||
},
|
||||
Run: p.startFrontendLink,
|
||||
},
|
||||
{
|
||||
Name: "unlink",
|
||||
Label: backendLinkLang + "unlink",
|
||||
Confirm: backendLinkLang + "unlink_confirm",
|
||||
Danger: true,
|
||||
Applies: func(ctx context.Context, adminID uint) (bool, error) {
|
||||
link, err := p.backendLinks().LinkFor(ctx, adminID)
|
||||
return link != nil, err
|
||||
},
|
||||
Run: p.unlinkOwnFrontendAccount,
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
// frontendAccountStatus answers "Linked to <email>" or "Not linked" in the
|
||||
// request locale for the signed-in administrator.
|
||||
func (p *Plugin) frontendAccountStatus(ctx context.Context, adminID uint) (string, error) {
|
||||
link, err := p.backendLinks().LinkFor(ctx, adminID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if link == nil {
|
||||
return backendLinkLang + "status_unlinked", nil
|
||||
}
|
||||
email, err := p.frontendEmail(ctx, link.UserID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
const key = backendLinkLang + "status_linked"
|
||||
if p.app != nil {
|
||||
if tr, ok := p.app.Lookup[*phrasebook.Translator](); ok && tr != nil {
|
||||
if text := tr.Get(ctx, key, map[string]string{"email": email}); text != "" && text != key {
|
||||
return text, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Linked to " + email, nil
|
||||
}
|
||||
|
||||
// frontendEmail reads the email of a frontend user, deactivated or not.
|
||||
func (p *Plugin) frontendEmail(ctx context.Context, userID uint) (string, error) {
|
||||
var db *gorm.DB
|
||||
if tx, ok := cabana.TxFromContext(ctx); ok {
|
||||
db = tx.Session(&gorm.Session{NewDB: true})
|
||||
} else if p.app != nil {
|
||||
db, _ = p.app.Lookup[*gorm.DB]()
|
||||
}
|
||||
if db == nil {
|
||||
return "", errors.New("golem15.user: gorm handle is missing")
|
||||
}
|
||||
var emails []string
|
||||
if err := db.WithContext(ctx).Unscoped().Model(&models.User{}).Where("id = ?", userID).Pluck("email", &emails).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(emails) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return emails[0], nil
|
||||
}
|
||||
|
||||
// startFrontendLink creates a link request for the signed-in administrator
|
||||
// and answers the site page that confirms it:
|
||||
// golem15.user.frontend_url (else app.url) + /link-admin?code=<code>.
|
||||
func (p *Plugin) startFrontendLink(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) {
|
||||
plain, _, err := p.backendLinks().CreateLinkRequest(ctx, in.AdminID)
|
||||
if err != nil {
|
||||
return pact.AccountActionResult{}, err
|
||||
}
|
||||
return pact.AccountActionResult{
|
||||
Message: backendLinkLang + "link_started",
|
||||
URL: classes.FrontendURL(p.app) + "/link-admin?code=" + plain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// unlinkOwnFrontendAccount removes the signed-in administrator's link in
|
||||
// the action's transaction.
|
||||
func (p *Plugin) unlinkOwnFrontendAccount(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) {
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return pact.AccountActionResult{}, errors.New("golem15.user: unlinking needs the action's transaction")
|
||||
}
|
||||
links := p.backendLinks()
|
||||
link, err := links.LinkFor(ctx, in.AdminID)
|
||||
if err != nil {
|
||||
return pact.AccountActionResult{}, err
|
||||
}
|
||||
if err := links.RemoveLink(ctx, tx, link); err != nil {
|
||||
return pact.AccountActionResult{}, err
|
||||
}
|
||||
return pact.AccountActionResult{Message: backendLinkLang + "unlinked"}, nil
|
||||
}
|
||||
@@ -163,6 +163,13 @@ func adminEnvConfig(t *testing.T) *compass.Config {
|
||||
// newAdminEnv boots the plugin alone with the admin API mounted. configure
|
||||
// functions adjust the config before the plugin is activated.
|
||||
func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
|
||||
t.Helper()
|
||||
return newAdminEnvWith(t, nil, configure...)
|
||||
}
|
||||
|
||||
// newAdminEnvWith is newAdminEnv with more registered plugins activated
|
||||
// after golem15.user, by ID.
|
||||
func newAdminEnvWith(t *testing.T, extra []string, configure ...func(*compass.Config)) *adminEnv {
|
||||
t.Helper()
|
||||
gdb, sqlDB := adminHarnessDB(t)
|
||||
app := backpack.New(adminEnvConfig(t))
|
||||
@@ -180,7 +187,7 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
|
||||
}
|
||||
// The registered plugin value is shared by every boot in this binary;
|
||||
// its blacklist sweep is started at most once and is harmless here.
|
||||
plugins, err := party.Activate(app, []string{"golem15.user"})
|
||||
plugins, err := party.Activate(app, append([]string{"golem15.user"}, extra...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
package user
|
||||
|
||||
import "git.golem15.com/golem15/summercms/modules/pact"
|
||||
import (
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
const userPermissionTab = "golem15.user::lang.plugin.tab"
|
||||
|
||||
// Permissions is the plugin's Winter registerPermissions() catalog plus one
|
||||
// code of its own. The four PHP codes default to the developer role;
|
||||
// Permissions is the plugin's Winter registerPermissions() catalog plus two
|
||||
// codes of its own. The four PHP codes default to the developer role;
|
||||
// impersonate_user is registered so imported backend roles that name it stay
|
||||
// valid, although no screen uses it. The fifth code
|
||||
// (classes.PermissionManagePrivilegedGroups) has no default role: only a
|
||||
// superuser, or a role that was granted it explicitly, may change the email
|
||||
// or password of a privileged-group member or delete one.
|
||||
// or password of a privileged-group member or delete one. The sixth
|
||||
// (classes.PermissionManageBackendLinks) has no default role either: it
|
||||
// links and unlinks another administrator's frontend account on the
|
||||
// Administrators screen.
|
||||
func (*Plugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{
|
||||
{
|
||||
@@ -42,5 +48,10 @@ func (*Plugin) Permissions() []pact.Permission {
|
||||
Tab: userPermissionTab,
|
||||
Label: "golem15.user::lang.plugin.manage_privileged_groups",
|
||||
},
|
||||
{
|
||||
Code: classes.PermissionManageBackendLinks,
|
||||
Tab: userPermissionTab,
|
||||
Label: "golem15.user::lang.plugin.manage_backend_links",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ var langKey = regexp.MustCompile(`golem15\.user::lang\.[a-z0-9_]+(\.[a-z0-9_]+)+
|
||||
func TestAdminRegistration(t *testing.T) {
|
||||
plugin := &Plugin{}
|
||||
|
||||
// The five permission codes, each on the plugin's tab.
|
||||
// The six permission codes, each on the plugin's tab.
|
||||
var codes []string
|
||||
for _, permission := range plugin.Permissions() {
|
||||
codes = append(codes, permission.Code)
|
||||
@@ -40,14 +40,15 @@ func TestAdminRegistration(t *testing.T) {
|
||||
wantCodes := []string{
|
||||
"golem15.users.access_users", "golem15.users.access_groups", "golem15.users.access_settings",
|
||||
"golem15.users.impersonate_user", "golem15.users.manage_privileged_groups",
|
||||
"golem15.users.manage_backend_links",
|
||||
}
|
||||
if !reflect.DeepEqual(codes, wantCodes) {
|
||||
t.Fatalf("permission codes = %v, want %v", codes, wantCodes)
|
||||
}
|
||||
// The privileged-groups permission has no default role: nobody holds it
|
||||
// until it is granted on purpose.
|
||||
// The privileged-groups and backend-links permissions have no default
|
||||
// role: nobody holds them until they are granted on purpose.
|
||||
for _, permission := range plugin.Permissions() {
|
||||
if privileged := permission.Code == permManagePrivileged; privileged != (len(permission.Roles) == 0) {
|
||||
if privileged := permission.Code == permManagePrivileged || permission.Code == permManageBackendLinks; privileged != (len(permission.Roles) == 0) {
|
||||
t.Fatalf("permission %s has roles %v", permission.Code, permission.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestAdminUsersTracer(t *testing.T) {
|
||||
if want := []string{
|
||||
"golem15.users.access_users@developer", "golem15.users.access_groups@developer",
|
||||
"golem15.users.access_settings@developer", "golem15.users.impersonate_user@developer",
|
||||
classes.PermissionManagePrivilegedGroups + "@-",
|
||||
classes.PermissionManagePrivilegedGroups + "@-", classes.PermissionManageBackendLinks + "@-",
|
||||
}; !reflect.DeepEqual(codes, want) || classes.PermissionManagePrivilegedGroups != permManagePrivileged {
|
||||
t.Fatalf("permissions = %v", codes)
|
||||
}
|
||||
|
||||
151
backend_link_admin_smoke_test.go
Normal file
151
backend_link_admin_smoke_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
const permManageBackendLinks = "golem15.users.manage_backend_links"
|
||||
|
||||
// administratorPath is the admin API path of one administrator on the
|
||||
// Administrators screen, with an optional suffix.
|
||||
func administratorPath(id uint, suffix string) string {
|
||||
return adminAPI("/summercms/cabana/users/" + strconv.FormatUint(uint64(id), 10) + suffix)
|
||||
}
|
||||
|
||||
// superAdmin is admin with the superuser flag set.
|
||||
func (e *adminEnv) superAdmin(t *testing.T) string {
|
||||
t.Helper()
|
||||
token := e.admin(t)
|
||||
if err := e.gdb.Exec(`UPDATE backend_users SET is_superuser = TRUE WHERE id = ?`, adminID(t, token)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// administratorActions returns the record actions the Administrators screen
|
||||
// offers on an administrator.
|
||||
func (e *adminEnv) administratorActions(t *testing.T, token string, id uint) []string {
|
||||
t.Helper()
|
||||
rec := e.expect(t, http.StatusOK, http.MethodGet, administratorPath(id, ""), token, nil)
|
||||
var meta struct {
|
||||
Actions []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"actions"`
|
||||
}
|
||||
if err := json.Unmarshal(adminDecode(t, rec).Meta, &meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := []string{}
|
||||
for _, action := range meta.Actions {
|
||||
if strings.HasSuffix(action.Name, "frontend_account") {
|
||||
names = append(names, action.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// TestBackendLinkAdminSmoke: on the Administrators screen an administrator
|
||||
// with golem15.users.manage_backend_links links a frontend account to
|
||||
// another administrator by its exact email (any letter case) and unlinks
|
||||
// it; linked_by records the caller, the event fires, a taken or unknown
|
||||
// account is a 422 on the prompt, and an administrator without the
|
||||
// permission is neither offered the actions nor allowed to run them.
|
||||
func TestBackendLinkAdminSmoke(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
var mu sync.Mutex
|
||||
var linked []models.BackendLink
|
||||
env.app.Events.Listen("test.backend_linked", func(_ context.Context, e *classes.BackendLinkedEvent) error {
|
||||
mu.Lock()
|
||||
linked = append(linked, *e.Link)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
stamp := adminStamp()
|
||||
editor := env.seedUser(t, "editor", func(u *models.User) { u.Email = "editor-" + stamp + "@example.test" })
|
||||
second := env.seedUser(t, "second", nil)
|
||||
targetToken := env.admin(t)
|
||||
target := adminID(t, targetToken)
|
||||
super := env.superAdmin(t)
|
||||
superID := adminID(t, super)
|
||||
resolver, _ := env.app.Lookup[pact.MCPLinkedBackendResolver]()
|
||||
|
||||
if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"link_frontend_account"}) {
|
||||
t.Fatalf("offered before the link = %v", got)
|
||||
}
|
||||
link := func(token, value string) *http.Response {
|
||||
t.Helper()
|
||||
rec := env.call(t, http.MethodPost, administratorPath(target, "/actions/link_frontend_account"), token, map[string]any{"payload": value})
|
||||
return rec.Result()
|
||||
}
|
||||
|
||||
// Unknown email: 422 on payload with the unknown_user text.
|
||||
rec := env.call(t, http.MethodPost, administratorPath(target, "/actions/link_frontend_account"), super, map[string]any{"payload": "nobody-" + stamp + "@example.test"})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !slices.Contains(adminDecode(t, rec).Error.Details["payload"], "No active frontend user has this email.") {
|
||||
t.Fatalf("unknown email = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// The email matches whatever its letter case.
|
||||
if res := link(super, " Editor-"+stamp+"@Example.TEST "); res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("set = %d", res.StatusCode)
|
||||
}
|
||||
var row models.BackendLink
|
||||
if err := env.gdb.Where("backend_user_id = ?", target).Take(&row).Error; err != nil || row.UserID != editor.ID || row.LinkedBy == nil || *row.LinkedBy != superID {
|
||||
t.Fatalf("link row = %+v %v", row, err)
|
||||
}
|
||||
if got, found, err := resolver.LinkedBackendUser(context.Background(), editor.ID); err != nil || !found || got.ID != target {
|
||||
t.Fatalf("LinkedBackendUser = %+v %v %v", got, found, err)
|
||||
}
|
||||
mu.Lock()
|
||||
if len(linked) != 1 || linked[0].UserID != editor.ID || linked[0].BackendUserID != target {
|
||||
t.Fatalf("BackendLinkedEvent saw %+v", linked)
|
||||
}
|
||||
mu.Unlock()
|
||||
if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"unlink_frontend_account"}) {
|
||||
t.Fatalf("offered after the link = %v", got)
|
||||
}
|
||||
|
||||
// The administrator is linked: a second set no longer applies (409),
|
||||
// and the same frontend user cannot be linked to another administrator.
|
||||
if res := link(super, second.Email); res.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("second set on a linked administrator = %d", res.StatusCode)
|
||||
}
|
||||
otherToken := env.admin(t)
|
||||
other := adminID(t, otherToken)
|
||||
rec = env.call(t, http.MethodPost, administratorPath(other, "/actions/link_frontend_account"), super, map[string]any{"payload": editor.Email})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !slices.Contains(adminDecode(t, rec).Error.Details["payload"], "This administrator or this frontend user is already linked.") {
|
||||
t.Fatalf("frontend user linked twice = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Without the permission: not offered, and refused.
|
||||
limited := env.admin(t, cabana.PermissionManageUsers)
|
||||
if got := env.administratorActions(t, limited, target); len(got) != 0 {
|
||||
t.Fatalf("limited administrator is offered %v", got)
|
||||
}
|
||||
env.expect(t, http.StatusForbidden, http.MethodPost, administratorPath(target, "/actions/unlink_frontend_account"), limited, map[string]any{})
|
||||
env.expect(t, http.StatusForbidden, http.MethodPost, administratorPath(other, "/actions/link_frontend_account"), limited, map[string]any{"payload": second.Email})
|
||||
manager := env.admin(t, cabana.PermissionManageUsers, permManageBackendLinks)
|
||||
if got := env.administratorActions(t, manager, target); !slices.Equal(got, []string{"unlink_frontend_account"}) {
|
||||
t.Fatalf("manager is offered %v", got)
|
||||
}
|
||||
|
||||
// Unlink.
|
||||
env.expect(t, http.StatusOK, http.MethodPost, administratorPath(target, "/actions/unlink_frontend_account"), manager, map[string]any{})
|
||||
if _, found, err := resolver.LinkedBackendUser(context.Background(), editor.ID); err != nil || found {
|
||||
t.Fatalf("after unlink found=%v err=%v", found, err)
|
||||
}
|
||||
if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"link_frontend_account"}) {
|
||||
t.Fatalf("offered after the unlink = %v", got)
|
||||
}
|
||||
}
|
||||
272
backend_link_smoke_test.go
Normal file
272
backend_link_smoke_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// subjectSpy records the pact.MCPSubjectChanged events fired on an app.
|
||||
type subjectSpy struct {
|
||||
mu sync.Mutex
|
||||
events []pact.MCPSubjectChanged
|
||||
}
|
||||
|
||||
func (s *subjectSpy) has(guard string, id uint, kind string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return slices.Contains(s.events, pact.MCPSubjectChanged{Guard: guard, UserID: id, Kind: kind})
|
||||
}
|
||||
|
||||
func (s *subjectSpy) reset() {
|
||||
s.mu.Lock()
|
||||
s.events = nil
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// spySubjects listens for the subject events the plugin fires.
|
||||
func (e *adminEnv) spySubjects() *subjectSpy {
|
||||
spy := &subjectSpy{}
|
||||
e.app.Events.Listen("test.subjects", func(_ context.Context, ev *pact.MCPSubjectChanged) error {
|
||||
spy.mu.Lock()
|
||||
spy.events = append(spy.events, *ev)
|
||||
spy.mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
return spy
|
||||
}
|
||||
|
||||
// adminID is the backend user id inside an admin session token.
|
||||
func adminID(t *testing.T, token string) uint {
|
||||
t.Helper()
|
||||
sub, _, _, _, err := bouncer.VerifyClaimsAudience(token, adminHarnessSecret, bouncer.AudienceBackend)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := strconv.ParseUint(sub, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return uint(id)
|
||||
}
|
||||
|
||||
// adminLogin reads an administrator's login.
|
||||
func (e *adminEnv) adminLogin(t *testing.T, id uint) string {
|
||||
t.Helper()
|
||||
var login string
|
||||
if err := e.gdb.Raw(`SELECT login FROM backend_users WHERE id = ?`, id).Scan(&login).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return login
|
||||
}
|
||||
|
||||
// freshUserToken mints a frontend session token issued now.
|
||||
func freshUserToken(t *testing.T, user *models.User) string {
|
||||
t.Helper()
|
||||
token, _, err := bouncer.Mint(sessionSecret, strconv.FormatUint(uint64(user.ID), 10), "https://app.example.test", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// accountSection is one My account contribution as the admin API lists it.
|
||||
type accountSection struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Actions []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"actions"`
|
||||
}
|
||||
|
||||
func (s accountSection) actions() []string {
|
||||
out := []string{}
|
||||
for _, a := range s.Actions {
|
||||
out = append(out, a.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// frontendSection lists My account and returns the frontend_account section.
|
||||
func (e *adminEnv) frontendSection(t *testing.T, token string) accountSection {
|
||||
t.Helper()
|
||||
var sections []accountSection
|
||||
adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, adminAPI("/account/contributions"), token, nil), §ions)
|
||||
for _, s := range sections {
|
||||
if s.Name == "frontend_account" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
t.Fatalf("My account has no frontend_account section: %+v", sections)
|
||||
return accountSection{}
|
||||
}
|
||||
|
||||
var linkURL = regexp.MustCompile(`^https://app\.example\.test/link-admin\?code=([a-f0-9]{64})$`)
|
||||
|
||||
// startLink runs My account's link action and returns the code in its URL.
|
||||
func (e *adminEnv) startLink(t *testing.T, token string) string {
|
||||
t.Helper()
|
||||
var out struct {
|
||||
Message string `json:"message"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
adminInto(t, e.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), token, map[string]any{}), &out)
|
||||
m := linkURL.FindStringSubmatch(out.URL)
|
||||
if m == nil || out.Message == "" {
|
||||
t.Fatalf("link action answered %+v", out)
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// confirmLink posts the code as the frontend session token.
|
||||
func (e *adminEnv) confirmLink(t *testing.T, userToken, code string) (int, string) {
|
||||
t.Helper()
|
||||
rec := e.call(t, http.MethodPost, "/_user/api/v1/backend-link/confirm", userToken, map[string]string{"code": code})
|
||||
return rec.Code, rec.Body.String()
|
||||
}
|
||||
|
||||
// TestBackendLinkSmoke: an administrator starts a link on My account, the
|
||||
// site shows who asked, a session older than the request is refused, a
|
||||
// fresh one links the accounts (the published resolver answers), the code is
|
||||
// one-shot, the 1:1 rule holds, unlink and deleting the frontend user remove
|
||||
// the link, and the subject events fire.
|
||||
func TestBackendLinkSmoke(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
spy := env.spySubjects()
|
||||
frontend := env.seedUser(t, "linker", nil)
|
||||
other := env.seedUser(t, "linker-other", nil)
|
||||
token := env.admin(t)
|
||||
id := adminID(t, token)
|
||||
login := env.adminLogin(t, id)
|
||||
links, ok := env.app.Lookup[*classes.BackendLinks]()
|
||||
if !ok || links == nil {
|
||||
t.Fatal("*classes.BackendLinks is not published")
|
||||
}
|
||||
resolver, ok := env.app.Lookup[pact.MCPLinkedBackendResolver]()
|
||||
if !ok || resolver == nil {
|
||||
t.Fatal("pact.MCPLinkedBackendResolver is not published")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
section := env.frontendSection(t, token)
|
||||
if section.Status != "Not linked" || !slices.Equal(section.actions(), []string{"link"}) {
|
||||
t.Fatalf("unlinked section = %+v", section)
|
||||
}
|
||||
|
||||
code := env.startLink(t, token)
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil)
|
||||
var shown struct {
|
||||
Data struct {
|
||||
Admin map[string]any `json:"admin"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &shown); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shown.Data.Admin["login"] != login || shown.Data.Admin["name"] != login || len(shown.Data.Admin) != 2 {
|
||||
t.Fatalf("show = %s", rec.Body.String())
|
||||
}
|
||||
if expires, err := time.Parse(time.RFC3339, shown.Data.ExpiresAt); err != nil || time.Until(expires) < 9*time.Minute || time.Until(expires) > 11*time.Minute {
|
||||
t.Fatalf("expires_at = %q (%v)", shown.Data.ExpiresAt, err)
|
||||
}
|
||||
env.expect(t, http.StatusNotFound, http.MethodGet, "/_user/api/v1/backend-link/"+strings.Repeat("0", 64), "", nil)
|
||||
|
||||
// A session signed in before the request is refused: sign in again.
|
||||
stale := craftToken(t, strconv.FormatUint(uint64(frontend.ID), 10), "stale-"+adminStamp(), time.Now().Add(-time.Hour), time.Now().Add(time.Hour))
|
||||
if status, body := env.confirmLink(t, stale, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
|
||||
t.Fatalf("stale confirm = %d %s", status, body)
|
||||
}
|
||||
if status, _ := env.confirmLink(t, "", code); status != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous confirm = %d", status)
|
||||
}
|
||||
|
||||
spy.reset()
|
||||
status, body := env.confirmLink(t, freshUserToken(t, frontend), code)
|
||||
if status != http.StatusOK || !strings.Contains(body, `"login":"`+login+`"`) {
|
||||
t.Fatalf("fresh confirm = %d %s", status, body)
|
||||
}
|
||||
linked, found, err := resolver.LinkedBackendUser(ctx, frontend.ID)
|
||||
if err != nil || !found || linked.ID != id || linked.Login != login || linked.DisplayName != login {
|
||||
t.Fatalf("LinkedBackendUser = %+v %v %v", linked, found, err)
|
||||
}
|
||||
if !spy.has("user", frontend.ID, pact.MCPSubjectLink) || !spy.has("backend", id, pact.MCPSubjectLink) {
|
||||
t.Fatalf("confirm fired %+v", spy.events)
|
||||
}
|
||||
var row models.BackendLink
|
||||
if err := env.gdb.Where("user_id = ?", frontend.ID).Take(&row).Error; err != nil || row.BackendUserID != id || row.LinkedBy != nil {
|
||||
t.Fatalf("link row = %+v %v", row, err)
|
||||
}
|
||||
section = env.frontendSection(t, token)
|
||||
if !strings.Contains(section.Status, frontend.Email) || !slices.Equal(section.actions(), []string{"unlink"}) {
|
||||
t.Fatalf("linked section = %+v", section)
|
||||
}
|
||||
|
||||
// The code is one-shot.
|
||||
if status, _ := env.confirmLink(t, freshUserToken(t, frontend), code); status != http.StatusNotFound {
|
||||
t.Fatalf("replayed confirm = %d", status)
|
||||
}
|
||||
env.expect(t, http.StatusNotFound, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil)
|
||||
|
||||
// One link per administrator: another frontend user cannot take it.
|
||||
second, _, err := links.CreateLinkRequest(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status, body := env.confirmLink(t, freshUserToken(t, other), second); status != http.StatusConflict || !strings.Contains(body, `"already_linked"`) {
|
||||
t.Fatalf("second link = %d %s", status, body)
|
||||
}
|
||||
|
||||
// Unlink on My account.
|
||||
spy.reset()
|
||||
env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/unlink"), token, map[string]any{})
|
||||
if _, found, err := resolver.LinkedBackendUser(ctx, frontend.ID); err != nil || found {
|
||||
t.Fatalf("after unlink LinkedBackendUser found=%v err=%v", found, err)
|
||||
}
|
||||
if !spy.has("user", frontend.ID, pact.MCPSubjectLink) || !spy.has("backend", id, pact.MCPSubjectLink) {
|
||||
t.Fatalf("unlink fired %+v", spy.events)
|
||||
}
|
||||
if section := env.frontendSection(t, token); !slices.Equal(section.actions(), []string{"link"}) {
|
||||
t.Fatalf("section after unlink = %+v", section)
|
||||
}
|
||||
|
||||
// An expired request answers 404 like an unknown one.
|
||||
expired, _, err := links.CreateLinkRequest(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.gdb.Exec(`UPDATE golem15_user_backend_link_requests SET expires_at = NOW() - INTERVAL '1 minute' WHERE backend_user_id = ? AND used_at IS NULL`, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status, _ := env.confirmLink(t, freshUserToken(t, frontend), expired); status != http.StatusNotFound {
|
||||
t.Fatalf("expired confirm = %d", status)
|
||||
}
|
||||
|
||||
// Deleting the frontend user removes the link and ends its subject.
|
||||
code = env.startLink(t, token)
|
||||
if status, body := env.confirmLink(t, freshUserToken(t, frontend), code); status != http.StatusOK {
|
||||
t.Fatalf("relink = %d %s", status, body)
|
||||
}
|
||||
spy.reset()
|
||||
manager := env.admin(t, permAccessUsers)
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, userPath(frontend.ID, ""), manager, nil)
|
||||
var left int64
|
||||
if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_backend_links WHERE backend_user_id = ?`, id).Scan(&left).Error; err != nil || left != 0 {
|
||||
t.Fatalf("links left after delete = %d %v", left, err)
|
||||
}
|
||||
if !spy.has("user", frontend.ID, pact.MCPSubjectDeleted) || !spy.has("backend", id, pact.MCPSubjectLink) {
|
||||
t.Fatalf("delete fired %+v", spy.events)
|
||||
}
|
||||
}
|
||||
128
backend_links_admin.go
Normal file
128
backend_links_admin.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var _ pact.HasAdminActionExtensions = (*Plugin)(nil)
|
||||
|
||||
// AdminActionExtensions adds two record actions to the admin's
|
||||
// Administrators screen (D-07, D-34), both behind
|
||||
// golem15.users.manage_backend_links: link_frontend_account asks for the
|
||||
// email of an active frontend user and links that user to the
|
||||
// administrator shown (exact, case-insensitive match; no search and no
|
||||
// list), recording who set it; unlink_frontend_account removes the
|
||||
// administrator's link. cabana's outranking check runs before either.
|
||||
func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
|
||||
return []pact.AdminActionExtension{{
|
||||
Controller: cabana.AdministratorsControllerID,
|
||||
RecordActions: []pact.AdminRecordAction{
|
||||
{
|
||||
Name: "link_frontend_account",
|
||||
Label: backendLinkLang + "set",
|
||||
Confirm: backendLinkLang + "set_confirm",
|
||||
Permissions: []string{classes.PermissionManageBackendLinks},
|
||||
Prompt: &pact.AdminActionPrompt{
|
||||
Label: backendLinkLang + "prompt_email",
|
||||
Comment: backendLinkLang + "prompt_comment",
|
||||
MaxLength: 254,
|
||||
},
|
||||
Applies: func(ctx context.Context, record any) (bool, error) {
|
||||
link, err := p.adminRecordLink(ctx, record)
|
||||
return link == nil, err
|
||||
},
|
||||
Run: p.setFrontendLink,
|
||||
},
|
||||
{
|
||||
Name: "unlink_frontend_account",
|
||||
Label: backendLinkLang + "remove",
|
||||
Confirm: backendLinkLang + "remove_confirm",
|
||||
Danger: true,
|
||||
Permissions: []string{classes.PermissionManageBackendLinks},
|
||||
Applies: func(ctx context.Context, record any) (bool, error) {
|
||||
link, err := p.adminRecordLink(ctx, record)
|
||||
return link != nil, err
|
||||
},
|
||||
Run: p.removeFrontendLink,
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// adminRecordLink returns the link of the administrator record, or nil.
|
||||
func (p *Plugin) adminRecordLink(ctx context.Context, record any) (*models.BackendLink, error) {
|
||||
admin, ok := record.(*cabana.BackendUser)
|
||||
if !ok || admin == nil {
|
||||
return nil, errors.New("golem15.user: the record is not an administrator")
|
||||
}
|
||||
return p.backendLinks().LinkFor(ctx, admin.ID)
|
||||
}
|
||||
|
||||
// payloadError is the 422 on the prompt's input.
|
||||
func payloadError(key string) error {
|
||||
return &cabana.ValidationError{Details: map[string]any{"payload": []string{backendLinkLang + key}}}
|
||||
}
|
||||
|
||||
// setFrontendLink links the active frontend user whose email is the prompt
|
||||
// value to the administrator record.
|
||||
func (p *Plugin) setFrontendLink(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||
admin, ok := in.Record.(*cabana.BackendUser)
|
||||
if !ok || admin == nil {
|
||||
return pact.AdminRecordActionResult{}, errors.New("golem15.user: the record is not an administrator")
|
||||
}
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return pact.AdminRecordActionResult{}, errors.New("golem15.user: linking needs the action's transaction")
|
||||
}
|
||||
email := strings.TrimSpace(in.Value)
|
||||
var ids []uint
|
||||
if err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Model(&models.User{}).
|
||||
Where("lower(email) = lower(?) AND is_activated = TRUE", email).Limit(2).Pluck("id", &ids).Error; err != nil {
|
||||
return pact.AdminRecordActionResult{}, err
|
||||
}
|
||||
if email == "" || len(ids) != 1 {
|
||||
return pact.AdminRecordActionResult{}, payloadError("unknown_user")
|
||||
}
|
||||
var by uint
|
||||
if caller, ok := bouncer.User(ctx); ok && caller != nil {
|
||||
by = caller.ID
|
||||
}
|
||||
_, err := p.backendLinks().SetLink(ctx, tx, ids[0], admin.ID, by)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, classes.ErrAlreadyLinked):
|
||||
return pact.AdminRecordActionResult{}, payloadError("already_linked")
|
||||
case errors.Is(err, classes.ErrFrontendUserUnavailable):
|
||||
return pact.AdminRecordActionResult{}, payloadError("unknown_user")
|
||||
case errors.Is(err, classes.ErrBackendUserUnavailable):
|
||||
return pact.AdminRecordActionResult{}, &cabana.ForbiddenError{}
|
||||
default:
|
||||
return pact.AdminRecordActionResult{}, err
|
||||
}
|
||||
return pact.AdminRecordActionResult{Message: backendLinkLang + "set_success"}, nil
|
||||
}
|
||||
|
||||
// removeFrontendLink removes the administrator record's link.
|
||||
func (p *Plugin) removeFrontendLink(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return pact.AdminRecordActionResult{}, errors.New("golem15.user: unlinking needs the action's transaction")
|
||||
}
|
||||
link, err := p.adminRecordLink(ctx, in.Record)
|
||||
if err != nil {
|
||||
return pact.AdminRecordActionResult{}, err
|
||||
}
|
||||
if err := p.backendLinks().RemoveLink(ctx, tx, link); err != nil {
|
||||
return pact.AdminRecordActionResult{}, err
|
||||
}
|
||||
return pact.AdminRecordActionResult{Message: backendLinkLang + "remove_success"}, nil
|
||||
}
|
||||
507
classes/backend_links.go
Normal file
507
classes/backend_links.go
Normal file
@@ -0,0 +1,507 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Errors of the account link service. A request that is unknown, used or
|
||||
// expired is reported apart so callers can log the difference; the site
|
||||
// routes answer all three with the same 404.
|
||||
var (
|
||||
ErrLinkRequestNotFound = errors.New("golem15.user: backend link request not found")
|
||||
ErrLinkRequestUsed = errors.New("golem15.user: backend link request already used")
|
||||
ErrLinkRequestExpired = errors.New("golem15.user: backend link request expired")
|
||||
ErrStaleSession = errors.New("golem15.user: the session was signed in before the link request")
|
||||
ErrAlreadyLinked = errors.New("golem15.user: one of the accounts is already linked")
|
||||
ErrBackendUserUnavailable = errors.New("golem15.user: the administrator is missing or not activated")
|
||||
ErrFrontendUserUnavailable = errors.New("golem15.user: the frontend user is missing or deactivated")
|
||||
)
|
||||
|
||||
// DefaultBackendLinkRequestTTL is how long a link request stays usable when
|
||||
// golem15.user.backend_links.request_ttl is not set.
|
||||
const DefaultBackendLinkRequestTTL = 10 * time.Minute
|
||||
|
||||
var _ pact.MCPLinkedBackendResolver = (*BackendLinks)(nil)
|
||||
|
||||
// BackendLinks is the verified link between a frontend user and a backend
|
||||
// administrator (D-03): one link per account on each side, written either
|
||||
// after the frontend user proves the account with a fresh sign-in
|
||||
// (CreateLinkRequest, then ConfirmLinkRequest) or by an administrator with
|
||||
// golem15.users.manage_backend_links on the Administrators screen (SetLink).
|
||||
// Boot publishes it as *BackendLinks and as pact.MCPLinkedBackendResolver.
|
||||
//
|
||||
// Every read and write uses the transaction cabana runs a hook or an action
|
||||
// in when the context carries one (cabana.TxFromContext), else the
|
||||
// application's database.
|
||||
type BackendLinks struct {
|
||||
App *backpack.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
|
||||
// lifetime.
|
||||
func BackendLinkRequestTTL(app *backpack.App) (time.Duration, error) {
|
||||
if app == nil || app.Config == nil {
|
||||
return DefaultBackendLinkRequestTTL, nil
|
||||
}
|
||||
raw := strings.TrimSpace(app.Config.String("golem15.user.backend_links.request_ttl"))
|
||||
if raw == "" {
|
||||
return DefaultBackendLinkRequestTTL, nil
|
||||
}
|
||||
ttl, err := time.ParseDuration(raw)
|
||||
if err != nil || ttl <= 0 {
|
||||
return 0, fmt.Errorf("golem15.user: backend_links.request_ttl must be a positive duration such as 10m, got %q", raw)
|
||||
}
|
||||
return ttl, nil
|
||||
}
|
||||
|
||||
// FrontendURL is the site the plugin sends people to for pages it does not
|
||||
// serve itself (the account link page): golem15.user.frontend_url, falling
|
||||
// back to app.url, without a trailing slash. Empty when neither is set.
|
||||
func FrontendURL(app *backpack.App) string {
|
||||
if app == nil || app.Config == nil {
|
||||
return ""
|
||||
}
|
||||
base := strings.TrimSpace(app.Config.String("golem15.user.frontend_url"))
|
||||
if base == "" {
|
||||
base = strings.TrimSpace(app.Config.String("app.url"))
|
||||
}
|
||||
return strings.TrimRight(base, "/")
|
||||
}
|
||||
|
||||
// db returns the handle a call works on: the cabana transaction on ctx, or
|
||||
// the application's database.
|
||||
func (s *BackendLinks) db(ctx context.Context) (*gorm.DB, error) {
|
||||
if tx, ok := cabana.TxFromContext(ctx); ok {
|
||||
return fresh(ctx, tx), nil
|
||||
}
|
||||
if s == nil || s.App == nil {
|
||||
return nil, fmt.Errorf("golem15.user: app is nil")
|
||||
}
|
||||
gdb, ok := s.App.Lookup[*gorm.DB]()
|
||||
if !ok || gdb == nil {
|
||||
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||
}
|
||||
return gdb.WithContext(ctx), nil
|
||||
}
|
||||
|
||||
// hashLinkCode is the only form of a link code that is stored.
|
||||
func hashLinkCode(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// validLinkCode reports whether plain has the shape CreateLinkRequest
|
||||
// issues: 64 lower-case hex characters.
|
||||
func validLinkCode(plain string) bool {
|
||||
if len(plain) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range plain {
|
||||
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateLinkRequest starts a link for the administrator: a one-shot code
|
||||
// (32 random bytes, hex) valid for golem15.user.backend_links.request_ttl,
|
||||
// stored as its SHA-256. The administrator's earlier requests that were not
|
||||
// used are dropped, so only the newest code works. The plain code is
|
||||
// returned once, for the site URL.
|
||||
func (s *BackendLinks) CreateLinkRequest(ctx context.Context, backendUserID uint) (string, time.Time, error) {
|
||||
if backendUserID == 0 {
|
||||
return "", time.Time{}, ErrBackendUserUnavailable
|
||||
}
|
||||
ttl, err := BackendLinkRequestTTL(s.app())
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
db, err := s.db(ctx)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
plain, err := newCode()
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
req := &models.BackendLinkRequest{
|
||||
CodeHash: hashLinkCode(plain),
|
||||
BackendUserID: backendUserID,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := fresh(ctx, db).Where("backend_user_id = ? AND used_at IS NULL", backendUserID).
|
||||
Delete(&models.BackendLinkRequest{}).Error; err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
if err := fresh(ctx, db).Create(req).Error; err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
return plain, req.ExpiresAt, nil
|
||||
}
|
||||
|
||||
// LinkRequestByCode returns a usable request and its administrator for the
|
||||
// site's link page. An unknown, used or expired code is
|
||||
// ErrLinkRequestNotFound, ErrLinkRequestUsed or ErrLinkRequestExpired; an
|
||||
// administrator who was deactivated or deleted since is
|
||||
// ErrBackendUserUnavailable.
|
||||
func (s *BackendLinks) LinkRequestByCode(ctx context.Context, plain string) (*models.BackendLinkRequest, *cabana.BackendUser, error) {
|
||||
if !validLinkCode(plain) {
|
||||
return nil, nil, ErrLinkRequestNotFound
|
||||
}
|
||||
db, err := s.db(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var rows []models.BackendLinkRequest
|
||||
if err := fresh(ctx, db).Where("code_hash = ?", hashLinkCode(plain)).Limit(1).Find(&rows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil, ErrLinkRequestNotFound
|
||||
}
|
||||
req := &rows[0]
|
||||
if err := requestUsable(req, time.Now()); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
admin, err := activeBackendUser(ctx, db, req.BackendUserID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return req, admin, nil
|
||||
}
|
||||
|
||||
func requestUsable(req *models.BackendLinkRequest, now time.Time) error {
|
||||
if req.UsedAt != nil {
|
||||
return ErrLinkRequestUsed
|
||||
}
|
||||
if !req.ExpiresAt.After(now) {
|
||||
return ErrLinkRequestExpired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfirmLinkRequest stores the link between the signed-in frontend user and
|
||||
// the administrator who created the request, in one transaction: the request
|
||||
// row is locked, must be unused and unexpired, and the session must have
|
||||
// been signed in at or after the request was created (sessionIssuedAt is
|
||||
// the JWT iat, compared in whole seconds because iat has no fraction), so a
|
||||
// browser that was already signed in has to sign in again (D-05). Neither
|
||||
// account may be linked already (ErrAlreadyLinked) and the administrator
|
||||
// must still be activated (ErrBackendUserUnavailable). The request is used
|
||||
// up with the link; BackendLinkedEvent fires inside the transaction and the
|
||||
// two pact.MCPSubjectChanged events after it commits.
|
||||
func (s *BackendLinks) ConfirmLinkRequest(ctx context.Context, plain string, frontendUserID uint, sessionIssuedAt time.Time) (*models.BackendLink, error) {
|
||||
if !validLinkCode(plain) {
|
||||
return nil, ErrLinkRequestNotFound
|
||||
}
|
||||
if frontendUserID == 0 {
|
||||
return nil, ErrFrontendUserUnavailable
|
||||
}
|
||||
gdb, err := s.pool()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var link *models.BackendLink
|
||||
err = lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error {
|
||||
var rows []models.BackendLinkRequest
|
||||
if err := fresh(ctx, tx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("code_hash = ?", hashLinkCode(plain)).Limit(1).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return ErrLinkRequestNotFound
|
||||
}
|
||||
req := &rows[0]
|
||||
now := time.Now()
|
||||
if err := requestUsable(req, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if sessionIssuedAt.IsZero() || sessionIssuedAt.Before(req.CreatedAt.Truncate(time.Second)) {
|
||||
return ErrStaleSession
|
||||
}
|
||||
if _, err := activeBackendUser(ctx, tx, req.BackendUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
created, err := s.insertLink(ctx, tx, frontendUserID, req.BackendUserID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fresh(ctx, tx).Model(&models.BackendLinkRequest{}).Where("id = ?", req.ID).
|
||||
Update("used_at", now).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
link = created
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
// SetLink stores a link an administrator sets on the Administrators screen
|
||||
// (D-07): the frontend user must exist and not be deactivated
|
||||
// (ErrFrontendUserUnavailable), the administrator must be activated
|
||||
// (ErrBackendUserUnavailable) and neither may be linked already
|
||||
// (ErrAlreadyLinked). linkedBy is recorded (0 records none). It runs in tx,
|
||||
// fires BackendLinkedEvent there and the two pact.MCPSubjectChanged events
|
||||
// after tx commits.
|
||||
func (s *BackendLinks) SetLink(ctx context.Context, tx *gorm.DB, frontendUserID, backendUserID, linkedBy uint) (*models.BackendLink, error) {
|
||||
if tx == nil {
|
||||
return nil, fmt.Errorf("golem15.user: setting a link needs a transaction")
|
||||
}
|
||||
var users []models.User
|
||||
if err := fresh(ctx, tx).Where("id = ?", frontendUserID).Limit(1).Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frontendUserID == 0 || len(users) == 0 {
|
||||
return nil, ErrFrontendUserUnavailable
|
||||
}
|
||||
if _, err := activeBackendUser(ctx, tx, backendUserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var by *uint
|
||||
if linkedBy != 0 {
|
||||
by = &linkedBy
|
||||
}
|
||||
return s.insertLink(ctx, tx, frontendUserID, backendUserID, by)
|
||||
}
|
||||
|
||||
// insertLink writes one link in tx after checking both sides are free; a
|
||||
// concurrent writer that wins the unique index also ends in
|
||||
// ErrAlreadyLinked.
|
||||
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{}).
|
||||
Where("user_id = ? OR backend_user_id = ?", frontendUserID, backendUserID).
|
||||
Count(&taken).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken > 0 {
|
||||
return nil, ErrAlreadyLinked
|
||||
}
|
||||
link := &models.BackendLink{UserID: frontendUserID, BackendUserID: backendUserID, LinkedBy: linkedBy, CreatedAt: time.Now()}
|
||||
if err := fresh(ctx, tx).Create(link).Error; err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return nil, ErrAlreadyLinked
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
app := s.app()
|
||||
if app != nil && app.Events != nil {
|
||||
if err := app.Events.Fire(ctx, &BackendLinkedEvent{Link: link, Tx: tx}); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", BackendLinkedEventName, err)
|
||||
}
|
||||
}
|
||||
FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardUser, link.UserID, pact.MCPSubjectLink)
|
||||
FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardBackend, link.BackendUserID, pact.MCPSubjectLink)
|
||||
return link, nil
|
||||
}
|
||||
|
||||
// RemoveLink deletes link in tx, fires BackendUnlinkedEvent 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 {
|
||||
if link == nil || link.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
if tx == nil {
|
||||
return fmt.Errorf("golem15.user: removing a link needs a transaction")
|
||||
}
|
||||
res := fresh(ctx, tx).Where("id = ?", link.ID).Delete(&models.BackendLink{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
app := s.app()
|
||||
if app != nil && app.Events != nil {
|
||||
if err := app.Events.Fire(ctx, &BackendUnlinkedEvent{Link: link, Tx: tx}); err != nil {
|
||||
return fmt.Errorf("%s: %w", BackendUnlinkedEventName, err)
|
||||
}
|
||||
}
|
||||
FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardUser, link.UserID, pact.MCPSubjectLink)
|
||||
FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardBackend, link.BackendUserID, pact.MCPSubjectLink)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LinkFor returns the link of the administrator, or nil.
|
||||
func (s *BackendLinks) LinkFor(ctx context.Context, backendUserID uint) (*models.BackendLink, error) {
|
||||
return s.linkWhere(ctx, "backend_user_id = ?", backendUserID)
|
||||
}
|
||||
|
||||
// LinkOf returns the link of the frontend user, or nil.
|
||||
func (s *BackendLinks) LinkOf(ctx context.Context, frontendUserID uint) (*models.BackendLink, error) {
|
||||
return s.linkWhere(ctx, "user_id = ?", frontendUserID)
|
||||
}
|
||||
|
||||
func (s *BackendLinks) linkWhere(ctx context.Context, where string, id uint) (*models.BackendLink, error) {
|
||||
if id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
db, err := s.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []models.BackendLink
|
||||
if err := fresh(ctx, db).Where(where, id).Limit(1).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *BackendLinks) LinkedBackendUser(ctx context.Context, frontendUserID uint) (pact.LinkedBackendUser, bool, error) {
|
||||
if frontendUserID == 0 {
|
||||
return pact.LinkedBackendUser{}, false, nil
|
||||
}
|
||||
db, err := s.db(ctx)
|
||||
if err != nil {
|
||||
return pact.LinkedBackendUser{}, false, err
|
||||
}
|
||||
var rows []struct {
|
||||
ID uint
|
||||
Login string
|
||||
FirstName *string
|
||||
LastName *string
|
||||
}
|
||||
err = fresh(ctx, db).Raw(`SELECT b.id, b.login, b.first_name, b.last_name
|
||||
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
|
||||
LIMIT 1`, frontendUserID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return pact.LinkedBackendUser{}, false, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return pact.LinkedBackendUser{}, false, nil
|
||||
}
|
||||
row := rows[0]
|
||||
return pact.LinkedBackendUser{ID: row.ID, Login: row.Login, DisplayName: backendDisplayName(deref(row.FirstName), deref(row.LastName), row.Login)}, true, nil
|
||||
}
|
||||
|
||||
// BackendDisplayName is the name the link pages show for an administrator:
|
||||
// first and last name, else the login.
|
||||
func BackendDisplayName(admin *cabana.BackendUser) string {
|
||||
if admin == nil {
|
||||
return ""
|
||||
}
|
||||
return backendDisplayName(admin.FirstName, admin.LastName, admin.Login)
|
||||
}
|
||||
|
||||
func backendDisplayName(first, last, login string) string {
|
||||
if name := strings.TrimSpace(strings.TrimSpace(first) + " " + strings.TrimSpace(last)); name != "" {
|
||||
return name
|
||||
}
|
||||
return login
|
||||
}
|
||||
|
||||
func deref(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// activeBackendUser loads an activated, not deleted administrator, else
|
||||
// ErrBackendUserUnavailable.
|
||||
func activeBackendUser(ctx context.Context, db *gorm.DB, id uint) (*cabana.BackendUser, error) {
|
||||
if id == 0 {
|
||||
return nil, ErrBackendUserUnavailable
|
||||
}
|
||||
var rows []cabana.BackendUser
|
||||
if err := fresh(ctx, db).Where("id = ? AND is_activated = TRUE", id).Limit(1).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, ErrBackendUserUnavailable
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
// RegisterBackendLinkListeners removes a frontend user's link inside the
|
||||
// transaction that deletes (DeletingEvent) or deactivates
|
||||
// (DeactivatingEvent) the user, and fires pact.MCPSubjectChanged deleted
|
||||
// for the user and link for the administrator after it commits.
|
||||
func (s *BackendLinks) RegisterBackendLinkListeners(app *backpack.App) {
|
||||
if app == nil || app.Events == nil {
|
||||
return
|
||||
}
|
||||
const owner = "golem15.user.backend_links"
|
||||
app.Events.Listen(owner, func(ctx context.Context, e *DeletingEvent) error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return s.dropUserLink(ctx, e.Tx, e.User)
|
||||
})
|
||||
app.Events.Listen(owner, func(ctx context.Context, e *DeactivatingEvent) error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return s.dropUserLink(ctx, e.Tx, e.User)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BackendLinks) dropUserLink(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
||||
if user == nil || user.ID == 0 || tx == nil {
|
||||
return nil
|
||||
}
|
||||
var rows []models.BackendLink
|
||||
if err := fresh(ctx, tx).Where("user_id = ?", user.ID).Limit(1).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
if err := s.RemoveLink(ctx, tx, &rows[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
FireSubjectChangedAfterCommit(ctx, s.app(), tx, SubjectGuardUser, user.ID, pact.MCPSubjectDeleted)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BackendLinks) app() *backpack.App {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.App
|
||||
}
|
||||
|
||||
func (s *BackendLinks) pool() (*gorm.DB, error) {
|
||||
if s == nil || s.App == nil {
|
||||
return nil, fmt.Errorf("golem15.user: app is nil")
|
||||
}
|
||||
gdb, ok := s.App.Lookup[*gorm.DB]()
|
||||
if !ok || gdb == nil {
|
||||
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||
}
|
||||
return gdb, nil
|
||||
}
|
||||
@@ -14,6 +14,12 @@ const DeletingEventName = "user.Deleting"
|
||||
// DeactivatingEventName is the festival name of DeactivatingEvent.
|
||||
const DeactivatingEventName = "user.Deactivating"
|
||||
|
||||
// BackendLinkedEventName is the festival name of BackendLinkedEvent.
|
||||
const BackendLinkedEventName = "user.BackendLinked"
|
||||
|
||||
// BackendUnlinkedEventName is the festival name of BackendUnlinkedEvent.
|
||||
const BackendUnlinkedEventName = "user.BackendUnlinked"
|
||||
|
||||
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
|
||||
type GetApiArrayEvent struct {
|
||||
User *models.User
|
||||
@@ -67,3 +73,20 @@ type DeactivatingEvent struct {
|
||||
User *models.User
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
// BackendLinkedEvent fires inside the write transaction after a link was
|
||||
// stored, by the frontend user's confirmation (Link.LinkedBy nil) or by an
|
||||
// administrator (Link.LinkedBy set). A listener error rolls the link back.
|
||||
type BackendLinkedEvent struct {
|
||||
Link *models.BackendLink
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
// BackendUnlinkedEvent fires inside the write transaction after a link was
|
||||
// removed, from My account, the Administrators screen or because one of the
|
||||
// accounts was deleted or deactivated. A listener error rolls the removal
|
||||
// back.
|
||||
type BackendUnlinkedEvent struct {
|
||||
Link *models.BackendLink
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
59
classes/frontend_permission_catalog.go
Normal file
59
classes/frontend_permission_catalog.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FrontendPermissionCatalog is what the user and user group permission
|
||||
// editors offer (D-09): the rows of golem15_user_frontend_permissions first,
|
||||
// in FrontendPermissionOptions order and unchanged, then the codes activated
|
||||
// plugins declare through pact.HasFrontendPermissions, in activation order.
|
||||
// A declared code that a seeded row already has is skipped (the seeded row
|
||||
// wins), and so is a code an earlier plugin declared; an empty code is
|
||||
// skipped. The table, its rows and the stored permissions are never
|
||||
// written.
|
||||
func FrontendPermissionCatalog(ctx context.Context, app *backpack.App, db *gorm.DB) ([]cabana.PermissionOption, error) {
|
||||
rows, err := FrontendPermissionOptions(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := make([]cabana.PermissionOption, 0, len(rows))
|
||||
seen := make(map[string]bool, len(rows))
|
||||
for _, row := range rows {
|
||||
option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab}
|
||||
if row.Comment != nil {
|
||||
option.Comment = *row.Comment
|
||||
}
|
||||
options = append(options, option)
|
||||
seen[row.Code] = true
|
||||
}
|
||||
if app == nil {
|
||||
return options, nil
|
||||
}
|
||||
activated, ok := app.Lookup[party.Activated]()
|
||||
if !ok {
|
||||
return options, nil
|
||||
}
|
||||
for _, plugin := range activated {
|
||||
declares, ok := plugin.(pact.HasFrontendPermissions)
|
||||
if !ok || declares == nil {
|
||||
continue
|
||||
}
|
||||
for _, perm := range declares.FrontendPermissions() {
|
||||
code := strings.TrimSpace(perm.Code)
|
||||
if code == "" || seen[code] {
|
||||
continue
|
||||
}
|
||||
seen[code] = true
|
||||
options = append(options, cabana.PermissionOption{Code: code, Label: perm.Label, Tab: perm.Tab, Comment: perm.Comment})
|
||||
}
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
192
classes/mcp_guard.go
Normal file
192
classes/mcp_guard.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The middleware names the guard provider reads on a route.
|
||||
const (
|
||||
mcpTokenMiddleware = "user.api_token"
|
||||
mcpScopeMiddleware = "user.scope:"
|
||||
mcpPermissionMiddleware = "user.permission:"
|
||||
)
|
||||
|
||||
var (
|
||||
_ pact.MCPGuard = (*MCPUserGuard)(nil)
|
||||
_ pact.MCPPersonalTokenResolver = (*MCPUserGuard)(nil)
|
||||
)
|
||||
|
||||
// MCPUserGuard is the plugin's MCP guard provider, named "user": MCP tools
|
||||
// bind to the plugin's token-guarded routes (user.api_token, user.scope:*,
|
||||
// user.permission:*) through it, and it resolves the plugin's personal API
|
||||
// tokens (the usr_ prefix, golem15.user.api_tokens.prefix) at the MCP
|
||||
// endpoint. Boot registers it in the application's *pact.MCPGuards and
|
||||
// publishes it as pact.MCPPersonalTokenResolver.
|
||||
//
|
||||
// Its checks are the ones the routes run: a scope must be granted to the
|
||||
// session (as user.scope checks the token), and a permission code must be
|
||||
// granted by one of the user's groups (as user.permission checks the
|
||||
// principal PrincipalByID loads; a user's own permission overrides do not
|
||||
// grant, D-35).
|
||||
type MCPUserGuard struct {
|
||||
App *backpack.App
|
||||
}
|
||||
|
||||
// MCPGuardName is "user".
|
||||
func (g *MCPUserGuard) MCPGuardName() string { return SubjectGuardUser }
|
||||
|
||||
// MCPTokenGuarded reports whether the route runs the user.api_token guard.
|
||||
func (g *MCPUserGuard) MCPTokenGuarded(middleware []string) bool {
|
||||
return slices.Contains(middleware, mcpTokenMiddleware)
|
||||
}
|
||||
|
||||
// MCPRequirements collects the scopes of user.scope:<scope> and the codes of
|
||||
// user.permission:<code>. Another user.* name than these and user.api_token
|
||||
// is an error, so a guard this provider does not understand cannot be
|
||||
// skipped silently.
|
||||
func (g *MCPUserGuard) MCPRequirements(middleware []string) (pact.MCPRequirements, error) {
|
||||
var req pact.MCPRequirements
|
||||
for _, name := range middleware {
|
||||
switch {
|
||||
case name == mcpTokenMiddleware:
|
||||
case strings.HasPrefix(name, mcpScopeMiddleware):
|
||||
scope := strings.TrimPrefix(name, mcpScopeMiddleware)
|
||||
if scope == "" {
|
||||
return pact.MCPRequirements{}, fmt.Errorf("golem15.user: middleware %q names no scope", name)
|
||||
}
|
||||
if !slices.Contains(req.Scopes, scope) {
|
||||
req.Scopes = append(req.Scopes, scope)
|
||||
}
|
||||
case strings.HasPrefix(name, mcpPermissionMiddleware):
|
||||
code := strings.TrimPrefix(name, mcpPermissionMiddleware)
|
||||
if code == "" {
|
||||
return pact.MCPRequirements{}, fmt.Errorf("golem15.user: middleware %q names no permission", name)
|
||||
}
|
||||
if !slices.Contains(req.Permissions, code) {
|
||||
req.Permissions = append(req.Permissions, code)
|
||||
}
|
||||
case strings.HasPrefix(name, "user."):
|
||||
return pact.MCPRequirements{}, fmt.Errorf("golem15.user: the MCP guard does not know middleware %q", name)
|
||||
}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// MCPAllows requires every scope of req in the subject's scopes, then every
|
||||
// permission code of req and of the tool, one by one (the route runs one
|
||||
// user.permission middleware per code), granted to the user's groups.
|
||||
func (g *MCPUserGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string) (bool, error) {
|
||||
for _, scope := range req.Scopes {
|
||||
if !slices.Contains(subject.Scopes, scope) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
db, err := g.db()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
principal, err := PrincipalByID(ctx, db.WithContext(ctx), subject.UserID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if principal == nil {
|
||||
return false, nil
|
||||
}
|
||||
for _, code := range append(slices.Clone(req.Permissions), permissions...) {
|
||||
if !cabana.Allows(principal, []string{code}) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// MCPDispatch builds the principal (the user with the group grants, as the
|
||||
// api_token guard loads it) and an in-memory token credential carrying the
|
||||
// session's scopes, so user.scope sees what the session was granted. The
|
||||
// credential has ID 0 and is never saved. The marker is honoured by the
|
||||
// api_token guard only.
|
||||
func (g *MCPUserGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
|
||||
db, err := g.db()
|
||||
if err != nil {
|
||||
return pact.MCPDispatch{}, err
|
||||
}
|
||||
principal, err := PrincipalByID(ctx, db.WithContext(ctx), subject.UserID)
|
||||
if err != nil {
|
||||
return pact.MCPDispatch{}, err
|
||||
}
|
||||
if principal == nil {
|
||||
return pact.MCPDispatch{}, fmt.Errorf("golem15.user: user %d is gone", subject.UserID)
|
||||
}
|
||||
credential := &models.APIToken{
|
||||
UserID: subject.UserID,
|
||||
Scopes: lagoon.Jsonable[[]string]{Data: slices.Clone(subject.Scopes), Valid: true},
|
||||
}
|
||||
return pact.MCPDispatch{Principal: principal, Credential: credential, Guards: []string{"api_token"}}, nil
|
||||
}
|
||||
|
||||
// MCPResolvePersonalToken resolves a personal API token of this plugin into
|
||||
// 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.
|
||||
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) {
|
||||
return pact.MCPSubject{}, false, nil
|
||||
}
|
||||
db, err := g.db()
|
||||
if err != nil {
|
||||
return pact.MCPSubject{}, true, err
|
||||
}
|
||||
db = db.WithContext(ctx)
|
||||
var token models.APIToken
|
||||
result := db.Where("token_hash = ?", HashAPIToken(raw)).Limit(1).Find(&token)
|
||||
if result.Error != nil {
|
||||
return pact.MCPSubject{}, true, result.Error
|
||||
}
|
||||
now := time.Now()
|
||||
if result.RowsAffected == 0 || !token.IsUsableAt(now) {
|
||||
return pact.MCPSubject{}, true, pact.ErrMCPTokenInvalid
|
||||
}
|
||||
principal, err := PrincipalByID(ctx, db, token.UserID)
|
||||
if err != nil {
|
||||
return pact.MCPSubject{}, true, err
|
||||
}
|
||||
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 {
|
||||
return pact.MCPSubject{}, true, err
|
||||
}
|
||||
return pact.MCPSubject{Guard: SubjectGuardUser, UserID: token.UserID, Scopes: slices.Clone(token.Scopes.Get()), TokenID: token.ID}, true, nil
|
||||
}
|
||||
|
||||
func (g *MCPUserGuard) app() *backpack.App {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return g.App
|
||||
}
|
||||
|
||||
func (g *MCPUserGuard) db() (*gorm.DB, error) {
|
||||
app := g.app()
|
||||
if app == nil {
|
||||
return nil, fmt.Errorf("golem15.user: app is nil")
|
||||
}
|
||||
gdb, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || gdb == nil {
|
||||
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||
}
|
||||
return gdb, nil
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import (
|
||||
// good.
|
||||
const PermissionManagePrivilegedGroups = "golem15.users.manage_privileged_groups"
|
||||
|
||||
// PermissionManageBackendLinks is the backend permission an administrator
|
||||
// needs to link a frontend account to another administrator, or to unlink
|
||||
// one, on the Administrators screen. No role has it by default.
|
||||
const PermissionManageBackendLinks = "golem15.users.manage_backend_links"
|
||||
|
||||
// privilegedGroupsKey is the config list of privileged group codes.
|
||||
const privilegedGroupsKey = "golem15.user.privileged_groups"
|
||||
|
||||
|
||||
55
classes/subject_events.go
Normal file
55
classes/subject_events.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Guard names the plugin's pact.MCPSubjectChanged events carry: the frontend
|
||||
// user guard this plugin provides and the admin's guard (for the backend
|
||||
// side of an account link).
|
||||
const (
|
||||
SubjectGuardUser = "user"
|
||||
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 == "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// FireSubjectChangedAfterCommit is FireSubjectChanged 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 == "" {
|
||||
return
|
||||
}
|
||||
if tx == nil {
|
||||
FireSubjectChanged(ctx, app, guard, userID, kind)
|
||||
return
|
||||
}
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
FireSubjectChanged(ctx, app, guard, userID, kind)
|
||||
})
|
||||
}
|
||||
@@ -38,3 +38,10 @@ privileged_groups:
|
||||
# refuses them. The turnstile:* middleware comes from the framework (summercms
|
||||
# v0.3.0 and later); its secret is turnstile.secret (SUMMER_TURNSTILE__SECRET).
|
||||
turnstile: ""
|
||||
# The site this plugin's links point to (the account link page
|
||||
# /link-admin?code=...). Empty falls back to app.url.
|
||||
frontend_url: ""
|
||||
backend_links:
|
||||
# How long a link request an administrator starts on My account stays
|
||||
# usable (a Go duration). The code is one-shot.
|
||||
request_ttl: 10m
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -106,6 +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)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"revoked": true}})
|
||||
}
|
||||
}
|
||||
|
||||
118
controllers/backend_link_controller.go
Normal file
118
controllers/backend_link_controller.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
)
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
// linkNotFound is the one answer for an unknown, used or expired code and
|
||||
// for an administrator who is no longer available: the page cannot tell
|
||||
// them apart.
|
||||
func linkNotFound(w http.ResponseWriter) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
}
|
||||
|
||||
// BackendLinkShow serves GET /_user/api/v1/backend-link/{code}, the site's
|
||||
// "link this account to the administrator?" page: the administrator's login
|
||||
// and display name and when the code expires, nothing else. Anyone holding
|
||||
// the code may read it; a code that is unknown, used or expired, or whose
|
||||
// administrator was deactivated, answers 404.
|
||||
func BackendLinkShow(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, admin, err := backendLinks(app).LinkRequestByCode(r.Context(), r.PathValue("code"))
|
||||
if err != nil {
|
||||
if isLinkGone(err) {
|
||||
linkNotFound(w)
|
||||
return
|
||||
}
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"admin": map[string]any{"login": admin.Login, "name": classes.BackendDisplayName(admin)},
|
||||
"expires_at": req.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
// BackendLinkConfirm serves POST /_user/api/v1/backend-link/confirm behind
|
||||
// jwt.auth: body {"code": ...}. The signed-in frontend user is linked to the
|
||||
// administrator who created the code, only when the session token was
|
||||
// issued at or after the request (the user signed in fresh for it): an
|
||||
// older session answers 409 with the stale-session error, an account already linked on
|
||||
// either side 409 already_linked, an unknown, used or expired code 404.
|
||||
func BackendLinkConfirm(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || principal.ID == 0 {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
secret, err := classes.JWTSecret(app)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
// The token jwt.auth accepted (bearer header, then the session
|
||||
// cookies), read again for its iat.
|
||||
sub, iat, _, _, err := bouncer.VerifyClaims(classes.SessionToken(r), secret)
|
||||
if err != nil || sub != strconv.FormatUint(uint64(principal.ID), 10) {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
fields, err := readFields(r)
|
||||
if err != nil {
|
||||
linkNotFound(w)
|
||||
return
|
||||
}
|
||||
links := backendLinks(app)
|
||||
_, err = links.ConfirmLinkRequest(r.Context(), strField(fields, "code"), principal.ID, iat)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, classes.ErrStaleSession):
|
||||
writeJSON(w, http.StatusConflict, map[string]any{"error": "stale_session"})
|
||||
return
|
||||
case errors.Is(err, classes.ErrAlreadyLinked):
|
||||
writeJSON(w, http.StatusConflict, map[string]any{"error": "already_linked"})
|
||||
return
|
||||
case isLinkGone(err):
|
||||
linkNotFound(w)
|
||||
return
|
||||
default:
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
linked, found, err := links.LinkedBackendUser(r.Context(), principal.ID)
|
||||
if err != nil || !found {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"backend_user": map[string]any{"login": linked.Login, "name": linked.DisplayName},
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
func isLinkGone(err error) bool {
|
||||
return errors.Is(err, classes.ErrLinkRequestNotFound) ||
|
||||
errors.Is(err, classes.ErrLinkRequestUsed) ||
|
||||
errors.Is(err, classes.ErrLinkRequestExpired) ||
|
||||
errors.Is(err, classes.ErrBackendUserUnavailable) ||
|
||||
errors.Is(err, classes.ErrFrontendUserUnavailable)
|
||||
}
|
||||
39
controllers/token_me.go
Normal file
39
controllers/token_me.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
)
|
||||
|
||||
// TokenMe serves GET /_user/api/v1/token/me behind user.api_token and
|
||||
// user.scope:read: the token owner's user payload, the same {"user": ...}
|
||||
// body GET /fetch answers for a session. It is the target of the MCP tool
|
||||
// user_me. The guard answers 401 for a missing or unusable token and the
|
||||
// scope middleware 403 for a token without read.
|
||||
func TokenMe(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || principal.ID == 0 {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
gdb, _, ok := sessionDeps(w, app)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err := gdb.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error; err != nil {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
payload, err := apiArray(r.Context(), app, &user)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": payload})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -119,16 +121,30 @@ func (c usergroupsAdminController) FormBeforeUpdate(ctx context.Context, model a
|
||||
// FormBeforeDelete refuses the delete of a privileged group without
|
||||
// golem15.users.manage_privileged_groups: it would take the privilege from
|
||||
// every member. The record is the stored row, loaded in the delete's
|
||||
// transaction.
|
||||
// transaction. A delete that goes ahead reads the members there; each gets
|
||||
// a pact.MCPSubjectChanged permissions event after the delete commits,
|
||||
// since the group's grants are gone (FormAfterDelete removes the
|
||||
// memberships).
|
||||
func (c usergroupsAdminController) FormBeforeDelete(ctx context.Context, model any) error {
|
||||
group, ok := model.(*models.UserGroup)
|
||||
if !ok || group == nil {
|
||||
return errors.New("user groups admin: form model is not a user group")
|
||||
}
|
||||
if !classes.IsPrivilegedCode(classes.PrivilegedGroupCodes(c.app.config()), group.Code) {
|
||||
if classes.IsPrivilegedCode(classes.PrivilegedGroupCodes(c.app.config()), group.Code) {
|
||||
if err := c.requirePrivilegedPermission(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return c.requirePrivilegedPermission(ctx)
|
||||
members, err := groupMemberIDs(ctx, tx, group.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.permissionsChanged(ctx, tx, members)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormAfterDelete removes the memberships of the deleted group in the same
|
||||
@@ -147,6 +163,49 @@ func (usergroupsAdminController) FormAfterDelete(ctx context.Context, model any)
|
||||
Where("user_group_id = ?", group.ID).Delete(&models.UsersGroup{}).Error
|
||||
}
|
||||
|
||||
// FormAfterUpdate tells the MCP endpoint that the members of the group may
|
||||
// do something else now (the form saves the group's permissions): one
|
||||
// pact.MCPSubjectChanged permissions event per member, the members read in
|
||||
// the save's transaction and the events fired after it commits.
|
||||
func (c usergroupsAdminController) FormAfterUpdate(ctx context.Context, model any) error {
|
||||
group, ok := model.(*models.UserGroup)
|
||||
if !ok || group == nil {
|
||||
return errors.New("user groups admin: form model is not a user group")
|
||||
}
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return errors.New("user groups admin: an update needs its transaction")
|
||||
}
|
||||
members, err := groupMemberIDs(ctx, tx, group.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.permissionsChanged(ctx, tx, members)
|
||||
return nil
|
||||
}
|
||||
|
||||
// permissionsChanged fires one permissions subject event per user after tx
|
||||
// commits.
|
||||
func (c usergroupsAdminController) permissionsChanged(ctx context.Context, tx *gorm.DB, userIDs []uint) {
|
||||
if len(userIDs) == 0 {
|
||||
return
|
||||
}
|
||||
app := c.app.application()
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
for _, id := range userIDs {
|
||||
classes.FireSubjectChanged(ctx, app, classes.SubjectGuardUser, id, pact.MCPSubjectPermissions)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// groupMemberIDs reads the ids of the group's members through tx.
|
||||
func groupMemberIDs(ctx context.Context, tx *gorm.DB, groupID uint) ([]uint, error) {
|
||||
ids := []uint{}
|
||||
err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
|
||||
Model(&models.UsersGroup{}).Where("user_group_id = ?", groupID).Order("user_id").Pluck("user_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// requirePrivilegedPermission answers nil when the administrator on ctx
|
||||
// holds golem15.users.manage_privileged_groups, else the 403 of the screen.
|
||||
// A context without a principal is refused.
|
||||
@@ -184,7 +243,9 @@ func sameCode(a, b *string) bool {
|
||||
}
|
||||
|
||||
// AdminPermissionOptions offers the rows of
|
||||
// golem15_user_frontend_permissions, by tab and code. None is locked.
|
||||
// golem15_user_frontend_permissions, by tab and code, then the frontend
|
||||
// permission codes activated plugins declare (classes.FrontendPermissionCatalog).
|
||||
// None is locked.
|
||||
func (c usergroupsAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
|
||||
if field != permissionsField {
|
||||
return nil, fmt.Errorf("user groups admin: unknown permission field %q", field)
|
||||
@@ -193,19 +254,7 @@ func (c usergroupsAdminController) AdminPermissionOptions(ctx context.Context, f
|
||||
if db == nil {
|
||||
return nil, errors.New("user groups admin: gorm handle is missing")
|
||||
}
|
||||
rows, err := classes.FrontendPermissionOptions(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := make([]cabana.PermissionOption, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab}
|
||||
if row.Comment != nil {
|
||||
option.Comment = *row.Comment
|
||||
}
|
||||
options = append(options, option)
|
||||
}
|
||||
return options, nil
|
||||
return classes.FrontendPermissionCatalog(ctx, c.app.application(), db)
|
||||
}
|
||||
|
||||
// AdminPermissionValues returns the group's stored permissions, read with
|
||||
|
||||
@@ -250,6 +250,24 @@ func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormAfterUpdate tells the MCP endpoint that what the user may do may have
|
||||
// changed (their permissions or group memberships are saved by this form):
|
||||
// one pact.MCPSubjectChanged permissions event for the user, fired after the
|
||||
// save commits. Every MCP call re-checks anyway; the event only refreshes
|
||||
// the tool list sooner.
|
||||
func (c usersAdminController) FormAfterUpdate(ctx context.Context, model any) error {
|
||||
user, ok := model.(*models.User)
|
||||
if !ok || user == nil {
|
||||
return errors.New("users admin: form model is not a user")
|
||||
}
|
||||
tx, _ := cabana.TxFromContext(ctx)
|
||||
app := c.application()
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
classes.FireSubjectChanged(ctx, app, classes.SubjectGuardUser, user.ID, pact.MCPSubjectPermissions)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormBeforeCreate checks the email against deactivated users too and stores
|
||||
// the hash of the submitted password. A user created here starts not
|
||||
// activated: is_activated is never written from the form, and only the
|
||||
@@ -427,7 +445,9 @@ func (c usersAdminController) AdminRelationLocks(ctx context.Context, field stri
|
||||
const permissionsField = "permissions"
|
||||
|
||||
// AdminPermissionOptions offers the rows of
|
||||
// golem15_user_frontend_permissions, by tab and code. None is locked.
|
||||
// golem15_user_frontend_permissions, by tab and code, then the frontend
|
||||
// permission codes activated plugins declare (classes.FrontendPermissionCatalog).
|
||||
// None is locked.
|
||||
func (c usersAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
|
||||
if field != permissionsField {
|
||||
return nil, fmt.Errorf("users admin: unknown permission field %q", field)
|
||||
@@ -436,19 +456,7 @@ func (c usersAdminController) AdminPermissionOptions(ctx context.Context, field
|
||||
if db == nil {
|
||||
return nil, errors.New("users admin: gorm handle is missing")
|
||||
}
|
||||
rows, err := classes.FrontendPermissionOptions(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := make([]cabana.PermissionOption, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab}
|
||||
if row.Comment != nil {
|
||||
option.Comment = *row.Comment
|
||||
}
|
||||
options = append(options, option)
|
||||
}
|
||||
return options, nil
|
||||
return classes.FrontendPermissionCatalog(ctx, c.application(), db)
|
||||
}
|
||||
|
||||
// AdminPermissionValues returns the user's own stored permissions.
|
||||
|
||||
112
frontend_permission_catalog_smoke_test.go
Normal file
112
frontend_permission_catalog_smoke_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// The frontend permission codes the acmeBlogPermissions test plugin declares.
|
||||
const (
|
||||
acmeBlogManage = "acme.blog.manage"
|
||||
// acmeBlogSeeded is also a seeded row of golem15_user_frontend_permissions
|
||||
// in the smoke, with another label: the seeded row wins.
|
||||
acmeBlogSeeded = "acme.blog.seeded"
|
||||
)
|
||||
|
||||
// acmeBlogPermissions is a plugin that only declares frontend permission
|
||||
// codes (pact.HasFrontendPermissions). It is registered for the test binary
|
||||
// and activated only by the tests that name it.
|
||||
type acmeBlogPermissions struct{}
|
||||
|
||||
func (acmeBlogPermissions) ID() string { return "acme.blog" }
|
||||
func (acmeBlogPermissions) Requires() []string { return nil }
|
||||
func (acmeBlogPermissions) Register(*backpack.App) error { return nil }
|
||||
func (acmeBlogPermissions) Boot(*backpack.App) error { return nil }
|
||||
|
||||
func (acmeBlogPermissions) FrontendPermissions() []pact.FrontendPermission {
|
||||
return []pact.FrontendPermission{
|
||||
{Code: acmeBlogManage, Label: "Manage blog posts", Tab: "Blog", Comment: "Create, edit and delete posts."},
|
||||
{Code: acmeBlogSeeded, Label: "Plugin label", Tab: "Blog"},
|
||||
{Code: acmeBlogManage, Label: "Declared twice", Tab: "Blog"},
|
||||
}
|
||||
}
|
||||
|
||||
func init() { party.Register(acmeBlogPermissions{}) }
|
||||
|
||||
type offeredPermission struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
Tab string `json:"tab"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// permissionOptions reads the options of the permissions field of a form
|
||||
// schema.
|
||||
func (e *adminEnv) permissionOptions(t *testing.T, token, path string) []offeredPermission {
|
||||
t.Helper()
|
||||
var form struct {
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Options []offeredPermission `json:"permissionOptions"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, adminAPI(path+"/schema/form"), token, nil), &form)
|
||||
for _, field := range form.Fields {
|
||||
if field.Name == "permissions" {
|
||||
return field.Options
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s has no permissions field", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestFrontendPermissionCatalogSmoke: both permission editors offer the
|
||||
// seeded rows first, then the codes an activated plugin declares; a seeded
|
||||
// row wins over a declared code and a code is offered once.
|
||||
func TestFrontendPermissionCatalogSmoke(t *testing.T) {
|
||||
env := newAdminEnvWith(t, []string{"acme.blog"})
|
||||
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'Seeded label', 'Blog') ON CONFLICT (code) DO NOTHING`, acmeBlogSeeded).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var seeded int64
|
||||
if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_frontend_permissions`).Scan(&seeded).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := env.admin(t, permAccessUsers, permAccessGroups)
|
||||
for _, path := range []string{adminUsersPath, adminGroupsPath} {
|
||||
options := env.permissionOptions(t, token, path)
|
||||
if int64(len(options)) != seeded+1 {
|
||||
t.Fatalf("%s offers %d options, want the %d seeded rows and one declared code: %+v", path, len(options), seeded, options)
|
||||
}
|
||||
last := options[len(options)-1]
|
||||
if last != (offeredPermission{Code: acmeBlogManage, Label: "Manage blog posts", Tab: "Blog", Comment: "Create, edit and delete posts."}) {
|
||||
t.Fatalf("%s: last option = %+v, want the declared acme.blog.manage", path, last)
|
||||
}
|
||||
found := 0
|
||||
for _, option := range options[:len(options)-1] {
|
||||
if option.Code == acmeBlogManage {
|
||||
t.Fatalf("%s offers %s twice", path, acmeBlogManage)
|
||||
}
|
||||
if option.Code == acmeBlogSeeded {
|
||||
found++
|
||||
if option.Label != "Seeded label" {
|
||||
t.Fatalf("%s: duplicate code label = %q, want the seeded row's", path, option.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
if found != 1 {
|
||||
t.Fatalf("%s offers %s %d times", path, acmeBlogSeeded, found)
|
||||
}
|
||||
}
|
||||
|
||||
// Without the declaring plugin the editors offer the seeded rows only.
|
||||
plain := newAdminEnv(t)
|
||||
plainToken := plain.admin(t, permAccessUsers)
|
||||
if options := plain.permissionOptions(t, plainToken, adminUsersPath); int64(len(options)) != seeded {
|
||||
t.Fatalf("without acme.blog: %d options, want %d", len(options), seeded)
|
||||
}
|
||||
}
|
||||
9
go.mod
9
go.mod
@@ -3,7 +3,7 @@ module git.golem15.com/golem15/sm-user-plugin
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
git.golem15.com/golem15/summercms v0.4.3
|
||||
git.golem15.com/golem15/summercms v0.4.4
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
@@ -80,6 +80,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.30.4 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/google/wire v0.7.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
||||
@@ -117,6 +118,7 @@ require (
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/mango v0.1.0 // indirect
|
||||
github.com/muesli/mango-cobra v1.2.0 // indirect
|
||||
@@ -134,6 +136,8 @@ require (
|
||||
github.com/riverqueue/river/rivershared v0.47.0 // indirect
|
||||
github.com/riverqueue/river/rivertype v0.47.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
@@ -147,6 +151,7 @@ require (
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/wneessen/go-mail v0.8.1 // indirect
|
||||
github.com/xo/terminfo v1.0.0 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yuin/goldmark v1.8.6 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
@@ -161,10 +166,12 @@ require (
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/image v0.46.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/term v0.46.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/api v0.272.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||
|
||||
12
go.sum
12
go.sum
@@ -203,6 +203,8 @@ github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV
|
||||
github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI=
|
||||
github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk=
|
||||
github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
@@ -293,6 +295,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
|
||||
@@ -333,6 +337,10 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
@@ -373,6 +381,8 @@ github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pc
|
||||
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
|
||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
@@ -436,6 +446,8 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
|
||||
@@ -8,6 +8,7 @@ plugin:
|
||||
access_settings: Manage User Settings
|
||||
impersonate_user: Impersonate Users
|
||||
manage_privileged_groups: Manage privileged group membership
|
||||
manage_backend_links: Link frontend accounts to administrators
|
||||
users:
|
||||
menu_label: Users
|
||||
new_user: New User
|
||||
@@ -115,3 +116,23 @@ organisation:
|
||||
empty_organisation: — No organisation —
|
||||
oauth:
|
||||
last_method_blocked: This is the only remaining way to sign in. Link another method before disconnecting this one.
|
||||
backend_link:
|
||||
section: Frontend account
|
||||
section_comment: Link your administrator account to your account on the site, so tools you use there can act as this administrator. You sign in on the site to prove the account is yours.
|
||||
status_linked: "Linked to :email"
|
||||
status_unlinked: Not linked
|
||||
link: Link frontend account
|
||||
link_started: Open the link and sign in on the site with the account to link. The link works once, for a few minutes.
|
||||
unlink: Unlink frontend account
|
||||
unlink_confirm: Unlink your frontend account? Tools used through it will no longer act as this administrator.
|
||||
unlinked: The frontend account is unlinked.
|
||||
prompt_email: Frontend account email
|
||||
prompt_comment: The exact email of the frontend user to link to this administrator.
|
||||
set: Link frontend account
|
||||
set_confirm: Link a frontend account to this administrator? Tools that user runs will act as this administrator.
|
||||
set_success: The frontend account is linked.
|
||||
remove: Unlink frontend account
|
||||
remove_confirm: Unlink the frontend account from this administrator?
|
||||
remove_success: The frontend account is unlinked.
|
||||
unknown_user: No active frontend user has this email.
|
||||
already_linked: This administrator or this frontend user is already linked.
|
||||
|
||||
@@ -8,6 +8,7 @@ plugin:
|
||||
access_settings: Zarządzaj ustawieniami użytkownika
|
||||
impersonate_user: Wcielaj się w użytkowników
|
||||
manage_privileged_groups: Zarządzaj członkostwem w grupach uprzywilejowanych
|
||||
manage_backend_links: Łącz konta na stronie z administratorami
|
||||
users:
|
||||
menu_label: Użytkownicy
|
||||
new_user: Nowy użytkownik
|
||||
@@ -115,3 +116,23 @@ organisation:
|
||||
empty_organisation: — Brak organizacji —
|
||||
oauth:
|
||||
last_method_blocked: To jedyna droga logowania na to konto. Najpierw podłącz inną, zanim odetniesz tę.
|
||||
backend_link:
|
||||
section: Konto na stronie
|
||||
section_comment: Połącz konto administratora ze swoim kontem na stronie, aby narzędzia używane tam mogły działać jako ten administrator. Aby potwierdzić, że konto jest Twoje, zaloguj się na stronie.
|
||||
status_linked: "Połączone z :email"
|
||||
status_unlinked: Niepołączone
|
||||
link: Połącz konto na stronie
|
||||
link_started: Otwórz link i zaloguj się na stronie kontem, które chcesz połączyć. Link działa raz, przez kilka minut.
|
||||
unlink: Odłącz konto na stronie
|
||||
unlink_confirm: Odłączyć konto na stronie? Narzędzia używane przez nie przestaną działać jako ten administrator.
|
||||
unlinked: Konto na stronie zostało odłączone.
|
||||
prompt_email: Email konta na stronie
|
||||
prompt_comment: Dokładny email użytkownika strony, którego połączyć z tym administratorem.
|
||||
set: Połącz konto na stronie
|
||||
set_confirm: Połączyć konto na stronie z tym administratorem? Narzędzia uruchamiane przez tego użytkownika będą działać jako ten administrator.
|
||||
set_success: Konto na stronie zostało połączone.
|
||||
remove: Odłącz konto na stronie
|
||||
remove_confirm: Odłączyć konto na stronie od tego administratora?
|
||||
remove_success: Konto na stronie zostało odłączone.
|
||||
unknown_user: Żaden aktywny użytkownik strony nie ma tego emaila.
|
||||
already_linked: Ten administrator albo ten użytkownik strony jest już połączony.
|
||||
|
||||
24
mcp.go
Normal file
24
mcp.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
var _ pact.HasMCP = (*Plugin)(nil)
|
||||
|
||||
// MCP declares the plugin's MCP tools (D-18). me (exposed as user_me) runs
|
||||
// GET /_user/api/v1/token/me as the signed-in frontend user under the
|
||||
// "user" guard and answers the user payload.
|
||||
func (p *Plugin) MCP() pact.MCP {
|
||||
return pact.MCP{Tools: []pact.MCPTool{{
|
||||
Name: "me",
|
||||
Title: "Current user",
|
||||
Description: "Returns the profile of the signed-in frontend user.",
|
||||
Guard: "user",
|
||||
Method: http.MethodGet,
|
||||
Path: "/_user/api/v1/token/me",
|
||||
Input: struct{}{},
|
||||
}}}
|
||||
}
|
||||
191
mcp_guard_smoke_test.go
Normal file
191
mcp_guard_smoke_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// mintUserToken stores a personal API token of the user with the scopes and
|
||||
// returns its secret and row.
|
||||
func (e *adminEnv) mintUserToken(t *testing.T, user *models.User, scopes ...string) (string, *models.APIToken) {
|
||||
t.Helper()
|
||||
secret, token, err := classes.MintAPIToken(classes.DefaultAPITokenPrefix, user.ID, "mcp "+adminStamp(), scopes, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.gdb.Create(token).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return secret, token
|
||||
}
|
||||
|
||||
// grantGroup stores the codes as the group's permissions.
|
||||
func (e *adminEnv) grantGroup(t *testing.T, group *models.UserGroup, codes ...string) {
|
||||
t.Helper()
|
||||
parts := make([]string, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
parts = append(parts, strconv.Quote(code)+":1")
|
||||
}
|
||||
if err := e.gdb.Exec(`UPDATE user_groups SET permissions = ? WHERE id = ?`, "{"+strings.Join(parts, ",")+"}", group.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserMCPGuardSmoke: the "user" guard provider is registered and
|
||||
// published as the personal token resolver; it reads the plugin's route
|
||||
// middleware, allows a tool with exactly the checks the routes run (scopes
|
||||
// from the session, permission codes from the user's groups, every code
|
||||
// required), dispatches with the group grants and an in-memory token
|
||||
// credential, and resolves usr_ tokens. GET /token/me, the user_me target,
|
||||
// answers the user payload to a read token only, and revoking a token fires
|
||||
// the revoked subject event.
|
||||
func TestUserMCPGuardSmoke(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
spy := env.spySubjects()
|
||||
ctx := context.Background()
|
||||
guards, ok := env.app.Lookup[*pact.MCPGuards]()
|
||||
if !ok || guards == nil {
|
||||
t.Fatal("*pact.MCPGuards is not published")
|
||||
}
|
||||
guard, ok := guards.Lookup("user")
|
||||
if !ok || guard.MCPGuardName() != "user" {
|
||||
t.Fatalf("user guard = %v %v", guard, ok)
|
||||
}
|
||||
resolver, ok := env.app.Lookup[pact.MCPPersonalTokenResolver]()
|
||||
if !ok || resolver == nil {
|
||||
t.Fatal("pact.MCPPersonalTokenResolver is not published")
|
||||
}
|
||||
|
||||
if !guard.MCPTokenGuarded([]string{"throttle:user-api", "user.api_token", "user.scope:read"}) || guard.MCPTokenGuarded([]string{"throttle:user-api", "jwt.auth"}) {
|
||||
t.Fatal("MCPTokenGuarded must answer true only with user.api_token")
|
||||
}
|
||||
req, err := guard.MCPRequirements([]string{"throttle:user-api", "user.api_token", "user.scope:read", "user.scope:write", "user.permission:acme.posts.edit"})
|
||||
if err != nil || !slices.Equal(req.Scopes, []string{"read", "write"}) || !slices.Equal(req.Permissions, []string{"acme.posts.edit"}) {
|
||||
t.Fatalf("MCPRequirements = %+v %v", req, err)
|
||||
}
|
||||
if _, err := guard.MCPRequirements([]string{"user.api_token", "user.bogus"}); err == nil {
|
||||
t.Fatal("an unknown user.* middleware must be an error")
|
||||
}
|
||||
|
||||
stamp := adminStamp()
|
||||
codeA, codeB, codeOwn := "mcp."+stamp+".a", "mcp."+stamp+".b", "mcp."+stamp+".own"
|
||||
user := env.seedUser(t, "mcp", func(u *models.User) { u.Permissions = models.PermissionSet{codeOwn: 1} })
|
||||
groupA, groupB := env.seedGroup(t, "MCP A "+stamp, "mcp-a-"+stamp), env.seedGroup(t, "MCP B "+stamp, "mcp-b-"+stamp)
|
||||
env.grantGroup(t, groupA, codeA)
|
||||
env.grantGroup(t, groupB, codeB)
|
||||
env.join(t, user, groupA, groupB)
|
||||
subject := pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: []string{"read"}}
|
||||
allows := func(req pact.MCPRequirements, perms ...string) bool {
|
||||
t.Helper()
|
||||
ok, err := guard.MCPAllows(ctx, subject, req, perms)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
if allows(pact.MCPRequirements{Scopes: []string{"read", "write"}}) {
|
||||
t.Fatal("a scope the session lacks must refuse")
|
||||
}
|
||||
if !allows(pact.MCPRequirements{Scopes: []string{"read"}, Permissions: []string{codeA}}, codeB) {
|
||||
t.Fatal("codes granted by the user's groups must allow")
|
||||
}
|
||||
if allows(pact.MCPRequirements{Scopes: []string{"read"}, Permissions: []string{codeA, "mcp." + stamp + ".missing"}}) {
|
||||
t.Fatal("every code is required: one missing code must refuse")
|
||||
}
|
||||
if allows(pact.MCPRequirements{Scopes: []string{"read"}}, codeOwn) {
|
||||
t.Fatal("a user-level permission alone must not grant (the route checks group grants)")
|
||||
}
|
||||
if ok, err := guard.MCPAllows(ctx, pact.MCPSubject{Guard: "user", UserID: user.ID + 1_000_000, Scopes: []string{"read"}}, pact.MCPRequirements{}, nil); err != nil || ok {
|
||||
t.Fatalf("an unknown user = %v %v", ok, err)
|
||||
}
|
||||
|
||||
dispatch, err := guard.MCPDispatch(ctx, subject, http.MethodGet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
principal, ok := dispatch.Principal.(*bouncer.Principal)
|
||||
if !ok || principal.ID != user.ID || !principal.PermissionGrants[codeA] || !principal.PermissionGrants[codeB] || principal.PermissionGrants[codeOwn] {
|
||||
t.Fatalf("dispatch principal = %+v", dispatch.Principal)
|
||||
}
|
||||
credential, ok := dispatch.Credential.(*models.APIToken)
|
||||
if !ok || credential.ID != 0 || credential.UserID != user.ID || !credential.HasScope("read") || credential.HasScope("write") {
|
||||
t.Fatalf("dispatch credential = %+v", dispatch.Credential)
|
||||
}
|
||||
if !slices.Equal(dispatch.Guards, []string{"api_token"}) {
|
||||
t.Fatalf("dispatch guards = %v", dispatch.Guards)
|
||||
}
|
||||
|
||||
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, "oas_at_x"); matched || err != nil {
|
||||
t.Fatalf("a foreign prefix = %v %v", matched, err)
|
||||
}
|
||||
readSecret, readToken := env.mintUserToken(t, user, "read", "ai")
|
||||
got, matched, err := resolver.MCPResolvePersonalToken(ctx, readSecret)
|
||||
if err != nil || !matched || got.Guard != "user" || got.UserID != user.ID || got.TokenID != readToken.ID || !slices.Equal(got.Scopes, []string{"read", "ai"}) {
|
||||
t.Fatalf("resolve = %+v %v %v", got, matched, err)
|
||||
}
|
||||
var stamped models.APIToken
|
||||
if err := env.gdb.First(&stamped, readToken.ID).Error; err != nil || stamped.LastUsedAt == nil {
|
||||
t.Fatalf("last_used_at not stamped: %+v %v", stamped, err)
|
||||
}
|
||||
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, classes.DefaultAPITokenPrefix+"not-a-token"); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {
|
||||
t.Fatalf("an unknown usr_ token = %v %v", matched, err)
|
||||
}
|
||||
|
||||
// GET /token/me: the user_me target.
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/token/me", readSecret, nil)
|
||||
if !strings.Contains(rec.Body.String(), `"email":"`+user.Email+`"`) || !strings.HasPrefix(rec.Body.String(), `{"user":`) {
|
||||
t.Fatalf("token/me = %s", rec.Body.String())
|
||||
}
|
||||
writeSecret, _ := env.mintUserToken(t, user, "write")
|
||||
rec = env.expect(t, http.StatusForbidden, http.MethodGet, "/_user/api/v1/token/me", writeSecret, nil)
|
||||
if !strings.Contains(rec.Body.String(), "Missing required scope: read") {
|
||||
t.Fatalf("token/me with write = %s", rec.Body.String())
|
||||
}
|
||||
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.
|
||||
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) {
|
||||
t.Fatalf("revoke fired %+v", spy.events)
|
||||
}
|
||||
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, readSecret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {
|
||||
t.Fatalf("a revoked token = %v %v", matched, err)
|
||||
}
|
||||
|
||||
// Saving a group's permissions on the admin screen fires permissions
|
||||
// for each member after the save commits.
|
||||
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'A', 'MCP'), (?, 'B', 'MCP')`, codeA, codeB).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spy.reset()
|
||||
manager := env.admin(t, permAccessGroups)
|
||||
env.expect(t, http.StatusOK, http.MethodPut, groupPath(groupA.ID), manager, map[string]any{"name": groupA.Name, "code": *groupA.Code, "permissions": map[string]int{codeA: 1, codeB: 1}})
|
||||
if !spy.has("user", user.ID, pact.MCPSubjectPermissions) {
|
||||
t.Fatalf("group save fired %+v", spy.events)
|
||||
}
|
||||
// Deleting a group fires permissions for its members once it commits.
|
||||
spy.reset()
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, groupPath(groupB.ID), manager, nil)
|
||||
if !spy.has("user", user.ID, pact.MCPSubjectPermissions) {
|
||||
t.Fatalf("group delete fired %+v", spy.events)
|
||||
}
|
||||
// Saving the user on the Users screen fires permissions too.
|
||||
spy.reset()
|
||||
users := env.admin(t, permAccessUsers)
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), users, map[string]any{"name": "MCP renamed", "email": user.Email})
|
||||
if !spy.has("user", user.ID, pact.MCPSubjectPermissions) {
|
||||
t.Fatalf("user save fired %+v", spy.events)
|
||||
}
|
||||
}
|
||||
24
models/backend_link.go
Normal file
24
models/backend_link.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// BackendLink is the verified link between one frontend user and one backend
|
||||
// administrator (golem15_user_backend_links). Each side appears in at most
|
||||
// one link. LinkedBy is the administrator who set the link on the
|
||||
// Administrators screen; it is nil for a link the frontend user confirmed
|
||||
// themselves after the administrator started it on My account.
|
||||
type BackendLink struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
BackendUserID uint `gorm:"column:backend_user_id"`
|
||||
LinkedBy *uint `gorm:"column:linked_by"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
func (BackendLink) TableName() string { return "golem15_user_backend_links" }
|
||||
|
||||
func (BackendLink) Fillable() []string {
|
||||
return []string{"user_id", "backend_user_id", "linked_by"}
|
||||
}
|
||||
|
||||
func init() { Register(BackendLink{}) }
|
||||
31
models/backend_link_request.go
Normal file
31
models/backend_link_request.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// BackendLinkRequest is a one-shot request an administrator starts to link
|
||||
// their account to a frontend user (golem15_user_backend_link_requests).
|
||||
// Only the SHA-256 of the code is stored; the plain code travels once, in
|
||||
// the site URL the administrator opens.
|
||||
type BackendLinkRequest struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
CodeHash string `gorm:"column:code_hash" json:"-"`
|
||||
BackendUserID uint `gorm:"column:backend_user_id"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at"`
|
||||
UsedAt *time.Time `gorm:"column:used_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
func (BackendLinkRequest) TableName() string { return "golem15_user_backend_link_requests" }
|
||||
|
||||
func (BackendLinkRequest) Fillable() []string {
|
||||
return []string{"code_hash", "backend_user_id", "expires_at", "used_at"}
|
||||
}
|
||||
|
||||
func (BackendLinkRequest) Hidden() []string { return []string{"code_hash"} }
|
||||
|
||||
// IsUsableAt is false once the request was used or has expired.
|
||||
func (r BackendLinkRequest) IsUsableAt(now time.Time) bool {
|
||||
return r.UsedAt == nil && r.ExpiresAt.After(now)
|
||||
}
|
||||
|
||||
func init() { Register(BackendLinkRequest{}) }
|
||||
@@ -95,8 +95,10 @@ func TestPasswordPublicRoutes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unset: Routes error %v", err)
|
||||
}
|
||||
if len(base) != 18 {
|
||||
t.Fatalf("unset: %d routes, want 18", len(base))
|
||||
// 18 routes up to v0.1.9, plus GET /backend-link/{code} and POST
|
||||
// /backend-link/confirm (the account link) and GET /token/me.
|
||||
if len(base) != 21 {
|
||||
t.Fatalf("unset: %d routes, want 21", len(base))
|
||||
}
|
||||
seen := 0
|
||||
for _, route := range base {
|
||||
@@ -108,8 +110,8 @@ func TestPasswordPublicRoutes(t *testing.T) {
|
||||
t.Fatalf("unset: found %d of the password routes, want %d", seen, len(passwordRouteKeys))
|
||||
}
|
||||
off := withoutPasswordRoutes(base)
|
||||
if len(off) != 15 {
|
||||
t.Fatalf("base without the password routes has %d routes, want 15", len(off))
|
||||
if len(off) != 18 {
|
||||
t.Fatalf("base without the password routes has %d routes, want 18", len(off))
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
|
||||
44
plugin.go
44
plugin.go
@@ -107,9 +107,53 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.bootBackendLinks(app); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.bootMCPGuard(app); err != nil {
|
||||
return err
|
||||
}
|
||||
return classes.RegisterUserAuthorizer(app)
|
||||
}
|
||||
|
||||
// bootMCPGuard registers the "user" MCP guard provider in the application's
|
||||
// *pact.MCPGuards (publishing the registry when no one did yet) and
|
||||
// publishes the same value as pact.MCPPersonalTokenResolver for the
|
||||
// plugin's personal API tokens. Without the MCP module both stay unused.
|
||||
func (p *Plugin) bootMCPGuard(app *backpack.App) error {
|
||||
guards, ok := app.Lookup[*pact.MCPGuards]()
|
||||
if !ok || guards == nil {
|
||||
guards = pact.NewMCPGuards()
|
||||
if err := app.Publish(guards); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
guard := &classes.MCPUserGuard{App: app}
|
||||
if err := guards.Register(guard); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.Publish[pact.MCPPersonalTokenResolver](guard)
|
||||
}
|
||||
|
||||
// bootBackendLinks publishes the account link service as *classes.BackendLinks
|
||||
// and as the MCP linked-backend resolver, and removes a frontend user's link
|
||||
// when the user is deleted or deactivated. The request lifetime is checked
|
||||
// here so a bad golem15.user.backend_links.request_ttl fails the boot.
|
||||
func (p *Plugin) bootBackendLinks(app *backpack.App) error {
|
||||
if _, err := classes.BackendLinkRequestTTL(app); err != nil {
|
||||
return err
|
||||
}
|
||||
links := &classes.BackendLinks{App: app}
|
||||
if err := app.Publish(links); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Publish[pact.MCPLinkedBackendResolver](links); err != nil {
|
||||
return err
|
||||
}
|
||||
links.RegisterBackendLinkListeners(app)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) {
|
||||
interval := 10 * time.Minute
|
||||
if p.app != nil && p.app.Config != nil {
|
||||
|
||||
@@ -42,6 +42,10 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
g.Get("/tokens", controllers.APITokenIndex(p.app), "jwt.auth")
|
||||
g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth")
|
||||
g.Where("id", "[0-9]+")
|
||||
g.Get("/backend-link/{code}", controllers.BackendLinkShow(p.app))
|
||||
g.Where("code", "[a-f0-9]{64}")
|
||||
g.Post("/backend-link/confirm", controllers.BackendLinkConfirm(p.app), "jwt.auth")
|
||||
g.Get("/token/me", controllers.TokenMe(p.app), "user.api_token", "user.scope:read")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
60
updates/202610220001_create_backend_links.go
Normal file
60
updates/202610220001_create_backend_links.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// backendLinkMigrations create the verified frontend-to-backend account link
|
||||
// (one frontend user to one administrator, both sides unique) and the
|
||||
// one-shot link requests an administrator starts on My account. A request
|
||||
// stores only the SHA-256 of its code. Deleting either account removes its
|
||||
// link and requests; deleting the administrator who set a link keeps the
|
||||
// link and clears linked_by.
|
||||
//
|
||||
// The foreign keys to backend_users are added when that table exists, which
|
||||
// it always does when lagoon.Migrate runs the plugin (the admin's own
|
||||
// migrations run first); a run of the plugin's migrations alone, as some
|
||||
// tests do, creates the tables without them.
|
||||
var backendLinkMigrations = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610220001_create_golem15_user_backend_links",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`CREATE TABLE golem15_user_backend_links (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
backend_user_id INTEGER NOT NULL UNIQUE,
|
||||
linked_by INTEGER NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE TABLE golem15_user_backend_link_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
code_hash CHAR(64) NOT NULL UNIQUE,
|
||||
backend_user_id INTEGER NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX golem15_user_backend_link_requests_backend_user_id_index ON golem15_user_backend_link_requests (backend_user_id);
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('backend_users') IS NOT NULL THEN
|
||||
ALTER TABLE golem15_user_backend_links
|
||||
ADD CONSTRAINT golem15_user_backend_links_backend_user_id_foreign
|
||||
FOREIGN KEY (backend_user_id) REFERENCES backend_users(id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT golem15_user_backend_links_linked_by_foreign
|
||||
FOREIGN KEY (linked_by) REFERENCES backend_users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE golem15_user_backend_link_requests
|
||||
ADD CONSTRAINT golem15_user_backend_link_requests_backend_user_id_foreign
|
||||
FOREIGN KEY (backend_user_id) REFERENCES backend_users(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END
|
||||
$$`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec("DROP TABLE IF EXISTS golem15_user_backend_link_requests; DROP TABLE IF EXISTS golem15_user_backend_links").Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() { Register(backendLinkMigrations...) }
|
||||
@@ -52,6 +52,7 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
||||
"202610050001_create_oauth_identities",
|
||||
"202610160002_add_handle_to_users",
|
||||
"202610210001_add_users_email_verified_at",
|
||||
"202610220001_create_golem15_user_backend_links",
|
||||
}
|
||||
if len(ids) != len(want) {
|
||||
t.Fatalf("history = %v", ids)
|
||||
@@ -62,9 +63,9 @@ func TestCreateOrganisationsMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Email proof, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
|
||||
// throttle, extend-users, then organisations.
|
||||
for range 12 {
|
||||
// Backend links, email proof, handle, OAuth identities, the three admin migrations, API tokens, user groups,
|
||||
// jwt blacklist, throttle, extend-users, then organisations.
|
||||
for range 13 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ func TestUserGroupsMigration(t *testing.T) {
|
||||
t.Fatalf("users_groups primary key = %q", pk)
|
||||
}
|
||||
|
||||
// Email verified at, handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
|
||||
for range 8 {
|
||||
// Backend links, email verified at, handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups.
|
||||
for range 9 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ func TestExtendUsersMigration(t *testing.T) {
|
||||
if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded {
|
||||
t.Fatalf("defaults = %+v", row)
|
||||
}
|
||||
// Email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist,
|
||||
// throttle, then extend-users.
|
||||
for range 11 {
|
||||
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups,
|
||||
// jwt blacklist, throttle, then extend-users.
|
||||
for range 12 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -88,8 +88,8 @@ func TestCreateUserThrottleMigration(t *testing.T) {
|
||||
t.Fatalf("missing column %s", col)
|
||||
}
|
||||
}
|
||||
// Email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
|
||||
for range 10 {
|
||||
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle.
|
||||
for range 11 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -120,8 +120,8 @@ func TestCreateJwtBlacklistMigration(t *testing.T) {
|
||||
t.Fatalf("missing column %s", col)
|
||||
}
|
||||
}
|
||||
// Email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
|
||||
for range 9 {
|
||||
// Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist.
|
||||
for range 10 {
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user