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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user