Add system status displays for Minio and Redis, and enhance backup configuration

This commit is contained in:
Jeppe Bundgaard
2026-07-13 10:08:00 +02:00
parent fa1ade555f
commit 012e5366ba
55 changed files with 7968 additions and 706 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,127 @@
<?php
namespace classes;
class backup_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(
"CREATE TABLE IF NOT EXISTS backup_records (
backup_uuid VARCHAR(64) NOT NULL PRIMARY KEY,
name VARCHAR(191) NOT NULL,
description TEXT NULL,
source VARCHAR(32) NOT NULL DEFAULT 'manual',
status VARCHAR(32) NOT NULL DEFAULT 'queued',
schema_version INT UNSIGNED NOT NULL DEFAULT 2,
storage_bucket VARCHAR(191) NOT NULL DEFAULT 'backups',
storage_prefix VARCHAR(255) NOT NULL,
manifest_key VARCHAR(255) NULL,
manifest_sha256 CHAR(64) NULL,
encryption_key_id VARCHAR(191) NULL,
component_count INT UNSIGNED NOT NULL DEFAULT 0,
object_count INT UNSIGNED NOT NULL DEFAULT 0,
total_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
requested_by_user_id INT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
verified_at DATETIME NULL,
expires_at DATETIME NULL,
last_error TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_backup_records_status_created (status, created_at),
KEY idx_backup_records_verified (verified_at),
KEY idx_backup_records_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS backup_components (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
backup_uuid VARCHAR(64) NOT NULL,
component_type VARCHAR(32) NOT NULL,
logical_name VARCHAR(191) NOT NULL,
source_bucket VARCHAR(191) NULL,
source_prefix VARCHAR(255) NULL,
storage_key VARCHAR(255) NULL,
manifest_key VARCHAR(255) NULL,
object_count INT UNSIGNED NOT NULL DEFAULT 0,
byte_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
content_sha256 CHAR(64) NULL,
encrypted_sha256 CHAR(64) NULL,
encryption_key_id VARCHAR(191) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
error_message TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_backup_components_backup (backup_uuid),
KEY idx_backup_components_status (status),
KEY idx_backup_components_type_name (component_type, logical_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS backup_jobs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
job_type VARCHAR(32) NOT NULL,
backup_uuid VARCHAR(64) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0,
progress_message VARCHAR(255) NULL,
payload_json LONGTEXT NULL,
result_json LONGTEXT NULL,
actor_user_id INT NULL,
locked_at DATETIME NULL,
lock_owner VARCHAR(191) NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
error_message TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_backup_jobs_status_created (status, created_at),
KEY idx_backup_jobs_backup (backup_uuid),
KEY idx_backup_jobs_type_status (job_type, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS backup_restore_audit (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
restore_job_id BIGINT UNSIGNED NULL,
preview_job_id BIGINT UNSIGNED NULL,
backup_uuid VARCHAR(64) NOT NULL,
actor_user_id INT NULL,
target_environment VARCHAR(64) NOT NULL DEFAULT 'production',
confirmation_fingerprint CHAR(64) NULL,
reason TEXT NULL,
ip_address VARCHAR(64) NULL,
user_agent VARCHAR(255) NULL,
pre_restore_backup_uuid VARCHAR(64) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
started_at DATETIME NULL,
completed_at DATETIME NULL,
error_message TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_backup_restore_audit_backup (backup_uuid),
KEY idx_backup_restore_audit_job (restore_job_id),
KEY idx_backup_restore_audit_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
File diff suppressed because it is too large Load Diff
@@ -64,6 +64,11 @@ class coolify_api_client
return $this->request('GET', '/services');
}
public function listApplications(): array
{
return $this->request('GET', '/applications');
}
public function listGithubApps(): array
{
return $this->request('GET', '/github-apps');
@@ -169,6 +174,11 @@ class coolify_api_client
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
}
public function stopService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/stop');
}
public function stopApplication(string $uuid): array
{
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
@@ -179,6 +189,11 @@ class coolify_api_client
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
}
public function deleteApplication(string $uuid): array
{
return $this->request('DELETE', '/applications/' . rawurlencode($uuid));
}
public function listDeployments(): array
{
return $this->request('GET', '/deployments');
@@ -106,6 +106,31 @@ class cron_scheduler
];
}
public function markExpiredRunningRuns(): int
{
$this->ensureReady();
$now = date('Y-m-d H:i:s');
$message = 'Task lock expired before completion.';
$this->query(
"UPDATE cron_task_runs r
INNER JOIN cron_task_state s ON s.task_id = r.task_id AND s.current_run_id = r.id
SET r.status = 'timed_out',
r.completed_at = COALESCE(s.locked_until, " . $this->sql($now) . "),
r.error_message = COALESCE(r.error_message, " . $this->sql($message) . "),
s.current_run_id = NULL,
s.locked_until = NULL,
s.lock_owner = NULL,
s.last_status = 'timed_out',
s.last_error = " . $this->sql($message) . "
WHERE r.status = 'running'
AND s.locked_until IS NOT NULL
AND s.locked_until < " . $this->sql($now)
);
return $this->affectedRows();
}
public function runTask(
string $task_id_or_legacy_name,
string $source = 'manual',
@@ -61,6 +61,37 @@ class cron_schema_bootstrap
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS cron_worker_state (
worker_id VARCHAR(191) NOT NULL PRIMARY KEY,
name VARCHAR(191) NOT NULL,
hostname VARCHAR(191) NULL,
pid INT UNSIGNED NULL,
source VARCHAR(64) NOT NULL DEFAULT 'coolify_worker',
status VARCHAR(32) NOT NULL DEFAULT 'starting',
release_channel_id BIGINT UNSIGNED NULL,
release_target_id BIGINT UNSIGNED NULL,
coolify_resource_uuid VARCHAR(128) NULL,
coolify_resource_type VARCHAR(32) NULL,
commit_sha VARCHAR(64) NULL,
poll_seconds INT UNSIGNED NOT NULL DEFAULT 15,
last_run_count INT UNSIGNED NOT NULL DEFAULT 0,
last_stale_run_count INT UNSIGNED NOT NULL DEFAULT 0,
last_error TEXT NULL,
started_at DATETIME NULL,
last_heartbeat_at DATETIME NULL,
last_loop_started_at DATETIME NULL,
last_loop_finished_at DATETIME NULL,
stopped_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_worker_state_heartbeat (last_heartbeat_at),
KEY idx_cron_worker_state_status (status),
KEY idx_cron_worker_state_release_target (release_target_id),
KEY idx_cron_worker_state_coolify_resource (coolify_resource_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
+322
View File
@@ -0,0 +1,322 @@
<?php
namespace classes;
use Throwable;
class cron_worker
{
private cron_scheduler $scheduler;
private string $worker_id;
private string $name;
private string $source;
private int $poll_seconds;
private int $heartbeat_seconds;
private int $max_runtime_seconds;
private bool $should_stop = false;
private int $last_heartbeat = 0;
public function __construct(?cron_scheduler $scheduler = null, array $options = [])
{
$this->scheduler = $scheduler ?? new cron_scheduler();
$this->name = $this->stringOption($options, 'name', 'CRON_WORKER_NAME', 'cron-worker');
$this->worker_id = $this->stringOption($options, 'worker_id', 'CRON_WORKER_ID', $this->name);
$this->source = $this->stringOption($options, 'source', 'CRON_WORKER_SOURCE', 'coolify_worker');
$this->poll_seconds = $this->intOption($options, 'poll_seconds', 'CRON_WORKER_POLL_SECONDS', 15, 1, 300);
$this->heartbeat_seconds = $this->intOption($options, 'heartbeat_seconds', 'CRON_WORKER_HEARTBEAT_SECONDS', 30, 5, 300);
$this->max_runtime_seconds = $this->intOption($options, 'max_runtime_seconds', 'CRON_WORKER_MAX_RUNTIME_SECONDS', 0, 0, 86400);
}
public function run(): int
{
if (!$this->boolOption('CRON_WORKER_ENABLED', true)) {
$this->heartbeat('disabled', 0, 0, null, true);
return 0;
}
$this->registerSignalHandlers();
$started = time();
$this->heartbeat('starting', 0, 0, null, true);
while (!$this->should_stop) {
$result = $this->tick();
$this->writeStatusLine($result);
if ($this->max_runtime_seconds > 0 && time() - $started >= $this->max_runtime_seconds) {
$this->should_stop = true;
break;
}
$this->sleepUntilNextPoll();
}
$this->heartbeat('stopped', 0, 0, null, true, true);
return 0;
}
public function tick(): array
{
$this->heartbeat('running');
$loopStartedAt = date('Y-m-d H:i:s');
$staleRuns = 0;
$ran = ['count' => 0, 'ran' => []];
$error = null;
$status = 'running';
try {
$staleRuns = $this->scheduler->markExpiredRunningRuns();
$ran = $this->scheduler->runDue($this->source);
} catch (Throwable $throwable) {
$status = 'failed';
$error = $throwable->getMessage();
}
$this->heartbeat($status, (int)($ran['count'] ?? 0), $staleRuns, $error, true, false, $loopStartedAt);
return [
'worker_id' => $this->worker_id,
'status' => $status,
'ran' => (int)($ran['count'] ?? 0),
'stale_runs' => $staleRuns,
'error' => $error,
];
}
public function listWorkers(): array
{
cron_schema_bootstrap::ensureTables();
$rows = $this->fetchAll('SELECT * FROM cron_worker_state ORDER BY last_heartbeat_at DESC, worker_id');
$workers = [];
foreach ($rows as $row) {
$workers[] = $this->publicWorker($row);
}
return [
'workers' => $workers,
'summary' => [
'total' => count($workers),
'running' => count(array_filter($workers, static fn(array $worker): bool => ($worker['status'] ?? '') === 'running')),
'stale' => count(array_filter($workers, static fn(array $worker): bool => (bool)($worker['stale'] ?? false))),
],
];
}
private function registerSignalHandlers(): void
{
if (!function_exists('pcntl_signal')) {
return;
}
if (function_exists('pcntl_async_signals')) {
pcntl_async_signals(true);
}
pcntl_signal(SIGTERM, function (): void {
$this->should_stop = true;
});
pcntl_signal(SIGINT, function (): void {
$this->should_stop = true;
});
}
private function sleepUntilNextPoll(): void
{
$remaining = $this->poll_seconds;
while ($remaining > 0 && !$this->should_stop) {
$sleep = min(1, $remaining);
sleep($sleep);
$remaining -= $sleep;
if (time() - $this->last_heartbeat >= $this->heartbeat_seconds) {
$this->heartbeat('running');
}
}
}
private function heartbeat(
string $status,
int $runCount = 0,
int $staleRunCount = 0,
?string $error = null,
bool $force = false,
bool $stopped = false,
?string $loopStartedAt = null
): void {
if (!$force && time() - $this->last_heartbeat < $this->heartbeat_seconds) {
return;
}
cron_schema_bootstrap::ensureTables();
$this->last_heartbeat = time();
$now = date('Y-m-d H:i:s');
$workerId = $this->sql($this->worker_id);
$name = $this->sql($this->name);
$hostname = $this->nullableSql(gethostname() ?: null);
$pid = getmypid() ?: 0;
$source = $this->sql($this->source);
$statusSql = $this->sql($status);
$releaseChannelId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_CHANNEL_ID'));
$releaseTargetId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_TARGET_ID'));
$resourceUuid = $this->nullableSql($this->env('COOLIFY_RESOURCE_UUID') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_UUID'));
$resourceType = $this->nullableSql($this->env('COOLIFY_RESOURCE_TYPE') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_TYPE') ?: 'application');
$commitSha = $this->nullableSql($this->commitSha());
$errorSql = $this->nullableSql($error);
$loopStarted = $this->nullableSql($loopStartedAt);
$stoppedAt = $stopped ? $this->sql($now) : 'NULL';
$this->query(
"INSERT INTO cron_worker_state (
worker_id, name, hostname, pid, source, status, release_channel_id, release_target_id,
coolify_resource_uuid, coolify_resource_type, commit_sha, poll_seconds, last_run_count,
last_stale_run_count, last_error, started_at, last_heartbeat_at, last_loop_started_at,
last_loop_finished_at, stopped_at
) VALUES (
$workerId, $name, $hostname, $pid, $source, $statusSql, $releaseChannelId, $releaseTargetId,
$resourceUuid, $resourceType, $commitSha, $this->poll_seconds, $runCount,
$staleRunCount, $errorSql, $this->sql($now), $this->sql($now), $loopStarted,
$this->sql($now), $stoppedAt
)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
hostname = VALUES(hostname),
pid = VALUES(pid),
source = VALUES(source),
status = VALUES(status),
release_channel_id = VALUES(release_channel_id),
release_target_id = VALUES(release_target_id),
coolify_resource_uuid = VALUES(coolify_resource_uuid),
coolify_resource_type = VALUES(coolify_resource_type),
commit_sha = VALUES(commit_sha),
poll_seconds = VALUES(poll_seconds),
last_run_count = VALUES(last_run_count),
last_stale_run_count = VALUES(last_stale_run_count),
last_error = VALUES(last_error),
last_heartbeat_at = VALUES(last_heartbeat_at),
last_loop_started_at = COALESCE(VALUES(last_loop_started_at), last_loop_started_at),
last_loop_finished_at = VALUES(last_loop_finished_at),
stopped_at = VALUES(stopped_at)"
);
}
private function publicWorker(array $row): array
{
$heartbeatAt = (string)($row['last_heartbeat_at'] ?? '');
$heartbeatTs = strtotime($heartbeatAt);
$threshold = max(60, ((int)($row['poll_seconds'] ?? 15) * 4) + 30);
$age = $heartbeatTs !== false ? max(0, time() - $heartbeatTs) : null;
return [
'worker_id' => (string)($row['worker_id'] ?? ''),
'name' => (string)($row['name'] ?? ''),
'hostname' => $row['hostname'] ?? null,
'pid' => isset($row['pid']) ? (int)$row['pid'] : null,
'source' => (string)($row['source'] ?? ''),
'status' => (string)($row['status'] ?? 'unknown'),
'release_channel_id' => isset($row['release_channel_id']) ? (int)$row['release_channel_id'] : null,
'release_target_id' => isset($row['release_target_id']) ? (int)$row['release_target_id'] : null,
'coolify_resource_uuid' => $row['coolify_resource_uuid'] ?? null,
'coolify_resource_type' => $row['coolify_resource_type'] ?? null,
'commit_sha' => $row['commit_sha'] ?? null,
'poll_seconds' => (int)($row['poll_seconds'] ?? 0),
'last_run_count' => (int)($row['last_run_count'] ?? 0),
'last_stale_run_count' => (int)($row['last_stale_run_count'] ?? 0),
'last_error' => $row['last_error'] ?? null,
'started_at' => $row['started_at'] ?? null,
'last_heartbeat_at' => $heartbeatAt !== '' ? $heartbeatAt : null,
'last_heartbeat_age_seconds' => $age,
'last_loop_started_at' => $row['last_loop_started_at'] ?? null,
'last_loop_finished_at' => $row['last_loop_finished_at'] ?? null,
'stopped_at' => $row['stopped_at'] ?? null,
'stale' => $age === null || $age > $threshold,
'stale_after_seconds' => $threshold,
];
}
private function writeStatusLine(array $result): void
{
echo '[' . date('Y-m-d H:i:s') . '][CRON_WORKER] '
. json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
. PHP_EOL;
}
private function stringOption(array $options, string $key, string $env, string $default): string
{
$value = trim((string)($options[$key] ?? $this->env($env) ?? ''));
return $value !== '' ? $value : $default;
}
private function intOption(array $options, string $key, string $env, int $default, int $min, int $max): int
{
$value = (int)($options[$key] ?? $this->env($env) ?? $default);
return max($min, min($max, $value));
}
private function boolOption(string $env, bool $default): bool
{
$value = $this->env($env);
if ($value === null || trim($value) === '') {
return $default;
}
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
}
private function commitSha(): string
{
foreach (['CRON_WORKER_COMMIT_SHA', 'API_COMMIT_SHA', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA'] as $key) {
$value = trim((string)($this->env($key) ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
private function env(string $key): ?string
{
$value = getenv($key);
if ($value !== false) {
return (string)$value;
}
return isset($_SERVER[$key]) ? (string)$_SERVER[$key] : null;
}
private function nullableInt(?string $value): string
{
$value = trim((string)$value);
if ($value === '' || filter_var($value, FILTER_VALIDATE_INT) === false) {
return 'NULL';
}
return (string)max(0, (int)$value);
}
private function nullableSql(?string $value): string
{
$value = $value !== null ? trim($value) : '';
return $value === '' ? 'NULL' : $this->sql($value);
}
private function fetchAll(string $sql): array
{
$result = $this->query($sql);
if ($result === false || $result === true) {
return [];
}
return $result->fetch_all(MYSQLI_ASSOC);
}
private function query(string $sql): \mysqli_result|bool
{
global $db;
return $db->query($sql);
}
private function sql(string $value): string
{
global $db;
return "'" . $db->escape_string($value) . "'";
}
}
+41 -12
View File
@@ -177,15 +177,19 @@ class db
return $this->database;
}
public function getPort(): int
{
return $this->port;
}
public function getSslMode(): string
{
return $this->ssl_mode;
}
public function backupDatabase(string $path): bool
{
// Save the database to the path
// Build a safe mysqldump command with configurable SSL (MariaDB-compatible flags)
$mode = strtoupper(trim($this->ssl_mode));
// Map ssl_mode to MariaDB client flags
// DISABLED => --skip-ssl (no TLS)
// PREFERRED => (no flag; client decides)
// REQUIRED/VERIFY_* => --ssl (enable TLS without strict verification unless CA materials provided)
$sslFlag = '';
switch ($mode) {
case 'DISABLED':
@@ -201,17 +205,42 @@ class db
$sslFlag = '--ssl';
break;
}
$host = escapeshellarg($this->host);
$user = escapeshellarg($this->user);
$pass = escapeshellarg($this->password);
$db = escapeshellarg($this->database);
$port = (int)$this->port;
$outfile = escapeshellarg($path);
$sslPart = $sslFlag !== '' ? ($sslFlag . ' ') : '';
$command = "mysqldump {$sslPart}-h $host -P $port -u $user --password=$pass $db > $outfile 2>&1";
exec($command, $output, $return);
// Check if the command was successful
return $return === 0;
$command = "mysqldump {$sslPart}--single-transaction --quick --routines --triggers --events --hex-blob -h $host -P $port -u $user $db";
$directory = dirname($path);
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
return false;
}
$environment = array_merge(getenv() ?: [], $_ENV);
$environment['MYSQL_PWD'] = $this->password;
$descriptors = [
0 => ['pipe', 'r'],
1 => ['file', $path, 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open($command, $descriptors, $pipes, null, $environment);
if (!is_resource($process)) {
return false;
}
fclose($pipes[0]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$return = proc_close($process);
if ($return !== 0 && is_string($stderr) && $stderr !== '') {
@file_put_contents($path . '.error.log', $stderr);
}
return $return === 0 && is_file($path) && filesize($path) !== false;
}
public function getView(string $view): array
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
<?php
namespace classes;
class security_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$queries = [
"CREATE TABLE IF NOT EXISTS security_firewall_rules (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
action VARCHAR(16) NOT NULL,
target_type VARCHAR(32) NOT NULL,
target_value VARCHAR(255) NOT NULL,
route_pattern VARCHAR(255) NULL,
priority INT NOT NULL DEFAULT 100,
reason TEXT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
expires_at DATETIME NULL,
metadata_json LONGTEXT NULL,
created_by INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_security_firewall_rules_active (enabled, deleted_at, expires_at),
INDEX idx_security_firewall_rules_target (target_type, target_value),
INDEX idx_security_firewall_rules_priority (priority)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS security_policy_rules (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
rule_key VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
threshold_count INT NOT NULL,
window_seconds INT NOT NULL,
mode VARCHAR(16) NOT NULL DEFAULT 'observe',
exempt_permission_nodes_json LONGTEXT NULL,
updated_by INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_security_policy_rules_key (rule_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS security_policy_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
rule_key VARCHAR(64) NOT NULL,
subject_type VARCHAR(32) NOT NULL,
subject_key VARCHAR(191) NOT NULL,
route_path VARCHAR(255) NULL,
route_template VARCHAR(255) NULL,
method VARCHAR(16) NULL,
source_ip VARCHAR(64) NULL,
customer_number INT NULL,
user_id INT NULL,
subuser_id INT NULL,
metadata_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_security_policy_events_window (rule_key, subject_type, subject_key, created_at),
INDEX idx_security_policy_events_created (created_at),
INDEX idx_security_policy_events_customer (customer_number, created_at),
INDEX idx_security_policy_events_ip (source_ip, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS security_incidents (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
incident_key VARCHAR(191) NOT NULL,
type VARCHAR(64) NOT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'medium',
status VARCHAR(32) NOT NULL DEFAULT 'open',
title VARCHAR(255) NOT NULL,
source_ip VARCHAR(64) NULL,
customer_number INT NULL,
user_id INT NULL,
subuser_id INT NULL,
route_path VARCHAR(255) NULL,
route_template VARCHAR(255) NULL,
method VARCHAR(16) NULL,
related_rule_id BIGINT UNSIGNED NULL,
related_firewall_rule_id BIGINT UNSIGNED NULL,
occurrence_count INT NOT NULL DEFAULT 1,
metadata_json LONGTEXT NULL,
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
resolved_by INT NULL,
resolved_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_security_incidents_key (incident_key),
INDEX idx_security_incidents_status_seen (status, last_seen_at),
INDEX idx_security_incidents_type_seen (type, last_seen_at),
INDEX idx_security_incidents_customer_seen (customer_number, last_seen_at),
INDEX idx_security_incidents_ip_seen (source_ip, last_seen_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS security_incident_notes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
incident_id BIGINT UNSIGNED NOT NULL,
note TEXT NOT NULL,
created_by INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_security_incident_notes_incident (incident_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $query) {
$db->query($query);
}
self::$initialized = true;
}
public static function tablesExist(): bool
{
global $db;
$database = $db->escape_string($db->getDatabase());
$result = $db->query(
"SELECT COUNT(*) AS count
FROM information_schema.tables
WHERE table_schema = '{$database}'
AND table_name IN (
'security_firewall_rules',
'security_policy_rules',
'security_policy_events',
'security_incidents',
'security_incident_notes'
)"
);
$row = $result ? $result->fetch_assoc() : ['count' => 0];
return (int)($row['count'] ?? 0) === 5;
}
}
@@ -54,6 +54,7 @@ class selfserve_schema_bootstrap
department_id INT NOT NULL,
machine_type_id INT NULL,
customer_number INT NULL,
subuser_id INT NULL,
vehicle_id INT NULL,
vehicle_type_id INT NULL,
reg VARCHAR(255) NOT NULL,
@@ -73,6 +74,8 @@ class selfserve_schema_bootstrap
INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg),
INDEX idx_selfserve_wash_sessions_status (status),
INDEX idx_selfserve_wash_sessions_customer (customer_number),
INDEX idx_selfserve_wash_sessions_subuser_active (subuser_id, completed_at),
INDEX idx_selfserve_wash_sessions_customer_subuser_active (customer_number, subuser_id, completed_at),
INDEX idx_selfserve_wash_sessions_created_at (created_at),
INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at),
INDEX idx_selfserve_wash_sessions_order (order_id)
@@ -216,11 +219,26 @@ class selfserve_schema_bootstrap
'dynamic_images_vehicle_type',
'ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons'
);
self::ensureColumn(
'selfserve_wash_sessions',
'subuser_id',
'ALTER TABLE selfserve_wash_sessions ADD COLUMN subuser_id INT NULL AFTER customer_number'
);
self::ensureColumn(
'selfserve_wash_sessions',
'wash_started_at',
'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'
);
self::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_subuser_active',
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_subuser_active (subuser_id, completed_at)'
);
self::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_customer_subuser_active',
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_customer_subuser_active (customer_number, subuser_id, completed_at)'
);
self::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_department_completed',
@@ -0,0 +1,72 @@
<?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::$initialized = true;
}
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)");
}
}
@@ -27,9 +27,6 @@ class superuser_system_status_service
$dependencies['database']['status'] ?? 'down',
$dependencies['redis']['status'] ?? 'down',
$dependencies['minio']['status'] ?? 'down',
$dependencies['database']['replication']['status'] ?? 'not_configured',
$dependencies['redis']['replication']['status'] ?? 'not_configured',
$dependencies['minio']['replication']['status'] ?? 'not_configured',
];
foreach ($modules as $module) {
if (($module['enabled'] ?? false) === true) {
@@ -298,16 +295,6 @@ class superuser_system_status_service
$database = $this->probeDatabase();
$redis = $this->probeRedis();
$minio = $this->probeMinio();
try {
$replicationManager = new replication_manager();
$database['replication'] = $replicationManager->dependencyReplication('database');
$redis['replication'] = $replicationManager->dependencyReplication('redis');
$minio['replication'] = $replicationManager->dependencyReplication('minio');
} catch (Throwable $throwable) {
$database['replication'] = $this->replicationStatusFallback('database', $throwable);
$redis['replication'] = $this->replicationStatusFallback('redis', $throwable);
$minio['replication'] = $this->replicationStatusFallback('minio', $throwable);
}
if (($redis['status'] ?? '') === 'down') {
$this->pushWarning(
@@ -333,19 +320,6 @@ class superuser_system_status_service
];
}
private function replicationStatusFallback(string $kind, Throwable $throwable): array
{
return [
'status' => 'degraded',
'min_percent' => 0.0,
'average_percent' => 0.0,
'replicas' => [],
'blockers' => [
'Replication status for ' . $kind . ' could not be loaded: ' . $throwable->getMessage(),
],
];
}
private function probeCpu(array &$warnings): array
{
$checkedAt = date('c');
@@ -990,12 +964,50 @@ class superuser_system_status_service
try {
$this->validateBackupsStore();
$health = $this->backupHealthSummary();
if (empty($health['encryption']['available'])) {
$error = (string)($health['encryption']['error'] ?? 'Backup encryption key is missing.');
return [
'status' => 'down',
'status_reason' => 'Backup encryption is not ready: ' . $error,
'status_reason_key' => 'backup_encryption_key_missing',
'status_reason_params' => ['error' => $error],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
}
if (empty($health['latest_verified_backup'])) {
return [
'status' => 'degraded',
'status_reason' => 'No verified backup is available for restore.',
'status_reason_key' => 'backup_no_verified_backup',
'status_reason_params' => [],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
}
if (empty($health['fresh'])) {
return [
'status' => 'degraded',
'status_reason' => 'Latest verified backup is stale.',
'status_reason_key' => 'backup_latest_verified_stale',
'status_reason_params' => ['age_seconds' => (string)($health['latest_verified_age_seconds'] ?? '')],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
}
return [
'status' => 'ok',
'status_reason' => 'Backup store connectivity confirmed.',
'status_reason_key' => 'backup_connectivity_confirmed',
'status_reason_params' => [],
'status_reason_params' => [
'latest_verified_age_seconds' => (string)($health['latest_verified_age_seconds'] ?? ''),
'encryption_key_id' => (string)($health['encryption']['key_id'] ?? ''),
],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
@@ -1539,6 +1551,11 @@ class superuser_system_status_service
new backup_store();
}
protected function backupHealthSummary(): array
{
return (new backup_store())->healthSummary();
}
protected function bootstrapSelfserveSchema(): void
{
selfserve_schema_bootstrap::ensureTables();
+4
View File
@@ -102,6 +102,10 @@ if ($args[1] === 'run') {
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running the cron script\n";
require_once 'cron/Cron.php';
break;
case 'cron-worker':
echo "[" . date('Y-m-d H:i:s') . "][CRON_WORKER] Starting cron worker\n";
(new \classes\cron_worker())->run();
break;
default:
echo "Invalid script name";
break;
+25 -1
View File
@@ -40,6 +40,8 @@ require_once __DIR__ . '/../classes/economic_transfer_executor.php';
const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
require_once __DIR__ . '/../classes/backup_schema_bootstrap.php';
require_once __DIR__ . '/../classes/backup_store.php';
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
require_once __DIR__ . '/../classes/cron_schedule.php';
require_once __DIR__ . '/../classes/cron_task_definition.php';
@@ -538,12 +540,34 @@ function backup(): void
{
try {
$backup = new backup_store();
$backup->createBackup();
$backup->enqueueCreateBackup(null, null, null, 'scheduled');
} catch (Exception $e) {
warn('Backup failed: ' . $e->getMessage());
}
}
function processBackupJobs(): array
{
try {
return (new backup_store())->processPendingJobs(3);
} catch (Exception $e) {
warn('Backup job processing failed: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
function pruneBackupRetention(): array
{
try {
$backup = new backup_store();
$queued = $backup->enqueueRetentionPrune();
return $backup->runJobById((int)$queued['job_id']);
} catch (Exception $e) {
warn('Backup retention prune failed: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
function SyncUserEconomicCustomerDiscounts(): void
{
$users_o = new users_o();
@@ -31,7 +31,7 @@ interface minio_backups_i
* Create a backup, upload it to the Minio bucket and return the UUID
* @return string The UUID of the backup
*/
public function createBackup(): string;
public function createBackup($backup_name = null, $backup_description = null): string;
/**
* Create the metadata for a backup
@@ -2,8 +2,22 @@
namespace backups;
require_once WD . '/modules/backups/config/backups_enabled_c.php';
require_once WD . '/modules/backups/config/backups_retention_recent_hours_c.php';
require_once WD . '/modules/backups/config/backups_retention_daily_days_c.php';
require_once WD . '/modules/backups/config/backups_retention_weekly_weeks_c.php';
require_once WD . '/modules/backups/config/backups_retention_monthly_months_c.php';
require_once WD . '/modules/backups/config/backups_app_data_enabled_c.php';
require_once WD . '/modules/backups/config/backups_verification_required_c.php';
require_once WD . '/modules/backups/config/backups_restore_enabled_c.php';
use backups\config\backups_app_data_enabled_c;
use backups\config\backups_enabled_c;
use backups\config\backups_restore_enabled_c;
use backups\config\backups_retention_daily_days_c;
use backups\config\backups_retention_monthly_months_c;
use backups\config\backups_retention_recent_hours_c;
use backups\config\backups_retention_weekly_weeks_c;
use backups\config\backups_verification_required_c;
use traits\module_config_t;
class backups_c
@@ -15,6 +29,13 @@ class backups_c
* @var backups_enabled_c
*/
public backups_enabled_c $enabled;
public backups_retention_recent_hours_c $retention_recent_hours;
public backups_retention_daily_days_c $retention_daily_days;
public backups_retention_weekly_weeks_c $retention_weekly_weeks;
public backups_retention_monthly_months_c $retention_monthly_months;
public backups_app_data_enabled_c $app_data_enabled;
public backups_verification_required_c $verification_required;
public backups_restore_enabled_c $restore_enabled;
public function __construct()
@@ -22,8 +43,22 @@ class backups_c
$this->setupConfig('Backups');
$this->allowUpdate([
backups_enabled_c::class,
backups_retention_recent_hours_c::class,
backups_retention_daily_days_c::class,
backups_retention_weekly_weeks_c::class,
backups_retention_monthly_months_c::class,
backups_app_data_enabled_c::class,
backups_verification_required_c::class,
backups_restore_enabled_c::class,
]);
$this->enabled = new backups_enabled_c();
$this->retention_recent_hours = new backups_retention_recent_hours_c();
$this->retention_daily_days = new backups_retention_daily_days_c();
$this->retention_weekly_weeks = new backups_retention_weekly_weeks_c();
$this->retention_monthly_months = new backups_retention_monthly_months_c();
$this->app_data_enabled = new backups_app_data_enabled_c();
$this->verification_required = new backups_verification_required_c();
$this->restore_enabled = new backups_restore_enabled_c();
}
}
@@ -0,0 +1,29 @@
<?php
namespace backups\config;
use Exception;
use traits\module_config_variable;
class backups_app_data_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Backups',
'app_data_enabled',
'bool',
true,
null,
'Whether app-owned object storage buckets are included in backups.',
'1',
false,
'true'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace backups\config;
use Exception;
use traits\module_config_variable;
class backups_restore_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Backups',
'restore_enabled',
'bool',
true,
null,
'Whether direct production restore execution is enabled for superusers.',
'0',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace backups\config;
use Exception;
use traits\module_config_variable;
class backups_retention_daily_days_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Backups',
'retention_daily_days',
'int',
true,
null,
'How many days of daily backup representatives are retained.',
'30',
false,
'30'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace backups\config;
use Exception;
use traits\module_config_variable;
class backups_retention_monthly_months_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Backups',
'retention_monthly_months',
'int',
true,
null,
'How many months of monthly backup representatives are retained.',
'3',
false,
'3'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace backups\config;
use Exception;
use traits\module_config_variable;
class backups_retention_recent_hours_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Backups',
'retention_recent_hours',
'int',
true,
null,
'How many hours of recent recovery points are protected before tiered pruning.',
'48',
false,
'48'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace backups\config;
use Exception;
use traits\module_config_variable;
class backups_retention_weekly_weeks_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Backups',
'retention_weekly_weeks',
'int',
true,
null,
'How many weeks of weekly backup representatives are retained.',
'8',
false,
'8'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace backups\config;
use Exception;
use traits\module_config_variable;
class backups_verification_required_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Backups',
'verification_required',
'bool',
true,
null,
'Whether a backup must pass verification before it is marked available for restore.',
'1',
false,
'true'
);
}
}
@@ -4,13 +4,37 @@ return [
[
'id' => 'backups.create_backup',
'legacy_name' => 'backup',
'name' => 'Create backup',
'description' => 'Creates the scheduled backup bundle through the configured backup store.',
'name' => 'Enqueue backup',
'description' => 'Queues the scheduled production recovery backup through the configured backup store.',
'module' => 'backups',
'handler' => 'backup',
'schedule' => ['type' => 'interval', 'seconds' => 43200],
'timeout_seconds' => 1800,
'estimated_duration_ms' => 60000,
'schedule' => ['type' => 'interval', 'seconds' => 3600],
'timeout_seconds' => 120,
'estimated_duration_ms' => 5000,
'priority' => 115,
],
[
'id' => 'backups.process_jobs',
'legacy_name' => null,
'name' => 'Process backup jobs',
'description' => 'Processes queued backup create, verify, restore, and prune jobs.',
'module' => 'backups',
'handler' => 'processBackupJobs',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 3600,
'estimated_duration_ms' => 60000,
'priority' => 116,
],
[
'id' => 'backups.prune_retention',
'legacy_name' => null,
'name' => 'Prune backup retention',
'description' => 'Queues tiered retention cleanup for old backup artifacts.',
'module' => 'backups',
'handler' => 'pruneBackupRetention',
'schedule' => ['type' => 'interval', 'seconds' => 86400],
'timeout_seconds' => 1800,
'estimated_duration_ms' => 30000,
'priority' => 117,
],
];
@@ -213,6 +213,18 @@
}
},
"/department/timebookings/departments/public": {
"get": {
"tags": ["Public Time Bookings"],
"summary": "List public departments with time bookings enabled",
"description": "Returns only visible, active departments where `bookingsystem_time_based_enabled` is `true`.",
"parameters": [ { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/search" }, { "$ref": "#/components/parameters/filters" }, { "$ref": "#/components/parameters/order" } ],
"responses": {
"200": { "description": "Departments", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopePublicTimeBookingDepartmentsList" } } } }
}
}
},
"/department/timebookings/opening-hours/public": {
"get": {
"tags": ["Public Time Bookings"],
@@ -339,6 +351,12 @@
{ "type": "object", "properties": { "data": { "$ref": "#/components/schemas/OpeningHours" } } }
]
},
"EnvelopePublicTimeBookingDepartmentsList": {
"allOf": [
{ "$ref": "#/components/schemas/Envelope" },
{ "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/PublicTimeBookingDepartment" } } } }
]
},
"EnvelopeBookingTypesList": {
"allOf": [
{ "$ref": "#/components/schemas/Envelope" },
@@ -396,6 +414,21 @@
"required": ["link"]
},
"PublicTimeBookingDepartment": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"description": { "type": "string" },
"address": { "type": "string" },
"longitude": { "type": "number", "format": "float" },
"latitude": { "type": "number", "format": "float" },
"order_priority": { "type": "integer" },
"time_booking_enabled": { "type": "boolean" }
},
"required": ["id", "name", "description", "address", "longitude", "latitude", "order_priority", "time_booking_enabled"]
},
"OpeningHours": {
"type": "object",
"properties": {
@@ -34,6 +34,7 @@ require_once WD . '/objects/selfserve_wash_session_answers_o.php';
require_once WD . '/objects/selfserve_wash_session_events_o.php';
require_once WD . '/objects/selfserve_wash_session_tasks_o.php';
require_once WD . '/objects/selfserve_wash_sessions_o.php';
require_once WD . '/objects/subusers_o.php';
use classes\selfserve;
use classes\selfserve_schema_bootstrap;
@@ -63,6 +64,7 @@ use objects\selfserve_wash_session_answers_o;
use objects\selfserve_wash_session_events_o;
use objects\selfserve_wash_session_tasks_o;
use objects\selfserve_wash_sessions_o;
use objects\subusers_o;
class selfserve_wash_flow implements selfserve_wash_flow_i
{
@@ -76,7 +78,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array
{
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
$actorSubuserId = $this->resolveActorSubuserId($options);
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
}
@@ -89,7 +92,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
{
$options['debug'] = true;
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
$actorSubuserId = $this->resolveActorSubuserId($options);
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
$response = $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
$response['simulator_version'] = 2;
$response['dry_run'] = true;
@@ -103,12 +107,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array
{
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
$actorSubuserId = $this->resolveActorSubuserId($options);
$mutationResult = $this->withSessionMutationLock(
$laneId,
$snapshot['reg'],
$snapshot['customer_number'],
function () use ($laneId, $snapshot, $options): array {
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
$actorSubuserId,
function () use ($laneId, $snapshot, $options, $actorSubuserId): array {
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
$createSession = (bool)($options['create_session'] ?? true);
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
@@ -139,10 +145,17 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$this->deriveBaseStatus($snapshot),
(bool)$snapshot['allowed'],
$this->buildSessionMetadata($snapshot),
$actorSubuserId,
);
} else {
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
$session->customer_number->set($snapshot['customer_number']);
if ($actorSubuserId !== null) {
$currentSubuserId = $this->nullableInt($session->subuser_id->value());
if ($currentSubuserId === null || $currentSubuserId === $actorSubuserId) {
$session->subuser_id->set($actorSubuserId);
}
}
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
$session->reg->set($snapshot['reg']);
@@ -158,6 +171,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
'allowed_services' => $snapshot['allowed_services'],
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
'subuser_id' => $actorSubuserId,
]);
return [
@@ -202,6 +216,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$lane = (new selfserve())->lane($laneId);
$effectiveReg = $normalizedReg ?? (string)$session->reg->value();
$customerNumber = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
$subuserId = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
if ($lane->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) {
$lane->setLaneStatus(selfserve_lane_status::OCCUPIED);
@@ -227,11 +242,13 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'lane_id' => $laneId,
'reg' => $effectiveReg,
'customer_number' => $customerNumber,
'subuser_id' => $subuserId,
]);
$actionContext = [
'lane_id' => $laneId,
'reg' => $effectiveReg,
'customer_number' => $customerNumber,
'subuser_id' => $subuserId,
'session_id' => (int)$session->id,
'source_payload' => $payload,
];
@@ -269,12 +286,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $this->getSessionSummary((int)$session->id);
}
public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null): bool
public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $subuserId = null): bool
{
$normalizedReg = $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg);
$session = $normalizedReg !== null
? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber)
: $this->findLatestOpenSessionByLane($laneId, $customerNumber);
? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber, $subuserId)
: $this->findLatestOpenSessionByLane($laneId, $customerNumber, $subuserId);
return $session->exists() && (bool)$session->machine_start_triggered->value();
}
@@ -403,10 +420,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
];
}, (new selfserve_wash_session_events_o())->listBySession($sessionId));
$sessionPayload = $session->asArray();
$sessionPayload['subuser'] = $this->formatSessionSubuser($this->nullableInt($session->subuser_id->value()));
return [
'session' => $session->asArray(),
'session' => $sessionPayload,
'lane' => $lane->exists() ? $lane->asArray() : null,
'machine_type' => $machineType,
'subuser' => $sessionPayload['subuser'],
'questions' => $answers,
'tasks' => $tasks,
'events' => $events,
@@ -430,15 +451,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $this->getSessionSummary((int)$session->id);
}
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true, ?int $subuserId = null): ?array
{
$session = $reg !== null
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
: $this->findLatestOpenSessionByLane($laneId, $customerNumber);
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber, $subuserId)
: $this->findLatestOpenSessionByLane($laneId, $customerNumber, $subuserId);
if (!$session->exists()) {
return null;
}
$resolvedSubuserId = $subuserId ?? $this->nullableInt($session->subuser_id->value());
$this->fillMissingWashStartedAtFromLaneRuntime($session, $laneId);
@@ -452,6 +474,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'lane_id' => $laneId,
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
'customer_number' => $customerNumber ?? ($session->customer_number->value() === null ? null : (int)$session->customer_number->value()),
'subuser_id' => $resolvedSubuserId,
'order_id' => $orderId,
]);
@@ -555,6 +578,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'vehicle' => $vehicleData,
'reg' => $normalizedReg,
'customer_number' => $resolvedCustomerNumber,
'subuser_id' => $this->resolveActorSubuserId($options),
'vehicle_type_id' => $vehicleTypeId,
'answers' => [],
'persisted_answers' => [],
@@ -746,6 +770,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'vehicle' => $vehicleData,
'reg' => $normalizedReg,
'customer_number' => $resolvedCustomerNumber,
'subuser_id' => $this->resolveActorSubuserId($options),
'vehicle_type_id' => $vehicleTypeId,
'answers' => $answers,
'persisted_answers' => $persistedAnswers,
@@ -854,6 +879,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'vehicle' => $snapshot['vehicle'],
'reg' => $snapshot['reg'],
'customer_number' => $snapshot['customer_number'],
'subuser_id' => $snapshot['subuser_id'] ?? null,
'vehicle_type_id' => $snapshot['vehicle_type_id'],
'questions' => $snapshot['questions'],
'tasks' => $snapshot['tasks'],
@@ -876,6 +902,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'session' => null,
'lane' => $snapshot['lane'],
'machine_type' => $snapshot['machine_type'],
'subuser_id' => $snapshot['subuser_id'] ?? null,
'questions' => [],
'tasks' => [],
'events' => [],
@@ -3259,13 +3286,47 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
(new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload);
}
/**
* @param array<string,mixed> $options
*/
protected function resolveActorSubuserId(array $options): ?int
{
return $this->nullableInt($options['subuser_id'] ?? null);
}
protected function formatSessionSubuser(?int $subuserId): ?array
{
if ($subuserId === null || $subuserId <= 0) {
return null;
}
try {
$subuser = (new subusers_o())->select($subuserId);
if (!$subuser->exists()) {
return [
'id' => $subuserId,
];
}
return [
'id' => (int)$subuser->id,
'name' => $subuser->name->value() === null ? null : (string)$subuser->name->value(),
'username' => $subuser->username->value() === null ? null : (string)$subuser->username->value(),
];
} catch (\Throwable) {
return [
'id' => $subuserId,
];
}
}
/**
* @param callable():array<string,mixed> $callback
* @return array<string,mixed>
*/
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, callable $callback): array
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, ?int $subuserId, callable $callback): array
{
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber);
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber, $subuserId);
$lock = $this->acquireSessionMutationLock($lockKey);
try {
@@ -3332,10 +3393,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
}
}
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber): string
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber, ?int $subuserId = null): string
{
return 'selfserve_session_mutation:' . (int)$laneId . ':' . sha1(
selfserve::standardize_registration($reg) . ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
selfserve::standardize_registration($reg)
. ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
. ':' . ($subuserId === null ? 'customer' : 'subuser:' . (int)$subuserId)
);
}
@@ -3345,6 +3408,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'allowed_services' => $snapshot['allowed_services'],
'machine_available' => (bool)$snapshot['machine_available'],
'machine_wash_enabled' => (bool)($snapshot['machine_wash_enabled'] ?? true),
'subuser_id' => $snapshot['subuser_id'] ?? null,
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
'config_version_id' => $snapshot['config_version_id'] ?? null,
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
@@ -3495,14 +3559,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $vehicleTypeId > 0 ? $vehicleTypeId : null;
}
protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null): selfserve_wash_sessions_o
protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null, ?int $subuserId = null): selfserve_wash_sessions_o
{
$session = new selfserve_wash_sessions_o();
$session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber);
$session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber, $subuserId);
return $session;
}
protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null): selfserve_wash_sessions_o
protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null, ?int $subuserId = null): selfserve_wash_sessions_o
{
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere(
[
@@ -3510,6 +3574,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'completed_at' => null,
'deleted_at' => null,
...($customerNumber !== null ? ['customer_number' => $customerNumber] : []),
...($subuserId !== null ? ['subuser_id' => $subuserId] : []),
],
['id', 'status']
);
+1 -1
View File
@@ -393,7 +393,7 @@ class departments_o extends db
public function isModuleTimeBookingsEnabled(): bool
{
self::requireSelected();
return (bool)$this->variables->getVariable('bookingsystem_time_based_enabled');
return $this->variables->getVariable('bookingsystem_time_based_enabled') === true;
}
/**
@@ -23,6 +23,7 @@ class selfserve_wash_sessions_o extends db
public object_property $department_id;
public object_property $machine_type_id;
public object_property $customer_number;
public object_property $subuser_id;
public object_property $vehicle_id;
public object_property $vehicle_type_id;
public object_property $reg;
@@ -56,13 +57,15 @@ class selfserve_wash_sessions_o extends db
?int $vehicleTypeId,
selfserve_wash_session_status $status,
bool $allowed = false,
?array $metadata = null
?array $metadata = null,
?int $subuserId = null
): self {
$this->id = self::add_object([
'lane_id' => $laneId,
'department_id' => $departmentId,
'machine_type_id' => $machineTypeId,
'customer_number' => $customerNumber,
'subuser_id' => $subuserId !== null && $subuserId > 0 ? $subuserId : null,
'vehicle_id' => $vehicleId,
'vehicle_type_id' => $vehicleTypeId,
'reg' => selfserve::standardize_registration($reg),
@@ -82,6 +85,7 @@ class selfserve_wash_sessions_o extends db
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
$this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false);
$this->customer_number = new object_property($this->table, $this->id, 'customer_number', 'int', false);
$this->subuser_id = new object_property($this->table, $this->id, 'subuser_id', 'int', false);
$this->vehicle_id = new object_property($this->table, $this->id, 'vehicle_id', 'int', false);
$this->vehicle_type_id = new object_property($this->table, $this->id, 'vehicle_type_id', 'int', false);
$this->reg = new object_property($this->table, $this->id, 'reg', 'string', false);
@@ -252,7 +256,7 @@ class selfserve_wash_sessions_o extends db
return isset($this->id) && (int)$this->id > 0 && $this->exists();
}
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null, ?int $subuserId = null): self
{
$filters = [
'lane_id' => $laneId,
@@ -262,6 +266,9 @@ class selfserve_wash_sessions_o extends db
if ($customerNumber !== null) {
$filters['customer_number'] = $customerNumber;
}
if ($subuserId !== null) {
$filters['subuser_id'] = $subuserId;
}
$rows = $this->getFieldsWhere($filters, ['id', 'status']);
$rows = array_values(array_filter(
$rows,
@@ -275,7 +282,7 @@ class selfserve_wash_sessions_o extends db
return $this;
}
public function selectLatestOpenByLaneAndReg(int $laneId, string $reg, ?int $customerNumber = null): self
public function selectLatestOpenByLaneAndReg(int $laneId, string $reg, ?int $customerNumber = null, ?int $subuserId = null): self
{
$filters = [
'lane_id' => $laneId,
@@ -286,6 +293,9 @@ class selfserve_wash_sessions_o extends db
if ($customerNumber !== null) {
$filters['customer_number'] = $customerNumber;
}
if ($subuserId !== null) {
$filters['subuser_id'] = $subuserId;
}
$rows = $this->getFieldsWhere($filters, ['id', 'status']);
$rows = array_values(array_filter(
$rows,
@@ -322,6 +332,7 @@ class selfserve_wash_sessions_o extends db
'department_id' => (int)$this->department_id->value(),
'machine_type_id' => $this->machine_type_id->value() === null ? null : (int)$this->machine_type_id->value(),
'customer_number' => $this->customer_number->value() === null ? null : (int)$this->customer_number->value(),
'subuser_id' => $this->subuser_id->value() === null ? null : (int)$this->subuser_id->value(),
'vehicle_id' => $this->vehicle_id->value() === null ? null : (int)$this->vehicle_id->value(),
'vehicle_type_id' => $this->vehicle_type_id->value() === null ? null : (int)$this->vehicle_type_id->value(),
'reg' => (string)$this->reg->value(),
@@ -15,6 +15,7 @@ class subuser_grants_o extends db
public object_property $billing_customer_number;
public object_property $subuser;
public object_property $assigned_vehicle_id;
public object_property $enabled;
public object_property $note;
public object_property $permissions;
@@ -94,6 +95,7 @@ class subuser_grants_o extends db
{
$this->billing_customer_number = new object_property($this->table, $this->id, 'billing_customer_number', 'int');
$this->subuser = new object_property($this->table, $this->id, 'subuser', 'int');
$this->assigned_vehicle_id = new object_property($this->table, $this->id, 'assigned_vehicle_id', 'int');
$this->enabled = new object_property($this->table, $this->id, 'enabled', 'bool');
$this->note = new object_property($this->table, $this->id, 'note', 'string');
$this->permissions = new object_property($this->table, $this->id, 'permissions', 'json');
@@ -109,6 +111,7 @@ class subuser_grants_o extends db
'id' => (int)$this->id,
'billing_customer_number' => (int)$this->billing_customer_number->value(),
'subuser' => (int)$this->subuser->value(),
'assigned_vehicle_id' => $this->assigned_vehicle_id->value() !== null ? (int)$this->assigned_vehicle_id->value() : null,
'enabled' => (bool)$this->enabled->value(),
'note' => $this->note->value(),
'permissions' => self::normalizePermissionsValue($this->permissions->value()),
+15
View File
@@ -280,6 +280,21 @@ class subusers_o extends db
}
}
public function invalidateCurrentSetupToken(): void
{
self::requireSelected();
$object_id = 'subuser_setup_token';
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$this->id;
$currentToken = $this->getCached($reverse_cache_key, $object_id);
if (is_string($currentToken) && $currentToken !== '') {
$this->deleteCached('setup_token:' . $currentToken, $object_id);
}
$this->deleteCached($reverse_cache_key, $object_id);
}
/**
* @throws Exception
*/
File diff suppressed because it is too large Load Diff
+18
View File
@@ -7,6 +7,7 @@ use classes\economic;
use classes\email;
use classes\release_manager;
use classes\recaptcha;
use classes\security_policy_service;
use classes\slack;
use classes\totp;
use classes\virkdata;
@@ -20,6 +21,8 @@ use objects\subusers_o;
use objects\passkeys_o;
use traits\route_t;
require_once WD . '/classes/security_policy_service.php';
class authRoute
{
use route_t;
@@ -119,6 +122,7 @@ class authRoute
$user = (new users_o())->getUserByCustomerNumber($data['customer_number']);
if (!$user->exists() || !$user->hasPassword()) {
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']);
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'missing_user_or_password']);
$response->error('Invalid credentials', 401);
}
@@ -134,6 +138,7 @@ class authRoute
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_SUCCESS', 'Customer number: ' . $data['customer_number']);
} else {
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']);
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'invalid_password']);
$response->error('Invalid credentials', 401);
}
@@ -346,6 +351,7 @@ class authRoute
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_SUCCESS', 'Employee number: ' . $data['user_id']);
} else {
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Employee number: ' . $data['user_id']);
$this->observeLoginFailure('employee', (string)$data['user_id'], ['reason' => 'invalid_credentials']);
$response->error('Invalid credentials', 401);
}
@@ -850,6 +856,7 @@ class authRoute
}
if ($passkey === null) {
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Unknown credential');
$this->observeLoginFailure('passkey', $credentialId, ['reason' => 'unknown_credential', 'user_id_hint' => $user_id_hint]);
$response->error('Invalid credential', 404);
}
@@ -858,6 +865,7 @@ class authRoute
$ok = $wa->verifyAssertion($credentialJson, $challenge_token, $passkey, $host);
if (!$ok) {
(new logs_o())->add('auth', 'global', 1, (int)$passkey->user_id->value(), 'AUTH_PASSKEY_VERIFY_FAILURE', 'Assertion verification failed');
$this->observeLoginFailure('passkey', $credentialId, ['reason' => 'assertion_failed', 'user_id_hint' => $user_id_hint]);
$response->error('Invalid passkey assertion', 401);
}
@@ -869,6 +877,7 @@ class authRoute
|| ($challengePrincipalType === 'user' && $is_subuser)
) {
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Credential principal mismatch');
$this->observeLoginFailure('passkey', $credentialId, ['reason' => 'principal_mismatch', 'user_id_hint' => $user_id_hint]);
$token_o->delete($challenge_token);
$this->clearPasskeyChallengePrincipal($challenge_token);
$response->error('Invalid credential', 401);
@@ -1054,6 +1063,15 @@ class authRoute
(new logs_o())->add('auth', 'global', 0, 0, $action, $message);
}
private function observeLoginFailure(string $principalType, string|int $identifier, array $metadata = []): void
{
try {
(new security_policy_service())->observeLoginFailure($principalType, $identifier, $metadata);
} catch (\Throwable) {
// Security observation must not change authentication responses.
}
}
private function appendRuntimeConfig(array $payload): array
{
$payload['runtime_config'] = array_replace_recursive(
+41
View File
@@ -4,6 +4,8 @@ namespace routes;
use classes\authentication;
use classes\cron_scheduler;
use classes\cron_worker;
use classes\release_manager;
use objects\logs_o;
use Throwable;
use traits\route_t;
@@ -36,6 +38,45 @@ class cronRoute
'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history',
]);
$this->get('/superuser/cron/workers', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_view');
$parameters = $this->getParametersAsArray();
$workers = (new cron_worker())->listWorkers();
try {
$workers['deployment'] = (new release_manager())->cronWorkerStatus($parameters);
} catch (Throwable $throwable) {
$workers['deployment'] = [
'ok' => false,
'error' => $throwable->getMessage(),
];
}
$response->success($workers);
}, [
'superuser_cron_view' => 'View cron worker deployment and heartbeat state',
]);
$this->post('/superuser/cron/workers/deploy', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_manage');
$this->requirePermission('superuser_coolify_manage');
try {
$result = (new release_manager())->deployCronWorker(
$this->getParametersAsArray(),
$this->actorUserId()
);
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_WORKER_DEPLOY', 'Deployed cron worker');
$response->success($result);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_cron_manage' => 'Deploy and configure cron workers',
'superuser_coolify_manage' => 'Deploy Coolify-managed infrastructure resources',
]);
$this->post('/superuser/cron/run', function () {
global $response;
@@ -15,6 +15,30 @@ class customerTimeBookingsRoute
{
use route_t;
private static function requirePublicTimeBookingsDepartment(string $parameterName = 'id'): departments_o
{
global $response;
if (!self::isParametersSet([$parameterName])) {
$response->error('Missing ' . $parameterName . ' parameter', 400);
}
self::requireType((int)self::getParameter($parameterName), self::type_int());
self::requireMinValue((int)self::getParameter($parameterName), 1);
self::requireSameLength(self::getParameter($parameterName), (int)self::getParameter($parameterName));
$department = new departments_o();
$department->select((int)self::getParameter($parameterName));
if (!$department->exists()) {
$response->error('Department not found', 404);
}
if (!$department->isModuleTimeBookingsEnabled()) {
$response->error('Department time bookings are not enabled', 404);
}
return $department;
}
public function run(): void
{
global /** @var response $response */
@@ -22,26 +46,60 @@ class customerTimeBookingsRoute
$router, $response;
/** Guest Time Bookings -> Departments -> GET */
$this->get('/department/timebookings/departments/public', function () {
global $response;
$departments = new departments_o();
$response->success(
$departments
->setSearchableFields([
'id',
'name',
'description',
'visible',
'archived',
'longitude',
'latitude',
])
->listObjectsWithPaginationIfSet(
function ($department): array {
return [
'id' => (int)$department['id'],
'name' => (string)$department['name'],
'description' => (string)$department['description'],
'address' => (string)$department['description'],
'longitude' => (float)$department['longitude'],
'latitude' => (float)$department['latitude'],
'order_priority' => (int)$department['order_priority'],
'time_booking_enabled' => true,
];
},
$departments->forceRestrictFilters([
'visible' => 1,
'archived' => 0,
]),
[],
"EXISTS (
SELECT 1
FROM department_variables time_booking_variables
WHERE time_booking_variables.department_id = departments.id
AND time_booking_variables.variable = 'bookingsystem_time_based_enabled'
AND time_booking_variables.value = 'true'
)"
)
);
},
[
// No permissions required for this endpoint, as it is for guests
]
);
/** Guest Time Bookings -> Opening Hours -> GET */
$this->get('/department/timebookings/opening-hours/public', function () {
global $response;
if (!self::isParametersSet(['id'])) {
$response->error('Missing id parameter', 400);
}
self::requireType((int)self::getParameter('id'), self::type_int());
self::requireMinValue((int)self::getParameter('id'), 1);
self::requireSameLength(self::getParameter('id'), (int)self::getParameter('id'));
// Check if the department exists
$department = new \objects\departments_o();
$department->select((int)self::getParameter('id'));
if (!$department->exists()) {
$response->error('Department not found', 404);
}
// Check if the department has time bookings enabled
$variable = $department->isModuleTimeBookingsEnabled();
if (!$variable) {
$response->error('Department time bookings are not enabled', 404);
}
self::requirePublicTimeBookingsDepartment();
$department_time_bookings_opening_hours = new department_time_bookings_opening_hours_o();
$department_time_bookings_opening_hours->selectByDepartment(
(int)self::getParameter('id')
@@ -79,23 +137,8 @@ class customerTimeBookingsRoute
/** Guest Time Bookings -> Types -> GET */
$this->get('/department/timebookings/types/public', function () {
global $response;
if (!self::isParametersSet(['id'])) {
$response->error('Missing id parameter', 400);
}
self::requireType((int)self::getParameter('id'), self::type_int());
self::requireMinValue((int)self::getParameter('id'), 1);
self::requireSameLength(self::getParameter('id'), (int)self::getParameter('id'));
// Check if the department exists
$department = new \objects\departments_o();
$department->select((int)self::getParameter('id'));
if (!$department->exists()) {
$response->error('Department not found', 404);
}
// Check if the department has time bookings enabled
$variable = $department->isModuleTimeBookingsEnabled();
if (!$variable) {
$response->error('Department time bookings are not enabled', 404);
}
self::requirePublicTimeBookingsDepartment();
$department_time_bookings_types = new department_time_bookings_types_o();
$booking_types_array = $department_time_bookings_types->getFieldsWhere(
[
@@ -134,23 +177,8 @@ class customerTimeBookingsRoute
* https://api.truckwash.dk:4433/department/timebookings/entries/public?id=1&filters=created_at-date_from:2025-04-01,created_at-date_to:2025-05-30&order=created_at:desc
*/
global $response;
if (!self::isParametersSet(['id'])) {
$response->error('Missing id parameter', 400);
}
self::requireType((int)self::getParameter('id'), self::type_int());
self::requireMinValue((int)self::getParameter('id'), 1);
self::requireSameLength(self::getParameter('id'), (int)self::getParameter('id'));
// Check if the department exists
$department = new \objects\departments_o();
$department->select((int)self::getParameter('id'));
if (!$department->exists()) {
$response->error('Department not found', 404);
}
// Check if the department has time bookings enabled
$variable = $department->isModuleTimeBookingsEnabled();
if (!$variable) {
$response->error('Department time bookings are not enabled', 404);
}
$department = self::requirePublicTimeBookingsDepartment();
$department_time_bookings_entries = new department_time_bookings_entries_o();
$booking_entries_array = $department_time_bookings_entries->listObjectsWithPaginationIfSet(
function ($booking_entry): array {
@@ -179,9 +207,7 @@ class customerTimeBookingsRoute
$this->post('/department/timebookings/entries/public', function () {
global $response;
self::requireParameters(['department', 'type', 'start']);
self::requireType((int)self::getParameter('department'), self::type_int());
self::requireMinValue((int)self::getParameter('department'), 1);
self::requireSameLength(self::getParameter('department'), (int)self::getParameter('department'));
$department = self::requirePublicTimeBookingsDepartment('department');
// Get the type
self::requireType((int)self::getParameter('type'), self::type_int());
@@ -195,17 +221,6 @@ class customerTimeBookingsRoute
self::requireSameLength(self::getParameter('start'), (string)self::getParameter('start'));
self::requireDateFormat((string)self::getParameter('start'), 'Y-m-d H:i:s');
// Check if the department exists
$department = new departments_o();
$department->select((int)self::getParameter('department'));
if (!$department->exists()) {
$response->error('Department not found', 404);
}
// Check if the department has time bookings enabled
$variable = $department->isModuleTimeBookingsEnabled();
if (!$variable) {
$response->error('Department time bookings are not enabled', 404);
}
// Check if the type exists
$department_time_bookings_types = new department_time_bookings_types_o();
$department_time_bookings_types->select((int)self::getParameter('type'));
+127 -31
View File
@@ -7,6 +7,7 @@ use classes\backup_store;
use classes\response;
use classes\router;
use objects\logs_o;
use Throwable;
use traits\route_t;
class moduleBackupsRoute
@@ -19,49 +20,144 @@ class moduleBackupsRoute
/** @var router $router */
$router, $response;
/** Modules > Backups > GET */
$this->get('/modules/backup/backups', function () {
global $response;
$this->requirePermission('modules_backup_list');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('modules_backup', 'global', 1, $user->id, 'MODULES_BACKUP', 'Successfully fetched backup modules');
$this->requireClassicSuperuserPermission('modules_backup_list');
try {
$response->success(
(new backup_store())->listBackups()
(new backup_store())->listBackups(
(int)($this->fromQuery('limit') ?? 50),
(int)($this->fromQuery('offset') ?? 0)
)
);
} else {
(new logs_o())->add('modules_backup', 'global', 1, 0, 'MODULES_BACKUP', 'No user found, or invalid session');
$response->error('Invalid session', 400);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
},
[
'modules_backup_list' => 'List all backup modules'
]
);
}, [
'modules_backup_list' => 'List backup records, components, and legacy metadata',
]);
/** Modules > Backups > POST */
$this->post('/modules/backup/backups', function () {
global $response;
$this->requirePermission('modules_backup_create');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('modules_backup', 'global', 1, $user->id, 'MODULES_BACKUP', 'Successfully created backup module');
// Check if the request contains a name, and description parameter
$name = $this->fromRequest('name');
$description = $this->fromRequest('description');
// Create the backup
$response->success(
(new backup_store())->createBackup($name, $description)
$this->requireClassicSuperuserPermission('modules_backup_create');
$user_id = $this->actorUserId();
try {
$parameters = $this->getParametersAsArray();
$result = (new backup_store())->enqueueCreateBackup(
$parameters['name'] ?? null,
$parameters['description'] ?? null,
$user_id,
'manual'
);
} else {
(new logs_o())->add('modules_backup', 'global', 1, 0, 'MODULES_BACKUP', 'No user found, or invalid session');
$response->error('Invalid session', 400);
(new logs_o())->add('modules_backup', 'global', 1, $user_id ?? 0, 'MODULES_BACKUP', 'Queued backup: ' . $result['backup_uuid']);
$response->success($result);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
},
}, [
'modules_backup_create' => 'Queue a backup job',
]);
$this->get('/modules/backup/jobs/{id}', function () {
global $response;
$this->requireClassicSuperuserPermission('modules_backup_list');
$job_id = (int)$this->fromRoute('id');
$job = (new backup_store())->getJob($job_id);
if ($job === null) {
$response->error('Backup job not found.', 404);
}
$response->success($job);
}, [
'modules_backup_list' => 'View backup job status',
]);
$this->post('/modules/backup/backups/{backup_uuid}/verify', function () {
global $response;
$this->requireClassicSuperuserPermission('modules_backup_verify');
try {
$backup_uuid = (string)$this->fromRoute('backup_uuid');
$result = (new backup_store())->enqueueVerifyBackup($backup_uuid, $this->actorUserId());
(new logs_o())->add('modules_backup', 'global', 1, $this->actorUserId() ?? 0, 'MODULES_BACKUP_VERIFY', 'Queued backup verification: ' . $backup_uuid);
$response->success($result);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'modules_backup_verify' => 'Verify a backup',
]);
$this->post('/modules/backup/backups/{backup_uuid}/restore/preview', function () {
global $response;
$this->requireClassicSuperuserPermission('modules_backup_restore');
try {
$backup_uuid = (string)$this->fromRoute('backup_uuid');
$result = (new backup_store())->previewRestore($backup_uuid, $this->actorUserId());
(new logs_o())->add('modules_backup', 'global', 1, $this->actorUserId() ?? 0, 'MODULES_BACKUP_RESTORE_PREVIEW', 'Previewed backup restore: ' . $backup_uuid);
$response->success($result);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'modules_backup_restore' => 'Preview production restore from a backup',
]);
$this->post('/modules/backup/backups/{backup_uuid}/restore', function () {
global $response;
$this->requireClassicSuperuserPermission('modules_backup_restore');
try {
$parameters = $this->getParametersAsArray();
$backup_uuid = (string)$this->fromRoute('backup_uuid');
$result = (new backup_store())->enqueueRestore(
$backup_uuid,
(int)($parameters['preview_id'] ?? 0),
(string)($parameters['confirmation_phrase'] ?? ''),
(string)($parameters['reason'] ?? ''),
$this->actorUserId(),
[
'modules_backup_create' => 'Create a backup module'
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
]
);
(new logs_o())->add('modules_backup', 'global', 1, $this->actorUserId() ?? 0, 'MODULES_BACKUP_RESTORE', 'Queued backup restore: ' . $backup_uuid);
$response->success($result);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'modules_backup_restore' => 'Execute production restore from a backup',
]);
$this->get('/modules/backup/restore-audit', function () {
global $response;
$this->requireClassicSuperuserPermission('modules_backup_restore');
try {
$response->success((new backup_store())->restoreAudit((int)($this->fromQuery('limit') ?? 50)));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'modules_backup_restore' => 'View backup restore audit log',
]);
}
private function requireClassicSuperuserPermission(string $permission): bool
{
global $response;
if ((new authentication())->get_subuser() !== false) {
$response->error('Subuser sessions cannot manage backup disaster recovery.', 403);
}
return $this->requirePermission($permission);
}
private function actorUserId(): ?int
{
try {
$user = (new authentication())->get_user();
return $user !== false && isset($user->id) ? (int)$user->id : null;
} catch (Throwable) {
return null;
}
}
}
@@ -7,6 +7,7 @@ use classes\email;
use classes\order_bookings_counts_cache;
use classes\order_bookings_list_cache;
use classes\redis;
use classes\security_policy_service;
use Exception;
use modules\subusers\helpers\subusers_permission_node_key;
use objects\departments_o;
@@ -18,6 +19,8 @@ use objects\products_o;
use objects\users_o;
use traits\route_t;
require_once WD . '/classes/security_policy_service.php';
class orderBookingRoute
{
use route_t;
@@ -84,6 +87,10 @@ class orderBookingRoute
try {
$order_bookings_o->add($data);
$this->storeBookingIdempotencyResult($fingerprint, (int)$order_bookings_o->id);
(new security_policy_service())->observeBookingCreated((int)$customer_number->customer_number->value(), [
'booking_id' => (int)$order_bookings_o->id,
'route' => '/order-bookings',
]);
} catch (\Throwable $e) {
$this->clearBookingCreationSlot($fingerprint);
throw $e;
@@ -104,6 +104,32 @@ class releaseManagerRoute
'superuser_release_manager_deploy' => 'Run Release Manager tests with operation diagnostics',
]);
$this->post('/superuser/releases/coolify-cleanup/preview', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success((new release_manager())->previewCoolifyCleanup($this->requestPayload(), $this->actorUserId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_deploy' => 'Preview Release Manager Coolify resource cleanup',
]);
$this->post('/superuser/releases/coolify-cleanup/apply', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
$this->requirePermission('superuser_coolify_manage');
try {
$response->success((new release_manager())->applyCoolifyCleanup($this->requestPayload(), $this->actorUserId()), 202);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_deploy' => 'Apply Release Manager Coolify resource cleanup',
'superuser_coolify_manage' => 'Stop or delete Coolify resources managed by Release Manager',
]);
$this->post('/superuser/releases/config', function () {
global $response;
$this->requirePermission('superuser_release_manager_manage');
+434 -80
View File
@@ -6,10 +6,12 @@ use classes\authentication;
use classes\economic;
use classes\gatewayapi;
use classes\response;
use classes\subusers_schema_bootstrap;
use classes\subuser_permission_templates_service;
use classes\virkdata;
use Exception;
use modules\virkdata\helpers\virkdata_response;
use objects\customer_vehicles_o;
use objects\logs_o;
use objects\subuser_grants_o;
use objects\subusers_o;
@@ -26,6 +28,8 @@ class subusersRoute
{
use route_t;
private const DOGNVASK_PERMISSION_KEYS = ['SELFSERVE_LIST', 'SELFSERVE_ADD'];
private function getOwnPermissionForNode(subusers_permission_node_key $node)
{
return match ($node) {
@@ -182,6 +186,100 @@ class subusersRoute
return $name === '' || $name === 'Unknown Customer' ? null : $name;
}
private function hasDognvaskAccess(array $permissions, bool $grantEnabled): bool
{
if (!$grantEnabled) {
return false;
}
$permissions = subuser_grants_o::normalizePermissionsValue($permissions);
foreach (self::DOGNVASK_PERMISSION_KEYS as $permission) {
if (!in_array($permission, $permissions, true)) {
return false;
}
}
return true;
}
private function normalizeAssignedVehicleId(mixed $value): ?int
{
global $response;
if ($value === null || $value === '') {
return null;
}
if (is_int($value)) {
$id = $value;
} else {
$raw = trim((string)$value);
if ($raw === '') {
return null;
}
if (!preg_match('/^[1-9][0-9]*$/', $raw)) {
$response->error('Invalid assigned vehicle id', 400);
}
$id = (int)$raw;
}
if ($id <= 0) {
$response->error('Invalid assigned vehicle id', 400);
}
return $id;
}
private function assignedVehiclePayload(?int $vehicleId, ?int $customerNumber = null, bool $strict = false): ?array
{
global $response;
if ($vehicleId === null || $vehicleId <= 0) {
return null;
}
$vehicle = (new customer_vehicles_o())->select($vehicleId);
if (!$vehicle->exists()) {
if ($strict) {
$response->error('Assigned vehicle not found', 404);
}
return null;
}
$vehicle->getObjectProperties();
if ($vehicle->deleted_at->value() !== null) {
if ($strict) {
$response->error('Assigned vehicle not found', 404);
}
return null;
}
$vehicleCustomerNumber = (int)$vehicle->customer_id->value();
if ($customerNumber !== null && $vehicleCustomerNumber !== (int)$customerNumber) {
if ($strict) {
$response->error('Assigned vehicle must belong to the selected customer', 400);
}
return null;
}
$reg = strtoupper(trim((string)$vehicle->reg->value()));
return [
'id' => (int)$vehicle->id,
'reg' => $reg !== '' ? $reg : null,
];
}
private function validateAssignedVehicleIdForCustomer(mixed $value, int $customerNumber): ?int
{
$vehicleId = $this->normalizeAssignedVehicleId($value);
if ($vehicleId === null) {
return null;
}
$this->assignedVehiclePayload($vehicleId, $customerNumber, true);
return $vehicleId;
}
private function assertSubuserIdentifiersAvailable(
?int $phoneCountryCode,
?int $phone,
@@ -264,6 +362,170 @@ class subusersRoute
return $frontendBaseUrl . '/complete-registration?token=' . rawurlencode($token);
}
private function buildDirectSubuserLoginPath(string $sessionToken, int $customerNumber): string
{
return '/login/qr?token=' . rawurlencode($sessionToken)
. '&type=subuser&customer_number=' . rawurlencode((string)$customerNumber);
}
private function loadSubuserOrFail(int $subuserId): subusers_o
{
global $response;
$subuser = (new subusers_o())->select($subuserId);
if (!$subuser->exists()) {
$response->error('Subuser not found', 404);
}
$subuser->getObjectProperties();
return $subuser;
}
private function buildSubuserAccountPayload(subusers_o $subuser): array
{
return [
'id' => (int)$subuser->id,
'username' => $subuser->username->value(),
'name' => $subuser->name->value(),
'email' => $subuser->email->value(),
'phone_country_code' => $subuser->phone_country_code->value() !== null
? (int)$subuser->phone_country_code->value()
: null,
'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null,
'setup_required' => $subuser->requiresSetup(),
'updated_at' => $subuser->updated_at->value() ?? null,
];
}
private function parseSuperuserSubuserProfileUpdates(int $subuserId): array
{
global $response;
$updates = [];
if (self::isParametersSet(['name'])) {
$name = $this->normalizeOptionalString(self::getParameter('name'));
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
$response->error('Name must be between 3 and 255 characters long', 400);
}
$updates['name'] = $name;
}
if (self::isParametersSet(['username'])) {
$username = $this->normalizeOptionalString(self::getParameter('username'));
if ($username !== null && (strlen($username) < 3 || strlen($username) > 50)) {
$response->error('Username must be between 3 and 50 characters long', 400);
}
$updates['username'] = $username;
}
if (self::isParametersSet(['email'])) {
$email = $this->normalizeOptionalString(self::getParameter('email'));
if ($email !== null && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$response->error('Invalid email format', 400);
}
if ($email !== null && strlen($email) > 255) {
$response->error('Email must be at most 255 characters long', 400);
}
$updates['email'] = $email;
}
$hasPhoneCountryCode = self::isParametersSet(['phone_country_code']);
$hasPhone = self::isParametersSet(['phone']);
if ($hasPhoneCountryCode || $hasPhone) {
if (!$hasPhoneCountryCode || !$hasPhone) {
$response->error('Phone country code and phone must be provided together', 400);
}
$phoneCountryCode = trim((string)self::getParameter('phone_country_code'));
$phone = trim((string)self::getParameter('phone'));
if (!preg_match('/^[0-9]{1,3}$/', $phoneCountryCode)) {
$response->error('Phone country code must be 1-3 digits', 400);
}
if (!preg_match('/^[0-9]{4,15}$/', $phone)) {
$response->error('Phone must be 4-15 digits', 400);
}
$updates['phone_country_code'] = (int)$phoneCountryCode;
$updates['phone'] = (int)$phone;
}
if ($updates === []) {
$response->error('No fields to update', 400);
}
$this->assertSubuserIdentifiersAvailable(
$updates['phone_country_code'] ?? null,
$updates['phone'] ?? null,
$updates['username'] ?? null,
$updates['email'] ?? null,
$subuserId
);
return $updates;
}
private function getEnabledGrantForSubuserAndCustomerOrFail(int $subuserId, int $customerNumber): subuser_grants_o
{
global $response;
if ($customerNumber <= 0) {
$response->error('Customer number is required', 400);
}
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true);
if ($grant === null || !(bool)$grant->enabled->value() || $grant->deleted_at->value() !== null) {
$response->error('Enabled subuser grant not found for selected customer', 404);
}
return $grant;
}
private function resolveDirectLoginCustomerNumber(subusers_o $subuser): int
{
global $response;
if (self::isParametersSet(['grant_id'])) {
$grant = (new subuser_grants_o())->select((int)self::getParameter('grant_id'));
if ($grant->exists()) {
$grant->getObjectProperties();
}
if (
!$grant->exists()
|| (int)$grant->subuser->value() !== (int)$subuser->id
|| !(bool)$grant->enabled->value()
|| $grant->deleted_at->value() !== null
) {
$response->error('Enabled subuser grant not found', 404);
}
return (int)$grant->billing_customer_number->value();
}
if (self::isParametersSet(['customer_number'])) {
$customerNumber = (int)self::getParameter('customer_number');
$this->getEnabledGrantForSubuserAndCustomerOrFail((int)$subuser->id, $customerNumber);
return $customerNumber;
}
$grants = (new subuser_grants_o())->getFieldsWhere([
'subuser' => (int)$subuser->id,
'enabled' => 1,
'deleted_at' => null,
], ['billing_customer_number']);
$customerNumbers = array_values(array_unique(array_map(
static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0),
$grants
)));
$customerNumbers = array_values(array_filter($customerNumbers, static fn (int $customerNumber): bool => $customerNumber > 0));
if (count($customerNumbers) !== 1) {
$response->error('Customer number or grant id is required for drivers with multiple customer grants', 400);
}
return $customerNumbers[0];
}
private function clientThrottleIp(): string
{
$remoteAddress = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
@@ -363,6 +625,10 @@ class subusersRoute
$setupRequired = $subuser->requiresSetup();
$grantEnabled = $grant ? (bool)$grant->enabled->value() : false;
$inviteAccepted = !$setupRequired;
$assignedVehicleId = $grant && $grant->assigned_vehicle_id->value() !== null
? (int)$grant->assigned_vehicle_id->value()
: null;
$assignedVehicle = $this->assignedVehiclePayload($assignedVehicleId, $customerNumber);
$accessState = 'inactive';
if ($grant !== null && $grantEnabled) {
@@ -391,6 +657,10 @@ class subusersRoute
'grant_id' => $grant ? (int)$grant->id : null,
'grant_enabled' => $grantEnabled,
'grant_note' => $grant ? $grant->note->value() : null,
'assigned_vehicle_id' => $assignedVehicle['id'] ?? null,
'assigned_vehicle_reg' => $assignedVehicle['reg'] ?? null,
'assigned_vehicle' => $assignedVehicle,
'dognvask_enabled' => $this->hasDognvaskAccess($grantPermissions, $grantEnabled),
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
@@ -454,8 +724,8 @@ class subusersRoute
'id' => 's.`id`',
'created_at' => 's.`created_at`',
'updated_at' => 'row_updated_at',
'customer_number' => 'customer_number_sort',
'grant_id' => 'grant_id_sort',
'customer_number' => 'g.`billing_customer_number`',
'grant_id' => 'g.`id`',
'name' => 's.`name`',
];
@@ -492,6 +762,15 @@ class subusersRoute
$grantPermissions = subuser_grants_o::normalizePermissionsValue($row['grant_permissions'] ?? null);
$templateService = new subuser_permission_templates_service();
$grantEnabled = (bool)((int)($row['grant_enabled'] ?? 0));
$assignedVehicleId = isset($row['assigned_vehicle_id']) && $row['assigned_vehicle_id'] !== null
? (int)$row['assigned_vehicle_id']
: null;
$assignedVehicleReg = isset($row['assigned_vehicle_reg']) && trim((string)$row['assigned_vehicle_reg']) !== ''
? strtoupper(trim((string)$row['assigned_vehicle_reg']))
: null;
$assignedVehicle = $assignedVehicleId !== null
? ['id' => $assignedVehicleId, 'reg' => $assignedVehicleReg]
: null;
$accessState = 'inactive';
if (!empty($row['grant_id']) && $grantEnabled) {
@@ -506,6 +785,10 @@ class subusersRoute
'customer_name' => $row['customer_name'] ?: null,
'grant_enabled' => $grantEnabled,
'grant_note' => $row['grant_note'] ?? null,
'assigned_vehicle_id' => $assignedVehicleId,
'assigned_vehicle_reg' => $assignedVehicleReg,
'assigned_vehicle' => $assignedVehicle,
'dognvask_enabled' => $this->hasDognvaskAccess($grantPermissions, $grantEnabled),
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
@@ -558,6 +841,10 @@ class subusersRoute
'grant_id' => $primaryGrant['grant_id'] ?? null,
'grant_enabled' => $primaryGrant['grant_enabled'] ?? false,
'grant_note' => $primaryGrant['grant_note'] ?? null,
'assigned_vehicle_id' => $primaryGrant['assigned_vehicle_id'] ?? null,
'assigned_vehicle_reg' => $primaryGrant['assigned_vehicle_reg'] ?? null,
'assigned_vehicle' => $primaryGrant['assigned_vehicle'] ?? null,
'dognvask_enabled' => $primaryGrant['dognvask_enabled'] ?? false,
'grant_permissions' => $primaryGrant['grant_permissions'] ?? [],
'permissions' => $primaryGrant['permissions'] ?? [],
'permission_template_key' => $primaryGrant['permission_template_key'] ?? subuser_permission_templates_service::TEMPLATE_DEACTIVATED,
@@ -756,6 +1043,7 @@ class subusersRoute
OR s.`email` LIKE ?
OR CAST(s.`phone_country_code` AS CHAR) LIKE ?
OR CAST(s.`phone` AS CHAR) LIKE ?
OR cv.`reg` LIKE ?
OR CAST(g.`billing_customer_number` AS CHAR) LIKE ?
OR g.`note` LIKE ?
OR EXISTS (
@@ -766,7 +1054,7 @@ class subusersRoute
)
)";
$search = '%' . $pagination['search'] . '%';
for ($i = 0; $i < 9; $i++) {
for ($i = 0; $i < 10; $i++) {
$params[] = $search;
$types .= 's';
}
@@ -776,9 +1064,10 @@ class subusersRoute
$fromSql = "
FROM `subuser_grants` g
INNER JOIN `subusers` s ON s.`id` = g.`subuser`
LEFT JOIN `customer_vehicles` cv ON cv.`id` = g.`assigned_vehicle_id` AND cv.`deleted_at` IS NULL
";
$countSql = "SELECT COUNT(DISTINCT s.`id`) AS `count` $fromSql $whereSql";
$countSql = "SELECT COUNT(*) AS `count` $fromSql $whereSql";
$countStatement = $db->conn->prepare($countSql);
if ($countStatement === false) {
throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error);
@@ -802,23 +1091,18 @@ class subusersRoute
s.`created_at`,
s.`updated_at`,
s.`suspended_at`,
MAX(COALESCE(g.`updated_at`, s.`updated_at`)) AS `row_updated_at`,
MIN(g.`billing_customer_number`) AS `customer_number_sort`,
MAX(g.`id`) AS `grant_id_sort`
COALESCE(g.`updated_at`, s.`updated_at`) AS `row_updated_at`,
g.`id` AS `grant_id`,
g.`billing_customer_number` AS `customer_number`,
g.`enabled` AS `grant_enabled`,
g.`note` AS `grant_note`,
g.`permissions` AS `grant_permissions`,
g.`assigned_vehicle_id`,
cv.`reg` AS `assigned_vehicle_reg`,
g.`created_at` AS `grant_created_at`,
g.`updated_at` AS `grant_updated_at`
$fromSql
$whereSql
GROUP BY
s.`id`,
s.`username`,
s.`password`,
s.`name`,
s.`email`,
s.`phone_country_code`,
s.`phone`,
s.`two_factor_enabled`,
s.`created_at`,
s.`updated_at`,
s.`suspended_at`
ORDER BY {$pagination['order_sql']} {$pagination['order_direction']}
LIMIT ? OFFSET ?
";
@@ -834,68 +1118,15 @@ class subusersRoute
$rows = $result->fetch_all(MYSQLI_ASSOC);
$pageStatement->close();
$subuserIds = array_map(static fn (array $row): int => (int)$row['id'], $rows);
$grantRowsBySubuserId = [];
if ($subuserIds !== []) {
$placeholders = implode(',', array_fill(0, count($subuserIds), '?'));
$grantWhere = [
'g.`deleted_at` IS NULL',
'g.`subuser` IN (' . $placeholders . ')',
];
$grantParams = $subuserIds;
$grantTypes = str_repeat('i', count($subuserIds));
if (!$includeNonEnabled) {
$grantWhere[] = 'g.`enabled` = 1';
}
if ($customerNumber !== null) {
$grantWhere[] = 'g.`billing_customer_number` = ?';
$grantParams[] = $customerNumber;
$grantTypes .= 'i';
}
$grantSql = "
SELECT
g.`subuser` AS `subuser_id`,
g.`id` AS `grant_id`,
g.`billing_customer_number` AS `customer_number`,
g.`enabled` AS `grant_enabled`,
g.`note` AS `grant_note`,
g.`permissions` AS `grant_permissions`,
g.`created_at` AS `grant_created_at`,
g.`updated_at` AS `grant_updated_at`
FROM `subuser_grants` g
WHERE " . implode(' AND ', $grantWhere) . "
ORDER BY
g.`subuser` ASC,
g.`enabled` DESC,
g.`billing_customer_number` ASC,
COALESCE(g.`updated_at`, g.`created_at`) DESC,
g.`id` DESC
";
$grantStatement = $db->conn->prepare($grantSql);
if ($grantStatement === false) {
throw new Exception('Failed to prepare subuser grant list query: ' . $db->conn->error);
}
$this->bindStatementParameters($grantStatement, $grantTypes, $grantParams);
$grantStatement->execute();
$grantResult = $grantStatement->get_result();
$grantRows = $grantResult->fetch_all(MYSQLI_ASSOC);
$grantStatement->close();
$customerNames = $this->resolveCustomerNames(array_map(
static fn (array $grantRow): int => (int)($grantRow['customer_number'] ?? 0),
$grantRows
static fn (array $row): int => (int)($row['customer_number'] ?? 0),
$rows
));
foreach ($grantRows as $grantRow) {
$subuserId = (int)$grantRow['subuser_id'];
$customerNumberForGrant = (int)$grantRow['customer_number'];
$grantRow['customer_name'] = $this->resolveCustomerName($customerNumberForGrant, $customerNames);
$grantRowsBySubuserId[$subuserId] ??= [];
$grantRowsBySubuserId[$subuserId][] = $grantRow;
}
}
$rows = array_map(function (array $row) use ($customerNames): array {
$customerNumberForGrant = (int)($row['customer_number'] ?? 0);
$row['customer_name'] = $this->resolveCustomerName($customerNumberForGrant, $customerNames);
return $row;
}, $rows);
$response->paginate(
(int)$pagination['page'],
@@ -907,7 +1138,7 @@ class subusersRoute
);
return array_map(
fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row, $grantRowsBySubuserId[(int)$row['id']] ?? []),
fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row, [$row]),
$rows
);
}
@@ -961,6 +1192,13 @@ class subusersRoute
$updates['permissions'] = $this->parsePermissionsPayload(self::getParameter('permissions'), []);
}
if (self::isParametersSet(['assigned_vehicle_id'])) {
$updates['assigned_vehicle_id'] = $this->validateAssignedVehicleIdForCustomer(
self::getParameter('assigned_vehicle_id'),
$customerNumber
);
}
if ($updates === []) {
$response->error('No fields to update', 400);
}
@@ -1109,6 +1347,8 @@ class subusersRoute
public function run(): void
{
subusers_schema_bootstrap::ensureTables();
// =============================
// Subuser Grant Management
// =============================
@@ -1182,6 +1422,7 @@ class subusersRoute
'name' => $subuser->name->value(),
'enabled' => $o->enabled,
'note' => $o->note,
'assigned_vehicle_id' => isset($o->assigned_vehicle_id) && $o->assigned_vehicle_id !== null ? (int)$o->assigned_vehicle_id : null,
'permissions' => subuser_grants_o::normalizePermissionsValue($o->permissions ?? null),
'created_at' => $o->created_at,
'updated_at' => $o->updated_at,
@@ -1228,8 +1469,18 @@ class subusersRoute
$enabled = $templateAccess['enabled'];
$permissions = $templateAccess['permissions'];
}
$assignedVehicleId = null;
if (self::isParametersSet(['assigned_vehicle_id'])) {
$assignedVehicleId = $this->validateAssignedVehicleIdForCustomer(
self::getParameter('assigned_vehicle_id'),
$customer_number
);
}
try {
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
if ($assignedVehicleId !== null) {
$grant->assigned_vehicle_id->set($assignedVehicleId);
}
$response->success(['grant' => $grant->asArray()]);
} catch (Exception $e) {
$response->error('Failed to add subuser grant', 500);
@@ -1287,6 +1538,12 @@ class subusersRoute
$permissions = $this->parsePermissionsPayload(self::getParameter('permissions'), []);
$grant->permissions->set($permissions);
}
if (self::isParametersSet(['assigned_vehicle_id'])) {
$grant->assigned_vehicle_id->set($this->validateAssignedVehicleIdForCustomer(
self::getParameter('assigned_vehicle_id'),
$targetCustomer
));
}
$response->success($grant->asArray());
},
[
@@ -1587,6 +1844,103 @@ class subusersRoute
'list_subusers' => 'List all chauffeur access grants for superusers.',
]);
$this->patch('/superuser/subusers/{subuser_id}', function () {
global $response;
$this->requirePermission('edit_subusers');
$subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id'));
$updates = $this->parseSuperuserSubuserProfileUpdates((int)$subuser->id);
try {
$subuser->update($updates);
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
$subuser = $this->loadSubuserOrFail((int)$subuser->id);
$authUser = (new authentication())->get_user();
(new logs_o())->add(
'subusers',
'global',
1,
$authUser !== false ? (int)$authUser->id : 0,
'SUPERUSER_SUBUSER_PROFILE_UPDATE',
'Updated chauffeur profile: ' . (int)$subuser->id
);
$response->success(['subuser' => $this->buildSubuserAccountPayload($subuser)]);
}, [
'edit_subusers' => 'Edit chauffeur account profile fields as a superuser.',
]);
$this->post('/superuser/subusers/{subuser_id}/password', function () {
global $response;
$this->requirePermission('edit_subusers');
self::requireParameters(['password']);
$password = (string)self::getParameter('password');
$this->requireSubuserPasswordPolicy($password);
$subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id'));
try {
$subuser->setPassword($password);
$subuser->invalidateCurrentSetupToken();
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
$subuser = $this->loadSubuserOrFail((int)$subuser->id);
$authUser = (new authentication())->get_user();
(new logs_o())->add(
'subusers',
'global',
1,
$authUser !== false ? (int)$authUser->id : 0,
'SUPERUSER_SUBUSER_PASSWORD_SET',
'Set chauffeur password: ' . (int)$subuser->id
);
$response->success(['subuser' => $this->buildSubuserAccountPayload($subuser)]);
}, [
'edit_subusers' => 'Set a chauffeur account password as a superuser.',
]);
$this->post('/superuser/subusers/{subuser_id}/login-link', function () {
global $response;
$this->requirePermission('edit_subusers');
$this->requirePermission('SUPERUSER_INTIMIDATE');
$subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id'));
$customerNumber = $this->resolveDirectLoginCustomerNumber($subuser);
try {
$sessionToken = $subuser->generateSession();
} catch (Exception $exception) {
$response->error($exception->getMessage(), 500);
}
$authUser = (new authentication())->get_user();
(new logs_o())->add(
'auth',
'global',
1,
$authUser !== false ? (int)$authUser->id : 0,
'SUPERUSER_SUBUSER_DIRECT_LOGIN_LINK',
'Created chauffeur direct login link for subuser ' . (int)$subuser->id . ' and customer ' . $customerNumber
);
$response->success([
'subuser_id' => (int)$subuser->id,
'customer_number' => $customerNumber,
'login_path' => $this->buildDirectSubuserLoginPath($sessionToken, $customerNumber),
]);
}, [
'edit_subusers' => 'Create chauffeur direct login links as a superuser.',
'SUPERUSER_INTIMIDATE' => 'Create direct login sessions for chauffeur accounts.',
]);
$this->get('/superuser/users/{user_id}/subusers', function () {
global $response;
$this->requirePermission('list_subusers');
@@ -4,13 +4,14 @@ namespace routes;
use classes\authentication;
use classes\replication_manager;
use Throwable;
use traits\route_t;
class superuserReplicationRoute
{
use route_t;
private const RETIRED_MANAGEMENT_MESSAGE = 'Replication management has been retired. Use System -> Database, System -> Redis, and System -> MinIO for read-only status.';
public function run(): void
{
$this->get('/superuser/replication', function () {
@@ -24,140 +25,71 @@ class superuserReplicationRoute
]);
$this->post('/superuser/replication/databases', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$host = (new replication_manager())->addHost('database', $this->getParametersAsArray(), $this->actorUserId());
$response->success($host, 201);
$this->rejectRetiredManagement();
}, [
'superuser_replication_manage' => 'Add and manage database replication host credentials',
]);
$this->post('/superuser/replication/redis', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$host = (new replication_manager())->addHost('redis', $this->getParametersAsArray(), $this->actorUserId());
$response->success($host, 201);
$this->rejectRetiredManagement();
}, [
'superuser_replication_manage' => 'Add and manage Redis replication host credentials',
]);
$this->post('/superuser/replication/minio', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$host = (new replication_manager())->addHost('minio', $this->getParametersAsArray(), $this->actorUserId());
$response->success($host, 201);
$this->rejectRetiredManagement();
}, [
'superuser_replication_manage' => 'Add and manage MinIO replication host credentials',
]);
$this->post('/superuser/replication/compose-template', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$response->success(replication_manager::composeTemplate($this->getParametersAsArray()));
$this->rejectRetiredManagement();
}, [
'superuser_replication_manage' => 'Generate Docker Compose templates for replication-ready database, Redis, and MinIO hosts',
]);
$this->post('/superuser/replication/test-credentials', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$parameters = $this->getParametersAsArray();
$response->success((new replication_manager())->testCredentials(
(string)($parameters['kind'] ?? ''),
$parameters
));
$this->rejectRetiredManagement();
}, [
'superuser_replication_manage' => 'Test database, Redis, and MinIO replication host credentials before saving them',
]);
$this->post('/superuser/replication/{kind}/{id}/test', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$response->success((new replication_manager())->testHost(
(string)$this->fromRoute('kind'),
$this->routeId(),
$this->actorUserId()
));
$this->rejectRetiredManagement();
}, [
'superuser_replication_manage' => 'Validate database, Redis, and MinIO replication host connectivity and privileges',
]);
$this->post('/superuser/replication/{kind}/{id}/provision', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_manage');
try {
$result = (new replication_manager())->provisionHost(
(string)$this->fromRoute('kind'),
$this->routeId(),
$this->actorUserId(),
true
);
if (($result['ok'] ?? false) !== true) {
$response->error($result, 409);
}
$response->success($result);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
$this->rejectRetiredManagement();
}, [
'superuser_replication_manage' => 'Provision a database, Redis, or MinIO host as a replica of the current primary',
]);
$this->post('/superuser/replication/{kind}/{id}/promote', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_promote');
try {
$response->success((new replication_manager())->promoteHost(
(string)$this->fromRoute('kind'),
$this->routeId(),
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
$this->rejectRetiredManagement();
}, [
'superuser_replication_promote' => 'Promote a healthy caught-up database, Redis, or MinIO replica to primary',
]);
$this->patch('/superuser/replication/{kind}/{id}', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_manage');
try {
$response->success((new replication_manager())->renameHost(
(string)$this->fromRoute('kind'),
$this->routeId(),
$this->getParametersAsArray(),
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
$this->rejectRetiredManagement();
}, [
'superuser_replication_manage' => 'Rename database, Redis, and MinIO replication hosts',
]);
$this->delete('/superuser/replication/{kind}/{id}', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_remove');
try {
$response->success((new replication_manager())->removeHost(
(string)$this->fromRoute('kind'),
$this->routeId(),
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
$this->rejectRetiredManagement();
}, [
'superuser_replication_remove' => 'Remove inactive prior hosts and unhealthy database, Redis, or MinIO replicas',
]);
@@ -180,21 +112,11 @@ class superuserReplicationRoute
return $this->requirePermission($permission);
}
private function routeId(): int
private function rejectRetiredManagement(): void
{
$id = (int)$this->fromRoute('id');
$this->requireParameterIntPositive($id, 'id');
return $id;
}
global $response;
private function actorUserId(): ?int
{
try {
$user = (new authentication())->get_user();
return $user !== false && isset($user->id) ? (int)$user->id : null;
} catch (Throwable) {
return null;
}
$response->error(['message' => self::RETIRED_MANAGEMENT_MESSAGE], 410);
}
private function toBool(mixed $value, bool $default): bool
@@ -0,0 +1,196 @@
<?php
namespace routes;
use classes\authentication;
use classes\security_policy_service;
use Throwable;
use traits\route_t;
require_once WD . '/classes/security_policy_service.php';
class superuserSecurityRoute
{
use route_t;
public function run(): void
{
$this->get('/superuser/system/security/summary', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_security_view');
$response->success((new security_policy_service())->summary());
}, [
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
]);
$this->get('/superuser/system/security/settings', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_security_view');
$response->success((new security_policy_service())->settings());
}, [
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
'superuser_security_limits_exempt' => 'Exempt requests from observe-mode security limit incidents',
]);
$this->patch('/superuser/system/security/settings', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_security_settings_manage');
try {
$response->success((new security_policy_service())->updateSettings(
$this->getParametersAsArray(),
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_security_settings_manage' => 'Configure observe-mode security thresholds and exemptions',
]);
$this->get('/superuser/system/security/firewall-rules', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_security_view');
$response->success((new security_policy_service())->listFirewallRules($this->getParametersAsArray()));
}, [
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
]);
$this->post('/superuser/system/security/firewall-rules', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_security_firewall_manage');
try {
$response->success((new security_policy_service())->createFirewallRule(
$this->getParametersAsArray(),
$this->actorUserId()
), 201);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_security_firewall_manage' => 'Create, update, and delete application firewall rules',
]);
$this->patch('/superuser/system/security/firewall-rules/{id}', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_security_firewall_manage');
try {
$response->success((new security_policy_service())->updateFirewallRule(
$this->routeId(),
$this->getParametersAsArray(),
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_security_firewall_manage' => 'Create, update, and delete application firewall rules',
]);
$this->delete('/superuser/system/security/firewall-rules/{id}', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_security_firewall_manage');
try {
$response->success((new security_policy_service())->deleteFirewallRule(
$this->routeId(),
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_security_firewall_manage' => 'Create, update, and delete application firewall rules',
]);
$this->get('/superuser/system/security/incidents', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_security_view');
$response->success((new security_policy_service())->listIncidents($this->getParametersAsArray()));
}, [
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
]);
$this->get('/superuser/system/security/incidents/{id}', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_security_view');
try {
$response->success((new security_policy_service())->incidentDetail($this->routeId()));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 404);
}
}, [
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
]);
$this->patch('/superuser/system/security/incidents/{id}', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_security_incidents_manage');
try {
$response->success((new security_policy_service())->updateIncident(
$this->routeId(),
$this->getParametersAsArray(),
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_security_incidents_manage' => 'Acknowledge, resolve, reopen, and note security incidents',
]);
$this->post('/superuser/system/security/incidents/{id}/notes', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_security_incidents_manage');
try {
$parameters = $this->getParametersAsArray();
$response->success((new security_policy_service())->addIncidentNote(
$this->routeId(),
(string)($parameters['note'] ?? ''),
$this->actorUserId()
), 201);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_security_incidents_manage' => 'Acknowledge, resolve, reopen, and note security incidents',
]);
}
private function requireClassicSuperuserPermission(string $permission): bool
{
global $response;
if ((new authentication())->get_subuser() !== false) {
$response->error('Subuser sessions cannot manage security controls.', 403);
}
return $this->requirePermission($permission);
}
private function routeId(): int
{
$id = (int)$this->fromRoute('id');
$this->requireParameterIntPositive($id, 'id');
return $id;
}
private function actorUserId(): ?int
{
try {
$user = (new authentication())->get_user();
return $user !== false && isset($user->id) ? (int)$user->id : null;
} catch (Throwable) {
return null;
}
}
}
@@ -4,6 +4,7 @@ namespace routes;
use classes\authentication;
use classes\economic_v2_versioning_service;
use classes\security_policy_service;
use customers\economic_customer_mo;
use objects\bookings_o;
use objects\customer_vehicles_o;
@@ -17,6 +18,8 @@ use objects\users_o;
use traits\route_t;
use modules\subusers\helpers\subusers_permission_node_key;
require_once WD . '/classes/security_policy_service.php';
class vehiclesRoute
{
use route_t;
@@ -208,9 +211,23 @@ class vehiclesRoute
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'ADD_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
}
$this->observeVehicleCreated($customerNumber, (int)$vehicle->id, '/superuser/users/{user_id}/vehicles');
return $vehicle->asArray();
}
private function observeVehicleCreated(int $customerNumber, int $vehicleId, string $route): void
{
try {
(new security_policy_service())->observeVehicleCreated($customerNumber, [
'vehicle_id' => $vehicleId,
'route' => $route,
]);
} catch (\Throwable) {
// Security observation must not change vehicle creation responses.
}
}
private function updateScopedVehicle(customer_vehicles_o $vehicle, int $customerNumber): array
{
global $response;
@@ -599,6 +616,7 @@ class vehiclesRoute
}
(new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'ADD_VEHICLE', 'Successfully added vehicle');
$this->observeVehicleCreated((int)$targetCustomer, (int)$vehicle->id, '/vehicles');
$response->success($vehicle->asArray());
},
[
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('lists only visible active departments with public time bookings enabled', function (): void {
api_test_covers('GET /department/timebookings/departments/public', 'happy');
$enabledDepartment = api_fixtures()->createDepartment([
'name' => 'Public Time Booking Enabled ' . uniqid('', false),
'description' => 'Enabled booking department address',
'visible' => 1,
'archived' => 0,
'latitude' => 55.1,
'longitude' => 12.1,
'order_priority' => 7,
]);
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$enabledDepartment['id'], true);
$disabledDepartment = api_fixtures()->createDepartment([
'name' => 'Public Time Booking Disabled ' . uniqid('', false),
'visible' => 1,
'archived' => 0,
]);
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$disabledDepartment['id'], false);
$missingVariableDepartment = api_fixtures()->createDepartment([
'name' => 'Public Time Booking Missing Variable ' . uniqid('', false),
'visible' => 1,
'archived' => 0,
]);
$legacyTruthyDepartment = api_fixtures()->createDepartment([
'name' => 'Public Time Booking Legacy Truthy ' . uniqid('', false),
'visible' => 1,
'archived' => 0,
]);
api_fixtures()->setDepartmentVariable(
(int)$legacyTruthyDepartment['id'],
'bookingsystem_time_based_enabled',
'1'
);
$hiddenDepartment = api_fixtures()->createDepartment([
'name' => 'Public Time Booking Hidden ' . uniqid('', false),
'visible' => 0,
'archived' => 0,
]);
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$hiddenDepartment['id'], true);
$archivedDepartment = api_fixtures()->createDepartment([
'name' => 'Public Time Booking Archived ' . uniqid('', false),
'visible' => 1,
'archived' => 1,
]);
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$archivedDepartment['id'], true);
$response = api_client()->get('/department/timebookings/departments/public');
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$departmentsById = [];
foreach ($response->data() as $department) {
$departmentsById[(int)($department['id'] ?? 0)] = $department;
}
expect($departmentsById)
->toHaveKey((int)$enabledDepartment['id'])
->not->toHaveKey((int)$disabledDepartment['id'])
->not->toHaveKey((int)$missingVariableDepartment['id'])
->not->toHaveKey((int)$legacyTruthyDepartment['id'])
->not->toHaveKey((int)$hiddenDepartment['id'])
->not->toHaveKey((int)$archivedDepartment['id']);
$returnedEnabledDepartment = $departmentsById[(int)$enabledDepartment['id']];
expect($returnedEnabledDepartment)
->toHaveKey('name')
->toHaveKey('description', 'Enabled booking department address')
->toHaveKey('address', 'Enabled booking department address')
->toHaveKey('time_booking_enabled', true)
->not->toHaveKey('slack_webhook')
->not->toHaveKey('custom_pricing_only')
->not->toHaveKey('variables')
->not->toHaveKey('bookingsystem_time_based_enabled');
});
it('returns an empty public time-booking department list when no matching department is enabled', function (): void {
api_test_covers('GET /department/timebookings/departments/public', 'empty');
$uniqueName = 'Public Time Booking Empty ' . uniqid('', false);
$disabledDepartment = api_fixtures()->createDepartment([
'name' => $uniqueName,
'visible' => 1,
'archived' => 0,
]);
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$disabledDepartment['id'], false);
$response = api_client()->get(
'/department/timebookings/departments/public?search=' . rawurlencode($uniqueName)
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())->toBe([]);
});
it('rejects public time-booking detail requests for disabled departments', function (): void {
api_test_covers('GET /department/timebookings/types/public', 'disabled');
$disabledDepartment = api_fixtures()->createDepartment([
'name' => 'Disabled Public Time Booking Detail ' . uniqid('', false),
'visible' => 1,
'archived' => 0,
]);
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$disabledDepartment['id'], false);
api_client()->get('/department/timebookings/types/public?id=' . (int)$disabledDepartment['id'])
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Department time bookings are not enabled');
});
+158 -14
View File
@@ -218,10 +218,16 @@ it('lists chauffeur grants across customers for superusers', function (): void {
]);
$firstSubuser = api_fixtures()->createSubuser(['name' => 'Alpha Driver']);
$secondSubuser = api_fixtures()->createSubuser(['name' => 'Beta Driver']);
$firstVehicle = api_fixtures()->createVehicle([
'customer_id' => (int)$firstCustomer['customer_number'],
'type' => 1,
'reg' => 'ab12345',
]);
$firstGrantId = api_fixtures()->grantSubuser(
(int)$firstSubuser['id'],
(int)$firstCustomer['customer_number'],
['VEHICLES_LIST', 'SUBUSERS_LIST']
['VEHICLES_LIST', 'SUBUSERS_LIST', 'SELFSERVE_LIST', 'SELFSERVE_ADD'],
['assigned_vehicle_id' => (int)$firstVehicle['id']]
);
$secondGrantId = api_fixtures()->grantSubuser(
(int)$secondSubuser['id'],
@@ -247,23 +253,23 @@ it('lists chauffeur grants across customers for superusers', function (): void {
&& in_array((int)($item['id'] ?? 0), [(int)$firstSubuser['id'], (int)$secondSubuser['id']], true)
));
expect($rows)->toHaveCount(2);
expect($rows)->toHaveCount(3);
$bySubuserId = [];
$byGrantId = [];
foreach ($rows as $row) {
$bySubuserId[(int)$row['id']] = $row;
$byGrantId[(int)$row['grant_id']] = $row;
}
expect($bySubuserId[(int)$firstSubuser['id']]['grant_count'])->toBe(2);
expect(array_column($bySubuserId[(int)$firstSubuser['id']]['grants'], 'grant_id'))
->toContain($firstGrantId)
->toContain($sharedGrantId);
expect($bySubuserId[(int)$firstSubuser['id']]['customer_numbers'])
->toContain((int)$firstCustomer['customer_number'])
->toContain((int)$secondCustomer['customer_number']);
expect($bySubuserId[(int)$secondSubuser['id']]['grant_count'])->toBe(1);
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['grant_id'])->toBe($secondGrantId);
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['customer_name'])->toBe('Fleet Customer Beta');
expect($byGrantId[$firstGrantId]['id'])->toBe((int)$firstSubuser['id']);
expect($byGrantId[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']);
expect($byGrantId[$firstGrantId]['assigned_vehicle_id'])->toBe((int)$firstVehicle['id']);
expect($byGrantId[$firstGrantId]['assigned_vehicle_reg'])->toBe('AB12345');
expect($byGrantId[$firstGrantId]['dognvask_enabled'])->toBeTrue();
expect($byGrantId[$sharedGrantId]['id'])->toBe((int)$firstSubuser['id']);
expect($byGrantId[$sharedGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']);
expect($byGrantId[$sharedGrantId]['dognvask_enabled'])->toBeFalse();
expect($byGrantId[$secondGrantId]['id'])->toBe((int)$secondSubuser['id']);
expect($byGrantId[$secondGrantId]['customer_name'])->toBe('Fleet Customer Beta');
});
it('lets superusers invite chauffeurs for a selected customer', function (): void {
@@ -483,6 +489,16 @@ it('edits only customer-matching chauffeur grants through the user-scoped route'
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Patch Other']);
$targetSubuser = api_fixtures()->createSubuser(['name' => 'Patch Target Driver']);
$otherSubuser = api_fixtures()->createSubuser(['name' => 'Patch Other Driver']);
$targetVehicle = api_fixtures()->createVehicle([
'customer_id' => (int)$targetCustomer['customer_number'],
'type' => 1,
'reg' => 'scope123',
]);
$otherVehicle = api_fixtures()->createVehicle([
'customer_id' => (int)$otherCustomer['customer_number'],
'type' => 1,
'reg' => 'other123',
]);
$targetGrantId = api_fixtures()->grantSubuser(
(int)$targetSubuser['id'],
(int)$targetCustomer['customer_number'],
@@ -501,9 +517,16 @@ it('edits only customer-matching chauffeur grants through the user-scoped route'
'enabled' => false,
'note' => 'Scoped note',
'permissions' => ['ORDERS_LIST'],
'assigned_vehicle_id' => (int)$targetVehicle['id'],
],
$session['headers']
);
$mismatchedVehicle = api_client()->request(
'PATCH',
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $targetGrantId,
['assigned_vehicle_id' => (int)$otherVehicle['id']],
$session['headers']
);
$crossCustomer = api_client()->request(
'PATCH',
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $otherGrantId,
@@ -515,6 +538,10 @@ it('edits only customer-matching chauffeur grants through the user-scoped route'
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$mismatchedVehicle
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
$crossCustomer
->assertStatus(404)
->assertEnvelope()
@@ -523,6 +550,123 @@ it('edits only customer-matching chauffeur grants through the user-scoped route'
expect($update->data()['grant']['enabled'] ?? null)->toBeFalse();
expect($update->data()['grant']['note'] ?? null)->toBe('Scoped note');
expect($update->data()['grant']['permissions'] ?? null)->toBe(['ORDERS_LIST']);
expect($update->data()['grant']['assigned_vehicle_id'] ?? null)->toBe((int)$targetVehicle['id']);
expect($update->data()['subuser']['assigned_vehicle_reg'] ?? null)->toBe('SCOPE123');
});
it('lets superusers edit chauffeur account details and set passwords', function (): void {
api_test_covers('PATCH /superuser/subusers/{subuser_id}', 'happy');
api_test_covers('POST /superuser/subusers/{subuser_id}/password', 'happy');
$session = api_fixtures()->createUserSession(['edit_subusers']);
$subuser = api_fixtures()->createSubuser([
'password_plaintext' => null,
'name' => 'Admin Managed Driver',
'email' => null,
'phone_country_code' => 45,
'phone' => 73123456,
]);
$subuserObject = (new \objects\subusers_o())->select((int)$subuser['id']);
$subuserObject->getObjectProperties();
$setupToken = $subuserObject->generateSetupToken();
try {
$profile = api_client()->request(
'PATCH',
'/superuser/subusers/' . $subuser['id'],
[
'name' => 'Admin Updated Driver',
'email' => 'admin.updated.driver@example.com',
'phone_country_code' => 46,
'phone' => 73123457,
],
$session['headers']
);
$password = api_client()->post(
'/superuser/subusers/' . $subuser['id'] . '/password',
['password' => 'ValidPass123'],
$session['headers']
);
$profile
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$password
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($profile->data()['subuser']['name'] ?? null)->toBe('Admin Updated Driver');
expect($profile->data()['subuser']['email'] ?? null)->toBe('admin.updated.driver@example.com');
expect($profile->data()['subuser']['phone_country_code'] ?? null)->toBe(46);
expect($profile->data()['subuser']['phone'] ?? null)->toBe(73123457);
expect($password->data()['subuser']['setup_required'] ?? null)->toBeFalse();
expect((new \objects\subusers_o())->getSubuserBySetupToken($setupToken))->toBeNull();
$row = api_fixtures()->fetchRowById('subusers', (int)$subuser['id']);
expect(password_verify('ValidPass123', (string)($row['password'] ?? '')))->toBeTrue();
} finally {
(new \objects\subusers_o())->invalidateSetupToken($setupToken);
}
});
it('creates direct chauffeur login links scoped to a selected customer grant', function (): void {
api_test_covers('POST /superuser/subusers/{subuser_id}/login-link', 'happy');
api_test_covers('POST /superuser/subusers/{subuser_id}/login-link', 'failure');
$session = api_fixtures()->createUserSession(['edit_subusers', 'SUPERUSER_INTIMIDATE']);
$firstCustomer = api_fixtures()->createUser(['display_name' => 'Direct Login Customer Alpha']);
$secondCustomer = api_fixtures()->createUser(['display_name' => 'Direct Login Customer Beta']);
$subuser = api_fixtures()->createSubuser(['name' => 'Direct Login Driver']);
api_fixtures()->grantSubuser(
(int)$subuser['id'],
(int)$firstCustomer['customer_number'],
['VEHICLES_LIST']
);
$secondGrantId = api_fixtures()->grantSubuser(
(int)$subuser['id'],
(int)$secondCustomer['customer_number'],
['ORDERS_LIST']
);
$ambiguous = api_client()->post(
'/superuser/subusers/' . $subuser['id'] . '/login-link',
[],
$session['headers']
);
$direct = api_client()->post(
'/superuser/subusers/' . $subuser['id'] . '/login-link',
['grant_id' => $secondGrantId],
$session['headers']
);
$ambiguous
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Customer number or grant id is required for drivers with multiple customer grants');
$direct
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($direct->data()['customer_number'] ?? null)->toBe((int)$secondCustomer['customer_number']);
expect($direct->data()['login_path'] ?? null)->toBeString();
$parts = parse_url((string)$direct->data()['login_path']);
parse_str((string)($parts['query'] ?? ''), $query);
expect($parts['path'] ?? null)->toBe('/login/qr');
expect($query['type'] ?? null)->toBe('subuser');
expect((int)($query['customer_number'] ?? 0))->toBe((int)$secondCustomer['customer_number']);
expect($query['token'] ?? null)->toBeString();
$resolved = (new \objects\subusers_o())->getSubuserBySessionToken((string)$query['token']);
expect($resolved)->not->toBeNull();
expect((int)$resolved->id)->toBe((int)$subuser['id']);
(new \objects\subusers_o())->invalidateSessionToken((string)$query['token']);
});
it('rejects mismatched customer numbers on user-scoped chauffeur invites', function (): void {
@@ -148,6 +148,42 @@ final class ApiFixtures
return ['id' => $departmentId];
}
public function setDepartmentVariable(int $departmentId, string $variable, mixed $value): void
{
if ($departmentId <= 0) {
throw new RuntimeException('Department variable fixtures require a positive department id.');
}
$variable = trim($variable);
if ($variable === '') {
throw new RuntimeException('Department variable fixtures require a variable name.');
}
$conditions = [
'department_id' => $departmentId,
'variable' => $variable,
];
$this->deleteWhereIfPossible('department_variables', $conditions);
$this->cleanupDeleteWhere('department_variables', $conditions);
$variableId = $this->insertRowWithExistingColumns('department_variables', [
'department_id' => $departmentId,
'variable' => $variable,
'value' => (string)$value,
]);
$this->cleanup->add(fn() => $this->deleteById('department_variables', $variableId));
}
public function setDepartmentTimeBookingsEnabled(int $departmentId, bool $enabled): void
{
$this->setDepartmentVariable(
$departmentId,
'bookingsystem_time_based_enabled',
$enabled ? 'true' : 'false'
);
}
public function setDepartmentSelfServeEnabled(int $departmentId, bool $enabled): void
{
if ($departmentId <= 0) {
@@ -1029,16 +1065,17 @@ final class ApiFixtures
];
}
public function grantSubuser(int $subuserId, int $customerNumber, array $permissions): int
public function grantSubuser(int $subuserId, int $customerNumber, array $permissions, array $attributes = []): int
{
$grantId = $this->insertRow('subuser_grants', [
'billing_customer_number' => $customerNumber,
'subuser' => $subuserId,
'enabled' => 1,
'note' => 'API test grant',
'assigned_vehicle_id' => $attributes['assigned_vehicle_id'] ?? null,
'enabled' => $attributes['enabled'] ?? 1,
'note' => $attributes['note'] ?? 'API test grant',
'permissions' => json_encode(array_values($permissions), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'created_at' => $this->now(),
'updated_at' => $this->now(),
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => null,
]);
@@ -95,6 +95,110 @@ CREATE TABLE IF NOT EXISTS `logs` (
KEY `idx_logs_action` (`action`),
KEY `idx_logs_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'security_firewall_rules' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `security_firewall_rules` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`action` VARCHAR(16) NOT NULL,
`target_type` VARCHAR(32) NOT NULL,
`target_value` VARCHAR(255) NOT NULL,
`route_pattern` VARCHAR(255) NULL,
`priority` INT NOT NULL DEFAULT 100,
`reason` TEXT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`expires_at` DATETIME NULL,
`metadata_json` LONGTEXT NULL,
`created_by` INT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `idx_security_firewall_rules_active` (`enabled`, `deleted_at`, `expires_at`),
KEY `idx_security_firewall_rules_target` (`target_type`, `target_value`),
KEY `idx_security_firewall_rules_priority` (`priority`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'security_policy_rules' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `security_policy_rules` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`rule_key` VARCHAR(64) NOT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`threshold_count` INT NOT NULL,
`window_seconds` INT NOT NULL,
`mode` VARCHAR(16) NOT NULL DEFAULT 'observe',
`exempt_permission_nodes_json` LONGTEXT NULL,
`updated_by` INT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_security_policy_rules_key` (`rule_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'security_policy_events' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `security_policy_events` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`rule_key` VARCHAR(64) NOT NULL,
`subject_type` VARCHAR(32) NOT NULL,
`subject_key` VARCHAR(191) NOT NULL,
`route_path` VARCHAR(255) NULL,
`route_template` VARCHAR(255) NULL,
`method` VARCHAR(16) NULL,
`source_ip` VARCHAR(64) NULL,
`customer_number` INT NULL,
`user_id` INT NULL,
`subuser_id` INT NULL,
`metadata_json` LONGTEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_security_policy_events_window` (`rule_key`, `subject_type`, `subject_key`, `created_at`),
KEY `idx_security_policy_events_created` (`created_at`),
KEY `idx_security_policy_events_customer` (`customer_number`, `created_at`),
KEY `idx_security_policy_events_ip` (`source_ip`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'security_incidents' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `security_incidents` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`incident_key` VARCHAR(191) NOT NULL,
`type` VARCHAR(64) NOT NULL,
`severity` VARCHAR(16) NOT NULL DEFAULT 'medium',
`status` VARCHAR(32) NOT NULL DEFAULT 'open',
`title` VARCHAR(255) NOT NULL,
`source_ip` VARCHAR(64) NULL,
`customer_number` INT NULL,
`user_id` INT NULL,
`subuser_id` INT NULL,
`route_path` VARCHAR(255) NULL,
`route_template` VARCHAR(255) NULL,
`method` VARCHAR(16) NULL,
`related_rule_id` BIGINT UNSIGNED NULL,
`related_firewall_rule_id` BIGINT UNSIGNED NULL,
`occurrence_count` INT NOT NULL DEFAULT 1,
`metadata_json` LONGTEXT NULL,
`first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`resolved_by` INT NULL,
`resolved_at` DATETIME NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_security_incidents_key` (`incident_key`),
KEY `idx_security_incidents_status_seen` (`status`, `last_seen_at`),
KEY `idx_security_incidents_type_seen` (`type`, `last_seen_at`),
KEY `idx_security_incidents_customer_seen` (`customer_number`, `last_seen_at`),
KEY `idx_security_incidents_ip_seen` (`source_ip`, `last_seen_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'security_incident_notes' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `security_incident_notes` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`incident_id` BIGINT UNSIGNED NOT NULL,
`note` TEXT NOT NULL,
`created_by` INT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_security_incident_notes_incident` (`incident_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'departments' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `departments` (
@@ -732,6 +836,7 @@ CREATE TABLE IF NOT EXISTS `subuser_grants` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`billing_customer_number` INT NOT NULL,
`subuser` INT NOT NULL,
`assigned_vehicle_id` INT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`note` TEXT NULL,
`permissions` LONGTEXT NULL,
@@ -740,6 +845,7 @@ CREATE TABLE IF NOT EXISTS `subuser_grants` (
`deleted_at` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `idx_subuser_grants_subuser` (`subuser`),
KEY `idx_subuser_grants_assigned_vehicle_id` (`assigned_vehicle_id`),
KEY `idx_subuser_grants_billing_customer_number` (`billing_customer_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
@@ -0,0 +1,78 @@
<?php
it('defines durable backup schema tables for records, components, jobs, and restore audit', function (): void {
$content = file_get_contents(app_path('classes/backup_schema_bootstrap.php'));
expect($content)->toContain('CREATE TABLE IF NOT EXISTS backup_records')
->and($content)->toContain('CREATE TABLE IF NOT EXISTS backup_components')
->and($content)->toContain('CREATE TABLE IF NOT EXISTS backup_jobs')
->and($content)->toContain('CREATE TABLE IF NOT EXISTS backup_restore_audit')
->and($content)->toContain('manifest_sha256')
->and($content)->toContain('encryption_key_id');
});
it('hardens database dumps by keeping passwords out of the mysqldump command', function (): void {
$content = file_get_contents(app_path('classes/db.php'));
expect($content)->toContain('MYSQL_PWD')
->and($content)->toContain('--single-transaction')
->and($content)->toContain('--routines')
->and($content)->toContain('--triggers')
->and($content)->toContain('--events')
->and($content)->not->toContain('--password=$pass');
});
it('uses encrypted component backups and never stores raw environment dumps', function (): void {
$content = file_get_contents(app_path('classes/backup_store.php'));
expect($content)->toContain('AES-256-GCM')
->and($content)->toContain('BACKUP_ENCRYPTION_KEY_V1')
->and($content)->toContain('required_runtime_config_keys')
->and($content)->toContain('createObjectBucketComponent')
->and($content)->not->toContain('json_encode($_ENV)')
->and($content)->not->toContain("exec('zip -r");
});
it('normalizes string boolean config values before destructive restore gates', function (): void {
$content = file_get_contents(app_path('classes/backup_store.php'));
expect($content)->toContain("['1', 'true', 'yes', 'on']")
->and($content)->toContain("['0', 'false', 'no', 'off', '']")
->and($content)->toContain('Backup system is disabled in backup configuration.')
->and($content)->toContain('Direct production restore is disabled in backup configuration.');
});
it('wires backup job, verification, restore preview, restore, and audit routes', function (): void {
$content = file_get_contents(app_path('routes/moduleBackupsRoute.php'));
expect($content)->toContain('/modules/backup/jobs/{id}')
->and($content)->toContain('/modules/backup/backups/{backup_uuid}/verify')
->and($content)->toContain('/modules/backup/backups/{backup_uuid}/restore/preview')
->and($content)->toContain('/modules/backup/backups/{backup_uuid}/restore')
->and($content)->toContain('/modules/backup/restore-audit')
->and($content)->toContain('modules_backup_restore')
->and($content)->toContain('Subuser sessions cannot manage backup disaster recovery.');
});
it('registers hourly backup enqueue, worker, and retention prune cron tasks', function (): void {
$content = file_get_contents(app_path('modules/backups/cron/tasks.php'));
$cron = file_get_contents(app_path('cron/Cron.php'));
expect($content)->toContain("'seconds' => 3600")
->and($content)->toContain('backups.process_jobs')
->and($content)->toContain('backups.prune_retention')
->and($cron)->toContain('processBackupJobs')
->and($cron)->toContain('pruneBackupRetention');
});
it('documents backup disaster recovery APIs and config variables in openapi', function (): void {
$content = file_get_contents(app_path('openapi.yaml'));
expect($content)->toContain('/modules/backup/jobs/{id}:')
->and($content)->toContain('/modules/backup/backups/{backup_uuid}/restore/preview:')
->and($content)->toContain('BackupRestoreRequest')
->and($content)->toContain('BackupRestoreAuditListResponse')
->and($content)->toContain('retention_recent_hours')
->and($content)->toContain('verification_required')
->and($content)->toContain('restore_enabled');
});
@@ -7,9 +7,11 @@ it('discovers module-owned cron task definitions', function (): void {
$registry = new cron_task_registry(app_path('modules'));
$definitions = $registry->definitions();
expect($definitions)->toHaveCount(20);
expect($definitions)->toHaveCount(22);
expect(array_keys($definitions))->toContain(
'system.sync_logs',
'backups.process_jobs',
'backups.prune_retention',
'economic.transfer_queue',
'dynamicimages.pre_render',
'weatherapi.preload_department_responses',
@@ -0,0 +1,39 @@
<?php
it('wires cron workers through schema, CLI, scheduler, and superuser routes', function (): void {
$schema = file_get_contents(app_path('classes/cron_schema_bootstrap.php'));
$worker = file_get_contents(app_path('classes/cron_worker.php'));
$scheduler = file_get_contents(app_path('classes/cron_scheduler.php'));
$cli = file_get_contents(app_path('cli.php'));
$route = file_get_contents(app_path('routes/cronRoute.php'));
$manager = file_get_contents(app_path('classes/release_manager.php'));
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS cron_worker_state');
expect($schema)->toContain('last_heartbeat_at');
expect($schema)->toContain('last_stale_run_count');
expect($worker)->toContain('CRON_WORKER_POLL_SECONDS');
expect($worker)->toContain('CRON_WORKER_HEARTBEAT_SECONDS');
expect($worker)->toContain('CRON_WORKER_RELEASE_TARGET_ID');
expect($worker)->toContain('markExpiredRunningRuns');
expect($worker)->toContain('runDue($this->source)');
expect($scheduler)->toContain('function markExpiredRunningRuns');
expect($scheduler)->toContain("r.status = 'timed_out'");
expect($scheduler)->toContain('s.current_run_id = NULL');
expect($cli)->toContain("case 'cron-worker'");
expect($cli)->toContain('new \\classes\\cron_worker()');
expect($route)->toContain('/superuser/cron/workers');
expect($route)->toContain('/superuser/cron/workers/deploy');
expect($route)->toContain('superuser_cron_view');
expect($route)->toContain('superuser_cron_manage');
expect($route)->toContain('superuser_coolify_manage');
expect($manager)->toContain("private const CRON_WORKER_APP = 'cron'");
expect($manager)->toContain("private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'");
expect($manager)->toContain('deployCronWorkerAfterApiDeployment');
expect($manager)->toContain('cron_worker_deploy_failed');
expect($manager)->toContain('auto_deploy = 0');
});
@@ -375,6 +375,73 @@ it('uses the self-contained Coolify API Dockerfile for API applications', functi
expect($payload)->not->toHaveKey('is_static');
});
it('creates private Coolify application payloads for cron workers', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
'app' => 'cron',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
'auto_deploy' => 0,
], [
'coolify_service_name' => 'release-internal-cron-worker',
'coolify_project_uuid' => 'project-internal',
'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
'coolify_build_pack' => 'dockerfile',
'coolify_deploy_now' => true,
'coolify_start_command' => 'php index.php run cron-worker',
], [
'default_environment_name' => 'production',
'default_server_uuid' => 'server-node3',
]);
expect($payload['name'])->toBe('release-internal-cron-worker');
expect($payload['build_pack'])->toBe('dockerfile');
expect($payload['ports_exposes'])->toBe('80');
expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-api');
expect($payload['start_command'])->toBe('php index.php run cron-worker');
expect($payload['is_auto_deploy_enabled'])->toBeFalse();
expect($payload)->not->toHaveKey('domains');
expect($payload)->not->toHaveKey('is_force_https_enabled');
});
it('derives cron worker deployment context from the API target without public routing', function (): void {
$manager = new release_manager();
$contextMethod = new ReflectionMethod(release_manager::class, 'cronWorkerDeployContext');
$contextMethod->setAccessible(true);
$context = $contextMethod->invoke($manager, [
'id' => 17,
'channel_id' => 3,
'channel_slug' => 'internal',
'deploy_context_json' => json_encode([
'coolify_project_uuid' => 'project-internal',
'coolify_environment_name' => 'production',
'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
'coolify_public_url' => 'https://api-v2.truckwash.io',
'manual_endpoint_host' => 'manual.example.test',
]),
], null, '5555555555555555555555555555555555555555', 41);
expect($context['coolify_auto_create'])->toBeTrue();
expect($context['coolify_resource_type'])->toBe('application');
expect($context['coolify_build_pack'])->toBe('dockerfile');
expect($context['coolify_dockerfile_location'])->toBe('/Dockerfile.coolify-api');
expect($context['coolify_start_command'])->toBe('php index.php run cron-worker');
expect($context['coolify_is_auto_deploy_enabled'])->toBeFalse();
expect($context['coolify_enable_ssl'])->toBeFalse();
expect($context['coolify_service_name'])->toBe('release-internal-cron-worker');
expect($context['coolify_git_commit_sha'])->toBe('5555555555555555555555555555555555555555');
expect($context['coolify_env']['CRON_WORKER_RELEASE_CHANNEL_ID'])->toBe('3');
expect($context['coolify_env']['CRON_WORKER_RELEASE_TARGET_ID'])->toBe('41');
expect($context['coolify_env']['CRON_WORKER_SOURCE'])->toBe('coolify_worker');
expect($context)->not->toHaveKey('coolify_public_url');
expect($context)->not->toHaveKey('manual_endpoint_host');
});
it('builds explicit Coolify application route labels for release API targets', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload');
@@ -658,6 +725,57 @@ it('forces selected API commit into generated Coolify runtime env keys', functio
expect($env['RELEASE_COMMIT_SHA'])->toBe($selectedCommit);
});
it('builds cron worker runtime environment from API runtime keys and cron context', function (): void {
$keys = ['CONFIG_DB_HOST', 'CRON_WORKER_NAME'];
$previous = [];
foreach ($keys as $key) {
$previous[$key] = getenv($key);
}
try {
putenv('CONFIG_DB_HOST=db.example.test');
$_ENV['CONFIG_DB_HOST'] = 'db.example.test';
$_SERVER['CONFIG_DB_HOST'] = 'db.example.test';
putenv('CRON_WORKER_NAME=ignored-runtime-name');
$_ENV['CRON_WORKER_NAME'] = 'ignored-runtime-name';
$_SERVER['CRON_WORKER_NAME'] = 'ignored-runtime-name';
$manager = new release_manager();
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
$runtimeEnv->setAccessible(true);
$selectedCommit = '4444444444444444444444444444444444444444';
$env = $runtimeEnv->invoke($manager, [
'app' => 'cron',
'commit_sha' => $selectedCommit,
], [
'coolify_env' => [
'CRON_WORKER_NAME' => 'release-internal-cron-worker',
'CRON_WORKER_SOURCE' => 'coolify_worker',
],
]);
expect($env['USE_ENV'])->toBe('true');
expect($env['CONFIG_DB_HOST'])->toBe('db.example.test');
expect($env['CRON_WORKER_NAME'])->toBe('release-internal-cron-worker');
expect($env['CRON_WORKER_SOURCE'])->toBe('coolify_worker');
expect($env['CRON_WORKER_COMMIT_SHA'])->toBe($selectedCommit);
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
expect($env['COMMIT_SHA'])->toBe($selectedCommit);
} finally {
foreach ($previous as $key => $value) {
if ($value === false) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
} else {
putenv($key . '=' . $value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
}
});
it('uses the selected deployment commit before stale Coolify context commits', function (): void {
$manager = new release_manager();
$gitCommitSha = new ReflectionMethod(release_manager::class, 'releaseCoolifyGitCommitSha');
@@ -1444,6 +1562,32 @@ it('resolves release deployment endpoints from manual overrides, URLs, health ch
'public_gateway_host' => 'gateway.example.test',
]),
]))->toBe('https://gateway.example.test/beta/frontend');
$cron = $endpoint->invoke($manager, [
'app' => 'cron',
'channel_slug' => 'beta',
'deploy_context_json' => json_encode([
'endpoint_mode' => 'auto',
'public_gateway_host' => 'gateway.example.test',
'coolify_public_url' => 'https://api-v2.truckwash.io',
]),
]);
expect($cron)->toMatchArray([
'status' => 'pending',
'host' => null,
'url' => null,
'source' => 'private_worker',
]);
expect($publicUrl->invoke($manager, ['app' => 'cron', 'channel_slug' => 'beta'], [
'public_gateway_host' => 'gateway.example.test',
]))->toBeNull();
expect($targetPublicBaseUrl->invoke($manager, [
'app' => 'cron',
'channel_slug' => 'beta',
'deploy_context_json' => json_encode([
'public_gateway_host' => 'gateway.example.test',
]),
]))->toBeNull();
});
it('collects previous Coolify application UUIDs for stale route cleanup', function (): void {
@@ -1634,3 +1778,56 @@ it('restricts release gate fetches to Truckwash release hosts and relative paths
expect(fn() => $joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '//127.0.0.1/ping'))
->toThrow(RuntimeException::class, 'relative');
});
it('normalizes Coolify application list payloads for release cleanup previews', function (): void {
$manager = new release_manager();
$payloadRows = new ReflectionMethod(release_manager::class, 'payloadRows');
expect($payloadRows->invoke($manager, [
'applications' => [
['uuid' => 'app-1', 'name' => 'release-canary-api'],
['uuid' => 'app-2', 'name' => 'release-old-frontend'],
],
]))->toBe([
['uuid' => 'app-1', 'name' => 'release-canary-api'],
['uuid' => 'app-2', 'name' => 'release-old-frontend'],
]);
});
it('keeps Coolify cleanup selection hashes tied to action and resource identity', function (): void {
$manager = new release_manager();
$hash = new ReflectionMethod(release_manager::class, 'coolifyCleanupSelectionHash');
$phrase = new ReflectionMethod(release_manager::class, 'coolifyCleanupConfirmationPhrase');
$matches = new ReflectionMethod(release_manager::class, 'coolifyCleanupReferenceMatchesPolicy');
$policy = [
'instance_id' => 3,
'channel_id' => 2,
'channel_slug' => 'canary',
'app' => 'api',
'resource_type' => 'application',
'action' => 'delete',
];
$candidate = [
'instance_id' => 3,
'type' => 'application',
'uuid' => 'old-api-canary-app',
'action' => 'delete',
];
$reference = [
'apps' => ['api' => 'api'],
'channels' => [2 => 2],
'resource_types' => ['application' => 'application'],
];
$deleteHash = $hash->invoke($manager, $policy, [$candidate]);
$stopHash = $hash->invoke($manager, array_replace($policy, ['action' => 'stop']), [
array_replace($candidate, ['action' => 'stop']),
]);
expect($matches->invoke($manager, $reference, $policy))->toBeTrue()
->and($matches->invoke($manager, $reference, array_replace($policy, ['app' => 'frontend'])))->toBeFalse()
->and($matches->invoke($manager, $reference, array_replace($policy, ['channel_id' => 99])))->toBeFalse()
->and($deleteHash)->not->toBe($stopHash)
->and($phrase->invoke($manager, $deleteHash))->toBe('cleanup-coolify-' . substr($deleteHash, 0, 12));
});
@@ -1,6 +1,6 @@
<?php
it('registers superuser replication endpoints and permissions', function (): void {
it('keeps the superuser replication read endpoint and retires mutation handlers', function (): void {
$content = file_get_contents(app_path('routes/superuserReplicationRoute.php'));
expect($content)->not->toBeFalse();
@@ -18,17 +18,32 @@ it('registers superuser replication endpoints and permissions', function (): voi
expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_manage')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_promote')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_remove')");
expect($content)->toContain('RETIRED_MANAGEMENT_MESSAGE');
expect($content)->toContain('private function rejectRetiredManagement(): void');
expect($content)->toContain("\$response->error(['message' => self::RETIRED_MANAGEMENT_MESSAGE], 410);");
expect($content)->not->toContain('->addHost(');
expect($content)->not->toContain('->generateComposeTemplate(');
expect($content)->not->toContain('->testUnsavedCredentials(');
expect($content)->not->toContain('->testHost(');
expect($content)->not->toContain('->provisionHost(');
expect($content)->not->toContain('->promoteHost(');
expect($content)->not->toContain('->renameHost(');
expect($content)->not->toContain('->deleteHost(');
});
it('documents replication management in openapi', function (): void {
it('documents retired replication mutations in openapi', function (): void {
$content = file_get_contents(app_path('openapi.yaml'));
expect($content)->toContain('/superuser/replication:');
expect($content)->toContain('operationId: getSuperuserReplication');
expect($content)->toContain('summary: Retired database replication host creation');
expect($content)->toContain('operationId: generateSuperuserReplicationComposeTemplate');
expect($content)->toContain('operationId: testSuperuserReplicationCredentials');
expect($content)->toContain('operationId: addSuperuserMinioReplicationHost');
expect($content)->toContain('operationId: renameSuperuserReplicationHost');
expect(preg_match_all('/deprecated:\s+true/', $content))->toBeGreaterThanOrEqual(9);
expect($content)->toContain("'410': { \$ref: '#/components/responses/Gone' }");
expect($content)->toContain('Gone:');
expect($content)->toContain('enum: [database, redis, minio]');
expect($content)->toContain('space_headroom_percent');
expect($content)->toContain('SuperuserReplicationStatus');
@@ -37,6 +52,17 @@ it('documents replication management in openapi', function (): void {
expect($content)->toContain('SuperuserReplicationComposeTemplateRequest');
});
it('keeps superuser system status independent from replica health', function (): void {
$content = file_get_contents(app_path('classes/superuser_system_status_service.php'));
expect($content)->not->toBeFalse();
expect($content)->not->toContain('dependencyReplication');
expect($content)->not->toContain('replicationStatusFallback');
expect($content)->not->toContain("\$dependencies['database']['replication']");
expect($content)->not->toContain("\$dependencies['redis']['replication']");
expect($content)->not->toContain("\$dependencies['minio']['replication']");
});
it('rejects subuser sessions before checking replication permissions', function (): void {
$content = file_get_contents(app_path('routes/superuserReplicationRoute.php'));
@@ -0,0 +1,75 @@
<?php
it('registers the superuser security endpoints and permissions', function (): void {
$content = file_get_contents(app_path('routes/superuserSecurityRoute.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('/superuser/system/security/summary');
expect($content)->toContain('/superuser/system/security/settings');
expect($content)->toContain('/superuser/system/security/firewall-rules');
expect($content)->toContain('/superuser/system/security/firewall-rules/{id}');
expect($content)->toContain('/superuser/system/security/incidents');
expect($content)->toContain('/superuser/system/security/incidents/{id}');
expect($content)->toContain('/superuser/system/security/incidents/{id}/notes');
expect($content)->toContain("requireClassicSuperuserPermission('superuser_security_view')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_security_settings_manage')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_security_firewall_manage')");
expect($content)->toContain("requireClassicSuperuserPermission('superuser_security_incidents_manage')");
expect($content)->toContain("'superuser_security_limits_exempt' => 'Exempt requests from observe-mode security limit incidents'");
});
it('keeps superuser security controls unavailable to subuser sessions', function (): void {
$content = file_get_contents(app_path('routes/superuserSecurityRoute.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('private function requireClassicSuperuserPermission(string $permission): bool');
expect($content)->toContain('get_subuser() !== false');
expect($content)->toContain("Subuser sessions cannot manage security controls.");
expect($content)->toContain('return $this->requirePermission($permission);');
expect($content)->not->toContain("requirePermission('superuser_security_");
});
it('wires request and domain event observation into the backend', function (): void {
$routeTrait = file_get_contents(app_path('traits/route_t.php'));
$authRoute = file_get_contents(app_path('routes/authRoute.php'));
$bookingRoute = file_get_contents(app_path('routes/orderBookingRoute.php'));
$vehiclesRoute = file_get_contents(app_path('routes/vehiclesRoute.php'));
expect($routeTrait)->toContain('security_policy_service');
expect($routeTrait)->toContain('inspectRequest($route, $method)');
expect($authRoute)->toContain('observeLoginFailure(');
expect($bookingRoute)->toContain('observeBookingCreated');
expect($vehiclesRoute)->toContain('observeVehicleCreated');
});
it('defines persistent security tables for runtime and api tests', function (): void {
$runtimeSchema = file_get_contents(app_path('classes/security_schema_bootstrap.php'));
$testSchema = file_get_contents(app_path('tests/Support/Api/ApiSchemaBootstrap.php'));
foreach ([
'security_firewall_rules',
'security_policy_rules',
'security_policy_events',
'security_incidents',
'security_incident_notes',
] as $table) {
expect($runtimeSchema)->toContain($table);
expect($testSchema)->toContain($table);
}
});
it('documents superuser security endpoints in openapi', function (): void {
$content = file_get_contents(app_path('openapi.yaml'));
expect($content)->not->toBeFalse();
expect($content)->toContain('/superuser/system/security/summary:');
expect($content)->toContain('/superuser/system/security/settings:');
expect($content)->toContain('/superuser/system/security/firewall-rules:');
expect($content)->toContain('/superuser/system/security/firewall-rules/{id}:');
expect($content)->toContain('/superuser/system/security/incidents:');
expect($content)->toContain('/superuser/system/security/incidents/{id}:');
expect($content)->toContain('/superuser/system/security/incidents/{id}/notes:');
expect($content)->toContain('SuperuserSecuritySettingsUpdateRequest');
expect($content)->toContain('SuperuserSecurityFirewallRuleMutation');
expect($content)->toContain('SuperuserSecurityIncidentResponse');
});
@@ -28,6 +28,14 @@ class SuperuserSystemStatusServiceProbeDouble extends superuser_system_status_se
public ?array $moduleConfigRowsOverride = null;
public bool $backupStoreValidationShouldFail = false;
public string $backupStoreFailureMessage = 'Backup bucket is missing.';
public array $backupHealthSummary = [
'latest_verified_backup' => ['backup_uuid' => 'verified-backup'],
'latest_verified_age_seconds' => 60,
'fresh' => true,
'encryption' => ['available' => true, 'key_id' => 'test-key'],
'verification_required' => true,
'restore_enabled' => false,
];
public bool $selfserveBootstrapShouldFail = false;
public string $selfserveBootstrapFailureMessage = 'Schema bootstrap failed.';
public bool $selfserveMinuteProductExistsValue = true;
@@ -167,6 +175,11 @@ class SuperuserSystemStatusServiceProbeDouble extends superuser_system_status_se
}
}
protected function backupHealthSummary(): array
{
return $this->backupHealthSummary;
}
protected function bootstrapSelfserveSchema(): void
{
if ($this->selfserveBootstrapShouldFail) {
@@ -525,6 +538,41 @@ it('reports backup probe failures from local validation', function (): void {
expect($result['status_reason_key'])->toBe('backup_probe_failed');
});
it('reports backups down when encryption key is missing', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$service->backupHealthSummary['encryption'] = [
'available' => false,
'error' => 'BACKUP_ENCRYPTION_KEY_V1 is not configured.',
];
$result = $service->probeBackupsModulePublic([]);
expect($result['status'])->toBe('down');
expect($result['status_reason_key'])->toBe('backup_encryption_key_missing');
});
it('reports backups degraded when no verified backup is available', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$service->backupHealthSummary['latest_verified_backup'] = null;
$service->backupHealthSummary['fresh'] = false;
$result = $service->probeBackupsModulePublic([]);
expect($result['status'])->toBe('degraded');
expect($result['status_reason_key'])->toBe('backup_no_verified_backup');
});
it('reports backups degraded when the latest verified backup is stale', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$service->backupHealthSummary['fresh'] = false;
$service->backupHealthSummary['latest_verified_age_seconds'] = 7200;
$result = $service->probeBackupsModulePublic([]);
expect($result['status'])->toBe('degraded');
expect($result['status_reason_key'])->toBe('backup_latest_verified_stale');
});
it('returns configured for shelly when no known device id is available for probing', function (): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
+9 -1
View File
@@ -679,9 +679,17 @@ trait route_t
global $router;
// Wrap the original callback so we can expose the matched route template to fromRoute()
$self = $this;
$wrapped = function () use ($callback, $route, $self) {
$wrapped = function () use ($callback, $route, $method, $self) {
// Set the current route template for parameter extraction
$self->__current_route_template = $route;
try {
if (defined('WD') && is_file(WD . '/classes/security_policy_service.php')) {
require_once WD . '/classes/security_policy_service.php';
(new \classes\security_policy_service())->inspectRequest($route, $method);
}
} catch (\Throwable $throwable) {
error_log('[security_policy_service] request inspection failed: ' . $throwable->getMessage());
}
// Execute the original callback
$callback();
};