diff --git a/PERSONALIZATION_PLAN.md b/PERSONALIZATION_PLAN.md
index 271d000028e..48f1aa8a5ba 100644
--- a/PERSONALIZATION_PLAN.md
+++ b/PERSONALIZATION_PLAN.md
@@ -68,6 +68,7 @@ Semantics:
| **D** | Per-user task assignment: `assignee: personal` on a user task → `flowable:assignee="${__personalUser}"`, resolved by the trigger listener through the personal relation + identity mapping and carried IN the start payload (assignee expressions evaluate at task creation, inside Process.start) | **done** |
| **E** | Collection-driven generation: `schedules[].generate.children[]` — one child per element of a source collection (`forEach: {entity, match}` over a LOCAL entity, or `forEach: {days: workingDays}` + `dayField`, defaults incl. typed numeric literals), `parent:` back-FK, nested one more level (line → allocations, depth ≤ 2). Pre-rendered in glue (the expansions convention); the Job template stays shape-only | **done** |
| **F** | Document item dialog honors `readOnly`: read-only columns render as values, not controls (saves already ignored them - the input was fake editability) | **done** |
+| **G** | **Act as (delegated entry)**: an entitled user (ADMINISTRATOR) arms an acting identity for the session (`/services/core/actas`, `ActAsFacade`); the generated personal controllers resolve `me()` against `User.getEffectiveName()` and the Inbox assignee query serves the acting identity's tasks, so a manager fills/submits in a worker's name using the worker's OWN surfaces. Roles, security and audit stamping stay the REAL user's (CreatedBy shows who really entered it); `sensitive`/`personalReadOnly` hold unchanged. Shell UX: My-shell banner + switcher, Applications-shell "Enter data as..." entry point | **done** |
## Flagship application (the consuming suite's timesheets flow)
diff --git a/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/security/User.java b/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/security/User.java
index 07a4503bce2..f2a51358356 100644
--- a/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/security/User.java
+++ b/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/security/User.java
@@ -10,6 +10,7 @@
package org.eclipse.dirigible.sdk.security;
import java.util.Collection;
+import org.eclipse.dirigible.components.api.security.ActAsFacade;
import org.eclipse.dirigible.components.api.security.UserFacade;
/**
@@ -32,6 +33,19 @@ public static String getName() {
return UserFacade.getName();
}
+ /**
+ * The identity PERSONAL surfaces resolve against: the acting identity when an entitled user armed
+ * "act as" (delegated entry - a manager filling a timesheet in a worker's name), else the real
+ * login. Use it ONLY where the question is "whose records/whose tasks" - roles, security checks and
+ * audit stamping stay on {@link #getName()}, so a record entered on behalf of someone always shows
+ * whose it is AND who really entered it.
+ *
+ * @return the effective username for personal-identity resolution
+ */
+ public static String getEffectiveName() {
+ return ActAsFacade.effectiveUser();
+ }
+
public static boolean isInRole(String role) {
return UserFacade.isInRole(role);
}
diff --git a/components/api/api-security/src/main/java/org/eclipse/dirigible/components/api/security/ActAsFacade.java b/components/api/api-security/src/main/java/org/eclipse/dirigible/components/api/security/ActAsFacade.java
new file mode 100644
index 00000000000..10fac547782
--- /dev/null
+++ b/components/api/api-security/src/main/java/org/eclipse/dirigible/components/api/security/ActAsFacade.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2010-2026 Eclipse Dirigible contributors
+ *
+ * All rights reserved. This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.dirigible.components.api.security;
+
+import org.eclipse.dirigible.components.api.http.HttpSessionFacade;
+import org.eclipse.dirigible.components.base.http.roles.Roles;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Delegated entry ("act as"): an entitled user - ADMINISTRATOR - may arm an acting identity for the
+ * CURRENT session and then use the personal ("My") surfaces in that identity's name: the generated
+ * personal controllers resolve their identity mapping against {@link #effectiveUser()} instead of
+ * the raw login, and the Inbox's assignee-task query does the same. Built for the
+ * delegated-data-entry scenario where one manager enters timesheets, leave requests or expenses for
+ * workers who do not use computers.
+ *
+ * Deliberately NOT an authentication-level impersonation:
+ *
+ * - Authentication, roles and role checks always remain the REAL user's -
+ * {@link UserFacade#getName()} and {@link UserFacade#isInRole(String)} are untouched, so audit
+ * columns ({@code CreatedBy}/{@code UpdatedBy}) keep stamping who really performed the write while
+ * the record's owner reference carries the acting identity.
+ * - Only the personal-identity resolution and the Inbox assignee filter read the override.
+ * - The override lives in the server-side HTTP session, never in a client-supplied header, and
+ * the entitlement is re-checked on EVERY read - a revoked role kills the override mid-session.
+ *
+ */
+public final class ActAsFacade {
+
+ private static final Logger logger = LoggerFactory.getLogger(ActAsFacade.class);
+
+ /** The HTTP-session attribute carrying the acting identity's username. */
+ private static final String SESSION_ATTRIBUTE = "dirigible-act-as-user";
+
+ private ActAsFacade() {}
+
+ /**
+ * Whether the CURRENT (real) user may arm an acting identity.
+ *
+ * @return true when the real user carries the entitling role
+ */
+ public static boolean isEntitled() {
+ return UserFacade.isInRole(Roles.RoleNames.ADMINISTRATOR);
+ }
+
+ /**
+ * The armed acting identity, or null when none is armed, the session is not valid, or the real user
+ * is not (or no longer) entitled. The entitlement re-check on every read is what makes a
+ * mid-session role revocation effective immediately.
+ *
+ * @return the acting username or null
+ */
+ public static String actingAs() {
+ if (!HttpSessionFacade.isValid() || !isEntitled()) {
+ return null;
+ }
+ String acting = HttpSessionFacade.getAttribute(SESSION_ATTRIBUTE);
+ return acting == null || acting.isBlank() ? null : acting;
+ }
+
+ /**
+ * The identity personal surfaces should resolve against: the armed acting identity when present,
+ * else the real login. This - and ONLY this - is what the generated personal controllers and the
+ * Inbox assignee query consume; everything else stays on {@link UserFacade#getName()}.
+ *
+ * @return the effective username
+ */
+ public static String effectiveUser() {
+ String acting = actingAs();
+ return acting != null ? acting : UserFacade.getName();
+ }
+
+ /**
+ * Arms the acting identity for the current session. Audit-logged.
+ *
+ * @param username the acting identity's username (the identity record's mapped value, e.g. the
+ * employee's e-mail)
+ * @throws SecurityException when the real user is not entitled
+ * @throws IllegalArgumentException on a blank username
+ */
+ public static void arm(String username) {
+ if (username == null || username.isBlank()) {
+ throw new IllegalArgumentException("The acting identity's username must not be blank");
+ }
+ if (!isEntitled()) {
+ throw new SecurityException("User [" + UserFacade.getName() + "] is not entitled to act as another identity");
+ }
+ String acting = username.trim();
+ HttpSessionFacade.setAttribute(SESSION_ATTRIBUTE, acting);
+ logger.info("Act-as ARMED: [{}] now acts as [{}] for this session", UserFacade.getName(), acting);
+ }
+
+ /** Disarms the acting identity for the current session. Audit-logged. */
+ public static void disarm() {
+ if (!HttpSessionFacade.isValid()) {
+ return;
+ }
+ String acting = HttpSessionFacade.getAttribute(SESSION_ATTRIBUTE);
+ HttpSessionFacade.removeAttribute(SESSION_ATTRIBUTE);
+ if (acting != null && !acting.isBlank()) {
+ logger.info("Act-as DISARMED: [{}] no longer acts as [{}]", UserFacade.getName(), acting);
+ }
+ }
+}
diff --git a/components/core/core-configurations/pom.xml b/components/core/core-configurations/pom.xml
index 8dad0d5c651..402a216615c 100644
--- a/components/core/core-configurations/pom.xml
+++ b/components/core/core-configurations/pom.xml
@@ -27,6 +27,12 @@
dirigible-components-data-sources
+
+
+ org.eclipse.dirigible
+ dirigible-components-api-security
+
+
diff --git a/components/core/core-configurations/src/main/java/org/eclipse/dirigible/components/configurations/endpoint/ActAsEndpoint.java b/components/core/core-configurations/src/main/java/org/eclipse/dirigible/components/configurations/endpoint/ActAsEndpoint.java
new file mode 100644
index 00000000000..4c8879e84e3
--- /dev/null
+++ b/components/core/core-configurations/src/main/java/org/eclipse/dirigible/components/configurations/endpoint/ActAsEndpoint.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2010-2026 Eclipse Dirigible contributors
+ *
+ * All rights reserved. This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.dirigible.components.configurations.endpoint;
+
+import org.eclipse.dirigible.components.api.security.ActAsFacade;
+import org.eclipse.dirigible.components.base.endpoint.BaseEndpoint;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+
+/**
+ * The act-as (delegated entry) session control: arm/disarm/report the acting identity behind
+ * {@link ActAsFacade}. GET is open to every authenticated user (the shells ask "am I entitled, am I
+ * armed" to decide what to render); arming is entitlement-gated in the facade itself and never
+ * trusts anything but the server-side session.
+ */
+@RestController
+@RequestMapping(BaseEndpoint.PREFIX_ENDPOINT_CORE + "actas")
+public class ActAsEndpoint extends BaseEndpoint {
+
+ /** The state the shells render from: may this user arm at all, and who is armed right now. */
+ public record ActAsState(boolean entitled, String actingAs) {
+ }
+
+ /** The arm request: the acting identity's username (e.g. the employee's e-mail). */
+ public record ArmRequest(String username) {
+ }
+
+ /**
+ * The current session's act-as state.
+ *
+ * @return entitled + the armed acting identity (null when none)
+ */
+ @GetMapping
+ public ResponseEntity state() {
+ return ResponseEntity.ok(new ActAsState(ActAsFacade.isEntitled(), ActAsFacade.actingAs()));
+ }
+
+ /**
+ * Arms the acting identity for the current session.
+ *
+ * @param request the acting identity's username
+ * @return the new state
+ */
+ @PutMapping
+ public ResponseEntity arm(@RequestBody ArmRequest request) {
+ try {
+ ActAsFacade.arm(request == null ? null : request.username());
+ } catch (SecurityException e) {
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN, e.getMessage());
+ } catch (IllegalArgumentException e) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
+ }
+ return ResponseEntity.ok(new ActAsState(ActAsFacade.isEntitled(), ActAsFacade.actingAs()));
+ }
+
+ /**
+ * Disarms the acting identity for the current session.
+ *
+ * @return the new state
+ */
+ @DeleteMapping
+ public ResponseEntity disarm() {
+ ActAsFacade.disarm();
+ return ResponseEntity.ok(new ActAsState(ActAsFacade.isEntitled(), ActAsFacade.actingAs()));
+ }
+}
diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/TaskServiceImpl.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/TaskServiceImpl.java
index 39589f40f96..6328ee98187 100644
--- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/TaskServiceImpl.java
+++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/TaskServiceImpl.java
@@ -10,6 +10,7 @@
package org.eclipse.dirigible.components.engine.bpm.flowable.config;
import org.eclipse.dirigible.commons.api.helpers.GsonHelper;
+import org.eclipse.dirigible.components.api.security.ActAsFacade;
import org.eclipse.dirigible.components.api.security.UserFacade;
import org.eclipse.dirigible.components.base.tenant.Tenant;
import org.eclipse.dirigible.components.base.tenant.TenantContext;
@@ -208,9 +209,12 @@ private TaskInfoQuery prepareQuery(PrincipalType type) {
TaskQuery taskQuery = flowableTaskService.createTaskQuery()
.taskTenantId(getTenantId());
if (PrincipalType.CANDIDATE_GROUPS.equals(type)) {
+ // roles/groups are NEVER impersonated - candidate visibility stays the REAL user's
return taskQuery.taskCandidateGroupIn(UserFacade.getUserRoles());
} else if (PrincipalType.ASSIGNEE.equals(type)) {
- return taskQuery.taskAssignee(UserFacade.getName());
+ // assignee = the effective identity: under act-as (delegated entry) the Inbox serves
+ // the acting identity's own tasks, e.g. a personal-assigned submit task
+ return taskQuery.taskAssignee(ActAsFacade.effectiveUser());
} else {
throw new IllegalArgumentException("Unrecognised principal type: " + type);
}
diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/endpoint/BpmInboxEndpoint.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/endpoint/BpmInboxEndpoint.java
index 15b80ccc75c..e0021172765 100644
--- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/endpoint/BpmInboxEndpoint.java
+++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/endpoint/BpmInboxEndpoint.java
@@ -9,6 +9,7 @@
*/
package org.eclipse.dirigible.components.engine.bpm.flowable.endpoint;
+import org.eclipse.dirigible.components.api.security.ActAsFacade;
import org.eclipse.dirigible.components.api.security.UserFacade;
import org.eclipse.dirigible.components.base.endpoint.BaseEndpoint;
import org.eclipse.dirigible.components.engine.bpm.flowable.dto.ProcessInstanceData;
@@ -123,7 +124,9 @@ public ResponseEntity executeTaskAction(@PathVariable("id") String taskI
if (CLAIM.getActionName()
.equals(actionData.getAction())) {
- bpmService.claimTask(taskId, UserFacade.getName());
+ // under act-as (delegated entry) a claim assigns the task to the ACTING identity, so
+ // the flow's record of who owns the step matches whose work it is
+ bpmService.claimTask(taskId, ActAsFacade.effectiveUser());
} else if (UNCLAIM.getActionName()
.equals(actionData.getAction())) {
bpmService.unclaimTask(taskId);
diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/i18n/bg-BG/shell.json b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/i18n/bg-BG/shell.json
index 8c360d7e52b..5fda8388497 100644
--- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/i18n/bg-BG/shell.json
+++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/i18n/bg-BG/shell.json
@@ -18,6 +18,18 @@
"user": "Потребител",
"logout": "Изход"
},
+ "actAs": {
+ "start": "Действай като...",
+ "enter": "Въвеждай от името на...",
+ "stop": "Спри да действаш като",
+ "title": "Действай от името на друг човек",
+ "hint": "Неговите отчети, заявки и задачи се отварят като негови собствени; всеки запис се води на негово име, а журналът пази вашето.",
+ "username": "Имейл за вход на човека",
+ "startShort": "Започни",
+ "bannerActing": "Действате като",
+ "bannerNote": "записите се водят на името на този човек; журналът пази вашето",
+ "exit": "Изход"
+ },
"notifications": {
"title": "Известия",
"caughtUp": "Нямате нови известия.",
@@ -134,4 +146,4 @@
"addNew": "Добави нов"
}
}
-}
\ No newline at end of file
+}
diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/i18n/en-US/shell.json b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/i18n/en-US/shell.json
index 821689315a2..e5f51d27bfe 100644
--- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/i18n/en-US/shell.json
+++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/i18n/en-US/shell.json
@@ -18,6 +18,18 @@
"user": "User",
"logout": "Log out"
},
+ "actAs": {
+ "start": "Act as...",
+ "enter": "Enter data as...",
+ "stop": "Stop acting as",
+ "title": "Act as another person",
+ "hint": "Their timesheets, requests and inbox tasks open as their own; every entry is recorded in their name, and the audit log keeps yours.",
+ "username": "Person's login e-mail",
+ "startShort": "Start",
+ "bannerActing": "Acting as",
+ "bannerNote": "entries are recorded in this person's name; the log keeps yours",
+ "exit": "Exit"
+ },
"notifications": {
"title": "Notifications",
"caughtUp": "You're all caught up.",
@@ -134,4 +146,4 @@
"addNew": "Add new"
}
}
-}
\ No newline at end of file
+}
diff --git a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html
index 1ec9fba8c87..3232c29b0a8 100644
--- a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html
+++ b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html
@@ -236,6 +236,13 @@
+
+
+
+
+
+
@@ -287,6 +294,30 @@
+
+
+
diff --git a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js
index 9210fa29edb..4f257cae240 100644
--- a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js
+++ b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js
@@ -329,7 +329,41 @@ document.addEventListener('alpine:init', () => {
return locale.languages().map(code => ({ value: code, text: locale.displayName(code) }));
},
+ // ---- Act as (delegated entry) - the Applications-shell entry point -------------------
+ // An entitled user (ADMINISTRATOR) arms an acting identity here and lands in the My shell
+ // as that person - the manager-does-the-entry mode. Server-side session + entitlement.
+ actAs: { entitled: false, acting: null },
+ actAsDialog: false,
+ actAsInput: '',
+ async loadActAs() {
+ try {
+ const res = await fetch('/services/core/actas', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' });
+ if (res.ok) {
+ const s = await res.json();
+ this.actAs = { entitled: !!s.entitled, acting: s.actingAs || null };
+ }
+ } catch (e) {
+ console.error('Failed to load the act-as state', e);
+ }
+ },
+ async armActAs() {
+ const username = (this.actAsInput || '').trim();
+ if (!username) return;
+ const res = await fetch('/services/core/actas', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ username }),
+ });
+ if (res.ok) {
+ window.location.href = '/services/web/my/';
+ } else {
+ console.error('Failed to arm act-as', res.status);
+ }
+ },
+
async init() {
+ this.loadActAs(); // fire-and-forget: the menu entry appears when the state arrives
const projectionsLoaded = this.loadProjections();
try {
const res = await fetch(PERSPECTIVES_URL, { headers: { 'Accept': 'application/json' } });
diff --git a/components/resources/resources-my/src/main/resources/META-INF/dirigible/my/index.html b/components/resources/resources-my/src/main/resources/META-INF/dirigible/my/index.html
index dddaf61fe66..f9a534d9635 100644
--- a/components/resources/resources-my/src/main/resources/META-INF/dirigible/my/index.html
+++ b/components/resources/resources-my/src/main/resources/META-INF/dirigible/my/index.html
@@ -211,12 +211,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -262,6 +291,29 @@
+
+
+
diff --git a/components/resources/resources-my/src/main/resources/META-INF/dirigible/my/js/appShell.js b/components/resources/resources-my/src/main/resources/META-INF/dirigible/my/js/appShell.js
index bfe73e014a8..ffce630e17a 100644
--- a/components/resources/resources-my/src/main/resources/META-INF/dirigible/my/js/appShell.js
+++ b/components/resources/resources-my/src/main/resources/META-INF/dirigible/my/js/appShell.js
@@ -190,7 +190,47 @@ document.addEventListener('alpine:init', () => {
return locale.languages().map(code => ({ value: code, text: locale.displayName(code) }));
},
+ // ---- Act as (delegated entry) ---------------------------------------------------------
+ // An entitled user (ADMINISTRATOR) arms an acting identity; while armed, the personal
+ // surfaces and the Inbox serve THAT person's world - the manager-does-the-entry mode. The
+ // state is server-side session; arming/exiting reloads so every page and hosted app
+ // re-resolves under the new identity. Entitlement + audit live server-side.
+ actAs: { entitled: false, acting: null },
+ actAsDialog: false,
+ actAsInput: '',
+ async loadActAs() {
+ try {
+ const res = await fetch('/services/core/actas', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' });
+ if (res.ok) {
+ const s = await res.json();
+ this.actAs = { entitled: !!s.entitled, acting: s.actingAs || null };
+ }
+ } catch (e) {
+ console.error('Failed to load the act-as state', e);
+ }
+ },
+ async armActAs() {
+ const username = (this.actAsInput || '').trim();
+ if (!username) return;
+ const res = await fetch('/services/core/actas', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ username }),
+ });
+ if (res.ok) {
+ window.location.reload();
+ } else {
+ console.error('Failed to arm act-as', res.status);
+ }
+ },
+ async disarmActAs() {
+ const res = await fetch('/services/core/actas', { method: 'DELETE', credentials: 'same-origin' });
+ if (res.ok) window.location.reload();
+ },
+
async init() {
+ this.loadActAs(); // fire-and-forget: the banner/menu appear when the state arrives
const projectionsLoaded = this.loadProjections();
try {
const res = await fetch(PERSPECTIVES_URL, { headers: { 'Accept': 'application/json' } });
diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template
index c285d9f455d..49c03bc0c5c 100644
--- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template
+++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template
@@ -186,11 +186,12 @@ public class ${name}MyController {
/**
* The current user's identity record id: the ${personalIdentityProperty} of the identity entity
- * matched against the logged-in username. Null when no record maps (the personal surface is then
- * empty, never an error).
+ * matched against the EFFECTIVE username - the acting identity when an entitled user armed
+ * "act as" (delegated entry), else the real login. Null when no record maps (the personal
+ * surface is then empty, never an error). Audit stamping stays on the real login.
*/
private #if($personalParent)${personalParent.personalFkJavaClass}#else${personalFkJavaClass}#end me() {
- String username = User.getName();
+ String username = User.getEffectiveName();
if (username == null || username.isBlank()) {
return null;
}
@@ -199,15 +200,17 @@ public class ${name}MyController {
}
/**
- * The logged-in user's own display name (the identity record's ${personalIdentityLabel}), for the
- * personal pages' "New/Edit ... for <owner>" title. Falls back to the username when no
- * identity record maps. Exposes only the display name - never the whole identity record.
+ * The EFFECTIVE user's own display name (the identity record's ${personalIdentityLabel}), for
+ * the personal pages' "New/Edit ... for <owner>" title - under "act as" this is the
+ * acting identity's name, which is exactly what the pages must show. Falls back to the
+ * username when no identity record maps. Exposes only the display name - never the whole
+ * identity record.
*/
@Get("/me")
- @Documentation("The logged-in user's own display name")
+ @Documentation("The effective user's own display name")
public Map currentIdentity() {
Map result = new LinkedHashMap<>();
- String username = User.getName();
+ String username = User.getEffectiveName();
result.put("name", username);
if (username != null && !username.isBlank()) {
var matches = identityRepository.findAll(Criteria.create().eq("${personalIdentityProperty}", username));
diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java
index 31a6a9a0aa1..cf0c3c2713b 100644
--- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java
+++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java
@@ -223,7 +223,10 @@ class IntentEmissionCoverageIT extends IntegrationTest {
# period is a month field: YYYY-MM string storage, month-picker widget on EVERY
# writable surface (power + my), a |format label token rendering "2026 July", and
# the schedule's `Period: now` below emitting the string shape (not LocalDate).
+ # audit: the act-as assertions below prove a delegated write carries the ACTING
+ # owner while CreatedBy keeps the REAL user.
- name: Claim
+ audit: true
label: "{note} ({Person.name}) {period|yyyy MMMM}"
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
@@ -936,6 +939,10 @@ private void assertEmission() {
assertTrue(claimMy.contains("eq(\"Email\", username)"), "personal must emit the identity match against the logged-in username");
assertTrue(claimMy.contains("entity.Rate = null"), "sensitive must emit the response scrub in the personal controller");
assertTrue(claimMy.contains("entity.Person = me"), "personal must force the owner FK server-side on create");
+ // act-as (delegated entry): the identity resolution reads the EFFECTIVE user, so an armed
+ // acting identity redirects the personal surface while audit stamping stays on getName().
+ assertTrue(claimMy.contains("User.getEffectiveName()"),
+ "the personal identity resolution must read the effective (act-as aware) user");
// Auto-sensitive derivation (U5 class): totalCost is NOT authored sensitive, but it sums the
// sensitive ClaimLine.cost into the personal-rooted Claim - the parser must propagate the
// flag so the total is scrubbed from the personal wire exactly like the leaf value.
@@ -1801,6 +1808,94 @@ private void assertRuntimeEnforcement() {
.statusCode(200)
.body("$", hasSize(1)));
+ // ---- Act as (delegated entry): an entitled user arms an acting identity for the SESSION
+ // and the personal surfaces serve THAT person's world - the manager-does-the-entry mode.
+ // The override lives in the server-side session, so the sequence pins one session.
+ restAssuredExecutor.execute(() -> {
+ io.restassured.filter.session.SessionFilter session = new io.restassured.filter.session.SessionFilter();
+ given().filter(session)
+ .when()
+ .get("/services/core/actas")
+ .then()
+ .statusCode(200)
+ .body("entitled", equalTo(true))
+ .body("actingAs", nullValue());
+ given().filter(session)
+ .contentType("application/json")
+ .body("{\"username\":\"other@example.com\"}")
+ .when()
+ .put("/services/core/actas")
+ .then()
+ .statusCode(200)
+ .body("actingAs", equalTo("other@example.com"));
+ // The my list now serves the ACTING person's rows - and the sensitive strip still holds.
+ given().filter(session)
+ .when()
+ .get(API + "/claim/ClaimMyController")
+ .then()
+ .statusCode(200)
+ .body("findAll { it.Person != 2 }.size()", equalTo(0))
+ .body("findAll { it.Note == 'foreign' }.size()", equalTo(1))
+ .body("[0].Rate", nullValue());
+ // A write goes under the ACTING identity, while the audit column keeps the REAL user -
+ // the record shows whose it is AND who really entered it.
+ given().filter(session)
+ .contentType("application/json")
+ .body("{\"Note\":\"delegated\",\"Rate\":123}")
+ .when()
+ .post(API + "/claim/ClaimMyController")
+ .then()
+ .statusCode(200)
+ .body("Person", equalTo(2))
+ .body("Rate", nullValue())
+ .body("CreatedBy", equalTo("admin"));
+ // personalReadOnly still refuses writes - acting as the owner does not grant authoring.
+ given().filter(session)
+ .contentType("application/json")
+ .body("{\"Days\":5}")
+ .when()
+ .post(API + "/balance/BalanceMyController")
+ .then()
+ .statusCode(403);
+ // Disarm restores self - the my list is the real user's again.
+ given().filter(session)
+ .when()
+ .delete("/services/core/actas")
+ .then()
+ .statusCode(200)
+ .body("actingAs", nullValue());
+ given().filter(session)
+ .when()
+ .get(API + "/claim/ClaimMyController")
+ .then()
+ .statusCode(200)
+ .body("findAll { it.Person != 1 }.size()", equalTo(0));
+ });
+ // While armed, the Inbox's assignee query serves the ACTING person's personal-assigned
+ // tasks - the delegated claim's confirm task, which the real user could never see before.
+ // Retried (the task spawns off the create event); every step here is idempotent.
+ restAssuredExecutor.execute(() -> {
+ io.restassured.filter.session.SessionFilter session = new io.restassured.filter.session.SessionFilter();
+ given().filter(session)
+ .contentType("application/json")
+ .body("{\"username\":\"other@example.com\"}")
+ .when()
+ .put("/services/core/actas")
+ .then()
+ .statusCode(200);
+ given().filter(session)
+ .when()
+ .get("/services/inbox/tasks?type=assigned")
+ .then()
+ .statusCode(200)
+ .body("findAll { it.assignee == 'other@example.com' }.size()", greaterThanOrEqualTo(1));
+ given().filter(session)
+ .when()
+ .delete("/services/core/actas")
+ .then()
+ .statusCode(200);
+ }, 30);
+
// My Shell (phase C): the shell page is served and aggregates the published personal
// perspective through the application-personal-perspectives extension point.
restAssuredExecutor.execute(() -> given().when()