You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2026/09/04 07:50:35 UTC

(causeway) 06/11: fix(webcomponents): restore secured tertiary actions

This is an automated email from the ASF dual-hosted git repository.

danhaywood pushed a commit to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git

commit f3d2933dfa4485fcbbc24f35c03b1a863d8a9ea4
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Fri Sep 4 00:17:16 2026 +0100

    fix(webcomponents): restore secured tertiary actions
---
 .../design.md                                      |  6 +++--
 .../proposal.md                                    |  3 ++-
 .../restore-secured-tertiary-menu-actions/tasks.md | 17 ++++++------
 .../webcomponents/htmx-security-secman/README.adoc |  1 +
 .../sample-htmx-petclinic/README.adoc              |  3 ++-
 .../PetClinicSecmanDataConfiguration.java          |  7 +++++
 .../main/resources/secured-application.properties  |  1 +
 .../petclinic/PetClinicHtmxPlaywrightTest.java     | 19 +++++++++++++
 .../PetClinicHtmxSecuredPlaywrightTest.java        | 29 +++++++++++++++++---
 .../PetClinicHtmxSecuredApplication_IntegTest.java | 29 +++++++++++++++++++-
 .../src/main/resources/menubars.layout.xml         |  2 ++
 .../webcomponents/sample-vue-petclinic/README.adoc |  4 ++-
 .../vue/petclinic/PetClinicVuePlaywrightTest.java  | 31 ++++++++++++++++------
 .../PetClinicVueSecuredPlaywrightAcceptance.java   | 29 +++++++++++++++++---
 .../PetClinicVueSecuredApplication_IntegTest.java  | 29 +++++++++++++++++++-
 .../PetClinicSecmanDataConfiguration.java          |  7 +++++
 .../resources/secured-application.properties       |  1 +
 .../webcomponents/vue-security-secman/README.adoc  |  1 +
 18 files changed, 190 insertions(+), 29 deletions(-)

diff --git a/openspec/changes/restore-secured-tertiary-menu-actions/design.md b/openspec/changes/restore-secured-tertiary-menu-actions/design.md
index 85f46383c14..ea53f2b085a 100644
--- a/openspec/changes/restore-secured-tertiary-menu-actions/design.md
+++ b/openspec/changes/restore-secured-tertiary-menu-actions/design.md
@@ -27,7 +27,8 @@ The HTMX and Vue secured variants maintain parallel seed configurations and use
 ### Assign established framework roles to the deterministic user
 
 Both secured seed configurations will add the existing SecMan regular-user role and configuration-access role to `sven`, in addition to the Petclinic application role.
-The regular-user role is the framework-owned authority for `MeService`, and the configuration role is the framework-owned authority for `ConfigurationMenu`.
+The regular-user role authorizes the framework `UserMenu#me` action, and the configuration role authorizes `ConfigurationMenu#configuration`.
+The secured profiles will enable the generic `UserMenu#me` action so the shared menu can use the same exact **Me** identity in ordinary and secured runtimes instead of referencing the SecMan-only `MeService` from a shared layout.
 This reuses established permission boundaries instead of duplicating feature strings or granting an entire framework namespace through the sample role.
 
 Adding direct broad permissions to the Petclinic role was rejected because it would couple the sample's application role to framework internals and could accidentally expand authority as namespaces evolve.
@@ -36,7 +37,8 @@ Changing the viewer to retain unauthorized menu entries was rejected because aut
 ### Keep menu placement metadata authoritative
 
 The viewers will not insert or move **Me** and **Configuration**.
-Once SecMan authorizes them, the existing application-menu resource and service layout metadata determine their tertiary placement, while host policy continues to relabel only exact Logout as **Sign out**.
+The shared application-menu resource will explicitly list `UserMenu#me`, `ConfigurationMenu#configuration`, and Logout in the Account section so both ordinary and secured runtimes retain the complete intended tertiary set.
+SecMan remains responsible for authorizing those entries, while host policy continues to relabel only exact Logout as **Sign out**.
 
 ### Assert semantic parity rather than markup identity
 
diff --git a/openspec/changes/restore-secured-tertiary-menu-actions/proposal.md b/openspec/changes/restore-secured-tertiary-menu-actions/proposal.md
index 96e68324bf7..730a90e80b7 100644
--- a/openspec/changes/restore-secured-tertiary-menu-actions/proposal.md
+++ b/openspec/changes/restore-secured-tertiary-menu-actions/proposal.md
@@ -6,6 +6,7 @@ This makes the secured menu incomplete and hides useful framework capabilities r
 ## What Changes
 
 - Make the secured Petclinic users receive the framework roles and permissions needed for the **Me** and **Configuration** actions.
+- Declare the complete intended Account action set in the shared menu layout and enable the same generic **Me** identity in secured profiles.
 - Preserve **Me**, **Configuration**, and **Sign out** together in the authoritative tertiary menu for both HTMX and Vue.
 - Keep authorization authoritative: viewers must not synthesize actions or expose actions that the current user is not permitted to invoke.
 - Add integration and headless browser coverage comparing ordinary and secured tertiary menu semantics.
@@ -24,5 +25,5 @@ None.
 
 ## Impact
 
-The change affects the deterministic SecMan seed configuration, secured HTMX and Vue integration and browser tests, and sample documentation.
+The change affects the shared Petclinic menu layout, secured profiles and deterministic SecMan seed configuration, secured HTMX and Vue integration and browser tests, and sample documentation.
 It does not change generic menu projection, action identity, GraphQL invocation, authentication endpoints, CSRF handling, or ordinary runtime authorization.
diff --git a/openspec/changes/restore-secured-tertiary-menu-actions/tasks.md b/openspec/changes/restore-secured-tertiary-menu-actions/tasks.md
index 1bc980e1258..888b6f9d806 100644
--- a/openspec/changes/restore-secured-tertiary-menu-actions/tasks.md
+++ b/openspec/changes/restore-secured-tertiary-menu-actions/tasks.md
@@ -1,19 +1,20 @@
 ## 1. Secured role seeding
 
-- [ ] 1.1 Assign the established SecMan regular-user and configuration-access roles to the deterministic HTMX user.
-- [ ] 1.2 Apply equivalent role assignments to the deterministic Vue user without broadening the Petclinic application role.
+- [x] 1.1 Assign the established SecMan regular-user and configuration-access roles to the deterministic HTMX user and enable the generic **Me** action.
+- [x] 1.2 Apply equivalent role assignments and **Me** policy to the deterministic Vue user without broadening the Petclinic application role.
+- [x] 1.3 Declare **Me**, **Configuration**, and Logout together in the shared tertiary Account menu without referencing SecMan-only services.
 
 ## 2. Verification coverage
 
-- [ ] 2.1 Extend HTMX secured integration and headless browser coverage for exact tertiary **Me**, **Configuration**, and **Sign out** actions.
-- [ ] 2.2 Extend Vue secured integration and headless browser coverage for the same authorized tertiary action set.
-- [ ] 2.3 Confirm ordinary HTMX and Vue runtimes retain their existing tertiary menu behavior.
+- [x] 2.1 Extend HTMX secured integration and headless browser coverage for exact tertiary **Me**, **Configuration**, and **Sign out** actions.
+- [x] 2.2 Extend Vue secured integration and headless browser coverage for the same authorized tertiary action set.
+- [x] 2.3 Confirm ordinary HTMX and Vue runtimes retain their existing tertiary menu behavior.
 
 ## 3. Documentation
 
-- [ ] 3.1 Document the role-backed secured tertiary action set in both Petclinic samples and security integration guidance.
+- [x] 3.1 Document the role-backed secured tertiary action set in both Petclinic samples and security integration guidance.
 
 ## 4. Validation
 
-- [ ] 4.1 Run focused secured HTMX and Vue tests, including headless browser acceptance.
-- [ ] 4.2 Run ordinary HTMX and Vue regression suites, formatting and license checks, and strict OpenSpec validation.
+- [x] 4.1 Run focused secured HTMX and Vue tests, including headless browser acceptance.
+- [x] 4.2 Run ordinary HTMX and Vue regression suites, formatting and license checks, and strict OpenSpec validation.
diff --git a/viewers/webcomponents/htmx-security-secman/README.adoc b/viewers/webcomponents/htmx-security-secman/README.adoc
index 58bc1ce7ad2..0ef71dc42d8 100644
--- a/viewers/webcomponents/htmx-security-secman/README.adoc
+++ b/viewers/webcomponents/htmx-security-secman/README.adoc
@@ -102,6 +102,7 @@ Spring Security owns credential processing, request caching, session-identifier
 Only canonical same-origin GET routes beneath the configured HTMX base path are restored after login.
 The HTMX host owns login presentation, session-expiry navigation, exact interception of `causeway.security.LogoutMenu#logout`, its **Sign out** menu label, and the non-visual native POST logout form.
 When the exact action is authorized, it remains in the GraphQL-authoritative tertiary menu position and activation submits the form before GraphQL invocation; the shell renders no separate visible username or logout block.
+Other tertiary actions remain subject to ordinary menu layout and SecMan authorization, so applications must grant any regular-user or configuration roles needed to preserve controls such as **Me** and **Configuration**.
 That menu control remains independent of typed `LocalResourcePathValue` action results: secure logout uses current CSRF evidence and Spring Security session invalidation, while local-resource results are only safe same-origin navigation.
 A local-resource path equal to `/logout` does not acquire authentication semantics.
 Invalid passwords, absent users, locked users, passwordless users, and internal lookup failures all produce the same public credential failure.
diff --git a/viewers/webcomponents/sample-htmx-petclinic/README.adoc b/viewers/webcomponents/sample-htmx-petclinic/README.adoc
index 22cc946d7c3..e0c7bc42474 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/README.adoc
+++ b/viewers/webcomponents/sample-htmx-petclinic/README.adoc
@@ -41,7 +41,8 @@ Run the separate local SecMan-authenticated configuration with:
 
 Open `http://localhost:8080/htmx` and sign in with username `sven` and password `pass`.
 This secured configuration enables Spring CSRF protection, consumes the shared presentation-neutral SecMan Spring bridge, and intentionally omits Wicket and bypass security.
