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 @@ -69,6 +69,7 @@ Semantics:
| **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: Personal-shell banner + switcher, Applications-shell "Enter data as..." entry point | **done** |
| **G2** | **A forgotten arming can no longer hide the real identity** (#6694). The armed state **expires** on its own after `DIRIGIBLE_ACT_AS_TTL_SECONDS` (30 min; the window is absolute — activity never renews it, or an Inbox poll would keep it alive forever). A **claim** stamps the acting identity only for a task addressed to that person as a candidate user; a back-office group task reached through the REAL user's roles is claimed by the real user, so an approval is never attributed to someone who never made it (and never stranded on an identity the group query has just lost sight of). The **Inbox** states who is armed and how many of the real user's own tasks the arming hides (`GET /services/inbox/act-as`) instead of rendering an indistinguishable empty list, and the **Applications shell** carries the same banner + exit the Personal shell has — the armed identity follows the session into the back office, so its absence there is what let an arming be forgotten | **done** |

## Flagship application (the consuming suite's timesheets flow)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/
package org.eclipse.dirigible.components.api.security;

import org.eclipse.dirigible.commons.config.DirigibleConfig;
import org.eclipse.dirigible.components.api.http.HttpSessionFacade;
import org.eclipse.dirigible.components.base.http.roles.Roles;
import org.slf4j.Logger;
Expand All @@ -31,6 +32,12 @@
* <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>
* <li>The armed state EXPIRES on its own after {@link DirigibleConfig#ACT_AS_TTL_SECONDS} (30
* minutes by default). The window is absolute - it starts at arming and no activity renews it -
* because the failure mode this guards against is a state that is never exited: while armed, the
* personal surfaces and the Inbox's assignee query serve the acting identity's world instead of the
* real user's, and a forgotten override makes the real user's own tasks look like they were never
* raised.</li>
* </ul>
*/
public final class ActAsFacade {
Expand All @@ -40,6 +47,9 @@ public final class ActAsFacade {
/** The HTTP-session attribute carrying the acting identity's username. */
private static final String SESSION_ATTRIBUTE = "dirigible-act-as-user";

/** The HTTP-session attribute carrying the epoch-milliseconds the identity was armed at. */
private static final String SESSION_ATTRIBUTE_ARMED_AT = "dirigible-act-as-armed-at";

private ActAsFacade() {}

/**
Expand All @@ -52,9 +62,10 @@ public static boolean isEntitled() {
}

/**
* 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.
* The armed acting identity, or null when none is armed, the session is not valid, the real user is
* not (or no longer) entitled, or the delegated-entry window has elapsed. The entitlement re-check
* on every read is what makes a mid-session role revocation effective immediately; the expiry check
* is what makes a forgotten arming harmless.
*
* @return the acting username or null
*/
Expand All @@ -63,7 +74,64 @@ public static String actingAs() {
return null;
}
String acting = HttpSessionFacade.getAttribute(SESSION_ATTRIBUTE);
return acting == null || acting.isBlank() ? null : acting;
if (acting == null || acting.isBlank()) {
return null;
}
if (isExpired(HttpSessionFacade.getAttribute(SESSION_ATTRIBUTE_ARMED_AT), System.currentTimeMillis())) {
clear();
logger.info("Act-as EXPIRED: [{}] no longer acts as [{}] - the {}s delegated-entry window has elapsed", UserFacade.getName(),
acting, ttlSeconds());
return null;
}
return acting;
}

/**
* When the currently armed state expires, or null when nothing is armed.
*
* @return the expiry as epoch milliseconds, or null
*/
public static Long expiresAt() {
if (actingAs() == null) {
return null;
}
Long armedAt = armedAt(HttpSessionFacade.getAttribute(SESSION_ATTRIBUTE_ARMED_AT));
return armedAt == null ? null : armedAt + ttlSeconds() * 1000L;
}

/**
* Whether an arming stamped at the given attribute value has run out. Fails CLOSED: a missing or
* unparsable stamp is treated as expired, because an armed state we cannot date is an armed state
* we cannot trust to end.
*
* @param armedAtAttribute the raw session attribute value
* @param now the current epoch milliseconds
* @return true when the state must be dropped
*/
static boolean isExpired(String armedAtAttribute, long now) {
Long armedAt = armedAt(armedAtAttribute);
return armedAt == null || now - armedAt >= ttlSeconds() * 1000L;
}

private static Long armedAt(String armedAtAttribute) {
if (armedAtAttribute == null || armedAtAttribute.isBlank()) {
return null;
}
try {
return Long.valueOf(armedAtAttribute.trim());
} catch (NumberFormatException e) {
logger.warn("Act-as arming timestamp [{}] is not a number - treating the state as expired", armedAtAttribute, e);
return null;
}
}

private static int ttlSeconds() {
return DirigibleConfig.ACT_AS_TTL_SECONDS.getIntValue();
}

private static void clear() {
HttpSessionFacade.removeAttribute(SESSION_ATTRIBUTE);
HttpSessionFacade.removeAttribute(SESSION_ATTRIBUTE_ARMED_AT);
}

/**
Expand Down Expand Up @@ -95,7 +163,8 @@ public static void arm(String username) {
}
String acting = username.trim();
HttpSessionFacade.setAttribute(SESSION_ATTRIBUTE, acting);
logger.info("Act-as ARMED: [{}] now acts as [{}] for this session", UserFacade.getName(), acting);
HttpSessionFacade.setAttribute(SESSION_ATTRIBUTE_ARMED_AT, Long.toString(System.currentTimeMillis()));
logger.info("Act-as ARMED: [{}] now acts as [{}] for the next {}s", UserFacade.getName(), acting, ttlSeconds());
}

/** Disarms the acting identity for the current session. Audit-logged. */
Expand All @@ -104,7 +173,7 @@ public static void disarm() {
return;
}
String acting = HttpSessionFacade.getAttribute(SESSION_ATTRIBUTE);
HttpSessionFacade.removeAttribute(SESSION_ATTRIBUTE);
clear();
if (acting != null && !acting.isBlank()) {
logger.info("Act-as DISARMED: [{}] no longer acts as [{}]", UserFacade.getName(), acting);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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 static org.assertj.core.api.Assertions.assertThat;

import org.eclipse.dirigible.commons.config.DirigibleConfig;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

/**
* The expiry decision behind the act-as (delegated entry) window: an arming that is never exited
* must stop being honoured on its own (#6694).
*/
class ActAsFacadeTest {

private static final long NOW = 1_800_000_000_000L;

@AfterEach
void restoreTheDefaultWindow() {
DirigibleConfig.ACT_AS_TTL_SECONDS.setStringValue(DirigibleConfig.ACT_AS_TTL_SECONDS.getDefaultValue());
}

@Test
void a_fresh_arming_is_honoured() {
assertThat(ActAsFacade.isExpired(Long.toString(NOW - 60_000), NOW)).isFalse();
}

@Test
void an_arming_older_than_the_window_is_dropped() {
long defaultWindowMillis = Integer.parseInt(DirigibleConfig.ACT_AS_TTL_SECONDS.getDefaultValue()) * 1000L;

assertThat(ActAsFacade.isExpired(Long.toString(NOW - defaultWindowMillis - 1), NOW)).isTrue();
}

@Test
void the_window_follows_the_configuration() {
DirigibleConfig.ACT_AS_TTL_SECONDS.setStringValue("60");

assertThat(ActAsFacade.isExpired(Long.toString(NOW - 59_000), NOW)).isFalse();
assertThat(ActAsFacade.isExpired(Long.toString(NOW - 61_000), NOW)).isTrue();
}

/** Fails closed: an armed state we cannot date is an armed state we cannot trust to end. */
@Test
void an_undatable_arming_is_dropped() {
assertThat(ActAsFacade.isExpired(null, NOW)).isTrue();
assertThat(ActAsFacade.isExpired(" ", NOW)).isTrue();
assertThat(ActAsFacade.isExpired("not-a-timestamp", NOW)).isTrue();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,15 @@
@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 state the shells render from: may this user arm at all, who is armed right now, and when that
* arming expires on its own (epoch milliseconds; null when nothing is armed).
*/
public record ActAsState(boolean entitled, String actingAs, Long expiresAt) {

static ActAsState current() {
return new ActAsState(ActAsFacade.isEntitled(), ActAsFacade.actingAs(), ActAsFacade.expiresAt());
}
}

/** The arm request: the acting identity's username (e.g. the employee's e-mail). */
Expand All @@ -42,11 +49,11 @@ public record ArmRequest(String username) {
/**
* The current session's act-as state.
*
* @return entitled + the armed acting identity (null when none)
* @return entitled + the armed acting identity (null when none) + its expiry
*/
@GetMapping
public ResponseEntity<ActAsState> state() {
return ResponseEntity.ok(new ActAsState(ActAsFacade.isEntitled(), ActAsFacade.actingAs()));
return ResponseEntity.ok(ActAsState.current());
}

/**
Expand All @@ -64,7 +71,7 @@ public ResponseEntity<ActAsState> arm(@RequestBody ArmRequest request) {
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
return ResponseEntity.ok(new ActAsState(ActAsFacade.isEntitled(), ActAsFacade.actingAs()));
return ResponseEntity.ok(ActAsState.current());
}

/**
Expand All @@ -75,6 +82,6 @@ public ResponseEntity<ActAsState> arm(@RequestBody ArmRequest request) {
@DeleteMapping
public ResponseEntity<ActAsState> disarm() {
ActAsFacade.disarm();
return ResponseEntity.ok(new ActAsState(ActAsFacade.isEntitled(), ActAsFacade.actingAs()));
return ResponseEntity.ok(ActAsState.current());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@ public interface TaskService {

List<Task> findTasks(PrincipalType type);

/**
* Counts the tenant's tasks assigned to the given user, whoever is asking - unlike
* {@link #findTasks(PrincipalType)}, which serves the caller's own (act-as aware) world.
*
* @param assignee the assignee to count for
* @return the number of tasks assigned to that user
*/
long countTasksByAssignee(String assignee);

void completeTask(String taskId, Map<String, Object> variables);

List<IdentityLink> getTaskIdentityLinks(String taskId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,14 @@ public List<Task> findTasks(PrincipalType type) {
return taskQuery.list();
}

@Override
public long countTasksByAssignee(String assignee) {
return flowableTaskService.createTaskQuery()
.taskTenantId(getTenantId())
.taskAssignee(assignee)
.count();
}

@Override
public List<IdentityLink> getTaskIdentityLinks(String taskId) {
flowableArtefactsValidator.validateTask(taskId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,7 @@ public ResponseEntity<String> executeTaskAction(@PathVariable("id") String taskI

if (CLAIM.getActionName()
.equals(actionData.getAction())) {
// 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());
bpmService.claimTask(taskId, claimantFor(taskId));
} else if (UNCLAIM.getActionName()
.equals(actionData.getAction())) {
bpmService.unclaimTask(taskId);
Expand All @@ -145,6 +143,53 @@ public ResponseEntity<String> executeTaskAction(@PathVariable("id") String taskI
.build();
}

/**
* Who a claim assigns the task to. Under act-as (delegated entry) that is the ACTING identity, but
* ONLY for a task addressed to that person - one where they are a candidate user, i.e. their own
* work the delegate is entering on their behalf. A task the caller reached through their OWN roles
* (a back-office group task: approve, issue, send) is claimed by the REAL user, whatever is armed:
* stamping the acted-as person there attributes a decision to someone who never made it, and
* strands the task on an identity whose group-candidate visibility the claim just removed it from.
*
* @param taskId the task about to be claimed
* @return the username to claim for
*/
private String claimantFor(String taskId) {
String realUser = UserFacade.getName();
String acting = ActAsFacade.actingAs();
if (acting == null) {
return realUser;
}
boolean addressedToActingIdentity = bpmService.getTaskIdentityLinks(taskId)
.stream()
.map(IdentityLinkInfo::getUserId)
.anyMatch(acting::equals);
if (addressedToActingIdentity) {
return acting;
}
logger.info("Act-as: task [{}] is not addressed to [{}] - claiming it for the real user [{}]", taskId, acting, realUser);
return realUser;
}

/**
* What act-as is currently doing to this Inbox: who is armed, and how many of the REAL user's own
* assigned tasks the armed state is hiding. An armed session's assignee query serves the acting
* identity's world, so the real user's tasks silently vanish from their own Inbox - this is what
* lets the Inbox say so instead of rendering an indistinguishable empty state.
*
* @return the acting identity (null when none) and the hidden-task count
*/
@GetMapping(value = "/act-as")
public ResponseEntity<ActAsInboxState> getActAsState() {
String acting = ActAsFacade.actingAs();
long hidden = acting == null ? 0 : bpmService.countTasksByAssignee(UserFacade.getName());
return ResponseEntity.ok(new ActAsInboxState(acting, hidden));
}

/** The Inbox's act-as state: the armed acting identity and the real user's tasks it hides. */
public record ActAsInboxState(String actingAs, long hiddenTasks) {
}

private void verifyCurrentUserHasPermissionForTask(String id) {
Set<String> userTaskIds = getUserTaskIds();
if (!userTaskIds.contains(id)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,11 @@ public List<Task> findTasks(PrincipalType type) {
.findTasks(type);
}

public long countTasksByAssignee(String assignee) {
return bpmProviderFlowable.getTaskService()
.countTasksByAssignee(assignee);
}

public long processDefinitionsCount() {
return bpmProviderFlowable.processDefinitionsCount();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"startShort": "Започни",
"bannerActing": "Действате като",
"bannerNote": "записите се водят на името на този човек; журналът пази вашето",
"exit": "Изход"
"exit": "Изход",
"bannerUntil": "до {{time}}"
},
"notifications": {
"title": "Известия",
Expand Down Expand Up @@ -57,7 +58,10 @@
"mine": "Моя",
"available": "Свободна",
"assignedToMe": "Възложена на мен",
"ref": "Реф. {{key}}"
"ref": "Реф. {{key}}",
"actAsHidden": "Действате като {{person}} — {{count}} от вашите собствени задачи са скрити",
"actAsServing": "Действате като {{person}} — тази пощенска кутия показва неговите задачи, не вашите",
"noTasksActAsHint": "Действате като {{person}} и този списък е негов. Излезте, за да видите своите задачи."
},
"documents": {
"newFolder": "Нова папка",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"startShort": "Start",
"bannerActing": "Acting as",
"bannerNote": "entries are recorded in this person's name; the log keeps yours",
"exit": "Exit"
"exit": "Exit",
"bannerUntil": "until {{time}}"
},
"notifications": {
"title": "Notifications",
Expand Down Expand Up @@ -57,7 +58,10 @@
"mine": "Mine",
"available": "Available",
"assignedToMe": "Assigned to me",
"ref": "Ref {{key}}"
"ref": "Ref {{key}}",
"actAsHidden": "Acting as {{person}} — {{count}} of your own tasks are hidden",
"actAsServing": "Acting as {{person}} — this Inbox serves their tasks, not yours",
"noTasksActAsHint": "You are acting as {{person}} and this list is theirs. Exit to see your own tasks."
},
"documents": {
"newFolder": "New folder",
Expand Down
Loading
Loading