From 7c29c8c4ec18235a40d8c6c544b57b4d049e257c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 5 Oct 2026 13:18:22 +0200 Subject: [PATCH 1/6] feat(core): runtime menu providers and permission sources (#340, #334) MenuRegistry.add_provider / remove; PermissionRegistry.add_source / invalidate_source, surfaced in all_permissions, groups and the role editor. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CLAUDE.md | 2 + docs/framework-conventions.md | 2 + docs/framework/permissions.md | 11 +++ framework/core/simple_module_core/menu.py | 71 +++++++++++++- .../core/simple_module_core/permissions.py | 73 +++++++++++++- .../core/tests/test_permission_sources.py | 51 ++++++++++ .../simple_module_hosting/middleware.py | 3 +- .../tests/test_menu_providers_wiring.py | 95 +++++++++++++++++++ .../permissions/contracts/schemas.py | 2 + modules/permissions/permissions/service.py | 3 +- .../tests/test_permissions_runtime_sources.py | 58 +++++++++++ 11 files changed, 362 insertions(+), 9 deletions(-) create mode 100644 framework/core/tests/test_permission_sources.py create mode 100644 framework/hosting/tests/test_menu_providers_wiring.py create mode 100644 modules/permissions/tests/test_permissions_runtime_sources.py diff --git a/CLAUDE.md b/CLAUDE.md index e34086f9..860e467e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,8 @@ hence `SM022`/`SM023`. See `docs/module-authoring.md` § Styling. **Lifecycle hooks** (in `framework/core/simple_module_core/module.py`) — all no-op by default; subclasses override as needed: `register_settings` → `register_menu_items` / `register_permissions` / `register_feature_flags` / `register_event_handlers` / `register_invalidations` / `register_health_checks` / `register_public_routes` / `register_csp_sources` / `register_setup_steps` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)` / `register_admin_routes(admin_router)` → async `on_startup` / `on_shutdown` (reverse order). `register_admin_routes` is only for modules that serve **both** public and admin pages: a module gets exactly one router per `view_prefix`, which `users` cannot express (sign-in at `/users/login`, management at `/admin/users`). Setting `ModuleMeta.admin_view_prefix` mounts a second view router there. A module whose views are *all* administrative just points `view_prefix` at `/admin/` and keeps using `register_routes`. The prefix is a URL convention, not a permission — guard these routes exactly as you would any other. `register_csp_sources(registry)` lets a module whitelist external asset origins (`registry.add("style-src", "https://rsms.me")`) — fetch directives only, validated at boot. `register_public_routes(registry)` lets a module exempt anonymous/read-only routes (STAC/OGC, webhooks) from `AuthMiddleware`; rules are method-aware (`registry.add_regex(r"…/tilejson$", methods={"GET"})`), so a GET read route can be public while sibling POST/PATCH mutations under the same prefix stay gated. See [docs/framework/public-routes.md](docs/framework/public-routes.md). `register_setup_steps(registry)` lets a module declare what a usable install still needs; while any required step is incomplete `SetupMiddleware` serves the first-run wizard at `/setup` instead of the app. A module that registers nothing never gates — that is how `keycloak` opts out, since its local users table is legitimately empty forever and a host-level superuser count would lock those installs out permanently. `register_invalidations(bus, app)` subscribes a module's **per-process caches** to `InvalidationBus`, so another worker's write drops this worker's entry instead of leaving it stale for its whole TTL; handlers may only *forget*, since there is no delivery guarantee. Publishing takes no hook — `await request.app.state.sm.invalidation.publish(channel, key=...)` from a `db.on_commit` callback. Cross-process delivery needs a transport, which `background_tasks` installs on its Redis connection (`SM_BG_TASKS_BROADCAST_INVALIDATIONS`); with none the bus is in-process and every cache still needs its TTL as a floor. See [docs/framework/invalidation.md](docs/framework/invalidation.md). +`MenuRegistry.add_provider(fn)` (from `register_menu_items`) contributes per-request menu items evaluated in `InertiaLayoutDataMiddleware` after auth/tenant resolution, and `PermissionRegistry.add_source(name, provider)` (from `register_permissions`) contributes runtime-defined permissions from a sync in-memory cache, refreshed with `invalidate_source(name)`; see [docs/framework/permissions.md](docs/framework/permissions.md). + **Middleware pipeline** (Starlette `add_middleware` is LIFO — last added runs first). Execution order on a request: `(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → GZip → SecurityHeaders → Session → → Tenant (opt-in) → Locale → InertiaLayoutData → InertiaCache → Setup → Maintenance → CommitBeforeResponse → app`. `InertiaCache` answers for `InertiaLayoutData` merging per-user `auth`/`menus` into every payload: a response to an `X-Inertia` request is forced to `private, no-store` with its ETag dropped, and both representations of a URL gain `Vary: X-Inertia` — so no cache can store the JSON payload or hand it back for a page request. A module wanting its public page content cached should set `Cache-Control` and an ETag on the *document*; that path is left alone. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs). Inertia does not depend on it: the page url is rewritten to the root-relative form the protocol specifies (`_inertia_url.py`), so no scheme travels in the payload to disagree with the document's — the cross-scheme `pushState` `SecurityError` of GH #223 cannot recur on an install that never set the variable. When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names. `Maintenance` serves a 503 page to everyone but admins while `maintenance_mode` is set on `HostSettings`; it sits inside `InertiaCache` because its 503 is an Inertia payload produced by short-circuiting, and outside the cache guard that payload would ship storable. `Setup` runs just before it, for the same cache reason and because an install that was never set up has nothing meaningful to put into maintenance. diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index 6b0dfef5..cc8c7b4f 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -358,6 +358,8 @@ way. Sidebar items can also set `group=" ); diff --git a/modules/permissions/permissions/pages/components/permission-groups.ts b/modules/permissions/permissions/pages/components/permission-groups.ts index 3398aefa..deaf082b 100644 --- a/modules/permissions/permissions/pages/components/permission-groups.ts +++ b/modules/permissions/permissions/pages/components/permission-groups.ts @@ -9,6 +9,8 @@ export interface PermissionGroup { name: string; permissions: string[]; + /** Human labels a runtime permission source supplied, keyed by permission. */ + labels?: Record; } /** A module, narrowed to the rows the current filters keep. */ diff --git a/modules/permissions/permissions/service.py b/modules/permissions/permissions/service.py index 39605a22..e9f0bc62 100644 --- a/modules/permissions/permissions/service.py +++ b/modules/permissions/permissions/service.py @@ -42,9 +42,12 @@ def __init__(self, db: AsyncSession, registry: PermissionRegistry) -> None: # ── Registry read-outs ───────────────────────────────────── def list_registered_groups(self) -> list[PermissionGroupOut]: - labels = self.registry.source_labels() return [ - PermissionGroupOut(name=g.name, permissions=sorted(g.permissions), labels=labels) + PermissionGroupOut( + name=g.name, + permissions=sorted(g.permissions), + labels=self.registry.source_labels(g.name), + ) for g in self.registry.groups ] @@ -112,9 +115,7 @@ async def set_role_permissions( for key in wanted - existing ) await self.db.flush() - - # `map_role` is additive — reset the role entry so removals take effect - # without a restart. No public replace API on PermissionRegistry yet. + # `map_role` is additive — reset the entry so removals apply without a restart. self.registry._role_map.pop(role.name, None) self.registry.map_role(role.name, sorted(wanted)) diff --git a/modules/permissions/tests-js/RoleEdit.test.tsx b/modules/permissions/tests-js/RoleEdit.test.tsx index 6dd46f68..8bf73d29 100644 --- a/modules/permissions/tests-js/RoleEdit.test.tsx +++ b/modules/permissions/tests-js/RoleEdit.test.tsx @@ -85,6 +85,28 @@ const keyRow = (permissionKey: string) => const filterBox = () => screen.getByPlaceholderText('Filter modules or permissions…'); describe('RoleEdit', () => { + test('shows a source-supplied label for a permission, falling back to the key', () => { + render( + , + ); + + expect(screen.getByText('Edit products', { selector: 'code' })).toHaveAttribute( + 'title', + 'records.product.edit', + ); + expect(screen.getByText('records.faq.edit', { selector: 'code' })).toBeVisible(); + }); + test('renders the deck header, actions and granted summary', () => { renderPage(); diff --git a/modules/permissions/tests/test_permissions_runtime_sources.py b/modules/permissions/tests/test_permissions_runtime_sources.py index 2eaacf87..a00362bb 100644 --- a/modules/permissions/tests/test_permissions_runtime_sources.py +++ b/modules/permissions/tests/test_permissions_runtime_sources.py @@ -25,6 +25,8 @@ async def test_source_permission_listed_grantable_and_invalidated( records = next(g for g in groups if g["name"] == "records") assert records["permissions"] == ["records.product.edit"] assert records["labels"] == {"records.product.edit": "Edit product"} + # Labels stay inside their own group: static groups carry none. + assert all(g["labels"] == {} for g in groups if g["name"] != "records") put = await authenticated_client.put( f"/api/permissions/roles/{USER_ROLE_ID}", json={"permissions": ["records.product.edit"]} From d103ee89c56216b8c07927283566b9ed89b488ae Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 6 Oct 2026 16:49:36 +0200 Subject: [PATCH 3/6] refactor(optimize): hoist role label, restore why-comment Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../permissions/pages/components/RoleGroupCard.tsx | 5 +++-- modules/permissions/permissions/service.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/permissions/permissions/pages/components/RoleGroupCard.tsx b/modules/permissions/permissions/pages/components/RoleGroupCard.tsx index 63032c41..7555da57 100644 --- a/modules/permissions/permissions/pages/components/RoleGroupCard.tsx +++ b/modules/permissions/permissions/pages/components/RoleGroupCard.tsx @@ -57,6 +57,7 @@ export function RoleGroupCard({ group, permissions, assigned, onToggle, onToggle
{permissions.map((key, index) => { const on = assigned.has(key); + const label = group.labels?.[key]; return ( ); diff --git a/modules/permissions/permissions/service.py b/modules/permissions/permissions/service.py index e9f0bc62..5f4ba46c 100644 --- a/modules/permissions/permissions/service.py +++ b/modules/permissions/permissions/service.py @@ -116,6 +116,7 @@ async def set_role_permissions( ) await self.db.flush() # `map_role` is additive — reset the entry so removals apply without a restart. + # No public replace API on PermissionRegistry yet. self.registry._role_map.pop(role.name, None) self.registry.map_role(role.name, sorted(wanted)) From fb5a073fee4665714d8b425b8f1b308aafa4bf2d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 6 Oct 2026 17:10:04 +0200 Subject: [PATCH 4/6] fix: address code review findings (round 1, pass 1) Materialise provider output before extending, show source labels in the user grants editor and role search, keep stored grants of currently unregistered source keys when saving a role or user. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- framework/core/simple_module_core/menu.py | 3 ++- .../pages/components/GrantsGroupCard.tsx | 1 + .../pages/components/PermissionRow.tsx | 6 ++++- .../pages/components/permission-groups.ts | 5 +++- modules/permissions/permissions/service.py | 6 +++-- .../tests/test_permissions_runtime_sources.py | 26 +++++++++++++++++++ 6 files changed, 42 insertions(+), 5 deletions(-) diff --git a/framework/core/simple_module_core/menu.py b/framework/core/simple_module_core/menu.py index 85312282..5a6ac85b 100644 --- a/framework/core/simple_module_core/menu.py +++ b/framework/core/simple_module_core/menu.py @@ -122,7 +122,8 @@ async def collect_provider_items(self, request: Request) -> list[MenuItem]: result = provider(request) if inspect.isawaitable(result): result = await result - items.extend(result) + # Materialise first: a generator failing midway must add nothing. + items.extend(list(result)) except Exception: logger.exception("Menu provider %r failed; contributing nothing", provider) return items diff --git a/modules/permissions/permissions/pages/components/GrantsGroupCard.tsx b/modules/permissions/permissions/pages/components/GrantsGroupCard.tsx index b62e46dd..0deea2a0 100644 --- a/modules/permissions/permissions/pages/components/GrantsGroupCard.tsx +++ b/modules/permissions/permissions/pages/components/GrantsGroupCard.tsx @@ -46,6 +46,7 @@ export function GrantsGroupCard({ - {permissionKey} + {label ?? permissionKey} {direct && ( diff --git a/modules/permissions/permissions/pages/components/permission-groups.ts b/modules/permissions/permissions/pages/components/permission-groups.ts index deaf082b..83261a3f 100644 --- a/modules/permissions/permissions/pages/components/permission-groups.ts +++ b/modules/permissions/permissions/pages/components/permission-groups.ts @@ -70,7 +70,10 @@ export function filterGroups( if (!moduleMatches && !matchKeys) continue; const permissions = group.permissions.filter( (key) => - (moduleMatches || key.toLowerCase().includes(needle)) && (keepKey ? keepKey(key) : true), + (moduleMatches || + key.toLowerCase().includes(needle) || + (group.labels?.[key] ?? '').toLowerCase().includes(needle)) && + (keepKey ? keepKey(key) : true), ); if (permissions.length > 0) result.push({ group, permissions }); } diff --git a/modules/permissions/permissions/service.py b/modules/permissions/permissions/service.py index 5f4ba46c..1802efda 100644 --- a/modules/permissions/permissions/service.py +++ b/modules/permissions/permissions/service.py @@ -101,7 +101,9 @@ async def set_role_permissions( wanted = {k for k in keys if k in self._registered_keys()} existing = set(await self._get_role_keys(role.name)) - to_remove = existing - wanted + # Unregistered keys (e.g. a runtime source that is empty right now) are left + # alone: pruning them would silently drop grants that return with the source. + to_remove = (existing - wanted) & self._registered_keys() if to_remove: await self.db.execute( @@ -192,7 +194,7 @@ async def set_user_permissions( wanted = {k for k in keys if k in self._registered_keys()} existing = set(await self.get_user_direct_keys(user.id)) - to_remove = existing - wanted + to_remove = (existing - wanted) & self._registered_keys() if to_remove: await self.db.execute( diff --git a/modules/permissions/tests/test_permissions_runtime_sources.py b/modules/permissions/tests/test_permissions_runtime_sources.py index a00362bb..30e9a4bb 100644 --- a/modules/permissions/tests/test_permissions_runtime_sources.py +++ b/modules/permissions/tests/test_permissions_runtime_sources.py @@ -58,3 +58,29 @@ async def test_source_permission_enforced_by_requires_permission(app: FastAPI): # Admin's implicit grant covers source permissions. assert "records.product.edit" in registry.get_permissions_for_roles(["admin"]) + + +async def test_saving_role_keeps_grants_of_currently_unregistered_source_keys( + authenticated_client: httpx.AsyncClient, app: FastAPI +): + from permissions.service import PermissionService + from users.constants import USER_ROLE_ID, USER_ROLE_NAME + from users.models import Role + + registry = app.state.sm.permissions + registry.add_source("records", lambda: ["records.product.edit"]) + async with app.state.sm.db.session_factory() as db: + if await db.get(Role, USER_ROLE_ID) is None: + db.add(Role(id=USER_ROLE_ID, name=USER_ROLE_NAME, description="Standard user")) + await db.commit() + url = f"/api/permissions/roles/{USER_ROLE_ID}" + await authenticated_client.put(url, json={"permissions": ["records.product.edit"]}) + + # The source goes empty (e.g. restart before its cache warms); an unrelated save + # must not prune the stored grant. + registry.add_source("records", lambda: []) + await authenticated_client.put(url, json={"permissions": []}) + + async with app.state.sm.db.session_factory() as db: + keys = await PermissionService(db, registry)._get_role_keys(USER_ROLE_NAME) + assert keys == ["records.product.edit"] From 4d9f3aa84e8891075166dfe4f2e03cee4579e311 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 6 Oct 2026 17:21:10 +0200 Subject: [PATCH 5/6] fix: always show the permission key beside a source label; keep in-memory grants Anti-spoofing for runtime permission-source labels: the key is always rendered, the label is plain, trimmed, capped text, and switches are named with the key. Role saves also keep currently-unregistered stored grants in the in-memory map. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../pages/components/PermissionLabel.tsx | 25 +++++++++ .../pages/components/PermissionRow.tsx | 15 +++-- .../pages/components/RoleGroupCard.tsx | 18 +++--- .../pages/components/permission-groups.ts | 18 ++++++ modules/permissions/permissions/service.py | 4 +- .../tests-js/PermissionRow.test.tsx | 56 +++++++++++++++++++ .../permissions/tests-js/RoleEdit.test.tsx | 36 ++++++++++-- 7 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 modules/permissions/permissions/pages/components/PermissionLabel.tsx create mode 100644 modules/permissions/tests-js/PermissionRow.test.tsx diff --git a/modules/permissions/permissions/pages/components/PermissionLabel.tsx b/modules/permissions/permissions/pages/components/PermissionLabel.tsx new file mode 100644 index 00000000..65509da4 --- /dev/null +++ b/modules/permissions/permissions/pages/components/PermissionLabel.tsx @@ -0,0 +1,25 @@ +import { cleanLabel } from './permission-groups'; + +interface Props { + permissionKey: string; + /** Free text from a runtime permission source — rendered as plain text only. */ + label?: string; + dimmed?: boolean; + className?: string; +} + +/** + * A permission's identity. The key is always visible: a source-supplied label is + * secondary text above it, never a replacement, so a label cannot make one + * permission look like another. + */ +export function PermissionLabel({ permissionKey, label, dimmed = false, className = '' }: Props) { + const text = cleanLabel(label); + const tone = dimmed ? 'text-muted-foreground' : 'text-foreground'; + return ( + + {text && {text}} + {permissionKey} + + ); +} diff --git a/modules/permissions/permissions/pages/components/PermissionRow.tsx b/modules/permissions/permissions/pages/components/PermissionRow.tsx index 2c54bcd7..ac33b393 100644 --- a/modules/permissions/permissions/pages/components/PermissionRow.tsx +++ b/modules/permissions/permissions/pages/components/PermissionRow.tsx @@ -1,6 +1,7 @@ import { keys, useT } from '@simple-module-py/i18n'; import { Badge } from '@simple-module-py/ui/components/ui/badge'; import { Switch } from '@simple-module-py/ui/components/ui/switch'; +import { PermissionLabel } from './PermissionLabel'; interface Props { permissionKey: string; @@ -54,14 +55,12 @@ export function PermissionRow({ {/* Wrapping beats truncating on a phone: a permission key is read from the right — `settings.create`, `settings.delete` — so cutting the end off removes the half that tells them apart. */} - - {label ?? permissionKey} - + {direct && ( diff --git a/modules/permissions/permissions/pages/components/RoleGroupCard.tsx b/modules/permissions/permissions/pages/components/RoleGroupCard.tsx index 7555da57..492efac5 100644 --- a/modules/permissions/permissions/pages/components/RoleGroupCard.tsx +++ b/modules/permissions/permissions/pages/components/RoleGroupCard.tsx @@ -4,7 +4,13 @@ import { Checkbox } from '@simple-module-py/ui/components/ui/checkbox'; import { Switch } from '@simple-module-py/ui/components/ui/switch'; import { Minus } from 'lucide-react'; import { GroupHeading } from './GroupHeading'; -import { lastRowStart, type PermissionGroup } from './permission-groups'; +import { PermissionLabel } from './PermissionLabel'; +import { + cleanLabel, + lastRowStart, + type PermissionGroup, + permissionAriaLabel, +} from './permission-groups'; interface Props { /** The whole module: the header counts and its checkbox speak for all of it. */ @@ -70,15 +76,9 @@ export function RoleGroupCard({ group, permissions, assigned, onToggle, onToggle id={`perm-${key}`} checked={on} onCheckedChange={(checked) => onToggle(key, checked === true)} + aria-label={permissionAriaLabel(key, cleanLabel(label))} /> - - {label ?? key} - + ); })} diff --git a/modules/permissions/permissions/pages/components/permission-groups.ts b/modules/permissions/permissions/pages/components/permission-groups.ts index 83261a3f..be42d0c4 100644 --- a/modules/permissions/permissions/pages/components/permission-groups.ts +++ b/modules/permissions/permissions/pages/components/permission-groups.ts @@ -80,6 +80,24 @@ export function filterGroups( return result; } +const MAX_LABEL_LENGTH = 80; + +/** + * A source-supplied label, made safe to show beside a key: trimmed, capped, and + * undefined when nothing is left. The key is always rendered too, so a label can + * never stand in for the permission it describes. + */ +export function cleanLabel(raw: string | undefined): string | undefined { + const trimmed = raw?.trim(); + if (!trimmed) return undefined; + return trimmed.length > MAX_LABEL_LENGTH ? `${trimmed.slice(0, MAX_LABEL_LENGTH)}…` : trimmed; +} + +/** Accessible name for a permission control: the key is always part of it. */ +export function permissionAriaLabel(key: string, label: string | undefined): string { + return label ? `${label} (${key})` : key; +} + /** * Index at which the last row of a two-column grid starts, so every earlier * row gets a bottom border and the last one does not. diff --git a/modules/permissions/permissions/service.py b/modules/permissions/permissions/service.py index 1802efda..b8229b84 100644 --- a/modules/permissions/permissions/service.py +++ b/modules/permissions/permissions/service.py @@ -120,7 +120,9 @@ async def set_role_permissions( # `map_role` is additive — reset the entry so removals apply without a restart. # No public replace API on PermissionRegistry yet. self.registry._role_map.pop(role.name, None) - self.registry.map_role(role.name, sorted(wanted)) + # Keep unregistered-but-stored grants in memory too, so they are live again + # the moment their source repopulates. + self.registry.map_role(role.name, sorted(wanted | (existing - self._registered_keys()))) return RolePermissionsOut(role=role, permissions=sorted(wanted)) diff --git a/modules/permissions/tests-js/PermissionRow.test.tsx b/modules/permissions/tests-js/PermissionRow.test.tsx new file mode 100644 index 00000000..10c14292 --- /dev/null +++ b/modules/permissions/tests-js/PermissionRow.test.tsx @@ -0,0 +1,56 @@ +import '@testing-library/jest-dom/vitest'; +import { configureI18n } from '@simple-module-py/i18n'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; +import { PermissionRow } from '../permissions/pages/components/PermissionRow'; + +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', ResizeObserverStub); + +configureI18n({ + locale: 'en', + messages: { + 'permissions.user_edit.direct_toggle_label': 'Grant {key} directly to this user', + 'permissions.user_edit.direct_badge': 'direct', + }, +}); + +describe('PermissionRow', () => { + test('shows the key beside a source label and names the switch after the key', () => { + render( + {}} + />, + ); + + expect(screen.getByText('Harmless read access')).toBeVisible(); + expect(screen.getByText('records.admin.delete', { selector: 'code' })).toBeVisible(); + expect( + screen.getByRole('switch', { name: 'Grant records.admin.delete directly to this user' }), + ).toBeVisible(); + }); + + test('ignores a blank label and shows only the key', () => { + render( + {}} + />, + ); + + const code = screen.getByText('records.faq.edit', { selector: 'code' }); + expect(code).toBeVisible(); + expect(code.parentElement?.children).toHaveLength(1); + }); +}); diff --git a/modules/permissions/tests-js/RoleEdit.test.tsx b/modules/permissions/tests-js/RoleEdit.test.tsx index 8bf73d29..10f48c27 100644 --- a/modules/permissions/tests-js/RoleEdit.test.tsx +++ b/modules/permissions/tests-js/RoleEdit.test.tsx @@ -100,11 +100,39 @@ describe('RoleEdit', () => { />, ); - expect(screen.getByText('Edit products', { selector: 'code' })).toHaveAttribute( - 'title', - 'records.product.edit', - ); + // The label is secondary text; the real key is always shown beside it. + expect(screen.getByText('Edit products')).toBeVisible(); + expect(screen.getByText('records.product.edit', { selector: 'code' })).toBeVisible(); expect(screen.getByText('records.faq.edit', { selector: 'code' })).toBeVisible(); + expect( + screen.getByRole('switch', { name: 'Edit products (records.product.edit)' }), + ).toBeVisible(); + }); + + test('a label never replaces its key, renders as text, and is length-capped', () => { + const spoof = 'x'.repeat(200); + render( + View faq records.faq.edit', + 'records.faq.edit': spoof, + }, + }, + ]} + />, + ); + + expect(screen.getByText('records.admin.delete', { selector: 'code' })).toBeVisible(); + // Markup in a label is shown literally, never interpreted. + expect(screen.getByText('View faq records.faq.edit')).toBeVisible(); + expect(screen.queryByText(spoof)).toBeNull(); + expect(screen.getByText(`${'x'.repeat(80)}…`)).toBeVisible(); }); test('renders the deck header, actions and granted summary', () => { From a5a2008b70bdfe65367ddf0eeb2dd783ad266c99 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 6 Oct 2026 18:07:41 +0200 Subject: [PATCH 6/6] chore: keep permissions service under the 300-line cap, lint fix Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/permissions/permissions/service.py | 25 ++++++++----------- .../tests/test_permissions_runtime_sources.py | 2 +- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/modules/permissions/permissions/service.py b/modules/permissions/permissions/service.py index b8229b84..4b101610 100644 --- a/modules/permissions/permissions/service.py +++ b/modules/permissions/permissions/service.py @@ -5,6 +5,7 @@ import uuid from typing import TYPE_CHECKING +from simple_module_core.permissions import WILDCARD from simple_module_db import LIKE_ESCAPE_CHAR, like_contains_pattern from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -99,11 +100,10 @@ async def set_role_permissions( if role is None: return None - wanted = {k for k in keys if k in self._registered_keys()} + registered = self._registered_keys() + wanted = {k for k in keys if k in registered} existing = set(await self._get_role_keys(role.name)) - # Unregistered keys (e.g. a runtime source that is empty right now) are left - # alone: pruning them would silently drop grants that return with the source. - to_remove = (existing - wanted) & self._registered_keys() + to_remove = (existing - wanted) & registered # unregistered grants are never pruned if to_remove: await self.db.execute( @@ -117,12 +117,10 @@ async def set_role_permissions( for key in wanted - existing ) await self.db.flush() - # `map_role` is additive — reset the entry so removals apply without a restart. - # No public replace API on PermissionRegistry yet. + # `map_role` is additive — reset the role entry so removals take effect without a + # restart (no public replace API yet); unregistered grants stay mapped. self.registry._role_map.pop(role.name, None) - # Keep unregistered-but-stored grants in memory too, so they are live again - # the moment their source repopulates. - self.registry.map_role(role.name, sorted(wanted | (existing - self._registered_keys()))) + self.registry.map_role(role.name, sorted(wanted | (existing - registered))) return RolePermissionsOut(role=role, permissions=sorted(wanted)) @@ -194,9 +192,10 @@ async def set_user_permissions( if user is None: return None - wanted = {k for k in keys if k in self._registered_keys()} + registered = self._registered_keys() + wanted = {k for k in keys if k in registered} existing = set(await self.get_user_direct_keys(user.id)) - to_remove = (existing - wanted) & self._registered_keys() + to_remove = (existing - wanted) & registered if to_remove: await self.db.execute( @@ -225,8 +224,6 @@ async def set_user_permissions( def _resolve_role_permissions(self, role_names: list[str]) -> set[str]: """Resolve role names to their permission keys via the registry.""" - from simple_module_core.permissions import WILDCARD - role_map = self.registry.role_map resolved: set[str] = set() for name in role_names: @@ -243,8 +240,6 @@ def _resolve_role_sources(self, role_names: list[str]) -> dict[str, list[str]]: know *which* role to edit. Two roles can grant the same key, so the value is a list. """ - from simple_module_core.permissions import WILDCARD - role_map = self.registry.role_map sources: dict[str, list[str]] = {} for name in sorted(role_names): diff --git a/modules/permissions/tests/test_permissions_runtime_sources.py b/modules/permissions/tests/test_permissions_runtime_sources.py index 30e9a4bb..f8cd049a 100644 --- a/modules/permissions/tests/test_permissions_runtime_sources.py +++ b/modules/permissions/tests/test_permissions_runtime_sources.py @@ -78,7 +78,7 @@ async def test_saving_role_keeps_grants_of_currently_unregistered_source_keys( # The source goes empty (e.g. restart before its cache warms); an unrelated save # must not prune the stored grant. - registry.add_source("records", lambda: []) + registry.add_source("records", list) await authenticated_client.put(url, json={"permissions": []}) async with app.state.sm.db.session_factory() as db: