Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91af10b572 | ||
|
|
8446a77605 | ||
| 756797c2c4 | |||
| 193a381ece | |||
| 25fd981d7f | |||
| 66fc107eb9 | |||
| bd2cc0c245 |
12
README.md
12
README.md
@@ -40,19 +40,19 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
|||||||
- **Address proof.** `users.email_verified_at` records when the account's owner proved control of the address (`User.EmailVerifiedAt`). Exactly two flows write it: the mailed activation code (`POST /activate`, `POST /activate-by-code` and the invitation link, through `VerifyActivationCode`) and a completed reset-code password reset (`POST /reset-password`). `POST /register` in `auto` or `admin` mode, `RegisterUser`, an administrator's create and the admin Activate action, `POST /change-password` and social sign-ins record none. The proof follows the address: an email change through `POST /update` or the admin Users form clears it, while a change of letter case only keeps it. The column is never mass-assigned and is in no user API payload, admin form or list. No migration or code path backfills it: accounts from before v0.1.5 have no proof. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not. A social-login plugin links a verified provider's identity onto an existing account only when it is set.
|
- **Address proof.** `users.email_verified_at` records when the account's owner proved control of the address (`User.EmailVerifiedAt`). Exactly two flows write it: the mailed activation code (`POST /activate`, `POST /activate-by-code` and the invitation link, through `VerifyActivationCode`) and a completed reset-code password reset (`POST /reset-password`). `POST /register` in `auto` or `admin` mode, `RegisterUser`, an administrator's create and the admin Activate action, `POST /change-password` and social sign-ins record none. The proof follows the address: an email change through `POST /update` or the admin Users form clears it, while a change of letter case only keeps it. The column is never mass-assigned and is in no user API payload, admin form or list. No migration or code path backfills it: accounts from before v0.1.5 have no proof. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not. A social-login plugin links a verified provider's identity onto an existing account only when it is set.
|
||||||
- **Handle.** `users.handle` is a unique nullable `VARCHAR(255)` generated on create (`classes.HandleGenerator`, charset `[a-z0-9._-]`, length 2–255). It is omitted from the user JSON payload (`json:"-"`). `user:backfill-handles` fills existing rows that still have none.
|
- **Handle.** `users.handle` is a unique nullable `VARCHAR(255)` generated on create (`classes.HandleGenerator`, charset `[a-z0-9._-]`, length 2–255). It is omitted from the user JSON payload (`json:"-"`). `user:backfill-handles` fills existing rows that still have none.
|
||||||
- **User lighthouse authorizer.** Boot calls `classes.RegisterUserAuthorizer`: `lighthouse.From` then `Registry.Register("user", …)` if the namespace is free. `classes.AuthorizeUser` allows only `user:{id}` for that user (`lighthouse.ChannelID` on the two-segment name).
|
- **User lighthouse authorizer.** Boot calls `classes.RegisterUserAuthorizer`: `lighthouse.From` then `Registry.Register("user", …)` if the namespace is free. `classes.AuthorizeUser` allows only `user:{id}` for that user (`lighthouse.ChannelID` on the two-segment name).
|
||||||
- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:<scope>` limits delegated access and `user.permission:<code>` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created.
|
- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:<scope>` limits delegated access and `user.permission:<code>` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created. A refusal by `user.scope:<scope>` or `user.permission:<code>` answers 403 with the existing `error` and `message` plus `missing_scope` or `missing_permission` naming what is missing.
|
||||||
- **Member token scopes.** A member personal token (`usr_`) carries the built-in scopes `read`, `write` and `ai`, plus any scope another plugin declares with `pact.HasFrontendTokenScopes`:
|
- **Member token scopes.** A member personal token (`usr_`) carries the built-in scopes `read`, `write` and `ai`, plus any scope another plugin declares with `pact.HasFrontendTokenScopes`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func (Plugin) FrontendTokenScopes() []pact.FrontendTokenScope {
|
func (Plugin) FrontendTokenScopes() []pact.FrontendTokenScope {
|
||||||
return []pact.FrontendTokenScope{
|
return []pact.FrontendTokenScope{
|
||||||
{Name: "inventory:read", Label: "acme.inventory::lang.scopes.read", Description: "acme.inventory::lang.scopes.read_description"},
|
{Name: "inventory:read", Label: "acme.inventory::lang.scopes.read", Description: "acme.inventory::lang.scopes.read_description", Implies: "read"},
|
||||||
{Name: "inventory:write", Label: "acme.inventory::lang.scopes.write", Description: "acme.inventory::lang.scopes.write_description"},
|
{Name: "inventory:write", Label: "acme.inventory::lang.scopes.write", Description: "acme.inventory::lang.scopes.write_description", Implies: "write"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Boot compiles the catalog from the activated plugins (`classes.CompileAPITokenScopes`, published as `*classes.APITokenScopeCatalog`) and fails on a name that does not pass `pact.ValidTokenScopeName`, a built-in redeclared, a name two plugins declare (the error names both), an empty label or description, or a label or description phrase key (containing `::`) that the translator does not have. `POST /tokens` and `classes.MintAPITokenFor(app, …)` accept the scopes of the application's own catalog (`classes.APITokenScopesFor(app)`) and answer an undeclared one with the usual 422 `The selected scope is invalid.`; the console's `user:token:create` uses `MintAPITokenFor` too. Applications mint with `MintAPITokenFor`. The older `classes.MintAPIToken` is deprecated; it keeps its signature and accepts the built-ins plus every scope a catalog compiled in the same process declared, a fallback for existing callers; production runs one application per process, so that is the application's own catalog, while a test binary that boots several applications shares it (`classes.ResetDeclaredAPITokenScopes` clears it, for tests only). `user.scope:<name>`, `middleware.Scope` and `APIToken.HasScope` check a declared scope like a built-in, and `GET /tokens` lists it in `scopes`. `GET /tokens/scopes` (`jwt.auth`) answers `{"data":[{"name","label","description","built_in"}]}`, the built-ins first, then the declared scopes in activation order, with phrase-key labels and descriptions translated for the request locale. The admin `g15_` token catalog never shows these scopes (it reads `pact.HasTokenScopes` only). `classes.AllowedAPITokenScopes` stays the built-in list.
|
Boot compiles the catalog from the activated plugins (`classes.CompileAPITokenScopes`, published as `*classes.APITokenScopeCatalog` and as `pact.MemberTokenScopeCatalog`) and fails on a name that does not pass `pact.ValidTokenScopeName`, a built-in redeclared, a reserved name `backend` or `offline_access`, a name two plugins declare (the error names both), an empty label or description, an `Implies` other than empty, `read` or `write` (the error names the plugin and that value), or a label or description phrase key (containing `::`) that the translator does not have. `Implies` is only those three values: empty means the declared scope is not implied, and the built-ins `read`, `write` and `ai` keep it empty. Implied names are not stored on a token row or a fingerprint. `POST /tokens` and `classes.MintAPITokenFor(app, …)` accept the scopes of the application's own catalog (`classes.APITokenScopesFor(app)`) and answer an undeclared one with the usual 422 `The selected scope is invalid.`; the console's `user:token:create` uses `MintAPITokenFor` too. Applications mint with `MintAPITokenFor`. The older `classes.MintAPIToken` is deprecated; it keeps its signature and accepts the built-ins plus every scope a catalog compiled in the same process declared, a fallback for existing callers; production runs one application per process, so that is the application's own catalog, while a test binary that boots several applications shares it (`classes.ResetDeclaredAPITokenScopes` clears it, for tests only). `user.scope:<name>`, `middleware.Scope` and `APIToken.HasScope` check a declared scope like a built-in, and `GET /tokens` lists it in `scopes`. `GET /tokens/scopes` (`jwt.auth`) answers `{"data":[{"name","label","description","built_in"}]}`, the built-ins first, then the declared scopes in activation order, with phrase-key labels and descriptions translated for the request locale. The admin `g15_` token catalog never shows these scopes (it reads `pact.HasTokenScopes` only). `classes.AllowedAPITokenScopes` stays the built-in list.
|
||||||
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
|
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
|
||||||
- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never), 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.
|
- **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.
|
- **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.
|
||||||
@@ -60,7 +60,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
|||||||
- **Frontend account link for administrators.** A backend administrator's account can be linked to one frontend user, and a frontend user to one administrator (`golem15_user_backend_links`, unique on both sides). The administrator starts it on My account (section `frontend_account`, action `link`): the plugin stores a one-shot request (32 random bytes as hex, kept only as its SHA-256, valid `golem15.user.backend_links.request_ttl`) and answers the site URL `golem15.user.frontend_url` (else `app.url`) + `golem15.user.backend_links.page_path` (default `/link-admin`, `classes.BackendLinkPagePath`) + `#code=<code>`, which the admin opens in a new tab. The code travels in the URL fragment, which browsers never send to a server, so it stays out of access logs. The site page reads the fragment, keeps the code in the browser (for example `sessionStorage`) across the sign-in, shows the request through `POST /backend-link/show` with `{"code":…}` in the body (the administrator's login and display name and the expiry, nothing else), shows which frontend account is signed in, and posts the code to `POST /backend-link/confirm` behind `jwt.auth`. Both POST routes read the code from the body only (JSON, or a form body), never from the query string. `GET /backend-link/{code}` answers the same as the POST show route and stays for compatibility; it is deprecated, because its path puts the code in access logs, so pages use POST. The link is written only by the confirm, in one transaction with the request row locked, and only when the session was signed in at or after the request was created: the proof is the session token's `auth_time` claim (`bouncer.VerifyAuthTime`), the time of the sign-in itself, compared in whole seconds with the request. `POST /refresh` copies `auth_time` and never renews it, so a refreshed old session does not pass, and a browser that was already signed in must sign in again; password, social login and any login gate apply as usual. Upgrade note: session tokens issued before the framework stamped `auth_time` (SummerCMS v0.5.0) carry no `auth_time`, and a refresh carries that absence forward, so a frontend user signed in before the upgrade gets `stale_session` and must sign in again before linking. A code works once; a used, expired or unknown code answers 404. No link is ever made by matching emails. The administrator removes it on My account (`unlink`), and an administrator holding `golem15.users.manage_backend_links` links or unlinks another administrator on the Administrators screen (see Admin). Deleting or deactivating the frontend user removes the link in the same transaction; deleting an administrator removes it through the foreign key, and a deactivated or soft-deleted administrator is not answered by the resolver. Boot publishes `*classes.BackendLinks` and the same value as `pact.MCPLinkedBackendResolver` (`LinkedBackendUser`, one query per call), which the framework's MCP endpoint uses to give a frontend user the linked administrator's backend tools. `BackendLinkedEvent` and `BackendUnlinkedEvent` fire inside the write transaction.
|
- **Frontend account link for administrators.** A backend administrator's account can be linked to one frontend user, and a frontend user to one administrator (`golem15_user_backend_links`, unique on both sides). The administrator starts it on My account (section `frontend_account`, action `link`): the plugin stores a one-shot request (32 random bytes as hex, kept only as its SHA-256, valid `golem15.user.backend_links.request_ttl`) and answers the site URL `golem15.user.frontend_url` (else `app.url`) + `golem15.user.backend_links.page_path` (default `/link-admin`, `classes.BackendLinkPagePath`) + `#code=<code>`, which the admin opens in a new tab. The code travels in the URL fragment, which browsers never send to a server, so it stays out of access logs. The site page reads the fragment, keeps the code in the browser (for example `sessionStorage`) across the sign-in, shows the request through `POST /backend-link/show` with `{"code":…}` in the body (the administrator's login and display name and the expiry, nothing else), shows which frontend account is signed in, and posts the code to `POST /backend-link/confirm` behind `jwt.auth`. Both POST routes read the code from the body only (JSON, or a form body), never from the query string. `GET /backend-link/{code}` answers the same as the POST show route and stays for compatibility; it is deprecated, because its path puts the code in access logs, so pages use POST. The link is written only by the confirm, in one transaction with the request row locked, and only when the session was signed in at or after the request was created: the proof is the session token's `auth_time` claim (`bouncer.VerifyAuthTime`), the time of the sign-in itself, compared in whole seconds with the request. `POST /refresh` copies `auth_time` and never renews it, so a refreshed old session does not pass, and a browser that was already signed in must sign in again; password, social login and any login gate apply as usual. Upgrade note: session tokens issued before the framework stamped `auth_time` (SummerCMS v0.5.0) carry no `auth_time`, and a refresh carries that absence forward, so a frontend user signed in before the upgrade gets `stale_session` and must sign in again before linking. A code works once; a used, expired or unknown code answers 404. No link is ever made by matching emails. The administrator removes it on My account (`unlink`), and an administrator holding `golem15.users.manage_backend_links` links or unlinks another administrator on the Administrators screen (see Admin). Deleting or deactivating the frontend user removes the link in the same transaction; deleting an administrator removes it through the foreign key, and a deactivated or soft-deleted administrator is not answered by the resolver. Boot publishes `*classes.BackendLinks` and the same value as `pact.MCPLinkedBackendResolver` (`LinkedBackendUser`, one query per call), which the framework's MCP endpoint uses to give a frontend user the linked administrator's backend tools. `BackendLinkedEvent` and `BackendUnlinkedEvent` fire inside the write transaction.
|
||||||
- **Frontend permission codes from plugins.** Another plugin declares the frontend permission codes its routes check (`user.permission:<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.
|
- **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.
|
||||||
- **Access revocation.** Banning a user (the admin `ban` bulk action, for the users it newly bans), a password reset (`POST /reset-password`), an administrator setting a password on the Users screen and the user's own `POST /change-password` fire `classes.AccessRevokedEvent` (`user.AccessRevoked`: `UserIDs`, `Before`, `Reason`, `Tx`) through `classes.FireAccessRevoked`, inside the write's transaction (a listener error rolls the write back), and a "revoked" `pact.MCPSubjectChanged` per user after it commits, so the users' MCP sessions end. `Before` is the untruncated instant of the revocation for every reason, the self-service change included, so the event ends every OAuth grant of the user however recently its client refreshed it. `tokens_valid_after` keeps its whole-second value: the second of `Before` for a ban, a reset or an administrator's password, and for the self-service change the start of the calling session's `iat` second, so the calling session and the session tokens issued since that second survive. `Reason` is `ban`, `password_reset`, `password_set_by_admin` or `password_changed` (`AccessRevokedBan`, `AccessRevokedPasswordReset`, `AccessRevokedPasswordSetByAdmin`, `AccessRevokedPasswordChanged`). Listeners revoke what such a user still holds elsewhere: `golem15.oauthserver` revokes the users' OAuth access and refresh tokens created before `Before`. `RevokeUserAccess`, `BanUsers` and `DeactivateUsers` keep their behaviour and fire nothing: an application that calls `RevokeUserAccess` or `BanUsers` directly calls `FireAccessRevoked` in the same transaction too. Deactivation and deletion keep their own events (`DeactivatingEvent`, `DeletingEvent`).
|
- **Access revocation.** Banning a user (the admin `ban` bulk action, for the users it newly bans), a password reset (`POST /reset-password`), an administrator setting a password on the Users screen and the user's own `POST /change-password` fire `classes.AccessRevokedEvent` (`user.AccessRevoked`: `UserIDs`, `Before`, `Reason`, `Tx`) through `classes.FireAccessRevoked`, inside the write's transaction (a listener error rolls the write back), and a "revoked" `pact.MCPSubjectChanged` per user after it commits, so the users' MCP sessions end. `Before` is the untruncated instant of the revocation for every reason, the self-service change included, so the event ends every OAuth grant of the user however recently its client refreshed it. `tokens_valid_after` keeps its whole-second value: the second of `Before` for a ban, a reset or an administrator's password, and for the self-service change the start of the calling session's `iat` second, so the calling session and the session tokens issued since that second survive. `Reason` is `ban`, `password_reset`, `password_set_by_admin` or `password_changed` (`AccessRevokedBan`, `AccessRevokedPasswordReset`, `AccessRevokedPasswordSetByAdmin`, `AccessRevokedPasswordChanged`). Listeners revoke what such a user still holds elsewhere: `golem15.oauthserver` revokes the users' OAuth access and refresh tokens created before `Before`. `RevokeUserAccess`, `BanUsers` and `DeactivateUsers` keep their behaviour and fire nothing: an application that calls `RevokeUserAccess` or `BanUsers` directly calls `FireAccessRevoked` in the same transaction too. Deactivation and deletion keep their own events (`DeactivatingEvent`, `DeletingEvent`).
|
||||||
- **MCP guard and the `user_me` tool.** Boot registers `classes.MCPUserGuard` as the MCP guard provider `user` in the application's `*pact.MCPGuards` and publishes it as `pact.MCPPersonalTokenResolver`, so the framework's MCP endpoint (built with `-tags summercms_mcp`) can bind tools to routes behind `user.api_token`, `user.scope:<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; for a session opened with a personal token the credential's ID is that token's id (the subject's `TokenID`), so a handler can record which token made a change, while an OAuth session's credential has ID 0. The credential is never saved. The plugin's one tool, `me` (exposed as `user_me`), runs `GET /token/me` and answers the signed-in user's payload. A personal token used at the MCP endpoint gets `last_used_at` stamped, and `last_used_ip` when the endpoint passes the client address (`towel.ClientIP`), as the `user.api_token` guard does. Revoking a personal token, saving a user on the Users screen, saving or deleting a group and the link changes fire `pact.MCPSubjectChanged` after they commit, so open MCP sessions re-list their tools or end; revoking a personal token (`DELETE /tokens/{id}`) carries the token's id (`TokenID`), so only the sessions that token opened end. `BackendLinkedEvent` and `BackendUnlinkedEvent`, with `Link.UserID` set, fire on every link and unlink: the confirmation, the Administrators screen, My account and the deletion or deactivation of the frontend user. Deleting an administrator removes the link through the foreign key and fires nothing.
|
- **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). For an OAuth subject (`ClientID` non-empty), `MCPAllows` and `MCPDispatch` also treat a declared scope as granted when that catalog entry's `Implies` is `read` or `write` and the grant holds that stock scope. `write` does not satisfy `Implies` `read`. A personal token (`ClientID` empty) is not expanded: its scopes stay the grant. The expanded names exist only for that check and on the in-memory credential; they are not written back onto `subject.Scopes`, the token row, or a fingerprint. A tool call runs the route with the user and an in-memory token credential carrying those effective scopes; for a session opened with a personal token the credential's ID is that token's id (the subject's `TokenID`), so a handler can record which token made a change, while an OAuth session's credential has ID 0. The credential is never saved. The plugin's one tool, `me` (exposed as `user_me`), runs `GET /token/me` and answers the signed-in user's payload. A personal token used at the MCP endpoint gets `last_used_at` stamped, and `last_used_ip` when the endpoint passes the client address (`towel.ClientIP`), as the `user.api_token` guard does. Revoking a personal token, saving a user on the Users screen, saving or deleting a group and the link changes fire `pact.MCPSubjectChanged` after they commit, so open MCP sessions re-list their tools or end; revoking a personal token (`DELETE /tokens/{id}`) carries the token's id (`TokenID`), so only the sessions that token opened end. `BackendLinkedEvent` and `BackendUnlinkedEvent`, with `Link.UserID` set, fire on every link and unlink: the confirmation, the Administrators screen, My account and the deletion or deactivation of the frontend user. Deleting an administrator removes the link through the foreign key and fires nothing.
|
||||||
- **Sign-in helpers for social login.** `RegisterOptions.PasswordLess`, `FinishLogin`, `RegistrationSettings`, `RegistrationsFromIP`, `RefreshTTL` and `RemoteIP` let a social-login plugin create accounts and finish logins with the user plugin's own rules instead of copies.
|
- **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
|
## Usage
|
||||||
@@ -238,7 +238,7 @@ Packages:
|
|||||||
- `DeviceInitiate`, `DeviceStatus`, `DeviceAuthorize`, `DeviceIndex` and `DeviceDestroy`, the device authorization handlers (see Device authorization).
|
- `DeviceInitiate`, `DeviceStatus`, `DeviceAuthorize`, `DeviceIndex` and `DeviceDestroy`, the device authorization handlers (see Device authorization).
|
||||||
- `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).
|
- `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).
|
||||||
- `RefreshTTL(app)` is the refresh window, `golem15.user.jwt.refresh_ttl` minutes (default 20160), and `RemoteIP(r)` the host part of `r.RemoteAddr`, with no proxy handling (`/login` and `/register` use `surf.ClientIP` with `http.trusted_proxies`).
|
- `RefreshTTL(app)` is the refresh window, `golem15.user.jwt.refresh_ttl` minutes (default 20160), and `RemoteIP(r)` the host part of `r.RemoteAddr`, with no proxy handling (`/login` and `/register` use `surf.ClientIP` with `http.trusted_proxies`).
|
||||||
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `MintAPITokenFor`, `HashAPIToken` and `RevokeAPIToken`, the member token scope catalog `APITokenScope`, `APITokenScopeCatalog` (`Scopes`, `Allowed`), `CompileAPITokenScopes`, `APITokenScopesFor` and the test seam `ResetDeclaredAPITokenScopes`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
|
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `MintAPITokenFor`, `HashAPIToken` and `RevokeAPIToken`, the member token scope catalog `APITokenScope`, `APITokenScopeCatalog` (`Scopes`, `Allowed`, `MemberTokenScopes`), `CompileAPITokenScopes`, `APITokenScopesFor` and the test seam `ResetDeclaredAPITokenScopes`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
|
||||||
- `MergedPermissions(ctx, db, userID)`, `HasPermission(merged, code)` and `UserHasPermission(ctx, db, userID, code)`, the frontend permission resolver, and `FrontendPermissionOptions(ctx, db)`, the catalogue by tab and code.
|
- `MergedPermissions(ctx, db, userID)`, `HasPermission(merged, code)` and `UserHasPermission(ctx, db, userID, code)`, the frontend permission resolver, and `FrontendPermissionOptions(ctx, db)`, the catalogue by tab and code.
|
||||||
- `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule.
|
- `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule.
|
||||||
- `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction.
|
- `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction.
|
||||||
|
|||||||
@@ -15,23 +15,29 @@ import (
|
|||||||
// a built-in (read, write, ai) or one a plugin declared through
|
// a built-in (read, write, ai) or one a plugin declared through
|
||||||
// pact.HasFrontendTokenScopes. Label and Description are phrase keys
|
// pact.HasFrontendTokenScopes. Label and Description are phrase keys
|
||||||
// (containing "::") or literal text; PluginID is the declaring plugin, empty
|
// (containing "::") or literal text; PluginID is the declaring plugin, empty
|
||||||
// for a built-in.
|
// for a built-in. Implies is empty on a built-in. On a declared scope it is
|
||||||
|
// empty, read, or write: an OAuth MCP grant of that stock scope also
|
||||||
|
// satisfies the declared name. The name is not stored on the grant.
|
||||||
type APITokenScope struct {
|
type APITokenScope struct {
|
||||||
Name string
|
Name string
|
||||||
Label string
|
Label string
|
||||||
Description string
|
Description string
|
||||||
PluginID string
|
PluginID string
|
||||||
BuiltIn bool
|
BuiltIn bool
|
||||||
|
Implies string
|
||||||
}
|
}
|
||||||
|
|
||||||
// APITokenScopeCatalog is the compiled list of member token scopes of one
|
// APITokenScopeCatalog is the compiled list of member token scopes of one
|
||||||
// application: the built-ins first, then the declared scopes in activation
|
// application: the built-ins first, then the declared scopes in activation
|
||||||
// order. Boot publishes it; APITokenScopesFor reads it.
|
// order. Boot publishes it, and the same value as pact.MemberTokenScopeCatalog.
|
||||||
|
// APITokenScopesFor reads the concrete catalog.
|
||||||
type APITokenScopeCatalog struct {
|
type APITokenScopeCatalog struct {
|
||||||
scopes []APITokenScope
|
scopes []APITokenScope
|
||||||
byName map[string]int
|
byName map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ pact.MemberTokenScopeCatalog = (*APITokenScopeCatalog)(nil)
|
||||||
|
|
||||||
// Scopes returns a copy of the catalog, built-ins first.
|
// Scopes returns a copy of the catalog, built-ins first.
|
||||||
func (c *APITokenScopeCatalog) Scopes() []APITokenScope {
|
func (c *APITokenScopeCatalog) Scopes() []APITokenScope {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
@@ -49,6 +55,23 @@ func (c *APITokenScopeCatalog) Allowed(name string) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MemberTokenScopes returns the compiled list as pact scopes, built-ins
|
||||||
|
// first, then declared scopes. Implied names are not a separate entry: they
|
||||||
|
// are the Implies field of the declared scope.
|
||||||
|
func (c *APITokenScopeCatalog) MemberTokenScopes() []pact.FrontendTokenScope {
|
||||||
|
scopes := c.Scopes()
|
||||||
|
out := make([]pact.FrontendTokenScope, 0, len(scopes))
|
||||||
|
for _, scope := range scopes {
|
||||||
|
out = append(out, pact.FrontendTokenScope{
|
||||||
|
Name: scope.Name,
|
||||||
|
Label: scope.Label,
|
||||||
|
Description: scope.Description,
|
||||||
|
Implies: scope.Implies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// builtInAPITokenScopeKeys are the label and description phrase keys of
|
// builtInAPITokenScopeKeys are the label and description phrase keys of
|
||||||
// the built-in scopes, in AllowedAPITokenScopes order.
|
// the built-in scopes, in AllowedAPITokenScopes order.
|
||||||
var builtInAPITokenScopeKeys = map[string][2]string{
|
var builtInAPITokenScopeKeys = map[string][2]string{
|
||||||
@@ -78,12 +101,14 @@ func newAPITokenScopeCatalog(scopes []APITokenScope) *APITokenScopeCatalog {
|
|||||||
// CompileAPITokenScopes builds the member token scope catalog from the
|
// CompileAPITokenScopes builds the member token scope catalog from the
|
||||||
// built-ins and every plugin that implements pact.HasFrontendTokenScopes, in
|
// built-ins and every plugin that implements pact.HasFrontendTokenScopes, in
|
||||||
// the order given (party.Activated). A declared name is trimmed and must
|
// the order given (party.Activated). A declared name is trimmed and must
|
||||||
// pass pact.ValidTokenScopeName, must not be a built-in (read, write, ai)
|
// pass pact.ValidTokenScopeName, must not be a built-in (read, write, ai),
|
||||||
// and must not repeat a name another plugin (or the same one) declared; its
|
// must not be the reserved name backend or offline_access, and must not
|
||||||
// label and description must not be empty. Any violation is an error, which
|
// repeat a name another plugin (or the same one) declared; its label and
|
||||||
// fails boot. Phrase keys are checked against the translator by Boot. Every
|
// description must not be empty, and Implies must be empty, read, or write.
|
||||||
// name of a catalog that compiles is added to the process-wide set the
|
// Built-ins keep Implies empty. Any violation is an error, which fails boot.
|
||||||
// legacy MintAPIToken accepts.
|
// Phrase keys are checked against the translator by Boot. Every name of a
|
||||||
|
// catalog that compiles is added to the process-wide set the legacy
|
||||||
|
// MintAPIToken accepts.
|
||||||
func CompileAPITokenScopes(plugins []party.Plugin) (*APITokenScopeCatalog, error) {
|
func CompileAPITokenScopes(plugins []party.Plugin) (*APITokenScopeCatalog, error) {
|
||||||
scopes := builtInAPITokenScopes()
|
scopes := builtInAPITokenScopes()
|
||||||
owner := map[string]string{}
|
owner := map[string]string{}
|
||||||
@@ -111,14 +136,22 @@ func CompileAPITokenScopes(plugins []party.Plugin) (*APITokenScopeCatalog, error
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("golem15.user: duplicate member token scope %q declared by %s and %s", name, other, id)
|
return nil, fmt.Errorf("golem15.user: duplicate member token scope %q declared by %s and %s", name, other, id)
|
||||||
}
|
}
|
||||||
|
if name == "backend" || name == "offline_access" {
|
||||||
|
return nil, fmt.Errorf("golem15.user: plugin %s declared the reserved member token scope %q", id, name)
|
||||||
|
}
|
||||||
if strings.TrimSpace(scope.Label) == "" {
|
if strings.TrimSpace(scope.Label) == "" {
|
||||||
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has an empty label", id, name)
|
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has an empty label", id, name)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(scope.Description) == "" {
|
if strings.TrimSpace(scope.Description) == "" {
|
||||||
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has an empty description", id, name)
|
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has an empty description", id, name)
|
||||||
}
|
}
|
||||||
|
if scope.Implies != "" && scope.Implies != "read" && scope.Implies != "write" {
|
||||||
|
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has implies %q", id, name, scope.Implies)
|
||||||
|
}
|
||||||
owner[name] = id
|
owner[name] = id
|
||||||
scopes = append(scopes, APITokenScope{Name: name, Label: scope.Label, Description: scope.Description, PluginID: id})
|
scopes = append(scopes, APITokenScope{
|
||||||
|
Name: name, Label: scope.Label, Description: scope.Description, PluginID: id, Implies: scope.Implies,
|
||||||
|
})
|
||||||
declared = append(declared, name)
|
declared = append(declared, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,12 +84,16 @@ func (g *MCPUserGuard) MCPRequirements(middleware []string) (pact.MCPRequirement
|
|||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCPAllows requires every scope of req in the subject's scopes, then every
|
// MCPAllows requires every scope of req in the subject's effective scopes,
|
||||||
// permission code of req and of the tool, one by one (the route runs one
|
// then every permission code of req and of the tool, one by one (the route
|
||||||
// user.permission middleware per code), granted to the user's groups.
|
// runs one user.permission middleware per code), granted to the user's
|
||||||
|
// groups. Effective scopes are the grant itself for a personal token, and
|
||||||
|
// for an OAuth subject also the declared names the catalog implies. The
|
||||||
|
// expanded list is not written back onto the subject.
|
||||||
func (g *MCPUserGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string) (bool, error) {
|
func (g *MCPUserGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string) (bool, error) {
|
||||||
|
scopes := g.mcpEffectiveScopes(subject)
|
||||||
for _, scope := range req.Scopes {
|
for _, scope := range req.Scopes {
|
||||||
if !slices.Contains(subject.Scopes, scope) {
|
if !slices.Contains(scopes, scope) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,13 +118,16 @@ func (g *MCPUserGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, r
|
|||||||
|
|
||||||
// MCPDispatch builds the principal (the user with the group grants, as the
|
// 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
|
// 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. For a
|
// subject's effective scopes, so user.scope sees what the session may call.
|
||||||
// personal-token subject (ClientID empty) the credential's ID is the
|
// For an OAuth subject that includes declared names the catalog marks as
|
||||||
// subject's TokenID, the user_api_tokens row the session was opened with, so
|
// implied by a stock scope the grant holds; a personal token's scopes are
|
||||||
// a handler can record which token made a change; an OAuth subject's TokenID
|
// the grant unchanged. For a personal-token subject (ClientID empty) the
|
||||||
// is the OAuth server's own token row, not one of this plugin's, and its
|
// credential's ID is the subject's TokenID, the user_api_tokens row the
|
||||||
// credential keeps ID 0. The credential is never saved. The marker is
|
// session was opened with, so a handler can record which token made a
|
||||||
// honoured by the api_token guard only.
|
// change; an OAuth subject's TokenID is the OAuth server's own token row,
|
||||||
|
// not one of this plugin's, and its credential keeps ID 0. The credential
|
||||||
|
// is never saved, and implied names are not written onto a token row or
|
||||||
|
// onto subject.Scopes. The marker is honoured by the api_token guard only.
|
||||||
func (g *MCPUserGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
|
func (g *MCPUserGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
|
||||||
db, err := g.db()
|
db, err := g.db()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -135,7 +142,7 @@ func (g *MCPUserGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject,
|
|||||||
}
|
}
|
||||||
credential := &models.APIToken{
|
credential := &models.APIToken{
|
||||||
UserID: subject.UserID,
|
UserID: subject.UserID,
|
||||||
Scopes: lagoon.Jsonable[[]string]{Data: slices.Clone(subject.Scopes), Valid: true},
|
Scopes: lagoon.Jsonable[[]string]{Data: g.mcpEffectiveScopes(subject), Valid: true},
|
||||||
}
|
}
|
||||||
if subject.ClientID == "" {
|
if subject.ClientID == "" {
|
||||||
credential.ID = subject.TokenID
|
credential.ID = subject.TokenID
|
||||||
@@ -143,6 +150,30 @@ func (g *MCPUserGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject,
|
|||||||
return pact.MCPDispatch{Principal: principal, Credential: credential, Guards: []string{"api_token"}}, nil
|
return pact.MCPDispatch{Principal: principal, Credential: credential, Guards: []string{"api_token"}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mcpEffectiveScopes is the scope list MCPAllows and MCPDispatch share.
|
||||||
|
// A personal token (ClientID empty) is returned unchanged: the same slice,
|
||||||
|
// with no declared name added. An OAuth subject gets a clone of its grant
|
||||||
|
// plus each catalog name whose Implies is read or write, when the clone
|
||||||
|
// holds that stock scope and not the name. write does not satisfy Implies
|
||||||
|
// read. Nothing the catalog does not mark is appended. The clone is not
|
||||||
|
// written back onto subject.Scopes, a token row, or a fingerprint.
|
||||||
|
func (g *MCPUserGuard) mcpEffectiveScopes(subject pact.MCPSubject) []string {
|
||||||
|
if subject.ClientID == "" {
|
||||||
|
return subject.Scopes
|
||||||
|
}
|
||||||
|
scopes := slices.Clone(subject.Scopes)
|
||||||
|
for _, entry := range APITokenScopesFor(g.app()).MemberTokenScopes() {
|
||||||
|
if entry.Implies != "read" && entry.Implies != "write" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.Name == "" || slices.Contains(scopes, entry.Name) || !slices.Contains(scopes, entry.Implies) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scopes = append(scopes, entry.Name)
|
||||||
|
}
|
||||||
|
return scopes
|
||||||
|
}
|
||||||
|
|
||||||
// MCPResolvePersonalToken resolves a personal API token of this plugin into
|
// MCPResolvePersonalToken resolves a personal API token of this plugin into
|
||||||
// an MCP subject with the token's own scopes. A token without the configured
|
// 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
|
// prefix answers false; one with it that is unknown, revoked, expired or
|
||||||
|
|||||||
12
go.mod
12
go.mod
@@ -3,20 +3,20 @@ module git.golem15.com/golem15/sm-user-plugin
|
|||||||
go 1.27.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.golem15.com/golem15/summercms v0.5.0
|
git.golem15.com/golem15/summercms v0.5.2
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/testcontainers/testcontainers-go v0.44.0
|
github.com/testcontainers/testcontainers-go v0.44.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
||||||
gocloud.dev v0.46.0
|
gocloud.dev v0.46.0
|
||||||
|
gorm.io/driver/postgres v1.6.3
|
||||||
gorm.io/gorm v1.31.2
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
charm.land/bubbles/v2 v2.2.1 // indirect
|
charm.land/bubbles/v2 v2.2.1 // indirect
|
||||||
charm.land/bubbletea/v2 v2.1.0 // indirect
|
charm.land/bubbletea/v2 v2.1.0 // indirect
|
||||||
charm.land/fang/v2 v2.0.1 // indirect
|
|
||||||
charm.land/huh/v2 v2.0.3 // indirect
|
charm.land/huh/v2 v2.0.3 // indirect
|
||||||
charm.land/lipgloss/v2 v2.0.6 // indirect
|
charm.land/lipgloss/v2 v2.0.6 // indirect
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
@@ -50,7 +50,6 @@ require (
|
|||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
||||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
|
|
||||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
@@ -120,10 +119,6 @@ require (
|
|||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // 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
|
|
||||||
github.com/muesli/mango-pflag v0.1.0 // indirect
|
|
||||||
github.com/muesli/roff v0.1.0 // indirect
|
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
@@ -177,7 +172,6 @@ require (
|
|||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||||
google.golang.org/grpc v1.79.3 // indirect
|
google.golang.org/grpc v1.79.3 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gorm.io/driver/postgres v1.6.3 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
replace git.golem15.com/golem15/summercms => ../../../../summercms.go
|
replace git.golem15.com/golem15/summercms => ../summercms.go
|
||||||
|
|||||||
12
go.sum
12
go.sum
@@ -4,8 +4,6 @@ charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
|||||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||||
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||||
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||||
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
|
|
||||||
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
|
|
||||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||||
@@ -110,8 +108,6 @@ github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4
|
|||||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
|
|
||||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
|
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||||
@@ -299,14 +295,6 @@ github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtw
|
|||||||
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
|
|
||||||
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
|
|
||||||
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
|
|
||||||
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
|
|
||||||
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
|
|
||||||
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
|
|
||||||
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
|
||||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
|||||||
2
mcp.go
2
mcp.go
@@ -15,7 +15,7 @@ func (p *Plugin) MCP() pact.MCP {
|
|||||||
return pact.MCP{Tools: []pact.MCPTool{{
|
return pact.MCP{Tools: []pact.MCPTool{{
|
||||||
Name: "me",
|
Name: "me",
|
||||||
Title: "Current user",
|
Title: "Current user",
|
||||||
Description: "Returns the profile of the signed-in frontend user.",
|
Description: "Returns the profile of the signed-in frontend user. Needs scope read.",
|
||||||
Guard: "user",
|
Guard: "user",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Path: "/_user/api/v1/token/me",
|
Path: "/_user/api/v1/token/me",
|
||||||
|
|||||||
335
mcp_implies_test.go
Normal file
335
mcp_implies_test.go
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"io"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
pgdriver "gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// impliesServersRead is the one declared scope the tracer exercises: an
|
||||||
|
// OAuth grant of read satisfies it, a grant of write does not.
|
||||||
|
func impliesServersRead() []pact.FrontendTokenScope {
|
||||||
|
return []pact.FrontendTokenScope{{
|
||||||
|
Name: "servers:read", Label: "Read servers", Description: "List servers.", Implies: "read",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// impliesApp boots the member-token catalog with scopes declared and
|
||||||
|
// publishes a database handle the guard can load a principal from. The
|
||||||
|
// handle answers one user and no group grants, in memory, so the scope
|
||||||
|
// check can reach a credential without Postgres. Nothing is written.
|
||||||
|
func impliesApp(t *testing.T, scopes []pact.FrontendTokenScope) (*backpack.App, *classes.MCPUserGuard) {
|
||||||
|
t.Helper()
|
||||||
|
t.Cleanup(classes.ResetDeclaredAPITokenScopes)
|
||||||
|
app := scopeBootApp(t, scopeDeclarer{"acme.scopes", scopes})
|
||||||
|
if err := (&Plugin{}).bootAPITokenScopes(app); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sqlDB, err := sql.Open("user_implies", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
db, err := gorm.Open(pgdriver.New(pgdriver.Config{Conn: sqlDB}), &gorm.Config{DisableAutomaticPing: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Publish(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return app, &classes.MCPUserGuard{App: app}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMCPImpliesOAuth pins the OAuth half of D-01a: a grant of read satisfies
|
||||||
|
// a declared scope whose Implies is read, in MCPAllows and on the credential
|
||||||
|
// MCPDispatch builds, and that credential keeps ID 0. The grant slice itself
|
||||||
|
// is not rewritten, and the catalog is published as pact.MemberTokenScopeCatalog.
|
||||||
|
func TestMCPImpliesOAuth(t *testing.T) {
|
||||||
|
app, guard := impliesApp(t, impliesServersRead())
|
||||||
|
catalog, ok := app.Lookup[pact.MemberTokenScopeCatalog]()
|
||||||
|
if !ok || catalog == nil {
|
||||||
|
t.Fatal("pact.MemberTokenScopeCatalog is not published")
|
||||||
|
}
|
||||||
|
if _, ok := app.Lookup[*classes.APITokenScopeCatalog](); !ok {
|
||||||
|
t.Fatal("the concrete catalog publish was removed")
|
||||||
|
}
|
||||||
|
var sawRead, sawDeclared bool
|
||||||
|
for _, scope := range catalog.MemberTokenScopes() {
|
||||||
|
switch scope.Name {
|
||||||
|
case "read", "write", "ai":
|
||||||
|
if scope.Implies != "" {
|
||||||
|
t.Fatalf("built-in %s Implies = %q", scope.Name, scope.Implies)
|
||||||
|
}
|
||||||
|
if scope.Name == "read" {
|
||||||
|
sawRead = true
|
||||||
|
}
|
||||||
|
case "servers:read":
|
||||||
|
sawDeclared = scope.Implies == "read"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawRead || !sawDeclared {
|
||||||
|
t.Fatalf("catalog = %+v", catalog.MemberTokenScopes())
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"read"}}
|
||||||
|
orig := subject.Scopes
|
||||||
|
ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{Scopes: []string{"servers:read"}}, nil)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("MCPAllows(servers:read) = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
credential, isToken := dispatch.Credential.(*models.APIToken)
|
||||||
|
if !isToken || credential.ID != 0 || credential.OAuthClientID != nil || !credential.HasScope("servers:read") || !credential.HasScope("read") {
|
||||||
|
t.Fatalf("credential = %+v", dispatch.Credential)
|
||||||
|
}
|
||||||
|
if len(subject.Scopes) != 1 || &subject.Scopes[0] != &orig[0] || subject.Scopes[0] != "read" {
|
||||||
|
t.Fatalf("subject.Scopes = %v, want the original grant slice", subject.Scopes)
|
||||||
|
}
|
||||||
|
if slices.Contains(subject.Scopes, "servers:read") {
|
||||||
|
t.Fatal("implied name was written onto subject.Scopes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMCPImpliesPersonalUnchanged pins D-11: a personal token is not expanded,
|
||||||
|
// and the credential ID stays the subject's TokenID.
|
||||||
|
func TestMCPImpliesPersonalUnchanged(t *testing.T) {
|
||||||
|
_, guard := impliesApp(t, impliesServersRead())
|
||||||
|
ctx := context.Background()
|
||||||
|
subject := pact.MCPSubject{Guard: "user", UserID: 7, TokenID: 42, Scopes: []string{"read"}}
|
||||||
|
orig := subject.Scopes
|
||||||
|
ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{Scopes: []string{"servers:read"}}, nil)
|
||||||
|
if err != nil || ok {
|
||||||
|
t.Fatalf("MCPAllows = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
if len(subject.Scopes) != 1 || &subject.Scopes[0] != &orig[0] || subject.Scopes[0] != "read" {
|
||||||
|
t.Fatalf("subject.Scopes = %v, want the original grant slice", subject.Scopes)
|
||||||
|
}
|
||||||
|
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
credential, isToken := dispatch.Credential.(*models.APIToken)
|
||||||
|
if !isToken || credential.ID != subject.TokenID || credential.HasScope("servers:read") || !credential.HasScope("read") {
|
||||||
|
t.Fatalf("credential = %+v, want ID %d and scopes [read]", dispatch.Credential, subject.TokenID)
|
||||||
|
}
|
||||||
|
got := credential.Scopes.Get()
|
||||||
|
if len(got) != 1 || &got[0] != &orig[0] {
|
||||||
|
t.Fatalf("credential scopes = %v, want the unexpanded grant slice", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMCPImpliesWriteDoesNotGrantRead pins that a write grant does not
|
||||||
|
// satisfy a scope whose Implies is read.
|
||||||
|
func TestMCPImpliesWriteDoesNotGrantRead(t *testing.T) {
|
||||||
|
_, guard := impliesApp(t, impliesServersRead())
|
||||||
|
ctx := context.Background()
|
||||||
|
subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"write"}}
|
||||||
|
ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{Scopes: []string{"servers:read"}}, nil)
|
||||||
|
if err != nil || ok {
|
||||||
|
t.Fatalf("MCPAllows = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
credential, isToken := dispatch.Credential.(*models.APIToken)
|
||||||
|
if !isToken || credential.ID != 0 || credential.HasScope("servers:read") || !credential.HasScope("write") {
|
||||||
|
t.Fatalf("credential = %+v", dispatch.Credential)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMCPImpliesNotPersisted pins that MCPDispatch expands scopes only on
|
||||||
|
// the credential it returns. The subject's grant slice is the same backing
|
||||||
|
// array afterwards.
|
||||||
|
func TestMCPImpliesNotPersisted(t *testing.T) {
|
||||||
|
_, guard := impliesApp(t, impliesServersRead())
|
||||||
|
ctx := context.Background()
|
||||||
|
subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"read"}}
|
||||||
|
orig := subject.Scopes
|
||||||
|
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
credential, isToken := dispatch.Credential.(*models.APIToken)
|
||||||
|
if !isToken || !credential.HasScope("servers:read") || !credential.HasScope("read") {
|
||||||
|
t.Fatalf("credential = %+v, want the expanded scopes on the credential only", dispatch.Credential)
|
||||||
|
}
|
||||||
|
if len(subject.Scopes) != 1 || &subject.Scopes[0] != &orig[0] || subject.Scopes[0] != "read" {
|
||||||
|
t.Fatalf("subject.Scopes = %v, want the original grant slice", subject.Scopes)
|
||||||
|
}
|
||||||
|
if slices.Contains(subject.Scopes, "servers:read") {
|
||||||
|
t.Fatal("implied name was written onto subject.Scopes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMCPImpliesAlreadyPresent does not append a declared name the grant
|
||||||
|
// already holds, and does not append a catalog name whose Implies is empty.
|
||||||
|
func TestMCPImpliesAlreadyPresent(t *testing.T) {
|
||||||
|
_, guard := impliesApp(t, []pact.FrontendTokenScope{
|
||||||
|
{Name: "servers:read", Label: "Read servers", Description: "List servers.", Implies: "read"},
|
||||||
|
{Name: "servers:list", Label: "List servers", Description: "List only.", Implies: ""},
|
||||||
|
})
|
||||||
|
ctx := context.Background()
|
||||||
|
subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"read", "servers:read"}}
|
||||||
|
dispatch, err := guard.MCPDispatch(ctx, subject, "GET")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
credential, isToken := dispatch.Credential.(*models.APIToken)
|
||||||
|
if !isToken || !slices.Equal(credential.Scopes.Get(), []string{"read", "servers:read"}) {
|
||||||
|
t.Fatalf("credential scopes = %+v", dispatch.Credential)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMCPImpliesCompileRejects pins the boot errors for a bad Implies and
|
||||||
|
// for the reserved names backend and offline_access. The error names the
|
||||||
|
// plugin and the bad value. Empty, read, and write still compile.
|
||||||
|
func TestMCPImpliesCompileRejects(t *testing.T) {
|
||||||
|
t.Cleanup(classes.ResetDeclaredAPITokenScopes)
|
||||||
|
one := func(name, implies string) pact.FrontendTokenScope {
|
||||||
|
return pact.FrontendTokenScope{Name: name, Label: "Label", Description: "Description", Implies: implies}
|
||||||
|
}
|
||||||
|
boot := func(scope pact.FrontendTokenScope) error {
|
||||||
|
classes.ResetDeclaredAPITokenScopes()
|
||||||
|
return (&Plugin{}).bootAPITokenScopes(scopeBootApp(t, scopeDeclarer{"acme.scopes", []pact.FrontendTokenScope{scope}}))
|
||||||
|
}
|
||||||
|
for _, implies := range []string{"ai", "backend", "READ", " read"} {
|
||||||
|
err := boot(one("servers:read", implies))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "acme.scopes") || !strings.Contains(err.Error(), implies) {
|
||||||
|
t.Fatalf("implies %q err = %v", implies, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"backend", "offline_access"} {
|
||||||
|
err := boot(one(name, ""))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "acme.scopes") || !strings.Contains(err.Error(), `"`+name+`"`) {
|
||||||
|
t.Fatalf("name %q err = %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, implies := range []string{"", "read", "write"} {
|
||||||
|
if err := boot(one("servers:read", implies)); err != nil {
|
||||||
|
t.Fatalf("implies %q: %v", implies, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sql.Register("user_implies", impliesDriver{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// impliesDriver is an in-memory database/sql driver for the Implies tests.
|
||||||
|
// A users query returns one row. A group-grant query returns none.
|
||||||
|
type impliesDriver struct{}
|
||||||
|
|
||||||
|
func (impliesDriver) Open(string) (driver.Conn, error) { return impliesConn{}, nil }
|
||||||
|
|
||||||
|
type impliesConn struct{}
|
||||||
|
|
||||||
|
func (impliesConn) Prepare(string) (driver.Stmt, error) {
|
||||||
|
return nil, driver.ErrSkip
|
||||||
|
}
|
||||||
|
func (impliesConn) Close() error { return nil }
|
||||||
|
func (impliesConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
|
||||||
|
|
||||||
|
func (impliesConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
|
||||||
|
return impliesRowsFor(query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impliesConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) {
|
||||||
|
return impliesResult(0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func impliesRowsFor(query string) driver.Rows {
|
||||||
|
upper := strings.ToUpper(query)
|
||||||
|
if strings.Contains(upper, "FROM \"USERS\"") || strings.Contains(upper, "FROM USERS") {
|
||||||
|
cols := impliesSelectCols(query)
|
||||||
|
vals := make([]driver.Value, len(cols))
|
||||||
|
for i, col := range cols {
|
||||||
|
vals[i] = impliesColValue(col)
|
||||||
|
}
|
||||||
|
return &impliesRows{cols: cols, vals: [][]driver.Value{vals}}
|
||||||
|
}
|
||||||
|
return &impliesRows{cols: []string{"permissions"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func impliesSelectCols(query string) []string {
|
||||||
|
upper := strings.ToUpper(query)
|
||||||
|
start := strings.Index(upper, "SELECT")
|
||||||
|
from := strings.Index(upper, " FROM ")
|
||||||
|
if start < 0 || from < 0 || from <= start+6 {
|
||||||
|
return []string{"id"}
|
||||||
|
}
|
||||||
|
raw := strings.Split(query[start+6:from], ",")
|
||||||
|
cols := make([]string, 0, len(raw))
|
||||||
|
for _, part := range raw {
|
||||||
|
name := strings.TrimSpace(part)
|
||||||
|
name = strings.Trim(name, `"`)
|
||||||
|
if i := strings.LastIndex(name, "."); i >= 0 {
|
||||||
|
name = name[i+1:]
|
||||||
|
}
|
||||||
|
name = strings.Trim(name, `"`)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" || name == "*" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cols = append(cols, name)
|
||||||
|
}
|
||||||
|
if len(cols) == 0 {
|
||||||
|
return []string{"id"}
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
func impliesColValue(col string) driver.Value {
|
||||||
|
switch col {
|
||||||
|
case "id":
|
||||||
|
return int64(7)
|
||||||
|
case "email":
|
||||||
|
return "ada@example.test"
|
||||||
|
case "password":
|
||||||
|
return "x"
|
||||||
|
case "must_change_password", "is_activated", "has_self_set_password", "marketing_consent", "is_onboarded":
|
||||||
|
return false
|
||||||
|
case "created_at", "updated_at":
|
||||||
|
return time.Time{}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type impliesRows struct {
|
||||||
|
cols []string
|
||||||
|
vals [][]driver.Value
|
||||||
|
i int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *impliesRows) Columns() []string { return r.cols }
|
||||||
|
func (r *impliesRows) Close() error { return nil }
|
||||||
|
func (r *impliesRows) Next(dest []driver.Value) error {
|
||||||
|
if r.i >= len(r.vals) {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
copy(dest, r.vals[r.i])
|
||||||
|
r.i++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type impliesResult int64
|
||||||
|
|
||||||
|
func (r impliesResult) LastInsertId() (int64, error) { return int64(r), nil }
|
||||||
|
func (r impliesResult) RowsAffected() (int64, error) { return int64(r), nil }
|
||||||
@@ -86,14 +86,15 @@ func (g *APITokenGuard) WriteUnauthorized(w http.ResponseWriter, _ error) {
|
|||||||
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Invalid token."})
|
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Invalid token."})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope requires one delegated scope from the shared token credential.
|
// Scope requires one delegated scope from the shared token credential. A
|
||||||
|
// refusal answers 403 with error, message and missing_scope naming the scope.
|
||||||
func Scope(scope string) pact.Middleware {
|
func Scope(scope string) pact.Middleware {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
credential, _ := bouncer.Credential(r.Context())
|
credential, _ := bouncer.Credential(r.Context())
|
||||||
token, ok := credential.(*models.APIToken)
|
token, ok := credential.(*models.APIToken)
|
||||||
if !ok || token == nil || !token.HasScope(scope) {
|
if !ok || token == nil || !token.HasScope(scope) {
|
||||||
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Missing required scope: " + scope})
|
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Missing required scope: " + scope, "missing_scope": scope})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
@@ -103,12 +104,14 @@ func Scope(scope string) pact.Middleware {
|
|||||||
|
|
||||||
// Permission requires one current user-group permission. Tokens never carry
|
// Permission requires one current user-group permission. Tokens never carry
|
||||||
// a permission snapshot: revoking the user's grant takes effect immediately.
|
// a permission snapshot: revoking the user's grant takes effect immediately.
|
||||||
|
// A refusal answers 403 with error, message and missing_permission naming the
|
||||||
|
// code.
|
||||||
func Permission(code string) pact.Middleware {
|
func Permission(code string) pact.Middleware {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
principal, _ := bouncer.User(r.Context())
|
principal, _ := bouncer.User(r.Context())
|
||||||
if !cabana.Allows(principal, []string{code}) {
|
if !cabana.Allows(principal, []string{code}) {
|
||||||
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Forbidden."})
|
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Forbidden.", "missing_permission": code})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|||||||
88
middleware/api_token_test.go
Normal file
88
middleware/api_token_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
)
|
||||||
|
|
||||||
|
// forbiddenBody is the 403 body of Scope and Permission.
|
||||||
|
type forbiddenBody struct {
|
||||||
|
Error bool `json:"error"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
MissingScope string `json:"missing_scope"`
|
||||||
|
MissingPermission string `json:"missing_permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenWith is an in-memory credential holding scopes, built the way
|
||||||
|
// MCPDispatch builds it.
|
||||||
|
func tokenWith(scopes ...string) *models.APIToken {
|
||||||
|
return &models.APIToken{Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve runs mw over a handler answering 200, with ctx on the request.
|
||||||
|
func serve(t *testing.T, mw func(http.Handler) http.Handler, ctx context.Context) (int, forbiddenBody) {
|
||||||
|
t.Helper()
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
||||||
|
mw(next).ServeHTTP(rec, req)
|
||||||
|
var body forbiddenBody
|
||||||
|
if rec.Code == http.StatusForbidden {
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("403 body %q: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rec.Code, body
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForbiddenBodiesNameWhatIsMissing (D-15, D-20): a refusal by Scope or
|
||||||
|
// Permission keeps error true and the message, and adds missing_scope or
|
||||||
|
// missing_permission naming what is missing. No database.
|
||||||
|
func TestForbiddenBodiesNameWhatIsMissing(t *testing.T) {
|
||||||
|
scopeRefused := forbiddenBody{Error: true, Message: "Missing required scope: write", MissingScope: "write"}
|
||||||
|
|
||||||
|
t.Run("scope missing from the credential", func(t *testing.T) {
|
||||||
|
ctx := bouncer.WithCredential(context.Background(), tokenWith("read"))
|
||||||
|
code, body := serve(t, Scope("write"), ctx)
|
||||||
|
if code != http.StatusForbidden || body != scopeRefused {
|
||||||
|
t.Fatalf("Scope(write) with read = %d %+v, want 403 %+v", code, body, scopeRefused)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no credential", func(t *testing.T) {
|
||||||
|
code, body := serve(t, Scope("write"), context.Background())
|
||||||
|
if code != http.StatusForbidden || body != scopeRefused {
|
||||||
|
t.Fatalf("Scope(write) without a credential = %d %+v, want 403 %+v", code, body, scopeRefused)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scope held", func(t *testing.T) {
|
||||||
|
ctx := bouncer.WithCredential(context.Background(), tokenWith("read"))
|
||||||
|
if code, _ := serve(t, Scope("read"), ctx); code != http.StatusOK {
|
||||||
|
t.Fatalf("Scope(read) with read = %d, want 200", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("permission missing", func(t *testing.T) {
|
||||||
|
ctx := bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"jz.bm.access_schedule": true}})
|
||||||
|
want := forbiddenBody{Error: true, Message: "Forbidden.", MissingPermission: "jz.bm.access_courses"}
|
||||||
|
code, body := serve(t, Permission("jz.bm.access_courses"), ctx)
|
||||||
|
if code != http.StatusForbidden || body != want {
|
||||||
|
t.Fatalf("Permission without the grant = %d %+v, want 403 %+v", code, body, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("permission granted", func(t *testing.T) {
|
||||||
|
ctx := bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"jz.bm.access_courses": true}})
|
||||||
|
if code, _ := serve(t, Permission("jz.bm.access_courses"), ctx); code != http.StatusOK {
|
||||||
|
t.Fatalf("Permission with the grant = %d, want 200", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -153,7 +153,8 @@ func (p *Plugin) bootDeviceRevocation(app *backpack.App) {
|
|||||||
|
|
||||||
// bootAPITokenScopes compiles the member token scope catalog from the
|
// bootAPITokenScopes compiles the member token scope catalog from the
|
||||||
// activated plugins (pact.HasFrontendTokenScopes), checks that every phrase
|
// activated plugins (pact.HasFrontendTokenScopes), checks that every phrase
|
||||||
// key it names exists, and publishes it. A bad declaration fails boot.
|
// key it names exists, and publishes it as *classes.APITokenScopeCatalog and
|
||||||
|
// as pact.MemberTokenScopeCatalog. A bad declaration fails boot.
|
||||||
func (p *Plugin) bootAPITokenScopes(app *backpack.App) error {
|
func (p *Plugin) bootAPITokenScopes(app *backpack.App) error {
|
||||||
activated, _ := app.Lookup[party.Activated]()
|
activated, _ := app.Lookup[party.Activated]()
|
||||||
catalog, err := classes.CompileAPITokenScopes(activated)
|
catalog, err := classes.CompileAPITokenScopes(activated)
|
||||||
@@ -173,7 +174,10 @@ func (p *Plugin) bootAPITokenScopes(app *backpack.App) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return app.Publish(catalog)
|
if err := app.Publish(catalog); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return app.Publish[pact.MemberTokenScopeCatalog](catalog)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bootMCPGuard registers the "user" MCP guard provider in the application's
|
// bootMCPGuard registers the "user" MCP guard provider in the application's
|
||||||
|
|||||||
Reference in New Issue
Block a user