Why
RolePermission/UserPermission (modules/permissions/permissions/models.py:37,56) are keyed by role_name/user_id with no tenant dimension, and per the tenancy design doc that's correct — permission grants are a platform-level policy ("what can an editor do"), while tenant membership roles (owner/admin/member) are per-tenant facts about a person, not a platform policy. So this module is a near no-op for MultiTenantMixin adoption, but it needs a concrete change to work with the design doc's tenant:<role> principal-augmentation scheme, since today it can only assign permissions to role names backed by a users.models.Role row.
Tables
RolePermission (models.py:37) — adopt MultiTenantMixin: no. Composite PK (role_name, permission_key) (models.py:42-43) stays global; a role's permission set is platform policy, not per-tenant data.
UserPermission (models.py:56) — adopt MultiTenantMixin: no. Composite PK (user_id, permission_key) (models.py:61-62) — a direct grant to a specific user account is also platform-level (per the users issue, a User is not itself tenant-scoped).
- Neither table has a unique constraint missing
tenant_id to fix; both PKs are intentionally global. No SM024 concern.
Code paths
- The real gap:
PermissionService._resolve_role_permissions/_resolve_role_sources (service.py:219-248) resolve permissions by looking up role_name in self.registry.role_map (simple_module_core.permissions.PermissionRegistry), and the admin UI's role list/edit (list_roles_with_counts, get_role, set_role_permissions — service.py:61-120) only enumerates roles backed by a users.models.Role row (_load_roles, service.py:55-59). Per the design doc, the active tenant's membership role is added to the request principal as tenant:<role> (e.g. tenant:owner), a synthetic string with no corresponding Role row — so there is currently no admin screen or service method to map platform permissions onto tenant:owner/tenant:admin/tenant:member, and _resolve_role_permissions would silently resolve an unmapped tenant:owner to an empty permission set (not an error, just quietly no permissions) unless tenants itself maps those roles onto its own permissions as the design doc says it will ("the module maps those roles onto its own permissions").
sync_admin_all_permissions (service.py:278-297) grants the platform ADMIN_ROLE_NAME role every registered key via the registry's wildcard — must never be reachable from a tenant:admin principal role; confirm (in tenants/auth, not here) that the string "admin" used as a tenant membership role is never compared equal to the platform ADMIN_ROLE_NAME without the tenant: prefix, since the design doc's own words — "a tenant admin is never the platform admin" — describe exactly the collision this naming scheme is built to avoid; this module's ADMIN_ROLE_NAME wildcard grant is the blast radius if that prefix is ever dropped somewhere in the resolution chain.
resolve_effective_permissions (service.py:250-257) combines a user's direct grants with role-inherited ones from user.roles (the UserRole join table) — this path never sees tenant:<role> strings at all today, since those are added to the request principal elsewhere (outside this module), not to user.roles. Confirm the actual request-time permission check (RequiresPermission, simple_module_hosting.permissions) merges both sources — this module's DB-backed roles and the tenant module's session-injected tenant:<role> — rather than only calling resolve_effective_permissions and missing tenant-role grants entirely.
- No caches, no file storage, no public routes in this module.
Migration
- No schema change.
- If tenant roles need to be admin-editable through the same UI as platform roles, that's a
tenants or permissions extension (e.g. PermissionService accepting a role name not backed by a Role row) — not a migration, a feature addition, and only needed if product wants tenant-role permissions configurable rather than fixed in tenants' own code.
Tests
- A user's effective permissions for a request combine platform role grants (
RolePermission) and their tenant:<role> grant for the active tenant, and the two are additive, not one overriding the other.
tenant:admin never resolves to the same permission set as the platform admin role's wildcard, even if tenants_membership.role happens to store the bare string "admin" — assert the tenant: prefix is preserved end-to-end from membership row to principal to permission check.
sync_admin_all_permissions only ever affects the platform ADMIN_ROLE_NAME row, never a tenant:* entry, regardless of how many tenants exist.
- Switching active tenant mid-session changes the effective
tenant:<role> permissions on the next request without requiring re-login (exercises whatever mechanism refreshes the principal's tenant-role claim).
Out of scope / open questions
- Whether tenant-role-to-permission mapping ever becomes admin-configurable through this module's UI, or stays fixed in
tenants' own code as the design doc currently describes — this issue only flags that permissions cannot express it today, not that it must.
- The actual merge point between
permissions' DB-backed resolution and the tenant module's principal augmentation lives outside this module (likely simple_module_hosting.permissions / RequiresPermission) and should be verified there, not here.
Context: part of the SaaS tenancy work in #370 (fail-closed isolation, tenant_context()/all_tenants(), the tenants module). Design: docs/plans/2026-09-27-saas-tenancy-design.md and docs/framework/multi-tenancy.md on that branch.
Why
RolePermission/UserPermission(modules/permissions/permissions/models.py:37,56) are keyed byrole_name/user_idwith no tenant dimension, and per the tenancy design doc that's correct — permission grants are a platform-level policy ("what can aneditordo"), while tenant membership roles (owner/admin/member) are per-tenant facts about a person, not a platform policy. So this module is a near no-op forMultiTenantMixinadoption, but it needs a concrete change to work with the design doc'stenant:<role>principal-augmentation scheme, since today it can only assign permissions to role names backed by ausers.models.Rolerow.Tables
RolePermission(models.py:37) — adoptMultiTenantMixin: no. Composite PK(role_name, permission_key)(models.py:42-43) stays global; a role's permission set is platform policy, not per-tenant data.UserPermission(models.py:56) — adoptMultiTenantMixin: no. Composite PK(user_id, permission_key)(models.py:61-62) — a direct grant to a specific user account is also platform-level (per theusersissue, aUseris not itself tenant-scoped).tenant_idto fix; both PKs are intentionally global. No SM024 concern.Code paths
PermissionService._resolve_role_permissions/_resolve_role_sources(service.py:219-248) resolve permissions by looking uprole_nameinself.registry.role_map(simple_module_core.permissions.PermissionRegistry), and the admin UI's role list/edit (list_roles_with_counts,get_role,set_role_permissions— service.py:61-120) only enumerates roles backed by ausers.models.Rolerow (_load_roles, service.py:55-59). Per the design doc, the active tenant's membership role is added to the request principal astenant:<role>(e.g.tenant:owner), a synthetic string with no correspondingRolerow — so there is currently no admin screen or service method to map platform permissions ontotenant:owner/tenant:admin/tenant:member, and_resolve_role_permissionswould silently resolve an unmappedtenant:ownerto an empty permission set (not an error, just quietly no permissions) unlesstenantsitself maps those roles onto its own permissions as the design doc says it will ("the module maps those roles onto its own permissions").sync_admin_all_permissions(service.py:278-297) grants the platformADMIN_ROLE_NAMErole every registered key via the registry's wildcard — must never be reachable from atenant:adminprincipal role; confirm (intenants/auth, not here) that the string"admin"used as a tenant membership role is never compared equal to the platformADMIN_ROLE_NAMEwithout thetenant:prefix, since the design doc's own words — "a tenant admin is never the platform admin" — describe exactly the collision this naming scheme is built to avoid; this module'sADMIN_ROLE_NAMEwildcard grant is the blast radius if that prefix is ever dropped somewhere in the resolution chain.resolve_effective_permissions(service.py:250-257) combines a user's direct grants with role-inherited ones fromuser.roles(theUserRolejoin table) — this path never seestenant:<role>strings at all today, since those are added to the request principal elsewhere (outside this module), not touser.roles. Confirm the actual request-time permission check (RequiresPermission,simple_module_hosting.permissions) merges both sources — this module's DB-backed roles and the tenant module's session-injectedtenant:<role>— rather than only callingresolve_effective_permissionsand missing tenant-role grants entirely.Migration
tenantsorpermissionsextension (e.g.PermissionServiceaccepting a role name not backed by aRolerow) — not a migration, a feature addition, and only needed if product wants tenant-role permissions configurable rather than fixed intenants' own code.Tests
RolePermission) and theirtenant:<role>grant for the active tenant, and the two are additive, not one overriding the other.tenant:adminnever resolves to the same permission set as the platformadminrole's wildcard, even iftenants_membership.rolehappens to store the bare string"admin"— assert thetenant:prefix is preserved end-to-end from membership row to principal to permission check.sync_admin_all_permissionsonly ever affects the platformADMIN_ROLE_NAMErow, never atenant:*entry, regardless of how many tenants exist.tenant:<role>permissions on the next request without requiring re-login (exercises whatever mechanism refreshes the principal's tenant-role claim).Out of scope / open questions
tenants' own code as the design doc currently describes — this issue only flags thatpermissionscannot express it today, not that it must.permissions' DB-backed resolution and the tenant module's principal augmentation lives outside this module (likelysimple_module_hosting.permissions/RequiresPermission) and should be verified there, not here.Context: part of the SaaS tenancy work in #370 (fail-closed isolation,
tenant_context()/all_tenants(), thetenantsmodule). Design:docs/plans/2026-09-27-saas-tenancy-design.mdanddocs/framework/multi-tenancy.mdon that branch.