From 17415b1e16aa7811a551289069cfde17dbcf9b9c Mon Sep 17 00:00:00 2001 From: delchev Date: Wed, 5 Aug 2026 13:41:27 +0300 Subject: [PATCH] feat(security): act as (delegated entry) - an entitled user works the personal surfaces in a chosen identity's name The delegated-data-entry scenario: one manager enters timesheets, leave requests and expenses for workers who do not use computers. Instead of manager-twin UIs per module, an ADMINISTRATOR arms an acting identity for the SESSION and the existing personal machinery serves that person's world: - ActAsFacade (api-security): server-side session override, entitlement re-checked on EVERY read (a revoked role kills it mid-session), arm/disarm audit-logged. Deliberately NOT authentication-level impersonation - roles, security checks and audit stamping stay the REAL user's. - /services/core/actas (core-configurations): GET state (any user - the shells render from it), PUT arm (403 unentitled), DELETE disarm. - SDK User.getEffectiveName(): the acting identity when armed, else the real login - for personal-identity resolution ONLY. - Generated personal controllers (EntityMyController template): me() and /me resolve against the effective user; sensitive scrubbing, forced owner FK and personalReadOnly 403s hold unchanged underneath. - Inbox: the ASSIGNEE task query serves the acting identity's tasks (a personal-assigned submit task becomes completable by the delegate); candidate-group visibility stays the REAL user's roles - groups are never impersonated. CLAIM assigns to the acting identity. - Shells: My shell gains the switcher, a loud persistent banner and one-click exit; the Applications shell gains the 'Enter data as...' entry point that arms and lands in the My shell. en + bg catalog entries. IntentEmissionCoverageIT (outermost layer, session-pinned): arm -> the my list serves the acting person's rows with sensitive still stripped; a delegated POST carries owner = acting person AND CreatedBy = the real user; personalReadOnly still 403s while armed; the Inbox assignee query serves the acting person's personal-assigned confirm task; disarm restores self. Emission: the my controller resolves via User.getEffectiveName(). PERSONALIZATION_PLAN.md phase G. Co-Authored-By: Claude Fable 5 --- PERSONALIZATION_PLAN.md | 1 + .../eclipse/dirigible/sdk/security/User.java | 14 +++ .../components/api/security/ActAsFacade.java | 112 ++++++++++++++++++ components/core/core-configurations/pom.xml | 6 + .../endpoint/ActAsEndpoint.java | 80 +++++++++++++ .../bpm/flowable/config/TaskServiceImpl.java | 6 +- .../flowable/endpoint/BpmInboxEndpoint.java | 5 +- .../application-core/i18n/bg-BG/shell.json | 14 ++- .../application-core/i18n/en-US/shell.json | 14 ++- .../META-INF/dirigible/application/index.html | 31 +++++ .../dirigible/application/js/appShell.js | 34 ++++++ .../META-INF/dirigible/my/index.html | 52 ++++++++ .../META-INF/dirigible/my/js/appShell.js | 40 +++++++ .../api/EntityMyController.java.template | 19 +-- .../tests/api/IntentEmissionCoverageIT.java | 95 +++++++++++++++ 15 files changed, 511 insertions(+), 12 deletions(-) create mode 100644 components/api/api-security/src/main/java/org/eclipse/dirigible/components/api/security/ActAsFacade.java create mode 100644 components/core/core-configurations/src/main/java/org/eclipse/dirigible/components/configurations/endpoint/ActAsEndpoint.java 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: + *

+ */ +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 @@

    + +
    +
    +
    +

    + +
    +
    +

    +
    + + +
    +
    +
    + + +
    +
    +
    +