Files
api/services/nginx/app/classes/limited_backoffice_schema_bootstrap.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

83 lines
3.1 KiB
PHP

<?php
namespace classes;
class limited_backoffice_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`managed_group_id` INT NOT NULL,
`role_key` VARCHAR(64) NOT NULL,
`department_ids` LONGTEXT NOT NULL,
`created_by_user_id` INT NOT NULL,
`updated_by_user_id` INT NULL,
`deactivated_at` DATETIME NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_limited_backoffice_employees_user_id` (`user_id`),
KEY `idx_limited_backoffice_employees_group_id` (`managed_group_id`),
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `limited_backoffice_login_grants` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`grant_id` CHAR(32) NOT NULL,
`secret_hash` CHAR(64) NOT NULL,
`target_user_id` INT NOT NULL,
`actor_user_id` INT NOT NULL,
`purpose` VARCHAR(64) NOT NULL,
`idempotency_key_hash` CHAR(64) NULL,
`expires_at` BIGINT UNSIGNED NOT NULL,
`consumed_at` DATETIME NULL,
`revoked_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_limited_backoffice_login_grants_grant_id` (`grant_id`),
UNIQUE KEY `uniq_limited_backoffice_login_grants_secret_hash` (`secret_hash`),
UNIQUE KEY `uniq_limited_backoffice_login_grants_idempotency` (`actor_user_id`, `target_user_id`, `purpose`, `idempotency_key_hash`),
KEY `idx_limited_backoffice_login_grants_target` (`target_user_id`, `expires_at`),
KEY `idx_limited_backoffice_login_grants_expiry` (`expires_at`),
KEY `idx_limited_backoffice_login_grants_state` (`consumed_at`, `revoked_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `limited_backoffice_action_idempotency` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`actor_user_id` INT NOT NULL,
`action_type` VARCHAR(64) NOT NULL,
`idempotency_key_hash` CHAR(64) NOT NULL,
`payload_hash` CHAR(64) NOT NULL,
`result_user_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_limited_backoffice_action_idempotency`
(`actor_user_id`, `action_type`, `idempotency_key_hash`),
KEY `idx_limited_backoffice_action_result` (`result_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
self::$initialized = true;
}
}