Skip to content

feat(security): offer only the scopes a user can exercise - #63

Merged
sylvesterdamgaard merged 3 commits into
cboxdk:mainfrom
Orange-Hotel-Marketing:feat/scopes-follow-permissions
Oct 7, 2026
Merged

sylvesterdamgaard merged 3 commits into
cboxdk:mainfrom
Orange-Hotel-Marketing:feat/scopes-follow-permissions

Conversation

@JorisOrangeStudio

Copy link
Copy Markdown
Contributor

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 in getRequiredPermissions(): 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:write or content-facade:*. The legacy content:* scopes follow entries and terms; blueprints:read follows the resource rule (resources.require_statamic_permission).

Three surfaces use it:

  • DashboardController::index: availableScopes lists only those. The admin page keeps the full list.
  • TokenController::store and update: a scope outside the list is refused with a 422 naming it and the available ones. update is 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 gets invalid_scope. approve applies the same cap, so a crafted form cannot grant what was never offered.

security.scopes_follow_permissions (STATAMIC_MCP_SCOPES_FOLLOW_PERMISSIONS, default true) restores the old behaviour when off.

Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • 🔧 Tool enhancement
  • 📝 Documentation
  • ♻️ Refactoring

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

  • Tests pass (composer test)
  • Code quality checks pass (composer quality)
  • Tested manually with Statamic

tests/Unit/Auth/ScopeAvailabilityTest.php, 10 cases: a super admin gets all; nobody and a user without permissions get nothing; never *, system:write or the content facade below super; one case per router mapping; blueprints:read once the schema permission is waived; reject() and filterValues(); the switch off.

tests/Feature/CP/ScopesFollowPermissionsTest.php, 7 cases: dashboard props for an editor and for a super admin; token store refuses users:write for an editor and accepts entries:*; 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 for users:write alone redirects with invalid_scope; approving ['*', 'users:write', 'entries:read'] and exchanging the code yields a token with entries:read only.

Verified against main: 6 of the 7 Feature cases fail there (the super-admin case passes), all pass here; the Unit file cannot run on main since the class does not exist.
Full gate green: pint, PHPStan level 9, 1237 tests / 6047 assertions.

Environment

  • Statamic: v6.35.0
  • Laravel: v13.34.0
  • PHP: 8.4.26

Checklist

  • My code follows the project style
  • I've added/updated tests if needed
  • Tool responses follow the standard format

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:read requires an edit permission because GlobalsRouter asks for it on get too.

oauth.default_scopes is 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 TokenController and AuthorizeController only in the scope-validation step; token storage, refresh and revocation are unchanged.

DevelopmentTOS and others added 3 commits October 5, 2026 16:51
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
sylvesterdamgaard merged commit 01c8c19 into cboxdk:main Oct 7, 2026
14 checks passed
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.
@sylvesterdamgaard

Copy link
Copy Markdown
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 * can still be renamed. A client that asks for * on an editor's behalf now gets the editor's own scopes instead of invalid_scope.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants