Honour direct user grants in every permission check - #398
Open
antosubash wants to merge 1 commit into
Open
antosubash wants to merge 1 commit into
antosubash wants to merge 1 commit into
Conversation
The framework's RequiresPermission resolved roles only, while permissions.deps.RequiresPermission also read the direct-grant table. Every module importing the framework class (users, settings, file_storage, feature_flags, audit_log, tenants, ...) ignored a grant made on the permissions screen: the PUT returned 200 and the user still got 403. - core: PermissionRegistry.add_grant_source(), an async per-principal seam, so hosting never imports the plugin (SM009). - hosting: one async resolve_principal_permissions() (roles + sources, fail closed on a raising source, skipped for wildcard holders) used by both InertiaLayoutDataMiddleware and RequiresPermission, so the door, the menu filter and auth.permissions agree. - permissions: registers a per-process TTL-cached grant source, evicted via the InvalidationBus from an on_commit callback when grants are saved; permissions.deps.RequiresPermission is now an alias of the framework class. - auth: require_permission resolved roles without the registry's role map, so every non-admin role held nothing; it now reads the same set. Claude-Session: https://claude.ai/code/session_01RqQH2V6szjHKpQxSg29LqP
antosubash
marked this pull request as ready for review
October 3, 2026 17:52
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
This was referenced Oct 5, 2026
This branch has not been deployed
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.
Fixes #337.
Problem
simple_module_hosting.permissions.RequiresPermissionresolved roles only.permissions.deps.RequiresPermissionalso readpermissions_user_permission. Every module importing the framework class ignored a grant made on the permissions screen: the PUT returned 200, and the user still got 403. In this repo that coveredusers,settings,file_storage,feature_flags,audit_log,tenantsand others. The menu filter and the frontend'sauth.permissionscouldn't see direct grants either.A third gate had a worse version of the same bug.
auth.deps.require_permissioncalledget_permissions_for_roles(user.roles)without the registry's role map, so every non-admin role resolved to no permissions at all.Approach: option 2 from the issue (fold grants into the resolved set)
PermissionRegistry.add_grant_source(source)adds an async(request, user) -> keysseam. Hosting never imports the plugin (SM009).resolve_principal_permissions()combines roles and grant sources. It's used by bothInertiaLayoutDataMiddlewareandRequiresPermission, which is nowasync. If a source raises, it contributes nothing and is logged, so the request fails closed with a 403 instead of a 500. Sources are skipped for principals that already hold*.ensure_resolved_permissions()covers bare routers that run without the middleware.permissions.grants.direct_grant_sourcereads direct grants through a per-processTTLCache(30 s). Saving a user's grants publishespermissions.user_grantson theInvalidationBusfrom anon_commitcallback, following the same pattern asusers.session_version_cache.permissions.deps.RequiresPermissionis now an alias of the framework class, so the two can't drift apart again.require_permissionreads the same resolved set, with any-of semantics unchanged.docs/modules/permissions.mdanddocs/modules/auth.mdand the permissions README now name a singleRequiresPermission.Trade-offs for review
permissions' own routes. These used to read direct grants live from the DB. They now share the cache. If another worker revokes a grant and no invalidation transport (Redis viabackground_tasks) is installed, the change can take up to 30 s to apply. This is the same windowusers.session_version_cachealready accepts. The worker that made the change sees it on the next request.set_role_permissionsonly updates the in-memory role map of the process that handled the save, so other workers stay stale until restart. It's the same class of bug but a separate fix.Tests
modules/permissions/tests/test_direct_grants_everywhere.pycovers the issue's reproduction. A user with no roles gets 403, then 200 after a direct grant (which also proves the cache eviction), then 403 after revoking. It also checks that the grant appears in Inertiaauth.permissions. Both tests fail with the grant source switched off.framework/hosting/tests/test_grant_sources.pychecks merging grants with roles, failing closed when a source raises, skipping sources for wildcard holders, the middleware passing grants to the frontend, andRequiresPermissionwith no middleware present.modules/auth/tests/test_deps.pyadds a regression test for a mapped non-admin role.ruff format,ruff checkandtyare clean, and the 300-line check passes.https://claude.ai/code/session_01RqQH2V6szjHKpQxSg29LqP
Generated by Claude Code