4 Commits

Author SHA1 Message Date
756797c2c4 test(32.1-05): keep implied MCP scopes off the subject
- MCPDispatch expands the credential and leaves subject.Scopes unchanged
2026-10-28 16:10:03 +01:00
193a381ece test(32.1-02): keep personal tokens unexpanded and reject bad Implies
- A personal token keeps its scopes and its credential TokenID
- A write grant does not satisfy Implies read, and a present name is not duplicated
- Document Implies and the pact.MemberTokenScopeCatalog publish
2026-10-28 15:03:28 +01:00
25fd981d7f feat(32.1-02): expand OAuth MCP scopes from the published catalog
- Publish the compiled member-token catalog as pact.MemberTokenScopeCatalog
- Imply declared names only when the MCP subject has a ClientID
- Reject a bad Implies and the reserved names backend and offline_access
2026-10-28 15:01:04 +01:00
a1b78b80f5 fix: the MCP dispatch credential carries the personal token id
MCPUserGuard.MCPDispatch now sets the in-memory credential's ID to the
subject's TokenID when the subject came from a personal usr_ token (no
ClientID), so a handler running under MCP dispatch can record which
token made a change. An OAuth subject's TokenID names the OAuth
server's token row, so its credential keeps ID 0. The credential is
still never saved.
2026-10-28 11:30:44 +01:00
7 changed files with 492 additions and 27 deletions

View File

