## 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.
79 lines
4.2 KiB
PHP
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}');
|
|
}
|
|
});
|