Files
api/services/nginx/app/tests/Unit/LimitedBackoffice/LoginGrantSecurityContractTest.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

79 lines
4.2 KiB
PHP

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