-The authorized tertiary Account menu remains part of the ordinary application-menu hierarchy and presents **Sign out**, whose activation submits the host's non-visual current-CSRF POST form rather than invoking GraphQL.
+The shared Account layout declares **Me**, **Configuration**, and framework Logout together in the tertiary application-menu hierarchy.
+The deterministic secured user receives the standard regular-user and configuration-access roles, so the secured menu preserves **Me** and **Configuration** while presenting Logout as **Sign out**, whose activation submits the host's non-visual current-CSRF POST form rather than invoking GraphQL.
 The secured shell adds no separate visible username or logout block.
 The secured launcher preserves the same `JAVA_HOME`, `MVN`, and pass-through Maven argument behavior as `run.sh`.
 
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicSecmanDataConfiguration.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicSecmanDataConfiguration.java
index ee1c5c95876..ea2b6f10631 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicSecmanDataConfiguration.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicSecmanDataConfiguration.java
@@ -28,10 +28,12 @@
 import org.apache.causeway.applib.services.appfeat.ApplicationFeatureSort;
 import org.apache.causeway.applib.services.xactn.TransactionService;
 import org.apache.causeway.applib.value.Password;
+import org.apache.causeway.core.config.CausewayConfiguration;
 import org.apache.causeway.extensions.secman.applib.permission.dom.ApplicationPermissionMode;
 import org.apache.causeway.extensions.secman.applib.permission.dom.ApplicationPermissionRepository;
 import org.apache.causeway.extensions.secman.applib.permission.dom.ApplicationPermissionRule;
 import org.apache.causeway.extensions.secman.applib.role.dom.ApplicationRoleRepository;
+import org.apache.causeway.extensions.secman.applib.role.seed.CausewayConfigurationRoleAndPermissions;
 import org.apache.causeway.extensions.secman.applib.user.dom.ApplicationUser;
 import org.apache.causeway.extensions.secman.applib.user.dom.ApplicationUserRepository;
 import org.apache.causeway.extensions.secman.applib.user.dom.ApplicationUserStatus;
