3 Commits

Author SHA1 Message Date
66fc107eb9 feat: name the missing scope or permission in 403 bodies
A refusal by user.scope:<scope> now answers 403 with missing_scope and a
refusal by user.permission:<code> with missing_permission, next to the
unchanged error (true) and message, so a plain-HTTP script or a personal
token client learns what it lacks. user_me says it needs scope read.
2026-10-28 14:05:57 +01:00
bd2cc0c245 test: expect the missing scope or permission in 403 bodies 2026-10-28 14:05:28 +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
6 changed files with 164 additions and 11 deletions

View File

@@ -40,7 +40,7 @@ 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.
- **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).
- **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`:
```go
@@ -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). 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.
- **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

View File

@@ -114,9 +114,13 @@ 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.
// session's scopes, so user.scope sees what the session was granted. 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. 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 {
@@ -133,6 +137,9 @@ func (g *MCPUserGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject,
UserID: subject.UserID,
Scopes: lagoon.Jsonable[[]string]{Data: slices.Clone(subject.Scopes), Valid: true},
}
if subject.ClientID == "" {
credential.ID = subject.TokenID
}
return pact.MCPDispatch{Principal: principal, Credential: credential, Guards: []string{"api_token"}}, nil
}

2
mcp.go
View File

@@ -15,7 +15,7 @@ func (p *Plugin) MCP() pact.MCP {
return pact.MCP{Tools: []pact.MCPTool{{
Name: "me",
Title: "Current user",
Description: "Returns the profile of the signed-in frontend user.",
Description: "Returns the profile of the signed-in frontend user. Needs scope read.",
Guard: "user",
Method: http.MethodGet,
Path: "/_user/api/v1/token/me",

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)

View File

@@ -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."})
}
// 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 {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
credential, _ := bouncer.Credential(r.Context())
token, ok := credential.(*models.APIToken)
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
}
next.ServeHTTP(w, r)
@@ -103,12 +104,14 @@ func Scope(scope string) pact.Middleware {
// Permission requires one current user-group permission. Tokens never carry
// 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 {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, _ := bouncer.User(r.Context())
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
}
next.ServeHTTP(w, r)

View 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)
}
})
}