Add one-time limited backoffice login grants (#329)

## Summary

Adds the missing backend contract used by Pleno Control Plane
Conversations/Suggestions to create an employee login action safely.

- issues 60–900 second one-time limited-backoffice login grants
- persists only SHA-256 bearer digests; bearer recovery is deterministic
under the server encryption key for identical idempotent retries
- enforces manager permissions, department scope, active
managed-employee constraints, one-time atomic exchange, revocation,
expiry, and account-deletion cleanup
- adds employee-create idempotency so an approved automation retry
cannot duplicate an employee
- documents the create, revoke, and unauthenticated exchange endpoints
in OpenAPI

## Security and concurrency

- bearer values are returned only in a URL fragment and are never
written to logs or database plaintext
- employee and grant rows use a consistent employee-then-grant lock
order
- deactivation revokes outstanding grants and existing sessions in the
same transaction
- consumed, revoked, expired, or payload-mismatched idempotent replays
fail closed

## Verification

- `scripts/php-ci-test.sh api`: 273 passed, 11,086 assertions (one
inherited warning)
- focused security contract: 1 passed, 21 assertions
- PHP syntax checks passed for the service and routes
- `git diff --check` passed

## Dependency

Required by copenhagentruckwash/pleno-control-plane#1. Merge before the
matching frontend and Control Plane PRs.
This commit is contained in:
Jeppe B
2026-07-29 00:01:13 +02:00
committed by GitHub
parent 42ddce84bc
commit 710baad28e
11 changed files with 1512 additions and 23 deletions
@@ -450,7 +450,25 @@ class account_deletion_service
{
global $db;
if ($type === 'customer') {
if (self::tableExists('limited_backoffice_employees')) {
$employeeLock = $db->query(
"SELECT user_id FROM limited_backoffice_employees
WHERE user_id = $id LIMIT 1 FOR UPDATE"
);
if ($employeeLock === false) {
throw new \RuntimeException('Unable to lock deleted employee.');
}
}
$this->updateExistingColumns('users', $id, ['deleted_at' => $now]);
if (self::tableExists('limited_backoffice_login_grants')) {
$this->execute(
"UPDATE limited_backoffice_login_grants
SET revoked_at = COALESCE(revoked_at, " . self::sql($now) . ")
WHERE (target_user_id = $id OR actor_user_id = $id)
AND consumed_at IS NULL",
'Unable to revoke deleted principal login grants.'
);
}
$this->deleteTokensForPrincipal('customer', $id);
} else {
$this->updateExistingColumns('subusers', $id, ['suspended_at' => $now, 'deleted_at' => $now]);
@@ -0,0 +1,579 @@
<?php
namespace classes;
use objects\logs_o;
use objects\users_o;
/**
* Issues narrowly scoped bearer grants which can be exchanged once for a normal
* employee session. Only a SHA-256 digest is persisted. The bearer is derived
* under the server encryption key so the same authorized idempotent request can
* recover an unconsumed grant after a lost response without storing plaintext.
*/
class limited_backoffice_login_grant_service
{
public const PURPOSE_EMPLOYEE_DIRECT_LOGIN = 'limited_backoffice_employee_login';
public const DEFAULT_TTL_SECONDS = 300;
public const MIN_TTL_SECONDS = 60;
public const MAX_TTL_SECONDS = 900;
public function __construct()
{
limited_backoffice_schema_bootstrap::ensureTables();
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function create(users_o $manager, int $employeeId, array $payload): array
{
(new limited_backoffice_service())->assertEmployeeLoginTarget($manager, $employeeId);
$purpose = trim((string)($payload['purpose'] ?? self::PURPOSE_EMPLOYEE_DIRECT_LOGIN));
if ($purpose !== self::PURPOSE_EMPLOYEE_DIRECT_LOGIN) {
throw new limited_backoffice_exception('Unsupported login grant purpose.', 400);
}
$ttlSeconds = $this->ttlSeconds($payload['ttl_seconds'] ?? self::DEFAULT_TTL_SECONDS);
$expiresAt = time() + $ttlSeconds;
$preflight = ($payload['preflight'] ?? false) === true;
$base = [
'employee_id' => $employeeId,
'purpose' => $purpose,
'ttl_seconds' => $ttlSeconds,
'expires_at' => gmdate('c', $expiresAt),
'one_time' => true,
];
if ($preflight) {
return $base + ['preflight' => true];
}
$idempotencyKey = trim((string)($payload['idempotency_key'] ?? ''));
if (strlen($idempotencyKey) < 16 || strlen($idempotencyKey) > 128) {
throw new limited_backoffice_exception(
'idempotency_key must contain between 16 and 128 characters.',
400
);
}
$idempotencyKeyHash = hash('sha256', $idempotencyKey);
$grantId = bin2hex(random_bytes(16));
$bearer = $this->bearerForIdempotency(
(int)$manager->id,
$employeeId,
$purpose,
$ttlSeconds,
$idempotencyKey
);
$secretHash = hash('sha256', $bearer);
$mysqli = $this->mysqli();
for ($attempt = 0; $attempt < 3; $attempt++) {
$mysqli->begin_transaction();
try {
if (!$this->lockActiveManagedEmployee($employeeId)) {
throw new limited_backoffice_exception(
'Cannot create a login grant for an inactive employee.',
409
);
}
// Target-first ordering matches employee update/deletion. Two
// cross-managing actors can still form a cycle, so deadlock
// victims are retried below with the same idempotency identity.
$authorizedActor = $this->lockAuthorizedActor($manager);
(new limited_backoffice_service())->assertEmployeeLoginTarget(
$authorizedActor['manager'],
$employeeId,
$authorizedActor['group_id']
);
$existing = $this->findIdempotentGrant(
(int)$manager->id,
$employeeId,
$purpose,
$idempotencyKeyHash
);
if ($existing !== null) {
if ($this->isReplayableGrant($existing, $secretHash)) {
$mysqli->commit();
return $this->grantResult($employeeId, $purpose, $bearer, $existing);
}
throw $this->duplicateGrantException($existing);
}
$statement = $mysqli->prepare(
'INSERT INTO `limited_backoffice_login_grants`
(`grant_id`, `secret_hash`, `target_user_id`, `actor_user_id`, `purpose`,
`idempotency_key_hash`, `expires_at`)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to prepare login grant.', 500);
}
$actorUserId = (int)$manager->id;
$statement->bind_param(
'ssiissi',
$grantId,
$secretHash,
$employeeId,
$actorUserId,
$purpose,
$idempotencyKeyHash,
$expiresAt
);
try {
$statement->execute();
} finally {
$statement->close();
}
$mysqli->commit();
break;
} catch (limited_backoffice_exception $exception) {
$mysqli->rollback();
throw $exception;
} catch (\mysqli_sql_exception $exception) {
$mysqli->rollback();
$errorCode = (int)$exception->getCode();
if (in_array($errorCode, [1205, 1213], true) && $attempt < 2) {
usleep(1000 * ($attempt + 1));
continue;
}
if ($errorCode === 1062) {
$existing = $this->findIdempotentGrant(
(int)$manager->id,
$employeeId,
$purpose,
$idempotencyKeyHash
);
if ($existing !== null) {
if ($this->isReplayableGrant($existing, $secretHash)) {
return $this->grantResult($employeeId, $purpose, $bearer, $existing);
}
throw $this->duplicateGrantException($existing);
}
}
throw new limited_backoffice_exception('Unable to create login grant.', 500);
} catch (\Throwable) {
$mysqli->rollback();
throw new limited_backoffice_exception('Unable to create login grant.', 500);
}
}
$this->audit(
(int)$manager->id,
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANT_CREATED',
'Created one-time login grant ' . $grantId . ' for employee: ' . $employeeId
);
return $this->grantResult($employeeId, $purpose, $bearer, [
'grant_id' => $grantId,
'expires_at' => $expiresAt,
]);
}
/**
* @return array{employee_id:int,token:string}
*/
public function exchange(string $bearer): array
{
if (!preg_match('/^lbg_[a-f0-9]{64}$/', $bearer)) {
throw $this->invalidGrantException();
}
$mysqli = $this->mysqli();
$secretHash = hash('sha256', $bearer);
$mysqli->begin_transaction();
try {
// Resolve the target without locking, then lock employee -> grant. Employee
// deactivation uses the same order, preventing a direct-login session from
// surviving a concurrent deactivation and avoiding inverse-order deadlocks.
$targetLookup = $mysqli->prepare(
'SELECT `target_user_id`
FROM `limited_backoffice_login_grants`
WHERE `secret_hash` = ?
LIMIT 1'
);
if ($targetLookup === false) {
throw new \RuntimeException('Unable to prepare login grant target lookup.');
}
$targetLookup->bind_param('s', $secretHash);
$targetLookup->execute();
$target = $targetLookup->get_result()->fetch_assoc() ?: null;
$targetLookup->close();
if ($target === null || !$this->lockActiveManagedEmployee((int)$target['target_user_id'])) {
throw $this->invalidGrantException();
}
$statement = $mysqli->prepare(
'SELECT `id`, `grant_id`, `target_user_id`, `purpose`, `expires_at`,
`consumed_at`, `revoked_at`
FROM `limited_backoffice_login_grants`
WHERE `secret_hash` = ?
LIMIT 1
FOR UPDATE'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare login grant exchange.');
}
$statement->bind_param('s', $secretHash);
$statement->execute();
$row = $statement->get_result()->fetch_assoc() ?: null;
$statement->close();
if (
$row === null
|| $row['purpose'] !== self::PURPOSE_EMPLOYEE_DIRECT_LOGIN
|| $row['consumed_at'] !== null
|| $row['revoked_at'] !== null
|| (int)$row['expires_at'] <= time()
|| (int)$row['target_user_id'] !== (int)$target['target_user_id']
) {
throw $this->invalidGrantException();
}
$grantRowId = (int)$row['id'];
$consume = $mysqli->prepare(
'UPDATE `limited_backoffice_login_grants`
SET `consumed_at` = UTC_TIMESTAMP()
WHERE `id` = ? AND `consumed_at` IS NULL AND `revoked_at` IS NULL
LIMIT 1'
);
if ($consume === false) {
throw new \RuntimeException('Unable to prepare login grant consumption.');
}
$consume->bind_param('i', $grantRowId);
$consume->execute();
$affectedRows = $consume->affected_rows;
$consume->close();
if ($affectedRows !== 1) {
throw $this->invalidGrantException();
}
$employeeId = (int)$row['target_user_id'];
$token = (new authentication())->create_employee_token($employeeId);
$mysqli->commit();
} catch (limited_backoffice_exception $exception) {
$mysqli->rollback();
throw $exception;
} catch (\Throwable) {
$mysqli->rollback();
throw new limited_backoffice_exception('Unable to exchange login grant.', 500);
}
$this->audit(
$employeeId,
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANT_EXCHANGED',
'Exchanged one-time login grant ' . (string)$row['grant_id'] . ' for employee: ' . $employeeId
);
return ['employee_id' => $employeeId, 'token' => $token];
}
/**
* @return array{employee_id:int,revoked_count:int}
*/
public function revokeForEmployee(users_o $manager, int $employeeId): array
{
(new limited_backoffice_service())->assertEmployeeLoginTarget($manager, $employeeId);
$statement = $this->mysqli()->prepare(
'UPDATE `limited_backoffice_login_grants`
SET `revoked_at` = UTC_TIMESTAMP()
WHERE `target_user_id` = ?
AND `consumed_at` IS NULL
AND `revoked_at` IS NULL
AND `expires_at` >= ?'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to prepare login grant revocation.', 500);
}
$now = time();
$statement->bind_param('ii', $employeeId, $now);
$statement->execute();
$revokedCount = $statement->affected_rows;
$statement->close();
$this->audit(
(int)$manager->id,
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANTS_REVOKED',
'Revoked ' . $revokedCount . ' login grants for employee: ' . $employeeId
);
return ['employee_id' => $employeeId, 'revoked_count' => $revokedCount];
}
private function ttlSeconds(mixed $value): int
{
if (is_string($value) && ctype_digit($value)) {
$value = (int)$value;
}
if (!is_int($value) || $value < self::MIN_TTL_SECONDS || $value > self::MAX_TTL_SECONDS) {
throw new limited_backoffice_exception(
'ttl_seconds must be between ' . self::MIN_TTL_SECONDS . ' and ' . self::MAX_TTL_SECONDS . '.',
400
);
}
return $value;
}
/**
* @return array<string, mixed>|null
*/
private function findIdempotentGrant(
int $actorUserId,
int $employeeId,
string $purpose,
string $idempotencyKeyHash
): ?array {
$statement = $this->mysqli()->prepare(
'SELECT `grant_id`, `secret_hash`, `target_user_id`, `purpose`, `expires_at`,
`consumed_at`, `revoked_at`
FROM `limited_backoffice_login_grants`
WHERE `actor_user_id` = ?
AND `target_user_id` = ?
AND `purpose` = ?
AND `idempotency_key_hash` = ?
LIMIT 1'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to check login grant idempotency.', 500);
}
$statement->bind_param('iiss', $actorUserId, $employeeId, $purpose, $idempotencyKeyHash);
$statement->execute();
$row = $statement->get_result()->fetch_assoc() ?: null;
$statement->close();
return $row;
}
private function bearerForIdempotency(
int $actorUserId,
int $employeeId,
string $purpose,
int $ttlSeconds,
string $idempotencyKey
): string {
$key = trim((string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: ''));
if ($key === '') {
throw new limited_backoffice_exception('Login grant encryption key is unavailable.', 503);
}
return 'lbg_' . hash_hmac(
'sha256',
$actorUserId . ':' . $employeeId . ':' . $purpose . ':' . $ttlSeconds . ':' . $idempotencyKey,
$key
);
}
private function isReplayableGrant(array $row, string $secretHash): bool
{
return hash_equals((string)($row['secret_hash'] ?? ''), $secretHash)
&& ($row['consumed_at'] ?? null) === null
&& ($row['revoked_at'] ?? null) === null
&& (int)($row['expires_at'] ?? 0) > time();
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function grantResult(
int $employeeId,
string $purpose,
string $bearer,
array $row
): array {
$expiresAt = (int)$row['expires_at'];
return [
'employee_id' => $employeeId,
'purpose' => $purpose,
'ttl_seconds' => max(0, $expiresAt - time()),
'expires_at' => gmdate('c', $expiresAt),
'one_time' => true,
'preflight' => false,
'grant_id' => (string)$row['grant_id'],
// The fragment avoids ingress request logs and Referer propagation.
'login_path' => '/login/qr#grant=' . rawurlencode($bearer),
'exchange_path' => '/auth/limited-backoffice-login-grants/exchange',
];
}
private function duplicateGrantException(array $row): limited_backoffice_exception
{
return new limited_backoffice_exception(
'A login grant already exists for this idempotency key.',
409,
[
'message' => 'A login grant already exists for this idempotency key.',
'code' => 'LOGIN_GRANT_IDEMPOTENCY_CONFLICT',
'grant_id' => (string)$row['grant_id'],
'employee_id' => (int)$row['target_user_id'],
'purpose' => (string)$row['purpose'],
'expires_at' => gmdate('c', (int)$row['expires_at']),
'consumed' => $row['consumed_at'] !== null,
'revoked' => $row['revoked_at'] !== null,
]
);
}
private function invalidGrantException(): limited_backoffice_exception
{
return new limited_backoffice_exception('Invalid or expired login grant.', 401);
}
private function lockActiveManagedEmployee(int $employeeId): bool
{
$statement = $this->mysqli()->prepare(
'SELECT lbe.`user_id`, lbe.`managed_group_id`, u.`group_id`
FROM `limited_backoffice_employees` lbe
INNER JOIN `users` u ON u.`id` = lbe.`user_id`
WHERE lbe.`user_id` = ? AND lbe.`deactivated_at` IS NULL
LIMIT 1
FOR UPDATE'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to lock login grant employee.', 500);
}
$statement->bind_param('i', $employeeId);
$statement->execute();
$employee = $statement->get_result()->fetch_assoc() ?: null;
$statement->close();
if ($employee === null) {
return false;
}
$groupId = (int)$employee['group_id'];
$managedGroupId = (int)$employee['managed_group_id'];
if ($groupId <= 0 || $groupId === 1 || $managedGroupId !== $groupId) {
throw new limited_backoffice_exception('Login grant target is no longer safe.', 403);
}
// Lock the complete permission range so role changes cannot add elevated
// capabilities between validation and token creation.
$permissions = $this->mysqli()->prepare(
'SELECT `permission`
FROM `groups_permissions`
WHERE `group_id` = ?
FOR UPDATE'
);
if ($permissions === false) {
throw new limited_backoffice_exception('Unable to validate login grant role.', 500);
}
$permissions->bind_param('i', $groupId);
$permissions->execute();
$result = $permissions->get_result();
while ($row = $result->fetch_assoc()) {
if ((string)($row['permission'] ?? '') === 'superuser') {
$permissions->close();
throw new limited_backoffice_exception('Login grant target is no longer safe.', 403);
}
}
$permissions->close();
$groupUsers = $this->mysqli()->prepare(
'SELECT `id` FROM `users` WHERE `group_id` = ? FOR UPDATE'
);
if ($groupUsers === false) {
throw new limited_backoffice_exception('Unable to validate login grant group.', 500);
}
$groupUsers->bind_param('i', $groupId);
$groupUsers->execute();
$groupUserResult = $groupUsers->get_result();
$userCount = 0;
while ($groupUserResult->fetch_assoc() !== null) {
$userCount++;
}
$groupUsers->close();
if ($userCount !== 1) {
throw new limited_backoffice_exception('Login grant target group is shared.', 403);
}
return true;
}
/**
* @return array{manager:users_o,group_id:int}
*/
private function lockAuthorizedActor(users_o $manager): array
{
$actorUserId = (int)$manager->id;
$statement = $this->mysqli()->prepare(
'SELECT `group_id` FROM `users` WHERE `id` = ? LIMIT 1 FOR UPDATE'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to lock login grant actor.', 500);
}
$statement->bind_param('i', $actorUserId);
$statement->execute();
$actor = $statement->get_result()->fetch_assoc() ?: null;
$statement->close();
if (
$actor === null
|| (int)$actor['group_id'] <= 0
|| account_deletion_service::principalIsBlocked('customer', $actorUserId)
) {
throw new limited_backoffice_exception('Login grant actor is no longer authorized.', 403);
}
$groupId = (int)$actor['group_id'];
if ($groupId !== 1) {
$requiredPermissions = [
limited_backoffice_service::PERMISSION_ACCESS,
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES,
];
// Lock the actor's complete permission set, including every department_access_* row
// consumed by assertEmployeeLoginTarget.
// The group_id range lock prevents concurrent role replacement
// from revoking scope between authorization and grant insertion.
$permissions = $this->mysqli()->prepare(
'SELECT `permission`
FROM `groups_permissions`
WHERE `group_id` = ?
FOR UPDATE'
);
if ($permissions === false) {
throw new limited_backoffice_exception('Unable to validate login grant actor.', 500);
}
$permissions->bind_param('i', $groupId);
$permissions->execute();
$result = $permissions->get_result();
$granted = [];
while ($row = $result->fetch_assoc()) {
$granted[] = (string)$row['permission'];
}
$permissions->close();
if (array_diff($requiredPermissions, $granted) !== []) {
throw new limited_backoffice_exception(
'Login grant actor is no longer authorized.',
403
);
}
}
$currentManager = (new users_o())->getUserById($actorUserId);
if (!$currentManager->exists()) {
throw new limited_backoffice_exception('Login grant actor is no longer authorized.', 403);
}
return [
'manager' => $currentManager,
'group_id' => $groupId,
];
}
private function audit(int $actorUserId, string $event, string $message): void
{
try {
(new logs_o())->add('auth', 'global', 1, $actorUserId, $event, $message);
} catch (\Throwable) {
// Audit logging must not expose a bearer or block the grant lifecycle.
}
}
private function mysqli(): \mysqli
{
global $db;
return $db->conn();
}
}
@@ -36,6 +36,45 @@ CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `limited_backoffice_login_grants` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`grant_id` CHAR(32) NOT NULL,
`secret_hash` CHAR(64) NOT NULL,
`target_user_id` INT NOT NULL,
`actor_user_id` INT NOT NULL,
`purpose` VARCHAR(64) NOT NULL,
`idempotency_key_hash` CHAR(64) NULL,
`expires_at` BIGINT UNSIGNED NOT NULL,
`consumed_at` DATETIME NULL,
`revoked_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_limited_backoffice_login_grants_grant_id` (`grant_id`),
UNIQUE KEY `uniq_limited_backoffice_login_grants_secret_hash` (`secret_hash`),
UNIQUE KEY `uniq_limited_backoffice_login_grants_idempotency` (`actor_user_id`, `target_user_id`, `purpose`, `idempotency_key_hash`),
KEY `idx_limited_backoffice_login_grants_target` (`target_user_id`, `expires_at`),
KEY `idx_limited_backoffice_login_grants_expiry` (`expires_at`),
KEY `idx_limited_backoffice_login_grants_state` (`consumed_at`, `revoked_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `limited_backoffice_action_idempotency` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`actor_user_id` INT NOT NULL,
`action_type` VARCHAR(64) NOT NULL,
`idempotency_key_hash` CHAR(64) NOT NULL,
`payload_hash` CHAR(64) NOT NULL,
`result_user_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_limited_backoffice_action_idempotency`
(`actor_user_id`, `action_type`, `idempotency_key_hash`),
KEY `idx_limited_backoffice_action_result` (`result_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
self::$initialized = true;
@@ -628,12 +628,26 @@ class limited_backoffice_service
}
if ($user->hasPermission('superuser')) {
global $db;
$rows = $db->fetch_all($db->query(
'SELECT `id` FROM `departments` ORDER BY `id` ASC'
));
return $this->allDepartmentIds();
}
return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows));
return $this->accessibleDepartmentIdsForGroup($groupId);
}
/**
* Loads department scope from an authoritative group identity rather than
* from user object properties that may be backed by a stale Redis value.
*
* @return array<int, int>
*/
public function accessibleDepartmentIdsForGroup(int $groupId): array
{
if ($groupId <= 0) {
return [];
}
if ($groupId === 1) {
return $this->allDepartmentIds();
}
global $db;
@@ -653,6 +667,9 @@ class limited_backoffice_service
$departmentIds = [];
foreach ($rows as $row) {
$permission = (string)($row['permission'] ?? '');
if ($permission === 'superuser') {
return $this->allDepartmentIds();
}
if (preg_match('/^department_access_([0-9]+)$/', $permission, $matches) !== 1) {
continue;
}
@@ -666,6 +683,19 @@ class limited_backoffice_service
return array_values(array_unique($departmentIds));
}
/**
* @return array<int, int>
*/
private function allDepartmentIds(): array
{
global $db;
$rows = $db->fetch_all($db->query(
'SELECT `id` FROM `departments` ORDER BY `id` ASC'
));
return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows));
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -899,11 +929,65 @@ class limited_backoffice_service
$password = $this->normalizePassword($payload['password'] ?? null, true);
$email = $this->normalizeEmail($payload['email'] ?? null, true);
$phone = $this->normalizeOptionalPhonePair($payload);
$idempotencyKey = trim((string)($payload['idempotency_key'] ?? ''));
if ($idempotencyKey !== '' && (strlen($idempotencyKey) < 16 || strlen($idempotencyKey) > 128)) {
throw new limited_backoffice_exception(
'idempotency_key must contain between 16 and 128 characters.',
400
);
}
$idempotencyKeyHash = $idempotencyKey === '' ? null : hash('sha256', $idempotencyKey);
$payloadJson = (string)json_encode([
'department_ids' => $departmentIds,
'role_key' => $roleKey,
'display_name' => $displayName,
'password' => $password,
'email' => $email,
'phone_country_code' => $phone['phone_country_code'],
'phone' => $phone['phone'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$payloadHash = $idempotencyKeyHash === null
? null
: hash_hmac('sha256', $payloadJson, $this->idempotencyDigestKey());
$mysqli = $this->mysqli();
$mysqli->begin_transaction();
try {
if ($idempotencyKeyHash !== null) {
$replayedEmployeeId = $this->reserveEmployeeCreateIdempotency(
(int)$manager->id,
$idempotencyKeyHash,
$payloadHash
);
if ($replayedEmployeeId !== null) {
$employee = $this->loadManagedEmployee($replayedEmployeeId);
if ($employee === null) {
throw new limited_backoffice_exception(
'Idempotent employee result is unavailable.',
409
);
}
$currentDepartmentIds = $this->decodeDepartmentIds(
(string)$employee['department_ids']
);
$this->assertDepartmentSubset($manager, $currentDepartmentIds);
$this->assertManagedTargetIsSafe($employee);
if (!$this->isEmployeeRowActive($employee)) {
throw new limited_backoffice_exception(
'Idempotent employee result is no longer active.',
409
);
}
$mysqli->commit();
return $this->formatEmployee(
$employee,
$currentDepartmentIds,
$this->isEmployeeRowActive($employee)
);
}
}
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
$customerNumber = self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER;
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
@@ -950,7 +1034,35 @@ class limited_backoffice_service
$statement->execute();
$statement->close();
if ($idempotencyKeyHash !== null) {
$statement = $mysqli->prepare(
'UPDATE `limited_backoffice_action_idempotency`
SET `result_user_id` = ?
WHERE `actor_user_id` = ?
AND `action_type` = ?
AND `idempotency_key_hash` = ?
LIMIT 1'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare employee idempotency result.');
}
$managerId = (int)$manager->id;
$actionType = 'employee.create';
$statement->bind_param(
'iiss',
$employeeId,
$managerId,
$actionType,
$idempotencyKeyHash
);
$statement->execute();
$statement->close();
}
$mysqli->commit();
} catch (limited_backoffice_exception $exception) {
$mysqli->rollback();
throw $exception;
} catch (\Throwable) {
$mysqli->rollback();
throw new limited_backoffice_exception('Unable to create employee.', 500);
@@ -964,6 +1076,66 @@ class limited_backoffice_service
return $this->formatEmployee($employee, $departmentIds, true);
}
private function reserveEmployeeCreateIdempotency(
int $actorUserId,
string $keyHash,
string $payloadHash
): ?int {
$mysqli = $this->mysqli();
$actionType = 'employee.create';
$statement = $mysqli->prepare(
'INSERT INTO `limited_backoffice_action_idempotency`
(`actor_user_id`, `action_type`, `idempotency_key_hash`, `payload_hash`)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE `id` = `id`'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare employee idempotency reservation.');
}
$statement->bind_param('isss', $actorUserId, $actionType, $keyHash, $payloadHash);
$statement->execute();
$statement->close();
$statement = $mysqli->prepare(
'SELECT `payload_hash`, `result_user_id`
FROM `limited_backoffice_action_idempotency`
WHERE `actor_user_id` = ?
AND `action_type` = ?
AND `idempotency_key_hash` = ?
LIMIT 1
FOR UPDATE'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare employee idempotency lookup.');
}
$statement->bind_param('iss', $actorUserId, $actionType, $keyHash);
$statement->execute();
$row = $statement->get_result()->fetch_assoc() ?: null;
$statement->close();
if ($row === null) {
throw new \RuntimeException('Unable to load employee idempotency reservation.');
}
if (!hash_equals((string)$row['payload_hash'], $payloadHash)) {
throw new limited_backoffice_exception(
'Idempotency key was already used with a different employee payload.',
409
);
}
return $row['result_user_id'] === null ? null : (int)$row['result_user_id'];
}
private function idempotencyDigestKey(): string
{
$key = trim((string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: ''));
if ($key === '') {
throw new limited_backoffice_exception(
'Employee idempotency protection is not configured.',
500
);
}
return hash_hmac('sha256', 'limited-backoffice-employee-idempotency-v1', $key, true);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
@@ -1082,6 +1254,25 @@ class limited_backoffice_service
$mysqli->begin_transaction();
try {
$lock = $mysqli->prepare(
'SELECT lbe.`user_id`
FROM `limited_backoffice_employees` lbe
INNER JOIN `users` u ON u.`id` = lbe.`user_id`
WHERE lbe.`user_id` = ? AND lbe.`managed_group_id` = ?
LIMIT 1
FOR UPDATE'
);
if ($lock === false) {
throw new \RuntimeException('Unable to prepare employee update lock.');
}
$lock->bind_param('ii', $employeeId, $managedGroupId);
$lock->execute();
$lockedEmployee = $lock->get_result()->fetch_assoc() ?: null;
$lock->close();
if ($lockedEmployee === null) {
throw new limited_backoffice_exception('Managed employee changed before update.', 409);
}
if ($active) {
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $newDepartmentIds));
}
@@ -1161,20 +1352,7 @@ class limited_backoffice_service
*/
public function createEmployeeLoginLink(users_o $manager, int $employeeId): array
{
$this->assertNotSelfEdit($manager, $employeeId);
$employee = $this->loadManagedEmployee($employeeId);
if ($employee === null) {
throw new limited_backoffice_exception('Managed employee not found.', 404);
}
$departmentIds = $this->decodeDepartmentIds((string)$employee['department_ids']);
$this->assertDepartmentSubset($manager, $departmentIds);
$this->assertManagedTargetIsSafe($employee);
if (!$this->isEmployeeRowActive($employee)) {
throw new limited_backoffice_exception('Cannot create a login link for an inactive employee.', 409);
}
$this->assertEmployeeLoginTarget($manager, $employeeId);
$token = (new authentication())->create_employee_token($employeeId);
@@ -1197,6 +1375,31 @@ class limited_backoffice_service
];
}
/**
* Applies the same target and department boundary used by the legacy login-link endpoint.
*/
public function assertEmployeeLoginTarget(
users_o $manager,
int $employeeId,
?int $authoritativeGroupId = null
): void
{
$this->assertNotSelfEdit($manager, $employeeId);
$employee = $this->loadManagedEmployee($employeeId);
if ($employee === null) {
throw new limited_backoffice_exception('Managed employee not found.', 404);
}
$departmentIds = $this->decodeDepartmentIds((string)$employee['department_ids']);
$this->assertDepartmentSubset($manager, $departmentIds, $authoritativeGroupId);
$this->assertManagedTargetIsSafe($employee);
if (!$this->isEmployeeRowActive($employee)) {
throw new limited_backoffice_exception('Cannot create a login link for an inactive employee.', 409);
}
}
private function mysqli(): mysqli
{
global $db;
@@ -1452,13 +1655,19 @@ class limited_backoffice_service
/**
* @param array<int, int> $departmentIds
*/
private function assertDepartmentSubset(users_o $manager, array $departmentIds): void
private function assertDepartmentSubset(
users_o $manager,
array $departmentIds,
?int $authoritativeGroupId = null
): void
{
if ($departmentIds === []) {
throw new limited_backoffice_exception('At least one department is required.', 400);
}
$managerDepartmentIds = $this->accessibleDepartmentIds($manager);
$managerDepartmentIds = $authoritativeGroupId === null
? $this->accessibleDepartmentIds($manager)
: $this->accessibleDepartmentIdsForGroup($authoritativeGroupId);
$outside = array_values(array_diff($departmentIds, $managerDepartmentIds));
if ($outside !== []) {
$permissions = array_map(static fn(int $id): string => 'department_access_' . $id, $outside);
@@ -2055,6 +2264,13 @@ class limited_backoffice_service
}
}
$db->query('DELETE FROM `tokens` WHERE `user_id` = ' . (int)$userId);
$db->query(
'UPDATE `limited_backoffice_login_grants`
SET `revoked_at` = UTC_TIMESTAMP()
WHERE `target_user_id` = ' . (int)$userId . '
AND `consumed_at` IS NULL
AND `revoked_at` IS NULL'
);
$this->clearUserSessionCache($userId);
}
+139
View File
@@ -2715,6 +2715,44 @@ paths:
'500': { $ref: '#/components/responses/InternalServerError' }
# Authentication Endpoints
/auth/limited-backoffice-login-grants/exchange:
post:
tags:
- Authentication
summary: Exchange a one-time limited-backoffice employee login grant
description: Exchanges an unexpired, unrevoked grant exactly once for a regular employee bearer session. The grant is invalidated atomically before the session is returned.
operationId: exchangeLimitedBackofficeEmployeeLoginGrant
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [grant]
properties:
grant:
type: string
pattern: '^lbg_[a-f0-9]{64}$'
writeOnly: true
responses:
'200':
description: Grant exchanged
content:
application/json:
schema:
type: object
required: [employee_id, token]
properties:
employee_id: {type: integer}
token:
type: string
description: Sensitive bearer token returned once by a successful grant exchange.
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalServerError'
/auth/login:
post:
tags:
@@ -14481,6 +14519,107 @@ paths:
'409':
$ref: '#/components/responses/Conflict'
/limited-backoffice/employees/{employeeId}/login-grants:
post:
tags:
- Limited Backoffice
summary: Create or preflight a one-time employee login grant
description: Requires limited-backoffice employee-management permissions and access to every department assigned to the employee. The bearer is deterministically derived under the server encryption key so an identical idempotent retry can recover the same unconsumed grant after a lost response; only its digest is stored.
operationId: createLimitedBackofficeEmployeeLoginGrant
parameters:
- name: employeeId
in: path
required: true
schema: {type: integer, minimum: 1}
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
purpose:
type: string
enum: [limited_backoffice_employee_login]
default: limited_backoffice_employee_login
ttl_seconds:
type: integer
minimum: 60
maximum: 900
default: 300
idempotency_key:
type: string
minLength: 16
maxLength: 128
writeOnly: true
preflight:
type: boolean
default: false
oneOf:
- required: [idempotency_key]
properties:
preflight:
type: boolean
enum: [false]
- required: [preflight]
properties:
preflight:
type: boolean
enum: [true]
responses:
'200':
description: Grant created, safely replayed for the same idempotency key, or request validated in preflight mode
content:
application/json:
schema:
type: object
required: [employee_id, purpose, ttl_seconds, expires_at, one_time, preflight]
properties:
employee_id: {type: integer}
purpose: {type: string}
ttl_seconds: {type: integer}
expires_at: {type: string, format: date-time}
one_time: {type: boolean}
preflight: {type: boolean}
grant_id: {type: string, pattern: '^[a-f0-9]{32}$'}
login_path:
type: string
description: Sensitive fragment URL returned only for a newly created or safely replayed grant.
exchange_path: {type: string}
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
delete:
tags:
- Limited Backoffice
summary: Revoke active one-time employee login grants
operationId: revokeLimitedBackofficeEmployeeLoginGrants
parameters:
- name: employeeId
in: path
required: true
schema: {type: integer, minimum: 1}
responses:
'200':
description: Active grants revoked
content:
application/json:
schema:
type: object
required: [employee_id, revoked_count]
properties:
employee_id: {type: integer}
revoked_count: {type: integer, minimum: 0}
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/superuser/department/variables:
get:
tags:
+15
View File
@@ -6,6 +6,8 @@ use classes\authentication;
use classes\account_deletion_service;
use classes\economic;
use classes\email;
use classes\limited_backoffice_exception;
use classes\limited_backoffice_login_grant_service;
use classes\release_manager;
use classes\recaptcha;
use classes\security_policy_service;
@@ -24,6 +26,7 @@ use traits\route_t;
require_once WD . '/classes/security_policy_service.php';
require_once WD . '/classes/account_deletion_service.php';
require_once WD . '/classes/limited_backoffice_login_grant_service.php';
class authRoute
{
@@ -97,6 +100,18 @@ class authRoute
public function run(): void
{
$this->post('/auth/limited-backoffice-login-grants/exchange', function () {
global $response;
try {
$payload = json_decode(file_get_contents('php://input'), true);
$grant = is_array($payload) ? trim((string)($payload['grant'] ?? '')) : '';
$response->success((new limited_backoffice_login_grant_service())->exchange($grant));
} catch (limited_backoffice_exception $exception) {
$response->error($exception->payload(), $exception->statusCode());
}
});
$this->post('/auth/login', function () {
// Get the post data
global $response;
@@ -4,9 +4,12 @@ namespace routes;
use classes\authentication;
use classes\limited_backoffice_exception;
use classes\limited_backoffice_login_grant_service;
use classes\limited_backoffice_service;
use traits\route_t;
require_once WD . '/classes/limited_backoffice_login_grant_service.php';
class limitedBackofficeRoute
{
use route_t;
@@ -125,6 +128,35 @@ class limitedBackofficeRoute
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->post('/limited-backoffice/employees/{employeeId}/login-grants', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return (new limited_backoffice_login_grant_service())->create(
$user,
$this->routePositiveInt('employeeId'),
$this->requestPayload()
);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->delete('/limited-backoffice/employees/{employeeId}/login-grants', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return (new limited_backoffice_login_grant_service())->revokeForEmployee(
$user,
$this->routePositiveInt('employeeId')
);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->put('/limited-backoffice/employees/{employeeId}', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
@@ -144,6 +144,8 @@ function limited_backoffice_cleanup_created_employee(int $employeeId): void
);
$groupId = (int)($row['managed_group_id'] ?? 0);
api_fixtures()->cleanupDeleteWhere('limited_backoffice_login_grants', ['target_user_id' => $employeeId]);
api_fixtures()->cleanupDeleteWhere('limited_backoffice_action_idempotency', ['result_user_id' => $employeeId]);
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => $employeeId]);
api_fixtures()->cleanupDeleteWhere('tokens', ['user_id' => $employeeId]);
api_fixtures()->cleanupDeleteById('users', $employeeId);
@@ -949,7 +951,8 @@ it('creates updates lists and deactivates scoped employees without exposing raw
expect($rolePayloadStrings)->not->toContain($rawPermission);
}
$created = api_client()->post('/limited-backoffice/employees', [
$employeeIdempotencyKey = 'limited-employee-create-' . bin2hex(random_bytes(12));
$employeePayload = [
'display_name' => 'Limited Cashier',
'email' => 'limited-cashier@example.test',
'phone_country_code' => 45,
@@ -957,7 +960,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw
'password' => 'Secret123!',
'role_key' => 'cashier',
'department_ids' => [(int)$department['id']],
], $session['headers']);
'idempotency_key' => $employeeIdempotencyKey,
];
$created = api_client()->post('/limited-backoffice/employees', $employeePayload, $session['headers']);
$created
->assertStatus(200)
@@ -967,6 +972,29 @@ it('creates updates lists and deactivates scoped employees without exposing raw
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$replayed = api_client()->post(
'/limited-backoffice/employees',
$employeePayload,
$session['headers']
);
$replayed->assertStatus(200)->assertSuccess();
expect($replayed->data()['id'] ?? null)->toBe($employeeId);
api_client()->post(
'/limited-backoffice/employees',
[...$employeePayload, 'password' => 'A different retry-only password 123!'],
$session['headers']
)
->assertStatus(409)
->assertSuccess(false);
api_client()->post(
'/limited-backoffice/employees',
[...$employeePayload, 'display_name' => 'Different Employee'],
$session['headers']
)
->assertStatus(409)
->assertSuccess(false);
expect($created->data()['user_id'] ?? null)->toBe($employeeId);
expect($created->data()['customer_number'] ?? null)->toBe(0);
expect($created->data()['email'] ?? null)->toBe('limited-cashier@example.test');
@@ -1211,6 +1239,175 @@ it('generates reusable QR login links for active scoped employees', function ():
expect($list->body)->not->toContain('login_path');
});
it('creates, exchanges once, idempotently guards, and revokes scoped employee login grants', function (): void {
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-grants', 'happy');
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-grants', 'idempotency');
api_test_covers('POST /auth/limited-backoffice-login-grants/exchange', 'happy');
api_test_covers('POST /auth/limited-backoffice-login-grants/exchange', 'one-time');
api_test_covers('DELETE /limited-backoffice/employees/{employeeId}/login-grants', 'happy');
$department = api_fixtures()->createDepartment(['name' => 'Limited One-time Grant Department']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited One-time Grant Employee',
'email' => 'limited-one-time-grant@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $session['headers']);
$created->assertStatus(200)->assertSuccess();
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$preflight = api_client()->post(
'/limited-backoffice/employees/' . $employeeId . '/login-grants',
[
'purpose' => 'limited_backoffice_employee_login',
'ttl_seconds' => 120,
'preflight' => true,
],
$session['headers']
);
$preflight
->assertStatus(200)
->assertSuccess();
expect($preflight->data()['preflight'] ?? null)->toBeTrue();
expect($preflight->data())->not->toHaveKey('login_path');
$idempotencyKey = 'limited-grant-test-' . bin2hex(random_bytes(12));
$grantResponse = api_client()->post(
'/limited-backoffice/employees/' . $employeeId . '/login-grants',
[
'purpose' => 'limited_backoffice_employee_login',
'ttl_seconds' => 120,
'idempotency_key' => $idempotencyKey,
],
$session['headers']
);
$grantResponse
->assertStatus(200)
->assertSuccess();
$loginPath = (string)($grantResponse->data()['login_path'] ?? '');
expect($loginPath)->toMatch('/^\\/login\\/qr#grant=lbg_[a-f0-9]{64}$/');
parse_str((string)parse_url($loginPath, PHP_URL_FRAGMENT), $query);
$grant = (string)($query['grant'] ?? '');
expect($grant)->toMatch('/^lbg_[a-f0-9]{64}$/');
$grantRow = api_test_runtime()->queryOne(
"SELECT `secret_hash`, `consumed_at`, `revoked_at`
FROM `limited_backoffice_login_grants`
WHERE `grant_id` = '" .
api_test_runtime()->db()->real_escape_string((string)$grantResponse->data()['grant_id']) .
"' LIMIT 1"
);
expect($grantRow)->not->toBeNull();
expect($grantRow['secret_hash'] ?? null)->toBe(hash('sha256', $grant));
expect((string)($grantRow['secret_hash'] ?? ''))->not->toContain($grant);
$duplicate = api_client()->post(
'/limited-backoffice/employees/' . $employeeId . '/login-grants',
[
'purpose' => 'limited_backoffice_employee_login',
'ttl_seconds' => 120,
'idempotency_key' => $idempotencyKey,
],
$session['headers']
);
$duplicate
->assertStatus(200)
->assertSuccess();
expect($duplicate->data()['grant_id'] ?? null)->toBe($grantResponse->data()['grant_id'] ?? null);
expect($duplicate->data()['login_path'] ?? null)->toBe($loginPath);
$differentTtlReplay = api_client()->post(
'/limited-backoffice/employees/' . $employeeId . '/login-grants',
[
'purpose' => 'limited_backoffice_employee_login',
'ttl_seconds' => 180,
'idempotency_key' => $idempotencyKey,
],
$session['headers']
);
$differentTtlReplay->assertStatus(409)->assertSuccess(false);
expect($differentTtlReplay->data()['code'] ?? null)->toBe('LOGIN_GRANT_IDEMPOTENCY_CONFLICT');
$exchange = api_client()->post('/auth/limited-backoffice-login-grants/exchange', ['grant' => $grant]);
$exchange
->assertStatus(200)
->assertSuccess();
$sessionToken = (string)($exchange->data()['token'] ?? '');
expect($exchange->data()['employee_id'] ?? null)->toBe($employeeId);
expect($sessionToken)->toMatch('/^[a-f0-9]{64}$/');
$consumedReplay = api_client()->post(
'/limited-backoffice/employees/' . $employeeId . '/login-grants',
[
'purpose' => 'limited_backoffice_employee_login',
'ttl_seconds' => 120,
'idempotency_key' => $idempotencyKey,
],
$session['headers']
);
$consumedReplay->assertStatus(409)->assertSuccess(false);
expect($consumedReplay->body)->not->toContain($grant);
expect($consumedReplay->data()['code'] ?? null)->toBe('LOGIN_GRANT_IDEMPOTENCY_CONFLICT');
api_client()->post('/auth/limited-backoffice-login-grants/exchange', ['grant' => $grant])
->assertStatus(401)
->assertSuccess(false)
->assertMessage('Invalid or expired login grant.');
$secondGrant = api_client()->post(
'/limited-backoffice/employees/' . $employeeId . '/login-grants',
[
'idempotency_key' => 'limited-grant-revoke-' . bin2hex(random_bytes(12)),
],
$session['headers']
);
$secondGrant->assertStatus(200)->assertSuccess();
parse_str((string)parse_url((string)$secondGrant->data()['login_path'], PHP_URL_FRAGMENT), $secondQuery);
api_client()->delete(
'/limited-backoffice/employees/' . $employeeId . '/login-grants',
null,
$session['headers']
)
->assertStatus(200)
->assertSuccess();
api_client()->post(
'/auth/limited-backoffice-login-grants/exchange',
['grant' => (string)($secondQuery['grant'] ?? '')]
)
->assertStatus(401)
->assertSuccess(false);
$deactivationGrant = api_client()->post(
'/limited-backoffice/employees/' . $employeeId . '/login-grants',
[
'idempotency_key' => 'limited-grant-deactivate-' . bin2hex(random_bytes(12)),
],
$session['headers']
);
$deactivationGrant->assertStatus(200)->assertSuccess();
parse_str((string)parse_url((string)$deactivationGrant->data()['login_path'], PHP_URL_FRAGMENT), $deactivationQuery);
api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers'])
->assertStatus(200)
->assertSuccess();
api_client()->post(
'/auth/limited-backoffice-login-grants/exchange',
['grant' => (string)($deactivationQuery['grant'] ?? '')]
)
->assertStatus(401)
->assertSuccess(false);
});
it('rejects invalid limited backoffice employee QR login link generation', function (): void {
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'auth');
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'validation');
@@ -611,6 +611,43 @@ CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'limited_backoffice_login_grants' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `limited_backoffice_login_grants` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`grant_id` CHAR(32) NOT NULL,
`secret_hash` CHAR(64) NOT NULL,
`target_user_id` INT NOT NULL,
`actor_user_id` INT NOT NULL,
`purpose` VARCHAR(64) NOT NULL,
`idempotency_key_hash` CHAR(64) NULL,
`expires_at` BIGINT UNSIGNED NOT NULL,
`consumed_at` DATETIME NULL,
`revoked_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_limited_backoffice_login_grants_grant_id` (`grant_id`),
UNIQUE KEY `uniq_limited_backoffice_login_grants_secret_hash` (`secret_hash`),
UNIQUE KEY `uniq_limited_backoffice_login_grants_idempotency` (`actor_user_id`, `target_user_id`, `purpose`, `idempotency_key_hash`),
KEY `idx_limited_backoffice_login_grants_target` (`target_user_id`, `expires_at`),
KEY `idx_limited_backoffice_login_grants_expiry` (`expires_at`),
KEY `idx_limited_backoffice_login_grants_state` (`consumed_at`, `revoked_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'limited_backoffice_action_idempotency' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `limited_backoffice_action_idempotency` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`actor_user_id` INT NOT NULL,
`action_type` VARCHAR(64) NOT NULL,
`idempotency_key_hash` CHAR(64) NOT NULL,
`payload_hash` CHAR(64) NOT NULL,
`result_user_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_limited_backoffice_action_idempotency`
(`actor_user_id`, `action_type`, `idempotency_key_hash`),
KEY `idx_limited_backoffice_action_result` (`result_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'collected_order_invoices' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `collected_order_invoices` (
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
it('keeps limited-backoffice login grants short-lived, hashed, one-time, and permission-scoped', function (): void {
$service = file_get_contents(__DIR__ . '/../../../classes/limited_backoffice_login_grant_service.php');
$schema = file_get_contents(__DIR__ . '/../../../classes/limited_backoffice_schema_bootstrap.php');
$route = file_get_contents(__DIR__ . '/../../../routes/limitedBackofficeRoute.php');
$accountDeletion = file_get_contents(__DIR__ . '/../../../classes/account_deletion_service.php');
$employeeService = file_get_contents(__DIR__ . '/../../../classes/limited_backoffice_service.php');
$runtimeOpenApi = file_get_contents(__DIR__ . '/../../../openapi.yaml');
$authoritativeOpenApiPath = __DIR__ . '/../../../../../../openapi.yaml';
$openApiContracts = [$runtimeOpenApi];
if (is_file($authoritativeOpenApiPath)) {
$openApiContracts[] = file_get_contents($authoritativeOpenApiPath);
}
expect($service)
->toContain("public const MAX_TTL_SECONDS = 900;")
->toContain("\$secretHash = hash('sha256', \$bearer);")
->toContain("hash_hmac(")
->toContain("bearerForIdempotency")
->toContain("isReplayableGrant")
->toContain("':' . \$ttlSeconds")
->toContain("(int)(\$row['expires_at'] ?? 0) > time()")
->toContain('lock employee -> grant')
->toContain('lockActiveManagedEmployee')
->toContain('lockAuthorizedActor')
->toContain("account_deletion_service::principalIsBlocked('customer', \$actorUserId)")
->toContain('Login grant actor is no longer authorized.')
->toContain('limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES')
->toContain('FROM `groups_permissions`')
->toContain('including every department_access_* row')
->toContain("'group_id' => \$groupId")
->toContain('in_array($errorCode, [1205, 1213], true)')
->toContain('usleep(1000 * ($attempt + 1))')
->toContain("=== 'superuser'")
->toContain('Login grant target group is shared.')
->toContain('FOR UPDATE')
->toContain('`consumed_at` = UTC_TIMESTAMP()')
->toContain('`revoked_at` = UTC_TIMESTAMP()')
->toContain('LOGIN_GRANT_IDEMPOTENCY_CONFLICT')
->not->toContain("'Created one-time login grant ' . \$bearer")
->not->toContain("'Exchanged one-time login grant ' . \$bearer");
expect($schema)
->toContain('`secret_hash` CHAR(64) NOT NULL')
->not->toContain('`secret` VARCHAR');
expect($route)
->toContain("'/limited-backoffice/employees/{employeeId}/login-grants'")
->toContain('limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES');
expect($accountDeletion)
->toContain("self::tableExists('limited_backoffice_login_grants')")
->toContain('(target_user_id = $id OR actor_user_id = $id)')
->toMatch('/limited_backoffice_employees[\\s\\S]+FOR UPDATE[\\s\\S]+limited_backoffice_login_grants[\\s\\S]+deleteTokensForPrincipal/u')
->toContain('Unable to revoke deleted principal login grants.');
expect($employeeService)
->toContain("hash_hmac('sha256', \$payloadJson, \$this->idempotencyDigestKey())")
->toContain('$this->assertDepartmentSubset($manager, $currentDepartmentIds);')
->toContain('accessibleDepartmentIdsForGroup(int $groupId)')
->toContain('$this->accessibleDepartmentIdsForGroup($authoritativeGroupId)')
->toMatch('/accessibleDepartmentIdsForGroup[\\s\\S]+\\$permission === \'superuser\'[\\s\\S]+return \\$this->allDepartmentIds\\(\\)/u')
->toContain('$this->assertManagedTargetIsSafe($employee);')
->toMatch('/limited_backoffice_employees[\\s\\S]+FOR UPDATE[\\s\\S]+replaceGroupPermissions/u');
foreach ($openApiContracts as $openApi) {
expect($openApi)
->toContain('/auth/limited-backoffice-login-grants/exchange:')
->toContain('/limited-backoffice/employees/{employeeId}/login-grants:')
->toContain('oneOf:')
->toContain('required: [preflight]')
->not->toContain('token: {type: string, writeOnly: true}')
->not->toContain('login_path: {type: string, writeOnly: true}');
}
});