@@ -48,6 +50,7 @@ public class PetClinicSecmanDataConfiguration {
 
     @Bean
     ApplicationRunner loadPetClinicSecurityData(
+            final CausewayConfiguration causewayConfiguration,
             final TransactionService transactionService,
             final ApplicationUserRepository userRepository,
             final ApplicationRoleRepository roleRepository,
@@ -72,6 +75,10 @@ ApplicationRunner loadPetClinicSecurityData(
             unlocked.setNumberFormat(Locale.GERMANY);
             unlocked.setTimeFormat(Locale.UK);
             addRoleIfMissing(roleRepository, role, unlocked);
+            addRoleIfMissing(roleRepository, roleRepository.findByName(
+                    causewayConfiguration.extensions().secman().seed().regularUser().roleName()).orElseThrow(), unlocked);
+            addRoleIfMissing(roleRepository, roleRepository.findByName(
+                    CausewayConfigurationRoleAndPermissions.ROLE_NAME).orElseThrow(), unlocked);
 
             final var locked = userRepository.upsertLocal(
                     LOCKED_USERNAME, Password.of(PASSWORD), ApplicationUserStatus.LOCKED);
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/secured-application.properties b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/secured-application.properties
index 5a0b128151b..58862222ae0 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/secured-application.properties
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/secured-application.properties
@@ -18,6 +18,7 @@
 #
 spring.datasource.url=jdbc:h2:mem:securedPetclinic;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;INIT=CREATE SCHEMA IF NOT EXISTS causewayExtSecman
 causeway.security.spring.allow-csrf-filters=true
+causeway.extensions.secman.user-menu-me-action-policy=enable
 causeway.viewer.webcomponents.htmx.wicket-comparison-path=
 server.servlet.session.cookie.http-only=true
 server.servlet.session.cookie.same-site=lax
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
index 6a339d0b02f..657d4c82e97 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
@@ -495,6 +495,7 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
     @Order(2)
     void serviceActionsCoverValidationCancellationScalarAndCollections() {
         openHome();
+        assertOrdinaryTertiaryActions();
 
         openMenu("Pet Owners");
         if (nativeToolkit()) {
@@ -1569,6 +1570,24 @@ private void waitForMenus() {
                 .isEqualTo(1);
     }
 
+    private void assertOrdinaryTertiaryActions() {
+        assertThat((Boolean) page.locator("cw-menubar-tertiary").evaluate("""
+                element => {
+                  const actions = Object.values(element._projection?.actions ?? {});
+                  const has = (identity, label) => actions.some(action =>
+                    `${action.serviceLogicalTypeName}#${action.actionId}` === identity
+                      && action.label === label
+                      && action.role === 'tertiary');
+                  return has('causeway.applib.UserMenu#me', 'Me')
+                    && has('causeway.conf.ConfigurationMenu#configuration', 'Configuration')
+                    && !actions.some(action => action.serviceLogicalTypeName === 'causeway.security.LogoutMenu')
+                    && !actions.some(action => action.serviceLogicalTypeName === 'causeway.ext.secman.MeService');
+                }
+                """))
+                .as(page.locator("cw-menubar-tertiary").evaluate("element => Object.values(element._projection?.actions ?? {}).map(action => `${action.serviceLogicalTypeName}#${action.actionId}:${action.label}:${action.role}`)").toString())
+                .isTrue();
+    }
+
     private void openObject(final String logicalTypeName, final String id) {
         page.navigate(url("/htmx/object/" + logicalTypeName + "/" + id),
                 new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicHtmxSecuredPlaywrightTest.java b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicHtmxSecuredPlaywrightTest.java
index c1f96497c8d..bcff350a93a 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicHtmxSecuredPlaywrightTest.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicHtmxSecuredPlaywrightTest.java
@@ -27,10 +27,12 @@
 import com.microsoft.playwright.Page;
 import com.microsoft.playwright.Playwright;
 import com.microsoft.playwright.options.WaitUntilState;
+
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInstance;
+
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.boot.test.web.server.LocalServerPort;
 
@@ -131,7 +133,22 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
                     .as(page.locator("cw-menubar-primary, cw-menubar-secondary, cw-menubar-tertiary")
                             .evaluateAll("elements => elements.map(element => ({role: element.role, hidden: element.hidden, actions: Object.values(element._projection?.actions ?? {}).map(action => `${action.serviceLogicalTypeName}#${action.actionId}:${action.label}`)}))").toString())
                     .isTrue();
-            assertThat((Boolean) page.locator("cw-menubar-tertiary").evaluate("element => { const action = Object.values(element._projection?.actions ?? {}).find(action => action.serviceLogicalTypeName === 'causeway.security.LogoutMenu' && action.actionId === 'logout'); return action?.label === 'Sign out' && action.role === 'tertiary'; }")).isTrue();
+            assertThat((Boolean) page.locator("cw-menubar-tertiary").evaluate("""
+                    element => {
+                      const actions = Object.values(element._projection?.actions ?? {});
+                      const expected = new Map([
+                        ['causeway.applib.UserMenu#me', 'Me'],
+                        ['causeway.conf.ConfigurationMenu#configuration', 'Configuration'],
+                        ['causeway.security.LogoutMenu#logout', 'Sign out']
+                      ]);
+                      return [...expected].every(([identity, label]) => {
+                        const action = actions.find(candidate => `${candidate.serviceLogicalTypeName}#${candidate.actionId}` === identity);
+                        return action?.label === label && action.role === 'tertiary';
+                      });
+                    }
+                    """))
+                    .as(page.locator("cw-menubar-tertiary").evaluate("element => Object.values(element._projection?.actions ?? {}).map(action => `${action.serviceLogicalTypeName}#${action.actionId}:${action.label}:${action.role}`)").toString())
+                    .isTrue();
             assertThat(graphQlCsrfHeaders).isNotEmpty().allSatisfy(value -> assertThat(value).isNotBlank());
 
             final var property = page.locator("cw-property[id='knownAs']");
@@ -168,9 +185,15 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
             page.locator("#password").fill(PetClinicSecmanDataConfiguration.PASSWORD);
             page.locator("button[type='submit']").click();
             page.waitForURL("**/htmx**");
-            page.waitForFunction("() => ['ready','partial-error'].includes(document.querySelector('cw-menubars')?.dataset.menuState) && document.querySelector('cw-menubar-control')?.dataset.widgetState === 'ready'");
+            page.waitForFunction("() => ['ready','partial-error'].includes(document.querySelector('cw-menubars')?.dataset.menuState) && [...document.querySelectorAll('cw-menubar-primary, cw-menubar-secondary, cw-menubar-tertiary')].filter(element => !element.hidden).every(element => element.querySelector('cw-menubar-control')?.dataset.widgetState === 'ready')");
             assertThat(menuBarRequests).hasSize(2);
-            page.locator("cw-menubar-tertiary").evaluate("element => { const projection = element._projection; const action = Object.values(projection.actions).find(action => action.serviceLogicalTypeName === 'causeway.security.LogoutMenu' && action.actionId === 'logout'); const control = element.querySelector('cw-menubar-control')._control; const items = control.items.flatMap(menu => menu.children ?? []); const item = items.find(candidate => candidate.causewayKey === action.key); contro [...]
+            final var accountMenu = page.locator("vaadin-menu-bar-button")
+                    .filter(new com.microsoft.playwright.Locator.FilterOptions().setHasText("Account")).first();
+            accountMenu.click();
+            final var signOutItem = page.locator("vaadin-menu-bar-item")
+                    .filter(new com.microsoft.playwright.Locator.FilterOptions().setHasText("Sign out")).last();
+            signOutItem.waitFor();
+            signOutItem.click();
             page.waitForURL("**/htmx/login?logout=true");
             assertThat(page.locator("[role='status']").textContent()).contains("signed out");
             assertThat(menuBarRequests).hasSize(2);
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicHtmxSecuredApplication_IntegTest.java b/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicHtmxSecuredApplication_IntegTest.java
index d8c465f7d96..6ed10a3de00 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicHtmxSecuredApplication_IntegTest.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinicsecured/PetClinicHtmxSecuredApplication_IntegTest.java
@@ -29,8 +29,16 @@
 import java.util.regex.Pattern;
 
 import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.transaction.annotation.Transactional;
+
+import org.apache.causeway.core.config.CausewayConfiguration;
+import org.apache.causeway.extensions.secman.applib.role.dom.ApplicationRole;
+import org.apache.causeway.extensions.secman.applib.role.seed.CausewayConfigurationRoleAndPermissions;
+import org.apache.causeway.extensions.secman.applib.user.dom.ApplicationUserRepository;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -46,6 +54,25 @@ class PetClinicHtmxSecuredApplication_IntegTest {
     @LocalServerPort
     private int port;
 
+    @Autowired
+    private CausewayConfiguration causewayConfiguration;
+
+    @Autowired
+    private ApplicationUserRepository userRepository;
+
+    @Test
+    @Transactional
+    void deterministicUserHasApplicationAndFrameworkRoles() {
+        final var roleNames = userRepository.findByUsername(PetClinicSecmanDataConfiguration.USERNAME).orElseThrow()
+                .getRoles().stream()
+                .map(ApplicationRole::getName)
+                .toList();
+        assertThat(roleNames).contains(
+                PetClinicSecmanDataConfiguration.ROLE_NAME,
+                causewayConfiguration.extensions().secman().seed().regularUser().roleName(),
+                CausewayConfigurationRoleAndPermissions.ROLE_NAME);
+    }
+
     @Test
     void realSecmanLoginCsrfGraphQlAndLogoutJourney() throws Exception {
         final var cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
@@ -146,7 +173,7 @@ private HttpResponse<String> graphQl(final HttpClient client, final String csrf)
             builder.header("X-CSRF-TOKEN", csrf);
         }
         return client.send(builder.POST(HttpRequest.BodyPublishers.ofString(
-                "{\"query\":\"query SecuredHome { rich { application { home { kind logicalTypeName } } } }\"}"))
+                        "{\"query\":\"query SecuredHome { rich { application { home { kind logicalTypeName } } } }\"}"))
                 .build(), HttpResponse.BodyHandlers.ofString());
     }
 
diff --git a/viewers/webcomponents/sample-petclinic-domain/src/main/resources/menubars.layout.xml b/viewers/webcomponents/sample-petclinic-domain/src/main/resources/menubars.layout.xml
index 0fcf9df5c79..8d821310c36 100644
--- a/viewers/webcomponents/sample-petclinic-domain/src/main/resources/menubars.layout.xml
+++ b/viewers/webcomponents/sample-petclinic-domain/src/main/resources/menubars.layout.xml
@@ -45,6 +45,8 @@
         <mb:menu>
             <mb:named>Account</mb:named>
             <mb:section>
+                <mb:serviceAction objectType="causeway.applib.UserMenu" id="me"/>
+                <mb:serviceAction objectType="causeway.conf.ConfigurationMenu" id="configuration"/>
                 <mb:serviceAction objectType="causeway.security.LogoutMenu" id="logout"/>
             </mb:section>
         </mb:menu>
diff --git a/viewers/webcomponents/sample-vue-petclinic/README.adoc b/viewers/webcomponents/sample-vue-petclinic/README.adoc
index 8af8f84b185..a23812830a5 100644
--- a/viewers/webcomponents/sample-vue-petclinic/README.adoc
+++ b/viewers/webcomponents/sample-vue-petclinic/README.adoc
@@ -53,7 +53,9 @@ Open any `/vue` route and sign in with username `sven` and password `pass`.
 The secured launcher preserves the environment and additional Maven argument behavior of `run.sh` while selecting `PetClinicVueSecuredApplication`.
 The application restores only safe Vue GET deep links, fetches bounded no-store username and CSRF context before mounting, decorates every GraphQL POST with the current CSRF header, and retains an application-owned non-visual POST logout form.
 Invalid, absent, locked, and passwordless accounts have the same public failure, and successful authentication migrates the session identifier.
-The authorized tertiary Account menu presents exact framework action `causeway.security.LogoutMenu#logout` as **Sign out**; host policy claims activation and submits the native form before GraphQL, while similarly named application actions and typed local-resource results remain independent.
+The shared Account layout declares **Me**, **Configuration**, and framework Logout together in the tertiary application-menu hierarchy.
+The deterministic secured user receives the standard regular-user and configuration-access roles, so the secured menu preserves **Me** and **Configuration** while presenting exact framework action `causeway.security.LogoutMenu#logout` as **Sign out**.
+Host policy claims Sign out activation and submits the native form before GraphQL, while similarly named application actions and typed local-resource results remain independent.
 The secured shell adds no separate visible username or logout block.
 
 == Test
diff --git a/viewers/webcomponents/sample-vue-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinic/PetClinicVuePlaywrightTest.java b/viewers/webcomponents/sample-vue-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinic/PetClinicVuePlaywrightTest.java
index be6d26e6348..c5db0fa455e 100644
--- a/viewers/webcomponents/sample-vue-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinic/PetClinicVuePlaywrightTest.java
+++ b/viewers/webcomponents/sample-vue-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinic/PetClinicVuePlaywrightTest.java
@@ -221,7 +221,7 @@ void shellAndOwnerLayoutMatchTheWideAndNarrowReferenceInvariants() {
                 .isEqualTo("rgb(23, 105, 170)");
         assertThat(page.locator("body").evaluate("element => getComputedStyle(element).fontFamily"))
                 .asString().contains("Inter");
-        assertThat(page.locator("cw-menubars").innerText()).containsSubsequence("Pet Owners", "Visits", "System");
+        assertThat(page.locator("cw-menubars").innerText()).containsSubsequence("Pet Owners", "Visits", "Account");
         assertThat(page.locator("footer").innerText()).contains("Powered by Apache Causeway", "Vue viewer");
         assertThat(page.locator(".causeway-object-identity").isVisible()).isFalse();
         assertThat(page.locator("[data-causeway-route-page]").evaluate("element => getComputedStyle(element).outlineStyle"))
@@ -270,13 +270,13 @@ void frameworkLogoutFailsClosedWhileApplicationActionsAndLocalResourcesRemainAva
         page.waitForFunction("() => document.querySelector('cw-object cw-action#openLocalResource')?.componentState?.status === 'ready'");
         page.locator("cw-menubars[data-menu-state='ready']").waitFor();
 
-        final var systemMenu = page.locator("vaadin-menu-bar-button")
-                .filter(new com.microsoft.playwright.Locator.FilterOptions().setHasText("System")).first();
-        systemMenu.waitFor();
-        systemMenu.click();
-        final var systemOverlay = page.locator("vaadin-menu-bar-overlay[opened]");
-        systemOverlay.waitFor();
-        assertThat(systemOverlay.innerText()).doesNotContain("Logout");
+        final var accountMenu = page.locator("vaadin-menu-bar-button")
+                .filter(new com.microsoft.playwright.Locator.FilterOptions().setHasText("Account")).first();
+        accountMenu.waitFor();
+        accountMenu.click();
+        final var accountOverlay = page.locator("vaadin-menu-bar-overlay[opened]");
+        accountOverlay.waitFor();
+        assertThat(accountOverlay.innerText()).doesNotContain("Logout");
         page.keyboard().press("Escape");
 
         final var logoutInvocationsBefore = graphQLRequests.stream()
@@ -332,6 +332,21 @@ void bothToolkitPoliciesPreserveAccessibleShellLandmarksAndMenus() {
             assertThat(page.locator("footer").count()).isEqualTo(1);
             page.locator("cw-menubars[data-menu-state='ready']").waitFor();
             assertThat(page.locator("cw-menubars[data-menu-state='ready']").count()).isEqualTo(1);
+            assertThat((Boolean) page.locator("cw-menubar-tertiary").evaluate("""
+                    element => {
+                      const actions = Object.values(element._projection?.actions ?? {});
+                      const has = (identity, label) => actions.some(action =>
+                        `${action.serviceLogicalTypeName}#${action.actionId}` === identity
+                          && action.label === label
+                          && action.role === 'tertiary');
+                      return has('causeway.applib.UserMenu#me', 'Me')
+                        && has('causeway.conf.ConfigurationMenu#configuration', 'Configuration')
+                        && !actions.some(action => action.serviceLogicalTypeName === 'causeway.security.LogoutMenu')
+                        && !actions.some(action => action.serviceLogicalTypeName === 'causeway.ext.secman.MeService');
+                    }
+                    """))
+                    .as(page.locator("cw-menubar-tertiary").evaluate("element => Object.values(element._projection?.actions ?? {}).map(action => `${action.serviceLogicalTypeName}#${action.actionId}:${action.label}:${action.role}`)").toString())
+                    .isTrue();
             final var skipLink = page.locator("a[href='#causeway-vue-route']");
             assertThat(skipLink.innerText()).isEqualTo("Skip to main content");
             skipLink.focus();
diff --git a/viewers/webcomponents/sample-vue-petclinic/src/secured-playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicVueSecuredPlaywrightAcceptance.java b/viewers/webcomponents/sample-vue-petclinic/src/secured-playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicVueSecuredPlaywrightAcceptance.java
index 680ecab1aab..2ad6ff3fddc 100644
--- a/viewers/webcomponents/sample-vue-petclinic/src/secured-playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicVueSecuredPlaywrightAcceptance.java
+++ b/viewers/webcomponents/sample-vue-petclinic/src/secured-playwright/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicVueSecuredPlaywrightAcceptance.java
@@ -27,10 +27,12 @@
 import com.microsoft.playwright.Page;
 import com.microsoft.playwright.Playwright;
 import com.microsoft.playwright.options.WaitUntilState;
+
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInstance;
+
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.boot.test.web.server.LocalServerPort;
 
@@ -127,7 +129,22 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
             page.waitForFunction("() => [...document.querySelectorAll('cw-menubar-primary, cw-menubar-secondary, cw-menubar-tertiary')].filter(element => !element.hidden).every(element => element.dataset.causewayMenubarPresentation?.startsWith('vaadin-') && element.querySelector('cw-menubar-control')?.dataset.widgetState === 'ready')");
             assertThat(menuBarRequests).hasSize(1);
             assertThat(page.locator("cw-menubar-tertiary").isVisible()).isTrue();
-            assertThat((Boolean) page.locator("cw-menubar-tertiary").evaluate("element => { const action = Object.values(element._projection?.actions ?? {}).find(action => action.serviceLogicalTypeName === 'causeway.security.LogoutMenu' && action.actionId === 'logout'); return action?.label === 'Sign out' && action.role === 'tertiary'; }")).isTrue();
+            assertThat((Boolean) page.locator("cw-menubar-tertiary").evaluate("""
+                    element => {
+                      const actions = Object.values(element._projection?.actions ?? {});
+                      const expected = new Map([
+                        ['causeway.applib.UserMenu#me', 'Me'],
+                        ['causeway.conf.ConfigurationMenu#configuration', 'Configuration'],
+                        ['causeway.security.LogoutMenu#logout', 'Sign out']
+                      ]);
+                      return [...expected].every(([identity, label]) => {
+                        const action = actions.find(candidate => `${candidate.serviceLogicalTypeName}#${candidate.actionId}` === identity);
+                        return action?.label === label && action.role === 'tertiary';
+                      });
+                    }
+                    """))
+                    .as(page.locator("cw-menubar-tertiary").evaluate("element => Object.values(element._projection?.actions ?? {}).map(action => `${action.serviceLogicalTypeName}#${action.actionId}:${action.label}:${action.role}`)").toString())
+                    .isTrue();
             assertThat(graphQlCsrfHeaders).isNotEmpty().allSatisfy(value -> assertThat(value).isNotBlank());
 
             final var graphQlCountBeforeLogoutPolicy = graphQlCsrfHeaders.size();
@@ -183,9 +200,15 @@ void anonymousLoginDeepLinkMutationCsrfLogoutHistoryAndExpiryJourney() {
             page.locator("#password").fill(PetClinicSecmanDataConfiguration.PASSWORD);
             page.locator("button[type='submit']").click();
             page.waitForURL("**/vue**");
-            page.waitForFunction("() => ['ready','partial-error'].includes(document.querySelector('cw-menubars')?.dataset.menuState) && document.querySelector('cw-menubar-control')?.dataset.widgetState === 'ready'");
+            page.waitForFunction("() => ['ready','partial-error'].includes(document.querySelector('cw-menubars')?.dataset.menuState) && [...document.querySelectorAll('cw-menubar-primary, cw-menubar-secondary, cw-menubar-tertiary')].filter(element => !element.hidden).every(element => element.querySelector('cw-menubar-control')?.dataset.widgetState === 'ready')");
             assertThat(menuBarRequests).hasSize(2);
-            page.locator("cw-menubar-tertiary").evaluate("element => { const projection = element._projection; const action = Object.values(projection.actions).find(action => action.serviceLogicalTypeName === 'causeway.security.LogoutMenu' && action.actionId === 'logout'); const control = element.querySelector('cw-menubar-control')._control; const items = control.items.flatMap(menu => menu.children ?? []); const item = items.find(candidate => candidate.causewayKey === action.key); contro [...]
+            final var accountMenu = page.locator("vaadin-menu-bar-button")
+                    .filter(new com.microsoft.playwright.Locator.FilterOptions().setHasText("Account")).first();
+            accountMenu.click();
+            final var signOutItem = page.locator("vaadin-menu-bar-item")
+                    .filter(new com.microsoft.playwright.Locator.FilterOptions().setHasText("Sign out")).last();
+            signOutItem.waitFor();
+            signOutItem.click();
             page.waitForURL("**/vue/login?logout=true");
             assertThat(page.locator("[role='status']").textContent()).contains("signed out");
             assertThat(menuBarRequests).hasSize(2);
diff --git a/viewers/webcomponents/sample-vue-petclinic/src/secured-test/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicVueSecuredApplication_IntegTest.java b/viewers/webcomponents/sample-vue-petclinic/src/secured-test/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicVueSecuredApplication_IntegTest.java
index 995137bf2c9..d10f8290500 100644
--- a/viewers/webcomponents/sample-vue-petclinic/src/secured-test/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicVueSecuredApplication_IntegTest.java
+++ b/viewers/webcomponents/sample-vue-petclinic/src/secured-test/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicVueSecuredApplication_IntegTest.java
@@ -29,8 +29,16 @@
 import java.util.regex.Pattern;
 
 import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.transaction.annotation.Transactional;
+
+import org.apache.causeway.core.config.CausewayConfiguration;
+import org.apache.causeway.extensions.secman.applib.role.dom.ApplicationRole;
+import org.apache.causeway.extensions.secman.applib.role.seed.CausewayConfigurationRoleAndPermissions;
+import org.apache.causeway.extensions.secman.applib.user.dom.ApplicationUserRepository;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -45,6 +53,25 @@ class PetClinicVueSecuredApplication_IntegTest {
     @LocalServerPort
     private int port;
 
+    @Autowired
+    private CausewayConfiguration causewayConfiguration;
+
+    @Autowired
+    private ApplicationUserRepository userRepository;
+
+    @Test
+    @Transactional
+    void deterministicUserHasApplicationAndFrameworkRoles() {
+        final var roleNames = userRepository.findByUsername(PetClinicSecmanDataConfiguration.USERNAME).orElseThrow()
+                .getRoles().stream()
+                .map(ApplicationRole::getName)
+                .toList();
+        assertThat(roleNames).contains(
+                PetClinicSecmanDataConfiguration.ROLE_NAME,
+                causewayConfiguration.extensions().secman().seed().regularUser().roleName(),
+                CausewayConfigurationRoleAndPermissions.ROLE_NAME);
+    }
+
     @Test
     void realSecmanLoginCsrfGraphQlAndLogoutJourney() throws Exception {
         final var cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
@@ -155,7 +182,7 @@ private HttpResponse<String> graphQl(final HttpClient client, final String csrf)
             builder.header("X-CSRF-TOKEN", csrf);
         }
         return client.send(builder.POST(HttpRequest.BodyPublishers.ofString(
-                "{\"query\":\"query SecuredHome { rich { application { home { kind logicalTypeName } } } }\"}"))
+                        "{\"query\":\"query SecuredHome { rich { application { home { kind logicalTypeName } } } }\"}"))
                 .build(), HttpResponse.BodyHandlers.ofString());
     }
 
diff --git a/viewers/webcomponents/sample-vue-petclinic/src/secured/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicSecmanDataConfiguration.java b/viewers/webcomponents/sample-vue-petclinic/src/secured/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicSecmanDataConfiguration.java
index e49a442aef0..d0e4f08adda 100644
--- a/viewers/webcomponents/sample-vue-petclinic/src/secured/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicSecmanDataConfiguration.java
+++ b/viewers/webcomponents/sample-vue-petclinic/src/secured/java/org/apache/causeway/viewer/webcomponents/sample/vue/petclinicsecured/PetClinicSecmanDataConfiguration.java
@@ -28,10 +28,12 @@
 import org.apache.causeway.applib.services.appfeat.ApplicationFeatureSort;
 import org.apache.causeway.applib.services.xactn.TransactionService;
 import org.apache.causeway.applib.value.Password;
+import org.apache.causeway.core.config.CausewayConfiguration;
 import org.apache.causeway.extensions.secman.applib.permission.dom.ApplicationPermissionMode;
 import org.apache.causeway.extensions.secman.applib.permission.dom.ApplicationPermissionRepository;
 import org.apache.causeway.extensions.secman.applib.permission.dom.ApplicationPermissionRule;
 import org.apache.causeway.extensions.secman.applib.role.dom.ApplicationRoleRepository;
+import org.apache.causeway.extensions.secman.applib.role.seed.CausewayConfigurationRoleAndPermissions;
 import org.apache.causeway.extensions.secman.applib.user.dom.ApplicationUser;
 import org.apache.causeway.extensions.secman.applib.user.dom.ApplicationUserRepository;
 import org.apache.causeway.extensions.secman.applib.user.dom.ApplicationUserStatus;
@@ -48,6 +50,7 @@ public class PetClinicSecmanDataConfiguration {
 
     @Bean
     ApplicationRunner loadPetClinicSecurityData(
+            final CausewayConfiguration causewayConfiguration,
             final TransactionService transactionService,
             final ApplicationUserRepository userRepository,
             final ApplicationRoleRepository roleRepository,
@@ -72,6 +75,10 @@ ApplicationRunner loadPetClinicSecurityData(
             unlocked.setNumberFormat(Locale.GERMANY);
             unlocked.setTimeFormat(Locale.UK);
             addRoleIfMissing(roleRepository, role, unlocked);
+            addRoleIfMissing(roleRepository, roleRepository.findByName(
+                    causewayConfiguration.extensions().secman().seed().regularUser().roleName()).orElseThrow(), unlocked);
+            addRoleIfMissing(roleRepository, roleRepository.findByName(
+                    CausewayConfigurationRoleAndPermissions.ROLE_NAME).orElseThrow(), unlocked);
 
             final var locked = userRepository.upsertLocal(
                     LOCKED_USERNAME, Password.of(PASSWORD), ApplicationUserStatus.LOCKED);
diff --git a/viewers/webcomponents/sample-vue-petclinic/src/secured/resources/secured-application.properties b/viewers/webcomponents/sample-vue-petclinic/src/secured/resources/secured-application.properties
index dced51cc9e1..b5462334967 100644
--- a/viewers/webcomponents/sample-vue-petclinic/src/secured/resources/secured-application.properties
+++ b/viewers/webcomponents/sample-vue-petclinic/src/secured/resources/secured-application.properties
@@ -18,6 +18,7 @@
 #
 spring.datasource.url=jdbc:h2:mem:securedVuePetclinic;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;INIT=CREATE SCHEMA IF NOT EXISTS causewayExtSecman
 causeway.security.spring.allow-csrf-filters=true
+causeway.extensions.secman.user-menu-me-action-policy=enable
 causeway.viewer.webcomponents.vue.security.secman.brand=Pet Clinic
 server.servlet.session.cookie.http-only=true
 server.servlet.session.cookie.same-site=lax
diff --git a/viewers/webcomponents/vue-security-secman/README.adoc b/viewers/webcomponents/vue-security-secman/README.adoc
index 5a96367cb03..b10b633b4c6 100644
--- a/viewers/webcomponents/vue-security-secman/README.adoc
+++ b/viewers/webcomponents/vue-security-secman/README.adoc
@@ -101,6 +101,7 @@ Spring migrates the session identifier after login.
 
 The stable Vue shell retains a non-visual native POST logout form carrying the context's CSRF parameter and token.
 Its `menuActionLabel` policy presents exact `causeway.security.LogoutMenu#logout` as **Sign out** in the authorized tertiary semantic menu location, while the typed action policy claims only that identity and submits the form before GraphQL invocation.
+Other tertiary actions remain subject to ordinary menu layout and SecMan authorization, so applications must grant any regular-user or configuration roles needed to preserve controls such as **Me** and **Configuration**.
 The shell renders no separate visible username or logout block.
 Successful logout clears authentication, invalidates the session, applies configured cookie cleanup, and redirects to the signed-out login page.
 A `LocalResourcePathValue` equal to `/logout` remains ordinary safe local-resource navigation and does not acquire authentication semantics.