## 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.
83 lines
3.1 KiB
PHP
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;
|
|
}
|
|
}
|