@@ -46,13 +46,13 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
```go
func (Plugin) FrontendTokenScopes() []pact.FrontendTokenScope {
return []pact.FrontendTokenScope{
{Name: "inventory:read", Label: "acme.inventory::lang.scopes.read", Description: "acme.inventory::lang.scopes.read_description"},
{Name: "inventory:write", Label: "acme.inventory::lang.scopes.write", Description: "acme.inventory::lang.scopes.write_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", 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.
- **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.
@@ -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 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`).
- **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. 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.
## Usage
@@ -238,7 +238,7 @@ Packages:
- `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).
- `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.
- `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.

View File

@@ -15,23 +15,29 @@ import (
// a built-in (read, write, ai) or one a plugin declared through
// pact.HasFrontendTokenScopes. Label and Description are phrase keys
// (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 {
Name string
Label string
Description string
PluginID string
BuiltIn bool
Implies string
}
// APITokenScopeCatalog is the compiled list of member token scopes of one
// 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 {
scopes []APITokenScope
byName map[string]int
}
var _ pact.MemberTokenScopeCatalog = (*APITokenScopeCatalog)(nil)
// Scopes returns a copy of the catalog, built-ins first.
func (c *APITokenScopeCatalog) Scopes() []APITokenScope {
if c == nil {
@@ -49,6 +55,23 @@ func (c *APITokenScopeCatalog) Allowed(name string) bool {
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
// the built-in scopes, in AllowedAPITokenScopes order.
var builtInAPITokenScopeKeys = map[string][2]string{
@@ -78,12 +101,14 @@ func newAPITokenScopeCatalog(scopes []APITokenScope) *APITokenScopeCatalog {
// CompileAPITokenScopes builds the member token scope catalog from the
// built-ins and every plugin that implements pact.HasFrontendTokenScopes, in
// the order given (party.Activated). A declared name is trimmed and must
// 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
// label and description must not be empty. Any violation is an error, which
// fails boot. 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.
// pass pact.ValidTokenScopeName, must not be a built-in (read, write, ai),
// must not be the reserved name backend or offline_access, and must not
// repeat a name another plugin (or the same one) declared; its label and
// description must not be empty, and Implies must be empty, read, or write.
// Built-ins keep Implies empty. Any violation is an error, which fails boot.
// 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) {
scopes := builtInAPITokenScopes()
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)
}
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) == "" {
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has an empty label", id, name)
}
if strings.TrimSpace(scope.Description) == "" {
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
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)
}
}

View File

@@ -84,12 +84,16 @@ func (g *MCPUserGuard) MCPRequirements(middleware []string) (pact.MCPRequirement
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.
// MCPAllows requires every scope of req in the subject's effective 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. 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) {
scopes := g.mcpEffectiveScopes(subject)
for _, scope := range req.Scopes {
if !slices.Contains(subject.Scopes, scope) {
if !slices.Contains(scopes, scope) {
return false, nil
}
}
@@ -114,9 +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
// 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.
// subject's effective scopes, so user.scope sees what the session may call.
// For an OAuth subject that includes declared names the catalog marks as
// implied by a stock scope the grant holds; a personal token's scopes are
// the grant unchanged. For a personal-token subject (ClientID empty) the
// credential's ID is the subject's TokenID, the user_api_tokens row the
// session was opened with, so a handler can record which token made a
// 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) {
db, err := g.db()
if err != nil {
@@ -131,11 +142,38 @@ func (g *MCPUserGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject,
}
credential := &models.APIToken{
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 == "" {
credential.ID = subject.TokenID
}
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
// 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

2
go.mod
View File

@@ -180,4 +180,4 @@ require (
gorm.io/driver/postgres v1.6.3 // indirect
)
replace git.golem15.com/golem15/summercms => ../../../../summercms.go
replace git.golem15.com/golem15/summercms => ../summercms.go

View File

@@ -21,7 +21,9 @@ import (
// do expired ones; and the in-memory credential MCPDispatch builds passes
// the route's own user.scope:read middleware when the subject was granted
// read and is refused (403) when it was not, through the plugin's real
// route chain in-process. Skips under -short.
// route chain in-process; that credential carries a personal token's id
// (the subject's TokenID) and keeps ID 0 for an OAuth subject. Skips under
// -short.
func TestUserMCPGuard(t *testing.T) {
env := newAdminEnv(t)
ctx := context.Background()
@@ -100,10 +102,63 @@ func TestUserMCPGuard(t *testing.T) {
t.Fatal("the dispatch credential is not an in-memory API token")
}
})
t.Run("dispatch credential carries the personal token id", func(t *testing.T) {
user := env.seedUser(t, "mcp-token-id", nil)
secret, minted := env.mintUserToken(t, user, "read")
subject, matched, err := resolver.MCPResolvePersonalToken(ctx, secret)
if !matched || err != nil {
t.Fatalf("resolve = %v, %v", matched, err)
}
if subject.ClientID != "" || subject.TokenID != minted.ID {
t.Fatalf("subject = %+v, want TokenID %d and no ClientID", subject, minted.ID)
}
d, err := guard.MCPDispatch(ctx, subject, http.MethodGet)
if err != nil {
t.Fatal(err)
}
tok, isToken := d.Credential.(*models.APIToken)
if !isToken {
t.Fatalf("credential = %+v", d.Credential)
}
if tok.ID != minted.ID || tok.UserID != user.ID || !tok.HasScope("read") || tok.HasScope("write") {
t.Fatalf("credential = %+v, want ID %d, UserID %d, scopes [read]", tok, minted.ID, user.ID)
}
principal, _ := d.Principal.(*bouncer.Principal)
req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/token/me", nil)
req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: d.Guards, Principal: principal, Credential: d.Credential}))
rec := httptest.NewRecorder()
env.h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"email":"`+user.Email+`"`) {
t.Fatalf("token/me = %d %s", rec.Code, rec.Body.String())
}
var rows int64
if err := env.gdb.Model(&models.APIToken{}).Where("user_id = ?", user.ID).Count(&rows).Error; err != nil {
t.Fatal(err)
}
if rows != 1 {
t.Fatalf("user_api_tokens rows = %d, want 1 (the credential was saved)", rows)
}
})
t.Run("dispatch credential of an OAuth subject has no id", func(t *testing.T) {
user := env.seedUser(t, "mcp-oauth-id", nil)
_, minted := env.mintUserToken(t, user, "read")
subject := pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: []string{"read"}, ClientID: "oauth-client-" + adminStamp(), TokenID: minted.ID}
d, err := guard.MCPDispatch(ctx, subject, http.MethodGet)
if err != nil {
t.Fatal(err)
}
tok, isToken := d.Credential.(*models.APIToken)
if !isToken || tok.ID != 0 || tok.UserID != user.ID || !tok.HasScope("read") {
t.Fatalf("credential = %+v, want ID 0, UserID %d, scopes [read]", d.Credential, user.ID)
}
})
}
// mustDispatchCredential returns the credential MCPDispatch builds for a
// read subject; it is never saved (ID 0).
// read subject without a TokenID, which therefore has ID 0 and is not
// saved.
func mustDispatchCredential(t *testing.T, guard pact.MCPGuard, userID uint) any {
t.Helper()
d, err := guard.MCPDispatch(context.Background(), pact.MCPSubject{Guard: "user", UserID: userID, Scopes: []string{"read"}}, http.MethodGet)

335
mcp_implies_test.go Normal file
View 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 }

View File

@@ -153,7 +153,8 @@ func (p *Plugin) bootDeviceRevocation(app *backpack.App) {
// bootAPITokenScopes compiles the member token scope catalog from the
// 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 {
activated, _ := app.Lookup[party.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