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]);