Repository navigation
feat(security): offer only the scopes a user can exercise - #63
Merged
sylvesterdamgaard merged 3 commits intoOct 7, 2026
Merged
sylvesterdamgaard merged 3 commits into
sylvesterdamgaard merged 3 commits into
Conversation
The dashboard's scope picker, token creation and the OAuth consent screen offered all 21 scopes to every user, * included. The per-call Statamic permission check caps a token to what its user may do, so a scope the user cannot exercise is dead weight — but it misleads: an editor who sees "Write Users" or "Full Access" on a consent screen reads it as something the client is about to do. ScopeAvailability derives the scopes a user can hold from the same permissions each router asks for: a scope is available when the user holds the permission behind at least one of its actions. Super admins get every scope; nobody else gets *, system:write or the content facade. The user dashboard lists only those scopes, creating or editing a token refuses others with a 422 that names them — capped by the token's owner, so an admin cannot hand an editor's token a scope the editor cannot use — and the consent screen shows only the requested scopes the user can hold. A client asking for more is not an error; one asking for nothing the user has gets invalid_scope, and the approval is capped the same way so a crafted form cannot grant what was never offered. The admin page keeps the full list. security.scopes_follow_permissions switches it off. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The token form's presets were a fixed list, so an editor whose picker listed only the scopes they can hold was still offered "Full Access", and "Content Editor" filled in scopes the server then refused with a 422. Each preset is now reduced to the available scopes, and a preset with nothing left is not shown. Rebuilt resources/dist. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A tool switched off, or a resource allowlist set to [], refuses every call in that domain whoever makes it, so the scope behind it is dead weight for super admins too. ScopeAvailability now rules those out before the permission match: an editor with edit rights on a nav no longer sees "Write Structures" on a site whose policy allows no structure writes, and a super admin does not either. The legacy content scopes stay while entries or terms remain open. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
sylvesterdamgaard
added a commit
that referenced
this pull request
Oct 7, 2026
Two edges in the scope cap from #63: - Editing a token checked every scope it carries, and the edit form resends all of them. A token minted before the cap (a '*', or a scope whose permission was since revoked) could no longer be renamed or re-dated. Only scopes being added are checked now. - An OAuth client asking for '*' on behalf of an editor was refused with invalid_scope. It is now offered the editor's own reach, which is what it asked for as far as that user goes. Also: structures scopes count 'configure globals', since the structures router accepts it for the globalset type, and the blueprint resource's permission comment now points at the tool's read rule from #62.
Contributor
|
Thanks for all five. Merged and released in v3.2.0. Two follow-ups on top of #63 in abe803f. Editing a token now only checks newly added scopes, so older tokens like an editor's |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The dashboard's scope picker, token creation and the OAuth consent screen offer all 21 scopes to every user,
*included. The per-call Statamic permission check caps a token to what its user may do, so a scope the user cannot exercise is dead weight. But it misleads: an editor who sees "Write Users" or "Full Access" on a consent screen reads it as something the client is about to do, and a token that looks like full access invites the wrong trust.Adds
Cboxdk\StatamicMcp\Auth\ScopeAvailability, which derives the scopes a user can hold from the same permissions each router asks for ingetRequiredPermissions(): a scope is available when the user holds the permission behind at least one of its actions (any collection for entries, any taxonomy for terms, any set, container or nav for globals, assets and structures). Super admins get every scope; nobody else gets*,system:writeorcontent-facade:*. The legacycontent:*scopes follow entries and terms;blueprints:readfollows the resource rule (resources.require_statamic_permission).Three surfaces use it:
DashboardController::index:availableScopeslists only those. The admin page keeps the full list.TokenController::storeandupdate: a scope outside the list is refused with a 422 naming it and the available ones.updateis capped by the token's owner, so an admin cannot hand an editor's token a scope the editor cannot use.AuthorizeController::show: the requested scopes (or the configured defaults) are filtered to what the user can hold, and the "Full Access" checkbox is hidden when*is not available. A client asking for more than that is not an error, it asked for its full feature set; one asking for nothing the user has getsinvalid_scope.approveapplies the same cap, so a crafted form cannot grant what was never offered.security.scopes_follow_permissions(STATAMIC_MCP_SCOPES_FOLLOW_PERMISSIONS, defaulttrue) restores the old behaviour when off.Type of Change
Related Issue
None. Found handing OAuth self-service to a hotel's editors: the first thing they asked on the consent screen was why Claude wanted to write users.
Testing
composer test)composer quality)tests/Unit/Auth/ScopeAvailabilityTest.php, 10 cases: a super admin gets all; nobody and a user without permissions get nothing; never*,system:writeor the content facade below super; one case per router mapping;blueprints:readonce the schema permission is waived;reject()andfilterValues(); the switch off.tests/Feature/CP/ScopesFollowPermissionsTest.php, 7 cases: dashboard props for an editor and for a super admin; token store refusesusers:writefor an editor and acceptsentries:*; an admin editing an editor's token is capped by the editor; the consent screen shows "Read Entries" but neither "Write Users" nor "Full Access"; a request forusers:writealone redirects withinvalid_scope; approving['*', 'users:write', 'entries:read']and exchanging the code yields a token withentries:readonly.Verified against
main: 6 of the 7 Feature cases fail there (the super-admin case passes), all pass here; the Unit file cannot run onmainsince the class does not exist.Full gate green: pint, PHPStan level 9, 1237 tests / 6047 assertions.
Environment
Checklist
Notes
This is about what is offered, not about access: the per-call permission check in
checkWebPermissions()runs regardless, so a site that turns the switch off loses nothing but clarity.The mapping is deliberately one-to-one with the routers rather than a separate table, so a router that gains an action with a new permission only needs its own
getRequiredPermissions()updated and the matching line here.globals:readrequires aneditpermission becauseGlobalsRouterasks for it ongettoo.oauth.default_scopesis filtered the same way before it pre-checks the boxes, so a default of*does not hide the individual scopes for a user who cannot hold it. The consent view guards the Full Access script, since the element is gone for that user.Touches
TokenControllerandAuthorizeControlleronly in the scope-validation step; token storage, refresh and revocation are unchanged.