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
@@ -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');