## Summary - notify customers by SMS with approve/deny links when a subuser requests access - notify subusers by SMS after approval or denial, including manual grant changes - support subuser password reset and authenticated password changes - add read-only token previews followed by explicit POST confirmation - store short-lived one-time purpose-bound action tokens only as SHA-256 digests - serialize grant decisions transactionally to prevent conflicting concurrent actions - document the API contract in OpenAPI ## Security - generic reset responses reduce account enumeration - URL tokens are removed from browser history after frontend bootstrap - approval previews never mutate state - concurrent decisions lock the exact grant row - SMS failures remain non-fatal and are returned as delivery status Residual risk: existing subuser sessions cannot all be centrally invalidated after password reset because there is no per-subuser session index; they expire normally within the existing session lifetime. ## Verification - backend Pest: 14 tests, 91 assertions - PHP syntax checks passed - focused PHPStan passed - OpenAPI YAML parsed successfully - `git diff --check` passed Database-backed API integration tests were unavailable because the local environment lacks the required database configuration. ## Paired delivery Paired Frontend PR: https://github.com/copenhagentruckwash/pleno-vue/pull/231 Both PRs are required before completion. The frontend PR contains the responsive visual comparisons. Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
114 lines
3.2 KiB
PHP
114 lines
3.2 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
class subusers_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;
|
|
}
|
|
|
|
self::ensureColumn(
|
|
'subuser_grants',
|
|
'assigned_vehicle_id',
|
|
'INT NULL AFTER `subuser`'
|
|
);
|
|
self::ensureIndex(
|
|
'subuser_grants',
|
|
'idx_subuser_grants_assigned_vehicle_id',
|
|
'`assigned_vehicle_id`'
|
|
);
|
|
self::ensureColumn(
|
|
'subusers',
|
|
'phone_verified_at',
|
|
'DATETIME NULL AFTER `phone`'
|
|
);
|
|
self::ensureColumn(
|
|
'subusers',
|
|
'email_verified_at',
|
|
'DATETIME NULL AFTER `email`'
|
|
);
|
|
self::ensureTable(
|
|
'subuser_action_tokens',
|
|
<<<'SQL'
|
|
CREATE TABLE IF NOT EXISTS `subuser_action_tokens` (
|
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
`token_hash` CHAR(64) NOT NULL,
|
|
`purpose` VARCHAR(32) NOT NULL,
|
|
`subuser_id` INT UNSIGNED NOT NULL,
|
|
`grant_id` INT UNSIGNED NULL,
|
|
`customer_number` INT NULL,
|
|
`expires_at` DATETIME NOT NULL,
|
|
`used_at` DATETIME NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_subuser_action_token_hash` (`token_hash`),
|
|
KEY `idx_subuser_action_token_subject` (`subuser_id`, `purpose`, `used_at`),
|
|
KEY `idx_subuser_action_token_expiry` (`expires_at`, `used_at`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
SQL
|
|
);
|
|
|
|
self::$initialized = true;
|
|
}
|
|
|
|
private static function ensureTable(string $table, string $definition): void
|
|
{
|
|
global $db;
|
|
|
|
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
|
if ($table === '') {
|
|
return;
|
|
}
|
|
$db->query($definition);
|
|
}
|
|
|
|
private static function ensureColumn(string $table, string $column, string $definition): void
|
|
{
|
|
global $db;
|
|
|
|
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
|
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
|
if ($table === '' || $column === '') {
|
|
return;
|
|
}
|
|
|
|
$columnSql = $db->escape_string($column);
|
|
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$columnSql'");
|
|
if ($result !== false && $result->num_rows > 0) {
|
|
return;
|
|
}
|
|
|
|
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
|
}
|
|
|
|
private static function ensureIndex(string $table, string $index, string $columns): void
|
|
{
|
|
global $db;
|
|
|
|
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
|
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
|
|
if ($table === '' || $index === '') {
|
|
return;
|
|
}
|
|
|
|
$indexSql = $db->escape_string($index);
|
|
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
|
|
if ($result !== false && $result->num_rows > 0) {
|
|
return;
|
|
}
|
|
|
|
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
|
}
|
|
}
|