Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PERSONALIZATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* Deliberately NOT an authentication-level impersonation:
* <ul>
* <li>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.</li>
* <li>Only the personal-identity resolution and the Inbox assignee filter read the override.</li>
* <li>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.</li>
* </ul>
*/
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);

Check warning

Code scanning / CodeQL

Log Injection Medium

This log entry depends on a
user-provided value
.
This log entry depends on a
user-provided value
.
}

/** 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);

Check warning

Code scanning / CodeQL

Log Injection Medium

This log entry depends on a
user-provided value
.
This log entry depends on a
user-provided value
.
}
}
}
6 changes: 6 additions & 0 deletions components/core/core-configurations/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@
<artifactId>dirigible-components-data-sources</artifactId>
</dependency>

<!-- Security API - the act-as (delegated entry) session facade behind /services/core/actas -->
<dependency>
<groupId>org.eclipse.dirigible</groupId>
<artifactId>dirigible-components-api-security</artifactId>
</dependency>

</dependencies>

<properties>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ActAsState> 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<ActAsState> 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<ActAsState> disarm() {
ActAsFacade.disarm();
return ResponseEntity.ok(new ActAsState(ActAsFacade.isEntitled(), ActAsFacade.actingAs()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -208,9 +209,12 @@ private TaskInfoQuery<TaskQuery, Task> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -123,7 +124,9 @@ public ResponseEntity<String> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@
"user": "Потребител",
"logout": "Изход"
},
"actAs": {
"start": "Действай като...",
"enter": "Въвеждай от името на...",
"stop": "Спри да действаш като",
"title": "Действай от името на друг човек",
"hint": "Неговите отчети, заявки и задачи се отварят като негови собствени; всеки запис се води на негово име, а журналът пази вашето.",
"username": "Имейл за вход на човека",
"startShort": "Започни",
"bannerActing": "Действате като",
"bannerNote": "записите се водят на името на този човек; журналът пази вашето",
"exit": "Изход"
},
"notifications": {
"title": "Известия",
"caughtUp": "Нямате нови известия.",
Expand Down Expand Up @@ -134,4 +146,4 @@
"addNew": "Добави нов"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -134,4 +146,4 @@
"addNew": "Add new"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,13 @@ <h1 x-h-notification-title x-text="n.title"></h1>
</div>
</div>
<div x-h-menu-separator></div>
<!-- Act as (delegated entry) - the Applications-shell entry point: arm the acting
identity here, then land in the My shell as that person (where the banner rules). -->
<template x-if="actAs.entitled">
<li x-h-menu-item @click="actAsDialog = true">
<i role="img" x-h-lucide data-lucide="user-round-cog"></i> <span x-text="T('application-core:shell.actAs.enter', 'Enter data as...')"></span>
</li>
</template>
<li x-h-menu-item data-variant="negative" @click="$store.currentUser.logout()">
<i role="img" x-h-lucide data-lucide="log-out"></i> <span x-text="T('application-core:shell.user.logout', 'Log out')"></span>
</li>
Expand Down Expand Up @@ -287,6 +294,30 @@ <h2 x-h-dialog-title x-text="$store.customActions.dialogTitle"></h2>
</div>
</div>

<!-- Act-as (delegated entry) dialog: arm the acting identity and land in the My shell as that
person. The identity value is the person's mapped username (their e-mail); entitlement is
enforced server-side. -->
<div x-h-dialog-overlay :data-open="actAsDialog">
<div x-h-dialog style="max-width: 460px;">
<div x-h-dialog-header>
<h2 x-h-dialog-title x-text="T('application-core:shell.actAs.title', 'Act as another person')"></h2>
<button x-h-dialog-close @click="actAsDialog = false" aria-label="Close"><i role="img" x-h-lucide data-lucide="x"></i></button>
</div>
<div x-h-dialog-content class="vbox gap-2">
<p class="text-sm" x-h-text.muted
x-text="T('application-core:shell.actAs.hint', 'Their timesheets, requests and inbox tasks open as their own; every entry is recorded in their name, and the audit log keeps yours.')"></p>
<div x-h-field>
<label x-h-label for="actas-username" x-text="T('application-core:shell.actAs.username', 'Person\'s login e-mail')"></label>
<input x-h-input type="text" id="actas-username" x-model="actAsInput" @keydown.enter="armActAs()" />
</div>
</div>
<div x-h-dialog-footer>
<button x-h-button data-variant="transparent" @click="actAsDialog = false" x-text="T('application-core:shell.defaults.cancel', 'Cancel')"></button>
<button x-h-button data-variant="primary" @click="armActAs()" x-text="T('application-core:shell.actAs.startShort', 'Start')"></button>
</div>
</div>
</div>

<!-- App-wide transient toasts (raised through Harmonia's notifications magic with this template). -->
<section x-h-notification-overlay>
<template id="toast">
Expand Down
Loading
Loading