Files
api/services/nginx/app/classes/limited_backoffice_login_grant_service.php
T
Jeppe B 710baad28e 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.
2026-07-29 00:01:13 +02:00

580 lines
22 KiB
PHP

<?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();
}
}