diff --git a/.env.example b/.env.example index 20533be..8b8fc00 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ APP_NAME=IMVS APP_URL=http://localhost:8080 APP_ENV=dev APP_DEBUG=true +APP_CONFIG_VALIDATE=true APP_TIMEZONE=Europe/Berlin APP_STORAGE_PATH=./storage APP_LOCALE=de diff --git a/.env.prod.example b/.env.prod.example index 1fbdce6..3467706 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -2,6 +2,7 @@ APP_NAME=IMVS APP_URL=https://example.com APP_ENV=prod APP_DEBUG=false +APP_CONFIG_VALIDATE=true APP_TIMEZONE=Europe/Berlin APP_STORAGE_PATH=./storage APP_LOCALE=de diff --git a/CLAUDE.md b/CLAUDE.md index 29604b6..72bbb7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,15 +19,12 @@ Multi-tenant admin application built on MintyPHP. Manages tenants, users, depart docker compose up --build -d # App: http://localhost:8080 | phpMyAdmin: http://localhost:8081 -# Run PHPUnit tests -docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php +# Run PHPUnit tests (phpunit.xml configures bootstrap + testsuite) +docker compose exec php vendor/bin/phpunit # Run PHPStan (level 5) docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress -# Run ESLint -npx eslint web/js - # Check unused Composer packages docker compose exec php vendor/bin/composer-unused ``` @@ -36,9 +33,11 @@ docker compose exec php vendor/bin/composer-unused ``` lib/ # All backend PHP (namespace MintyPHP\) + App/ # DI container + bootstrap (AppContainer, Container/Registrars/) Repository/ # SQL queries, prepared statements, filters/paging Service/ # Business logic, validation, orchestration Http/ # Auth guards, API auth, request helpers + Input/ # Request input handling (RequestInput, FormErrors) Support/ # Crypto, flash, guard, search, helpers pages/ # Actions (controllers) — file-based routing pages/**/*.php # Action files (read input, check permissions, call services) @@ -52,11 +51,14 @@ web/ # Document root (entry point, CSS, JS, static assets) js/core/ # DOM utilities, Grid.js factory js/components/ # Reusable UI components css/ # Layered CSS (base → components → layout → pages) +storage/ # Uploaded files (branding/, imports/, tenants/, users/) db/init/init.sql # Full schema + seed data (no migration framework) +db/updates/ # Idempotent SQL update scripts for existing installs tests/ # PHPUnit tests (namespace MintyPHP\Tests\) docs/ # German-language documentation (Markdown) i18n/ # Translation files (de, en) -bin/ # CLI scripts (scheduler) +bin/ # CLI scripts (scheduler, doctor.php health check) +tools/ # Dev tooling scripts docker/ # Dockerfiles, Nginx configs, PHP configs ``` @@ -64,11 +66,12 @@ docker/ # Dockerfiles, Nginx configs, PHP configs ### Strict Layering (enforce in all changes) -1. **Repository** (`lib/Repository/`): All SQL lives here. No HTTP, session, or rendering. -2. **Service** (`lib/Service/`): Business rules and validation. No HTML. Uses factory pattern (`*ServicesFactory`). -3. **Action** (`pages/**/*.php`): Reads input, checks permissions + tenant scope, calls services, sets view vars. -4. **View** (`pages/**/*.phtml`): Pure rendering. **No DB calls.** HTML escaping via `e(...)`. -5. **Template** (`templates/`): Layouts and partials. +1. **App** (`lib/App/`): DI container bootstrap. Registers all service/repository factories. No business logic. +2. **Repository** (`lib/Repository/`): All SQL lives here. No HTTP, session, or rendering. +3. **Service** (`lib/Service/`): Business rules and validation. No HTML. Uses factory pattern (`*ServicesFactory`). +4. **Action** (`pages/**/*.php`): Reads input, checks permissions + tenant scope, calls services, sets view vars. +5. **View** (`pages/**/*.phtml`): Pure rendering. **No DB calls.** HTML escaping via `e(...)`. +6. **Template** (`templates/`): Layouts and partials. ### Routing @@ -110,7 +113,7 @@ docker/ # Dockerfiles, Nginx configs, PHP configs - **PHPStan level 5** — scans `config/`, `lib/`, `pages/`, `tests/` - **PHPUnit 11** — bootstrap: `tests/bootstrap.php` -- **ESLint** — ES2022, strict `eqeqeq`, `curly`, `no-unused-vars`, `no-console` (warn, allow warn/error) +- **Frontend Smoke-Check** — browser console bleibt fehlerfrei in Kernflows - **composer-unused** — periodic check for unused dependencies ## Environment diff --git a/README.md b/README.md index 9c2dcf4..94e2107 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,9 @@ Standard-Demo-User: - SQL bleibt in Repositories. - Geschaeftslogik bleibt in Services. - `pages/.../*.php` fungiert als Action-/Controller-Schicht. +- Service-Aufloesung in Actions/Helpern ueber `app(Foo::class)`. +- `new ...Factory()` nur im Composition Root (`web/index.php` + `lib/App/registerContainer.php`) oder in `*Factory.php`. +- `pages/**/data().php` sind GET-only (`gridRequireGetRequest()`) und normalisieren `limit`/`offset`/`order`/`dir` ueber Grid-Helper. - Permissions + Tenant-Scope werden in Actions/Services erzwungen. - Wiederkehrende UI-Bloecke als Partials zentralisieren. @@ -190,27 +193,21 @@ docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php tests docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress ``` -### ESLint (Frontend-JS) +### Frontend-JS Smoke-Check (ohne Node) -```bash -npx eslint web/js -``` +- Browser-DevTools öffnen (Console + Preserve log) +- Kernflows klicken: Users, Address-Book, Audit-Listen, Filter setzen/zurücksetzen +- Keine JavaScript-Errors/Unhandled-Rejections in der Console ## Dokumentation -- `/docs/index.md` Einstieg und Dokumentationsindex -- `/docs/architektur.md` Request-Flow und Layering -- `/docs/konventionen.md` Coding- und UI-Konventionen -- `/docs/sicherheitsmodell.md` Auth/Permission/Tenant-Scope -- `/docs/entwickler-checkliste.md` konkrete Checkliste fuer Aenderungen -- `/docs/benutzerdefinierte-felder.md` Quick Reference fuer Tenant/User-Zusatzfelder -- `/docs/api.md` API v1 Referenz (inkl. Self-Service Token-Endpunkte) -- `/docs/einstellungen-speicherung.md` Globale Settings: DB als Quelle, `config/settings.php` als Teil-Cache -- `/docs/frontend-javascript.md` Frontend-JS-Struktur -- `/docs/frontend-css.md` Frontend-CSS-Struktur -- `/docs/docker-lokal.md` Docker lokal (Entwicklung) -- `/docs/docker-produktiv.md` Docker produktiv (Serverbetrieb) -- `/docs/docker-betrieb.md` Dev/Prod Docker-Betrieb +- `/docs/index.md` Dokumentationsstart mit chronologischem Lernpfad und Referenzbereichen +- `/docs/00-systemueberblick.md` Einstieg in Produktbild und Kernprinzipien +- `/docs/01-setup-und-erster-login.md` lokales Setup und erster Smoke-Test +- `/docs/02-domain-glossar.md` zentrale Begriffe fuer Tenant/Role/Scope +- `/docs/03-architektur-request-flow.md` Request-Flow und Schichtmodell +- `/docs/06-security-tenant-scope.md` Security-Grundlagen vor Feature-Arbeit +- `/docs/07-api-erweitern.md` verbindlicher API-Aenderungsablauf inkl. OpenAPI ## Troubleshooting diff --git a/bin/codex-skills-sync.sh b/bin/codex-skills-sync.sh new file mode 100755 index 0000000..a2cada6 --- /dev/null +++ b/bin/codex-skills-sync.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: bin/codex-skills-sync.sh [--check|--apply] + +Options: + --check Prüft Drift zwischen Repo-Skills und ~/.codex/skills (Default) + --apply Synchronisiert Repo-Skills nach ~/.codex/skills +USAGE +} + +mode="check" +if [[ $# -gt 1 ]]; then + usage + exit 2 +fi + +if [[ $# -eq 1 ]]; then + case "$1" in + --check) + mode="check" + ;; + --apply) + mode="apply" + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[ERROR] Unknown option: $1" >&2 + usage + exit 2 + ;; + esac +fi + +if ! command -v rsync >/dev/null 2>&1; then + echo "[ERROR] rsync is required." >&2 + exit 3 +fi + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "${script_dir}/.." && pwd)" +source_dir="${repo_root}/tools/codex-skills" +codex_home_dir="${CODEX_HOME:-${HOME}/.codex}" +target_dir="${codex_home_dir}/skills" +managed_skills=( + "core-guardrails" + "starterkit-planner" +) + +for skill in "${managed_skills[@]}"; do + if [[ ! -d "${source_dir}/${skill}" ]]; then + echo "[ERROR] Missing source skill: ${source_dir}/${skill}" >&2 + exit 2 + fi +done + +check_skill() { + local skill="$1" + local src="${source_dir}/${skill}" + local dst="${target_dir}/${skill}" + + if [[ ! -d "${dst}" ]]; then + echo "[DRIFT] Missing target skill: ${dst}" + return 1 + fi + + local diff + diff="$(rsync -rcn --delete --itemize-changes "${src}/" "${dst}/" || true)" + if [[ -n "${diff}" ]]; then + echo "[DRIFT] ${skill} differs:" + echo "${diff}" | sed 's/^/ /' + return 1 + fi + + echo "[OK] ${skill} is in sync" + return 0 +} + +apply_skill() { + local skill="$1" + local src="${source_dir}/${skill}" + local dst="${target_dir}/${skill}" + + mkdir -p "${dst}" + rsync -a --delete "${src}/" "${dst}/" + echo "[SYNC] ${skill} -> ${dst}" +} + +status=0 + +if [[ "${mode}" == "apply" ]]; then + mkdir -p "${target_dir}" + for skill in "${managed_skills[@]}"; do + apply_skill "${skill}" + done +fi + +for skill in "${managed_skills[@]}"; do + if ! check_skill "${skill}"; then + status=1 + fi +done + +if [[ ${status} -eq 0 ]]; then + echo "[OK] codex skill sync check passed" +else + echo "[FAIL] codex skill sync check found drift" +fi + +exit ${status} diff --git a/bin/doctor.php b/bin/doctor.php new file mode 100755 index 0000000..3238435 --- /dev/null +++ b/bin/doctor.php @@ -0,0 +1,323 @@ +#!/usr/bin/env php + 'cli', + 'method' => 'CLI', + 'path' => 'bin/doctor.php', +]); +$startedAt = microtime(true); + +/** @var list $results */ +$results = []; +$failCount = 0; +$warnCount = 0; + +/** + * @param callable():array{status: string, message: string} $check + */ +$runCheck = static function (string $name, callable $check) use (&$results, &$failCount, &$warnCount): void { + try { + $result = $check(); + } catch (Throwable $exception) { + $result = [ + 'status' => 'fail', + 'message' => $exception->getMessage(), + ]; + } + + $status = strtolower(trim((string) ($result['status'] ?? 'fail'))); + if (!in_array($status, ['ok', 'warn', 'fail'], true)) { + $status = 'fail'; + } + + $message = trim((string) ($result['message'] ?? '')); + if ($message === '') { + $message = 'no details'; + } + + if ($status === 'fail') { + $failCount++; + } elseif ($status === 'warn') { + $warnCount++; + } + + $results[] = [ + 'status' => $status, + 'name' => $name, + 'message' => $message, + ]; +}; + +$runCheck('Environment validation', static function (): array { + EnvValidator::validate(); + return [ + 'status' => 'ok', + 'message' => 'all required env keys and formats are valid', + ]; + }); + + $runCheck('App container bootstrap', static function () use ($container): array { + if (!$container instanceof AppContainer) { + return [ + 'status' => 'fail', + 'message' => 'registerContainer.php did not return AppContainer', + ]; + } + + app(AuthService::class); + app(AuthorizationService::class); + app(UiAccessService::class); + + return [ + 'status' => 'ok', + 'message' => 'core services resolved successfully', + ]; + }); + + $runCheck('Database connectivity', static function (): array { + $pong = DB::selectValue('select 1'); + if ((int) $pong !== 1) { + return [ + 'status' => 'fail', + 'message' => 'select 1 did not return expected value', + ]; + } + + return [ + 'status' => 'ok', + 'message' => 'connection established', + ]; + }); + + $runCheck('Database schema basics', static function (): array { + $requiredTables = [ + 'users', + 'roles', + 'permissions', + 'user_roles', + 'role_permissions', + 'tenants', + 'departments', + 'settings', + 'scheduler_runtime_status', + ]; + + $rows = DB::select( + 'select table_name from information_schema.tables where table_schema = database() and table_name in (???)', + $requiredTables + ); + + $present = []; + foreach ((array) $rows as $row) { + $table = (string) (($row['tables']['table_name'] ?? $row['table_name'] ?? '')); + if ($table !== '') { + $present[] = $table; + } + } + $present = array_values(array_unique($present)); + sort($present, SORT_STRING); + + $missing = array_values(array_diff($requiredTables, $present)); + if ($missing) { + return [ + 'status' => 'fail', + 'message' => 'missing tables: ' . implode(', ', $missing), + ]; + } + + return [ + 'status' => 'ok', + 'message' => sprintf('%d core tables present', count($requiredTables)), + ]; + }); + + $runCheck('Storage path writeability', static function (): array { + $storagePath = defined('APP_STORAGE_PATH') && APP_STORAGE_PATH + ? rtrim((string) APP_STORAGE_PATH, '/') + : rtrim(dirname(__DIR__) . '/storage', '/'); + + if (!is_dir($storagePath)) { + return [ + 'status' => 'fail', + 'message' => "storage directory not found: {$storagePath}", + ]; + } + if (!is_writable($storagePath)) { + return [ + 'status' => 'fail', + 'message' => "storage directory not writable: {$storagePath}", + ]; + } + + $probeFile = $storagePath . '/.doctor-write-probe-' . uniqid('', true); + $written = @file_put_contents($probeFile, 'ok'); + if ($written === false) { + return [ + 'status' => 'fail', + 'message' => "write probe failed in: {$storagePath}", + ]; + } + @unlink($probeFile); + + return [ + 'status' => 'ok', + 'message' => "storage path is writable ({$storagePath})", + ]; + }); + + $runCheck('RBAC baseline permissions', static function (): array { + $requiredPermissions = [ + PermissionService::USERS_VIEW, + PermissionService::TENANTS_VIEW, + PermissionService::DEPARTMENTS_VIEW, + PermissionService::ROLES_VIEW, + PermissionService::PERMISSIONS_VIEW, + PermissionService::SETTINGS_VIEW, + ]; + + $rows = DB::select( + 'select `key` from permissions where active = 1 and `key` in (???)', + $requiredPermissions + ); + + $present = []; + foreach ((array) $rows as $row) { + $key = (string) (($row['permissions']['key'] ?? $row['key'] ?? '')); + if ($key !== '') { + $present[] = $key; + } + } + $present = array_values(array_unique($present)); + sort($present, SORT_STRING); + + $missing = array_values(array_diff($requiredPermissions, $present)); + if ($missing) { + return [ + 'status' => 'fail', + 'message' => 'missing active permissions: ' . implode(', ', $missing), + ]; + } + + return [ + 'status' => 'ok', + 'message' => sprintf('%d baseline permissions active', count($requiredPermissions)), + ]; + }); + + $runCheck('Admin role assignment', static function (): array { + $count = (int) (DB::selectValue( + 'select count(distinct ur.user_id) from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 where r.description in (?, ?) or r.id = 1', + 'Admin', + 'Administrator' + ) ?? 0); + + if ($count <= 0) { + return [ + 'status' => 'fail', + 'message' => 'no active user assigned to Admin/Administrator role', + ]; + } + + return [ + 'status' => 'ok', + 'message' => sprintf('%d admin user(s) assigned', $count), + ]; + }); + +$runCheck('Scheduler heartbeat', static function (): array { + $row = DB::selectOne('select last_heartbeat_at, last_result, last_error_code from scheduler_runtime_status where id = 1 limit 1'); + $status = is_array($row) ? ($row['scheduler_runtime_status'] ?? $row) : null; + if (!is_array($status)) { + return [ + 'status' => 'warn', + 'message' => 'no scheduler runtime status row found yet', + ]; + } + + $heartbeat = trim((string) ($status['last_heartbeat_at'] ?? '')); + $result = trim((string) ($status['last_result'] ?? 'unknown')); + $errorCode = trim((string) ($status['last_error_code'] ?? '')); + if ($heartbeat === '') { + return [ + 'status' => 'warn', + 'message' => 'scheduler heartbeat is empty', + ]; + } + + $seconds = time() - strtotime($heartbeat . ' UTC'); + if ($seconds < 0) { + $seconds = 0; + } + + if ($seconds > 300) { + return [ + 'status' => 'warn', + 'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')', + ]; + } + + return [ + 'status' => 'ok', + 'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')', + ]; + }); + +fwrite(STDOUT, "Minty Doctor Report\n"); +fwrite(STDOUT, "===================\n"); +foreach ($results as $item) { + $statusLabel = strtoupper($item['status']); + fwrite(STDOUT, sprintf("[%s] %s: %s\n", $statusLabel, $item['name'], $item['message'])); +} + +$okCount = count($results) - $warnCount - $failCount; +fwrite(STDOUT, sprintf("\nSummary: ok=%d warn=%d fail=%d\n", $okCount, $warnCount, $failCount)); + +$exitCode = $failCount > 0 ? 1 : 0; +$resultLabel = $failCount > 0 ? 'fail' : ($warnCount > 0 ? 'warn' : 'ok'); + +try { + app(SystemAuditService::class)->record( + 'cli.command', + $failCount > 0 ? 'failed' : 'success', + [ + 'metadata' => [ + 'command' => 'bin/doctor.php', + 'exit_code' => $exitCode, + 'duration_ms' => max(0, (int) round((microtime(true) - $startedAt) * 1000)), + 'result' => $resultLabel, + 'warn_count' => $warnCount, + 'fail_count' => $failCount, + ], + ] + ); +} catch (Throwable) { + // fail-open +} + +DB::close(); +exit($exitCode); diff --git a/bin/scheduler-run.php b/bin/scheduler-run.php index 4cee042..c5f3a28 100755 --- a/bin/scheduler-run.php +++ b/bin/scheduler-run.php @@ -8,13 +8,23 @@ chdir(__DIR__ . '/..'); require 'vendor/autoload.php'; require 'config/config.php'; require 'lib/Support/helpers.php'; +$container = require 'lib/App/registerContainer.php'; +setAppContainer($container); use MintyPHP\DB; +use MintyPHP\Http\RequestContext; +use MintyPHP\Service\Audit\SystemAuditService; use MintyPHP\Service\Scheduler\SchedulerServicesFactory; $exitCode = 0; +$startedAt = microtime(true); try { - $factory = new SchedulerServicesFactory(); + RequestContext::start([ + 'channel' => 'scheduler', + 'method' => 'CLI', + 'path' => 'bin/scheduler-run.php', + ]); + $factory = app(SchedulerServicesFactory::class); $result = $factory->createSchedulerRunService()->runDueJobs(); if (!($result['ok'] ?? false)) { $error = (string) ($result['error'] ?? 'unexpected_error'); @@ -32,6 +42,24 @@ try { fwrite(STDOUT, $line); } } catch (Throwable $exception) { + try { + app(SystemAuditService::class)->record('scheduler.run', 'failed', [ + 'error_code' => 'unexpected_error', + 'metadata' => [ + 'trigger_type' => 'scheduler', + 'run_status' => 'failed', + 'processed' => 0, + 'success_count' => 0, + 'failed_count' => 0, + 'skipped_count' => 0, + 'duration_ms' => max(0, (int) round((microtime(true) - $startedAt) * 1000)), + 'bootstrap_error' => true, + ], + ]); + } catch (Throwable) { + // fail-open + } + fwrite(STDERR, sprintf("scheduler run failed: unexpected_error: %s\n", $exception->getMessage())); $exitCode = 1; } finally { diff --git a/config/assets.php b/config/assets.php index f4dbee3..3dcf5d0 100644 --- a/config/assets.php +++ b/config/assets.php @@ -26,12 +26,15 @@ return [ 'css/components/app-search.css', 'css/components/app-list-titlebar.css', 'css/components/app-details-titlebar.css', + 'css/components/app-details-validation-summary.css', 'css/components/app-dashboard-titlebar.css', 'css/components/app-details.css', 'css/components/app-list-table.css', 'css/components/app-list-tabs.css', 'css/components/app-tabs.css', 'css/components/app-list-toolbar.css', + 'css/components/app-filter-drawer.css', + 'css/components/app-active-filter-chips.css', 'css/components/app-breadcrumb.css', 'css/components/app-tile.css', 'css/components/app-tooltips.css', diff --git a/config/config.php.example b/config/config.php.example index 2a1e035..92105d3 100644 --- a/config/config.php.example +++ b/config/config.php.example @@ -1,6 +1,7 @@ 'Malphite', + 'app_title' => 'CoreCore', 'app_locale' => 'de', 'app_theme' => 'light', 'app_theme_user' => '1', diff --git a/db/init/init.sql b/db/init/init.sql index c50c719..46c88cd 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS `tenants` ( KEY `idx_tenants_created_by` (`created_by`), KEY `idx_tenants_modified_by` (`modified_by`), KEY `idx_tenants_status_changed_by` (`status_changed_by`), + CONSTRAINT `chk_tenants_status` CHECK (`status` IN ('active', 'inactive')), CONSTRAINT `fk_tenants_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL, CONSTRAINT `fk_tenants_modified_by` FOREIGN KEY (`modified_by`) REFERENCES `users` (`id`) ON DELETE SET NULL, CONSTRAINT `fk_tenants_status_changed_by` FOREIGN KEY (`status_changed_by`) REFERENCES `users` (`id`) ON DELETE SET NULL @@ -395,7 +396,8 @@ CREATE TABLE IF NOT EXISTS `mail_log` ( `provider_message_id` VARCHAR(255) NULL, PRIMARY KEY (`id`), KEY `idx_mail_log_status` (`status`), - KEY `idx_mail_log_to_email` (`to_email`) + KEY `idx_mail_log_to_email` (`to_email`), + CONSTRAINT `chk_mail_log_status` CHECK (`status` IN ('queued', 'sent', 'failed')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS `api_audit_log` ( @@ -428,6 +430,39 @@ CREATE TABLE IF NOT EXISTS `api_audit_log` ( CONSTRAINT `fk_api_audit_token` FOREIGN KEY (`api_token_id`) REFERENCES `user_api_tokens` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `system_audit_log` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `event_uuid` CHAR(36) NOT NULL, + `request_id` CHAR(36) NULL, + `channel` VARCHAR(16) NOT NULL, + `event_type` VARCHAR(64) NOT NULL, + `outcome` VARCHAR(16) NOT NULL, + `error_code` VARCHAR(100) NULL, + `actor_user_id` INT UNSIGNED NULL, + `actor_tenant_id` INT UNSIGNED NULL, + `target_type` VARCHAR(64) NULL, + `target_id` INT UNSIGNED NULL, + `target_uuid` CHAR(36) NULL, + `method` VARCHAR(8) NULL, + `path` VARCHAR(255) NULL, + `ip_hash` CHAR(64) NULL, + `user_agent_hash` CHAR(64) NULL, + `metadata_json` TEXT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_system_audit_event_uuid` (`event_uuid`), + KEY `idx_system_audit_created` (`created_at`), + KEY `idx_system_audit_event_created` (`event_type`, `created_at`), + KEY `idx_system_audit_actor_created` (`actor_user_id`, `created_at`), + KEY `idx_system_audit_target_created` (`target_type`, `target_uuid`, `created_at`), + KEY `idx_system_audit_request_created` (`request_id`, `created_at`), + KEY `idx_system_audit_outcome_created` (`outcome`, `created_at`), + CONSTRAINT `chk_system_audit_log_channel` CHECK (`channel` IN ('web', 'api', 'scheduler', 'cli')), + CONSTRAINT `chk_system_audit_log_outcome` CHECK (`outcome` IN ('success', 'failed', 'denied')), + CONSTRAINT `fk_system_audit_actor_user` FOREIGN KEY (`actor_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_system_audit_actor_tenant` FOREIGN KEY (`actor_tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `import_audit_runs` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `run_uuid` CHAR(36) NOT NULL, @@ -451,6 +486,7 @@ CREATE TABLE IF NOT EXISTS `import_audit_runs` ( KEY `idx_import_audit_status_started` (`status`, `started_at`), KEY `idx_import_audit_profile_started` (`profile_key`, `started_at`), KEY `idx_import_audit_user_started` (`user_id`, `started_at`), + CONSTRAINT `chk_import_audit_runs_status` CHECK (`status` IN ('running', 'success', 'partial', 'failed')), CONSTRAINT `fk_import_audit_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, CONSTRAINT `fk_import_audit_tenant` FOREIGN KEY (`current_tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; @@ -481,6 +517,9 @@ CREATE TABLE IF NOT EXISTS `user_lifecycle_audit_log` ( KEY `idx_user_lifecycle_target_uuid_created` (`target_user_uuid`, `created_at`), KEY `idx_user_lifecycle_run_uuid` (`run_uuid`), KEY `idx_user_lifecycle_restored` (`restored_at`), + CONSTRAINT `chk_user_lifecycle_action` CHECK (`action` IN ('deactivate', 'delete', 'restore')), + CONSTRAINT `chk_user_lifecycle_trigger_type` CHECK (`trigger_type` IN ('manual', 'cron', 'system')), + CONSTRAINT `chk_user_lifecycle_status` CHECK (`status` IN ('success', 'skipped', 'failed')), CONSTRAINT `fk_user_lifecycle_actor` FOREIGN KEY (`actor_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, CONSTRAINT `fk_user_lifecycle_target` FOREIGN KEY (`target_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, CONSTRAINT `fk_user_lifecycle_restored_by` FOREIGN KEY (`restored_by_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, @@ -510,7 +549,8 @@ CREATE TABLE IF NOT EXISTS `scheduled_jobs` ( PRIMARY KEY (`id`), UNIQUE KEY `uniq_scheduled_jobs_job_key` (`job_key`), KEY `idx_scheduled_jobs_enabled_next` (`enabled`, `next_run_at`), - KEY `idx_scheduled_jobs_status` (`last_run_status`) + KEY `idx_scheduled_jobs_status` (`last_run_status`), + CONSTRAINT `chk_scheduled_jobs_last_run_status` CHECK (`last_run_status` IS NULL OR `last_run_status` IN ('running', 'success', 'failed', 'skipped')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS `scheduled_job_runs` ( @@ -533,6 +573,8 @@ CREATE TABLE IF NOT EXISTS `scheduled_job_runs` ( KEY `idx_sched_runs_job_created` (`job_id`, `created_at`), KEY `idx_sched_runs_status_created` (`status`, `created_at`), KEY `idx_sched_runs_trigger_created` (`trigger_type`, `created_at`), + CONSTRAINT `chk_scheduled_job_runs_status` CHECK (`status` IN ('success', 'failed', 'skipped')), + CONSTRAINT `chk_scheduled_job_runs_trigger_type` CHECK (`trigger_type` IN ('scheduler', 'manual')), CONSTRAINT `fk_sched_runs_job` FOREIGN KEY (`job_id`) REFERENCES `scheduled_jobs` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_sched_runs_actor_user` FOREIGN KEY (`actor_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; @@ -543,7 +585,8 @@ CREATE TABLE IF NOT EXISTS `scheduler_runtime_status` ( `last_result` VARCHAR(32) NULL, `last_error_code` VARCHAR(64) NULL, `updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`) + PRIMARY KEY (`id`), + CONSTRAINT `chk_scheduler_runtime_status_last_result` CHECK (`last_result` IS NULL OR `last_result` IN ('ok', 'lock_not_acquired', 'unexpected_error')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS `request_rate_limits` ( @@ -740,6 +783,8 @@ VALUES ('docs.view', 'Can view developer documentation', 1, 1), ('mail_log.view', 'Can view mail logs', 1, 1), ('api_audit.view', 'Can view API audit logs', 1, 1), + ('system_audit.view', 'Can view system audit logs', 1, 1), + ('system_audit.purge', 'Can purge system audit logs', 1, 1), ('stats.view', 'Can view statistics', 1, 1) ON DUPLICATE KEY UPDATE `description` = VALUES(`description`), @@ -776,6 +821,36 @@ ON DUPLICATE KEY UPDATE `label` = VALUES(`label`), `description` = VALUES(`description`); +INSERT INTO `scheduled_jobs` ( + `job_key`, + `label`, + `description`, + `enabled`, + `timezone`, + `schedule_type`, + `schedule_interval`, + `schedule_time`, + `schedule_weekdays_csv`, + `catchup_once`, + `next_run_at` +) +VALUES ( + 'system_audit_purge', + 'System audit purge', + 'Purges system audit entries by retention policy', + 1, + 'Europe/Berlin', + 'daily', + 1, + '03:00', + NULL, + 1, + NULL +) +ON DUPLICATE KEY UPDATE + `label` = VALUES(`label`), + `description` = VALUES(`description`); + INSERT INTO `scheduler_runtime_status` ( `id`, `last_heartbeat_at`, @@ -808,8 +883,16 @@ JOIN permissions p ON p.`key` IN ( 'users.lifecycle_restore', 'custom_fields.manage', 'custom_fields.edit_values', 'api_docs.view', 'docs.view', - 'mail_log.view', 'api_audit.view', + 'mail_log.view', 'api_audit.view', 'system_audit.view', 'system_audit.purge', 'stats.view' ) WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 ON DUPLICATE KEY UPDATE role_id = role_id; + +INSERT INTO `settings` (`key`, `value`, `description`) +VALUES + ('system_audit_enabled', '1', 'setting.system_audit_enabled'), + ('system_audit_retention_days', '365', 'setting.system_audit_retention_days') +ON DUPLICATE KEY UPDATE + `value` = VALUES(`value`), + `description` = VALUES(`description`); diff --git a/db/updates/2026-02-25-system-audit.sql b/db/updates/2026-02-25-system-audit.sql new file mode 100644 index 0000000..e15f2d2 --- /dev/null +++ b/db/updates/2026-02-25-system-audit.sql @@ -0,0 +1,86 @@ +-- Idempotent update: introduce system audit log, permissions and defaults. + +CREATE TABLE IF NOT EXISTS `system_audit_log` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `event_uuid` CHAR(36) NOT NULL, + `request_id` CHAR(36) NULL, + `channel` VARCHAR(16) NOT NULL, + `event_type` VARCHAR(64) NOT NULL, + `outcome` VARCHAR(16) NOT NULL, + `error_code` VARCHAR(100) NULL, + `actor_user_id` INT UNSIGNED NULL, + `actor_tenant_id` INT UNSIGNED NULL, + `target_type` VARCHAR(64) NULL, + `target_id` INT UNSIGNED NULL, + `target_uuid` CHAR(36) NULL, + `method` VARCHAR(8) NULL, + `path` VARCHAR(255) NULL, + `ip_hash` CHAR(64) NULL, + `user_agent_hash` CHAR(64) NULL, + `metadata_json` TEXT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_system_audit_event_uuid` (`event_uuid`), + KEY `idx_system_audit_created` (`created_at`), + KEY `idx_system_audit_event_created` (`event_type`, `created_at`), + KEY `idx_system_audit_actor_created` (`actor_user_id`, `created_at`), + KEY `idx_system_audit_target_created` (`target_type`, `target_uuid`, `created_at`), + KEY `idx_system_audit_request_created` (`request_id`, `created_at`), + KEY `idx_system_audit_outcome_created` (`outcome`, `created_at`), + CONSTRAINT `fk_system_audit_actor_user` FOREIGN KEY (`actor_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_system_audit_actor_tenant` FOREIGN KEY (`actor_tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO `permissions` (`key`, `description`, `active`, `is_system`) +VALUES + ('system_audit.view', 'Can view system audit logs', 1, 1), + ('system_audit.purge', 'Can purge system audit logs', 1, 1) +ON DUPLICATE KEY UPDATE + `description` = VALUES(`description`), + `active` = VALUES(`active`), + `is_system` = VALUES(`is_system`); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` IN ('system_audit.view', 'system_audit.purge') +WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 +ON DUPLICATE KEY UPDATE role_id = role_id; + +INSERT INTO `settings` (`key`, `value`, `description`) +VALUES + ('system_audit_enabled', '1', 'setting.system_audit_enabled'), + ('system_audit_retention_days', '365', 'setting.system_audit_retention_days') +ON DUPLICATE KEY UPDATE + `value` = VALUES(`value`), + `description` = VALUES(`description`); + +INSERT INTO `scheduled_jobs` ( + `job_key`, + `label`, + `description`, + `enabled`, + `timezone`, + `schedule_type`, + `schedule_interval`, + `schedule_time`, + `schedule_weekdays_csv`, + `catchup_once`, + `next_run_at` +) +VALUES ( + 'system_audit_purge', + 'System audit purge', + 'Purges system audit entries by retention policy', + 1, + 'Europe/Berlin', + 'daily', + 1, + '03:00', + NULL, + 1, + NULL +) +ON DUPLICATE KEY UPDATE + `label` = VALUES(`label`), + `description` = VALUES(`description`); diff --git a/db/updates/2026-03-03-status-taxonomy-hard-cut.sql b/db/updates/2026-03-03-status-taxonomy-hard-cut.sql new file mode 100644 index 0000000..388618e --- /dev/null +++ b/db/updates/2026-03-03-status-taxonomy-hard-cut.sql @@ -0,0 +1,222 @@ +-- Hard-cut: central status/outcome taxonomy with deterministic cleanup and CHECK constraints. + +-- Preflight: count invalid values per in-scope column before cleanup. +SELECT 'system_audit_log.outcome' AS field_name, COUNT(*) AS invalid_count +FROM system_audit_log +WHERE outcome IS NULL OR outcome NOT IN ('success', 'failed', 'denied'); + +SELECT 'system_audit_log.channel' AS field_name, COUNT(*) AS invalid_count +FROM system_audit_log +WHERE channel IS NULL OR channel NOT IN ('web', 'api', 'scheduler', 'cli'); + +SELECT 'user_lifecycle_audit_log.action' AS field_name, COUNT(*) AS invalid_count +FROM user_lifecycle_audit_log +WHERE action IS NULL OR action NOT IN ('deactivate', 'delete', 'restore'); + +SELECT 'user_lifecycle_audit_log.trigger_type' AS field_name, COUNT(*) AS invalid_count +FROM user_lifecycle_audit_log +WHERE trigger_type IS NULL OR trigger_type NOT IN ('manual', 'cron', 'system'); + +SELECT 'user_lifecycle_audit_log.status' AS field_name, COUNT(*) AS invalid_count +FROM user_lifecycle_audit_log +WHERE status IS NULL OR status NOT IN ('success', 'skipped', 'failed'); + +SELECT 'import_audit_runs.status' AS field_name, COUNT(*) AS invalid_count +FROM import_audit_runs +WHERE status IS NULL OR status NOT IN ('running', 'success', 'partial', 'failed'); + +SELECT 'scheduled_jobs.last_run_status' AS field_name, COUNT(*) AS invalid_count +FROM scheduled_jobs +WHERE last_run_status IS NOT NULL AND last_run_status NOT IN ('running', 'success', 'failed', 'skipped'); + +SELECT 'scheduled_job_runs.status' AS field_name, COUNT(*) AS invalid_count +FROM scheduled_job_runs +WHERE status IS NULL OR status NOT IN ('success', 'failed', 'skipped'); + +SELECT 'scheduled_job_runs.trigger_type' AS field_name, COUNT(*) AS invalid_count +FROM scheduled_job_runs +WHERE trigger_type IS NULL OR trigger_type NOT IN ('scheduler', 'manual'); + +SELECT 'mail_log.status' AS field_name, COUNT(*) AS invalid_count +FROM mail_log +WHERE status IS NULL OR status NOT IN ('queued', 'sent', 'failed'); + +SELECT 'tenants.status' AS field_name, COUNT(*) AS invalid_count +FROM tenants +WHERE status IS NULL OR status NOT IN ('active', 'inactive'); + +SELECT 'scheduler_runtime_status.last_result' AS field_name, COUNT(*) AS invalid_count +FROM scheduler_runtime_status +WHERE last_result IS NOT NULL AND last_result NOT IN ('ok', 'lock_not_acquired', 'unexpected_error'); + +-- Deterministic normalization before constraints. +UPDATE system_audit_log +SET outcome = 'success' +WHERE outcome IS NULL OR outcome NOT IN ('success', 'failed', 'denied'); + +UPDATE system_audit_log +SET channel = 'web' +WHERE channel IS NULL OR channel NOT IN ('web', 'api', 'scheduler', 'cli'); + +UPDATE user_lifecycle_audit_log +SET action = 'deactivate' +WHERE action IS NULL OR action NOT IN ('deactivate', 'delete', 'restore'); + +UPDATE user_lifecycle_audit_log +SET trigger_type = 'system' +WHERE trigger_type IS NULL OR trigger_type NOT IN ('manual', 'cron', 'system'); + +UPDATE user_lifecycle_audit_log +SET status = 'failed' +WHERE status IS NULL OR status NOT IN ('success', 'skipped', 'failed'); + +UPDATE import_audit_runs +SET status = 'failed' +WHERE status IS NULL OR status NOT IN ('running', 'success', 'partial', 'failed'); + +UPDATE scheduled_jobs +SET last_run_status = NULL +WHERE last_run_status IS NOT NULL AND last_run_status NOT IN ('running', 'success', 'failed', 'skipped'); + +UPDATE scheduled_job_runs +SET status = 'failed' +WHERE status IS NULL OR status NOT IN ('success', 'failed', 'skipped'); + +UPDATE scheduled_job_runs +SET trigger_type = 'scheduler' +WHERE trigger_type IS NULL OR trigger_type NOT IN ('scheduler', 'manual'); + +UPDATE mail_log +SET status = 'queued' +WHERE status IS NULL OR status NOT IN ('queued', 'sent', 'failed'); + +UPDATE tenants +SET status = 'active' +WHERE status IS NULL OR status NOT IN ('active', 'inactive'); + +UPDATE scheduler_runtime_status +SET last_result = NULL +WHERE last_result IS NOT NULL AND last_result NOT IN ('ok', 'lock_not_acquired', 'unexpected_error'); + +-- Add CHECK constraints once (idempotent via information_schema guard). +SET @schema_name := DATABASE(); + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = @schema_name + AND table_name = 'system_audit_log' + AND constraint_name = 'chk_system_audit_log_outcome') = 0, + 'ALTER TABLE system_audit_log ADD CONSTRAINT chk_system_audit_log_outcome CHECK (outcome IN (''success'', ''failed'', ''denied''))', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = @schema_name + AND table_name = 'system_audit_log' + AND constraint_name = 'chk_system_audit_log_channel') = 0, + 'ALTER TABLE system_audit_log ADD CONSTRAINT chk_system_audit_log_channel CHECK (channel IN (''web'', ''api'', ''scheduler'', ''cli''))', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = @schema_name + AND table_name = 'user_lifecycle_audit_log' + AND constraint_name = 'chk_user_lifecycle_action') = 0, + 'ALTER TABLE user_lifecycle_audit_log ADD CONSTRAINT chk_user_lifecycle_action CHECK (action IN (''deactivate'', ''delete'', ''restore''))', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = @schema_name + AND table_name = 'user_lifecycle_audit_log' + AND constraint_name = 'chk_user_lifecycle_trigger_type') = 0, + 'ALTER TABLE user_lifecycle_audit_log ADD CONSTRAINT chk_user_lifecycle_trigger_type CHECK (trigger_type IN (''manual'', ''cron'', ''system''))', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = @schema_name + AND table_name = 'user_lifecycle_audit_log' + AND constraint_name = 'chk_user_lifecycle_status') = 0, + 'ALTER TABLE user_lifecycle_audit_log ADD CONSTRAINT chk_user_lifecycle_status CHECK (status IN (''success'', ''skipped'', ''failed''))', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = @schema_name + AND table_name = 'import_audit_runs' + AND constraint_name = 'chk_import_audit_runs_status') = 0, + 'ALTER TABLE import_audit_runs ADD CONSTRAINT chk_import_audit_runs_status CHECK (status IN (''running'', ''success'', ''partial'', ''failed''))', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = @schema_name + AND table_name = 'scheduled_jobs' + AND constraint_name = 'chk_scheduled_jobs_last_run_status') = 0, + 'ALTER TABLE scheduled_jobs ADD CONSTRAINT chk_scheduled_jobs_last_run_status CHECK (last_run_status IS NULL OR last_run_status IN (''running'', ''success'', ''failed'', ''skipped''))', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = @schema_name + AND table_name = 'scheduled_job_runs' + AND constraint_name = 'chk_scheduled_job_runs_status') = 0, + 'ALTER TABLE scheduled_job_runs ADD CONSTRAINT chk_scheduled_job_runs_status CHECK (status IN (''success'', ''failed'', ''skipped''))', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = @schema_name + AND table_name = 'scheduled_job_runs' + AND constraint_name = 'chk_scheduled_job_runs_trigger_type') = 0, + 'ALTER TABLE scheduled_job_runs ADD CONSTRAINT chk_scheduled_job_runs_trigger_type CHECK (trigger_type IN (''scheduler'', ''manual''))', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = @schema_name + AND table_name = 'mail_log' + AND constraint_name = 'chk_mail_log_status') = 0, + 'ALTER TABLE mail_log ADD CONSTRAINT chk_mail_log_status CHECK (status IN (''queued'', ''sent'', ''failed''))', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = @schema_name + AND table_name = 'tenants' + AND constraint_name = 'chk_tenants_status') = 0, + 'ALTER TABLE tenants ADD CONSTRAINT chk_tenants_status CHECK (status IN (''active'', ''inactive''))', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = @schema_name + AND table_name = 'scheduler_runtime_status' + AND constraint_name = 'chk_scheduler_runtime_status_last_result') = 0, + 'ALTER TABLE scheduler_runtime_status ADD CONSTRAINT chk_scheduler_runtime_status_last_result CHECK (last_result IS NULL OR last_result IN (''ok'', ''lock_not_acquired'', ''unexpected_error''))', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 6912040..ca71e9e 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -4,6 +4,12 @@ server { client_max_body_size 10M; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; + add_header X-Permitted-Cross-Domain-Policies "none" always; + root /var/www/web; index index.php; diff --git a/docker/nginx/prod.conf b/docker/nginx/prod.conf index c3deb8f..26c7486 100644 --- a/docker/nginx/prod.conf +++ b/docker/nginx/prod.conf @@ -2,6 +2,12 @@ server { listen 80; server_name example.com www.example.com; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; + add_header X-Permitted-Cross-Domain-Policies "none" always; + return 301 https://$host$request_uri; } @@ -21,6 +27,9 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; + add_header X-Permitted-Cross-Domain-Policies "none" always; + add_header Strict-Transport-Security "max-age=31536000" always; root /var/www/web; index index.php; diff --git a/docs/00-systemueberblick.md b/docs/00-systemueberblick.md new file mode 100644 index 0000000..d365b21 --- /dev/null +++ b/docs/00-systemueberblick.md @@ -0,0 +1,42 @@ +# Systemüberblick + +Letzte Aktualisierung: 2026-02-24 + +## Ziel + +In 10 Minuten verstehen, was die Software tut, wie sie aufgebaut ist und welche Kernprinzipien immer gelten. + +## Was ist IMVS? + +IMVS ist eine mandantenfähige Admin-Software für Immobilienmakler-Prozesse mit klarer Rollen-/Rechtestruktur. + +Kernmodule: + +- Mandanten, Abteilungen, Benutzer, Rollen, Berechtigungen +- Adressbuch und globale Suche +- API (`/api/v1`) +- Scheduler (geplante Aufgaben) +- Optional Microsoft-SSO + +## Kernprinzipien + +- Multi-Tenant by default: Daten und Sichtbarkeit sind tenant-gebunden. +- Security first: Auth, Permission und Tenant-Scope sind Pflicht, nicht optional. +- Schichtentrennung: `pages` -> `Service` -> `Repository`. +- API-Verträge sind UUID-first. + +## Projektkarte + +- `config/` Konfiguration +- `db/` Schema/Updates +- `lib/` Fachlogik +- `pages/` Actions + Views +- `templates/` Partials/Layout +- `web/` CSS/JS/Assets +- `docs/` Entwicklerdokumentation + +## Danach + +1. Setup und erster Login: `/docs/01-setup-und-erster-login.md` +2. Begriffe sicher verstehen: `/docs/02-domain-glossar.md` +3. Architekturfluss verinnerlichen: `/docs/03-architektur-request-flow.md` diff --git a/docs/01-setup-und-erster-login.md b/docs/01-setup-und-erster-login.md new file mode 100644 index 0000000..f169dc3 --- /dev/null +++ b/docs/01-setup-und-erster-login.md @@ -0,0 +1,52 @@ +# Setup und erster Login + +Letzte Aktualisierung: 2026-02-24 + +## Ziel + +Lokal starten, einloggen, Basisfunktion prüfen. + +## Voraussetzungen + +- Docker + Docker Compose +- Zugriff auf das Repository + +## Schritte + +1. Umgebung anlegen und Container starten: + +```bash +cp .env.example .env +docker compose up --build -d +``` + +2. App öffnen: + +- `http://localhost:8080` +- `http://localhost:8081` (phpMyAdmin) + +3. Mit Seed-User einloggen (Fresh-Install): + +- E-Mail: `demo@user.com` +- Passwort: `Demo123` + +4. Schnellcheck: + +- Login funktioniert +- `Admin -> Benutzer` lädt +- `Admin -> Einstellungen` lädt + +## Typisches Problem + +Bei altem DB-Volume weichen Seed-Daten ab. +Fresh-Reset (löscht lokale DB-Daten): + +```bash +docker compose down -v +docker compose up --build -d +``` + +## Danach + +- Begriffe: `/docs/02-domain-glossar.md` +- Tagesworkflow: `/docs/lokale-entwicklung.md` diff --git a/docs/02-domain-glossar.md b/docs/02-domain-glossar.md new file mode 100644 index 0000000..5f6e3a4 --- /dev/null +++ b/docs/02-domain-glossar.md @@ -0,0 +1,32 @@ +# Domain-Glossar + +Letzte Aktualisierung: 2026-02-24 + +## Ziel + +Alle Kernbegriffe kurz und einheitlich verstehen. + +## Begriffe + +- `Tenant`: Mandant; isolierter Daten- und Rechteraum. +- `Department`: Organisationseinheit innerhalb eines Tenants. +- `Role`: Bündel von Berechtigungen. +- `Permission`: einzelne Berechtigung (z. B. `users.view`). +- `Policy` / `Ability`: zentrale Autorisierungsregel. +- `Tenant-Scope`: Regel, ob Zugriff im Ziel-Tenant erlaubt ist. +- `Action`: Request-Einstieg in `pages/**`. +- `Service`: Fachlogik in `lib/Service/**`. +- `Repository`: SQL und Mapping in `lib/Repository/**`. +- `Factory`: erstellt/verdrahtet Service-Objekte (z. B. `UserServicesFactory`). +- `Gateway`: schmale Schnittstelle/Fassade zu Domain-Funktionen oder Querschnitt (z. B. `PermissionGateway`). +- `AppContainer`: zentrale Service-Registrierung und -Auflösung (`app(Foo::class)`). +- `UUID-first`: APIs geben externe UUIDs aus, keine internen IDs. + +## Wichtig + +Wenn ein Begriff unklar ist, zuerst hier klären und dann implementieren. + +## Danach + +- Architekturfluss: `/docs/03-architektur-request-flow.md` +- Sicherheitskontext: `/docs/06-security-tenant-scope.md` diff --git a/docs/03-architektur-request-flow.md b/docs/03-architektur-request-flow.md new file mode 100644 index 0000000..b335214 --- /dev/null +++ b/docs/03-architektur-request-flow.md @@ -0,0 +1,86 @@ +# Architektur und Request-Flow + +Letzte Aktualisierung: 2026-03-03 + +## Ziel + +Verstehen, wo Änderungen hingehören und wo nicht. + +## Request-Flow + +1. `web/index.php` lädt Bootstrap, registriert den `AppContainer` und setzt globale Service-Auflösung. +2. `RequestContext` initialisiert `request_id` und Basis-Metadaten; `X-Request-Id` wird gesetzt. +3. Guard/Sicherheitslayer laufen. +4. Action in `pages/**` nimmt Input an. +5. Action delegiert an Service. +6. Service nutzt Repository für Datenzugriff. +7. Response wird als HTML/JSON ausgegeben. + +API-Ergänzung: + +1. `ApiBootstrap::init()` startet API-Audit (`ApiAuditService`) und API-System-Audit (`ApiSystemAuditReporter`). +2. `ApiResponse::send()` finalisiert beide Reporter (inkl. `request_id`-Korrelation). +3. System-Protokolle schreiben nur selektive `api.request`-Events (kein Voll-Request-Logging). + +## Schichtregeln + +- `pages/**`: Input, Auth/AuthZ, Service-Aufruf, Response. +- `lib/Service/**`: Business-Regeln und Orchestrierung. +- `lib/Repository/**`: SQL, Filter, Mapping. +- `templates/**`: Rendering ohne Business-Logik. +- Request-Daten in Actions nur über `requestInput()` (keine Input-Superglobals). +- Validierungsfehler zentral über `FormErrors`; API-Fehler über `ApiResponse::validationFromFormErrors(...)`. +- Template-AuthZ konsumiert nur vorbereitete `$viewAuth`-Daten (kein `can('...')` im Template). +- Service-Auflösung in Actions/Helpern über `app(Foo::class)`. +- Fachliche Audit-Events laufen zentral über `SystemAuditService`. +- Persistierte Status/Outcome-Werte laufen über die zentrale Taxonomie-Schicht `lib/Domain/Taxonomy/*` (Enums + DB-Checks). +- `pages/**/data().php` sind GET-only (`gridRequireGetRequest()`) und normalisieren `limit`/`offset`/`order`/`dir` über Grid-Helper. + +## Einfaches ASCII-Beispiel + +Beispiel: `GET /admin/users/data` + +```text +Browser + -> web/index.php (Bootstrap + AppContainer) + -> pages/admin/users/data().php (Action) + -> Guard + AuthorizationService (Auth/AuthZ) + -> UserAccountService (Fachlogik) + -> UserListQueryRepository (SQL + Filter + Paging) + -> DB + <- { data, total } JSON +``` + +Factory/Gateway im selben Bild: + +```text +AppContainer + -> UserServicesFactory + -> createUserAccountService() -> UserAccountService + +Guard::requireAbilityOrForbidden(ops....) + -> app(AuthorizationService::class) + -> AccessPolicyFactory -> *AuthorizationPolicy + -> PermissionGateway -> PermissionService -> PermissionRepository -> DB + +Front Controller (web/index.php) + -> app(UiAccessService::class) + -> UiCapabilityMap::LAYOUT -> $viewAuth['layout'] + +Action + -> app(UiAccessService::class) + -> UiCapabilityMap::* -> $viewAuth['page'] + -> templates/** (nur Darstellung) +``` + +## Absolute No-Go's + +- SQL in View-Dateien +- Business-Logik in Actions +- Tenant-Scope als UI-Only-Check + +## Vertiefung + +- Vollständiger Kompass: `/docs/architektur.md` +- Regeln für `lib/**`: `/docs/lib-standards.md` +- RBAC-Rechte erweitern: `/docs/rbac-permissions-playbook.md` diff --git a/docs/04-erste-aenderung-ui.md b/docs/04-erste-aenderung-ui.md new file mode 100644 index 0000000..c1d2ebe --- /dev/null +++ b/docs/04-erste-aenderung-ui.md @@ -0,0 +1,35 @@ +# Erste Änderung (UI) + +Letzte Aktualisierung: 2026-02-24 + +## Ziel + +Eine kleine, sichere UI-Änderung durchziehen, ohne Architekturbruch. + +## Standardablauf + +1. Zielseite und Datenquelle identifizieren (`pages/admin/**`). +2. Falls neue Daten nötig: Endpoint/Repository erweitern. +3. UI in `*.phtml` anpassen (nur Rendering). +4. Texte über `t('...')` ergänzen. +5. Manuell testen (Liste, Filter, Save, Rechte). + +## Done-Kriterien + +- Kein SQL in Views +- Keine Business-Logik in Templates +- Rechte-/Scope-Verhalten unverändert korrekt + +## Checks + +```bash +docker compose exec php vendor/bin/phpunit +docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress +``` + +Bei JS-Änderungen zusätzlich Browser-Smoke mit DevTools-Console (keine JS-Errors). + +## Vertiefung + +- Detailleitfaden: `/docs/erste-aenderung.md` +- UI-Standards: `/docs/entwickler-checkliste.md` diff --git a/docs/05-erste-aenderung-backend.md b/docs/05-erste-aenderung-backend.md new file mode 100644 index 0000000..45d68dd --- /dev/null +++ b/docs/05-erste-aenderung-backend.md @@ -0,0 +1,34 @@ +# Erste Änderung (Backend) + +Letzte Aktualisierung: 2026-02-24 + +## Ziel + +Eine kleine Backend-Änderung korrekt über Repository und Service umsetzen. + +## Standardablauf + +1. SQL/Leselogik im Repository erweitern. +2. Business-Regel im Service ergänzen. +3. Action anpassen und Response stabil halten. +4. Bei API-Änderungen OpenAPI aktualisieren. + +## Regeln + +- Repository kennt keine HTTP-/Session-Logik. +- Service kennt keine Superglobals/Redirects. +- Externe API bleibt UUID-first. +- Service-Auflösung in `pages/**` via `app(Foo::class)`. +- Keine direkten `new ...Factory()` in `lib/Service/**` außerhalb `*Factory.php`. + +## Checks + +```bash +docker compose exec php vendor/bin/phpunit +docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress +``` + +## Vertiefung + +- `lib/**`-Regeln: `/docs/lib-standards.md` +- API-Prozess: `/docs/07-api-erweitern.md` diff --git a/docs/06-security-tenant-scope.md b/docs/06-security-tenant-scope.md new file mode 100644 index 0000000..2dab138 --- /dev/null +++ b/docs/06-security-tenant-scope.md @@ -0,0 +1,31 @@ +# Security und Tenant-Scope + +Letzte Aktualisierung: 2026-02-24 + +## Ziel + +Die wichtigsten Sicherheitsregeln verstehen, bevor Features erweitert werden. + +## Pflichtmodell pro Request + +1. Authentifizierung (eingeloggt?) +2. Permission-Check (darf der User das?) +3. Tenant-Scope-Check (darf er es im Ziel-Tenant?) + +## Muss-Regeln + +- Keine sicherheitsrelevanten Checks nur im Frontend. +- Denials je Endpunkt-Semantik als `403` oder `404`. +- Sensible Daten nicht in Fehlern oder Logs leaken. + +## Quick-Checklist + +- Permission vorhanden und geprüft? +- Tenant-Bindung serverseitig geprüft? +- CSRF bei Schreibaktionen aktiv? +- API-Fehlerformat konsistent? + +## Vertiefung + +- Vollständiges Modell: `/docs/sicherheitsmodell.md` +- Rate Limits: `/docs/anfragelimits.md` diff --git a/docs/07-api-erweitern.md b/docs/07-api-erweitern.md new file mode 100644 index 0000000..5f8ae22 --- /dev/null +++ b/docs/07-api-erweitern.md @@ -0,0 +1,32 @@ +# API erweitern + +Letzte Aktualisierung: 2026-02-24 + +## Ziel + +API-Änderungen konsistent ausrollen (Code + Spec + Test). + +## Ablauf + +1. Endpoint in `pages/api/v1/**` anpassen. +2. Service/Repository bei Bedarf erweitern. +3. OpenAPI in `/docs/openapi.yaml` im selben Change aktualisieren. +4. Endpoint per cURL/Postman gegenprüfen. + +## API-Regeln + +- UUID-first nach außen +- Einheitliche Fehlerstruktur (`error`, optional `errors`) +- Auth/Scope-Regeln unverändert einhalten + +## Minimaltest + +```bash +curl -H "Authorization: Bearer " \ + http://localhost:8080/api/v1/me +``` + +## Vertiefung + +- API-Referenz: `/docs/api.md` +- Sicherheitsdetails: `/docs/sicherheitsmodell.md` diff --git a/docs/08-advanced-scheduler-sso.md b/docs/08-advanced-scheduler-sso.md new file mode 100644 index 0000000..63205bb --- /dev/null +++ b/docs/08-advanced-scheduler-sso.md @@ -0,0 +1,36 @@ +# Advanced: Scheduler und SSO + +Letzte Aktualisierung: 2026-02-24 + +## Ziel + +Fortgeschrittene, betriebskritische Themen strukturiert einordnen. + +## Scheduler + +- Globaler Runner verarbeitet fällige Whitelist-Jobs. +- Neue Jobs werden über Handler + Registry integriert. +- Parallelität wird über Locking begrenzt. + +Vertiefung: `/docs/geplante-aufgaben.md` + +## User Lifecycle + +- Automatisierte Deaktivierung/Löschung nach Policy. +- Audit/Restore-Pfade sind separat abgesichert. + +Vertiefung: `/docs/benutzer-lifecycle-policy.md` + +## Microsoft SSO + +- Login über OIDC-Flow mit Tenant-Bezug. +- Tenant-SSO-Konfiguration und Secret-Handling sind strikt abgesichert. + +Vertiefung: + +- `/docs/auth-sso-smoke-test.md` +- `/docs/sicherheitsmodell.md` + +## Betriebsregel + +Diese Themen nur mit Runbook + manuellen Smoke-Tests ändern. diff --git a/docs/anfragelimits.md b/docs/anfragelimits.md index d21261f..945a756 100644 --- a/docs/anfragelimits.md +++ b/docs/anfragelimits.md @@ -1,141 +1,68 @@ # Anfragelimits (Rate Limiting) -Letzte Aktualisierung: 2026-02-23 - -Dieses Dokument beschreibt das aktuelle zentrale Rate-Limiting für Login und API. +Letzte Aktualisierung: 2026-02-24 ## Ziel -- Brute-Force auf Login reduzieren. -- API-Missbrauch (Burst/Spam) abfangen. -- Einheitliches Verhalten mit `429` + `Retry-After`. +Brute-Force und API-Spam begrenzen, ohne Verfügbarkeit zu gefährden. -## Technischer Aufbau +## Technischer Kern -### Storage +- Service: `lib/Service/Security/RateLimiterService.php` +- Repository: `lib/Repository/Security/RateLimitRepository.php` +- Storage: `request_rate_limits` + - `scope`, `subject_hash`, `hits`, `window_started_at`, `blocked_until` -Tabelle: `request_rate_limits` +Wichtig: -Spalten: -- `scope`: Technischer Bereich (z. B. `login.password.email_ip`) -- `subject_hash`: SHA-256 Hash des Subjects (keine Klartexte) -- `hits`: Treffer im aktuellen Fenster -- `window_started_at`: Beginn des aktuellen Zeitfensters (UTC) -- `blocked_until`: Block-Ende (UTC), `NULL` wenn nicht geblockt +- Subject wird gehasht (SHA-256), kein Klartext-Key. +- Verhalten ist fail-open bei Storage-Fehlern. -Schema: -- Tabelle wird in `db/init/init.sql` mit angelegt. -- Für Bestandsumgebungen sollte ein idempotentes SQL-Update bereitgestellt werden. +## API-Limits (global) -### Service/Repository +Durchgesetzt in `lib/Http/ApiBootstrap.php`: -- Service: `/lib/Service/Security/RateLimiterService.php` -- Repository: `/lib/Repository/Security/RateLimitRepository.php` +1. IP-only (`api.ip`) + - 120 Requests / 60s + - Block 120s +2. Token+IP (`api.token_ip`) + - 300 Requests / 60s + - Block 120s -Der Service ist absichtlich **fail-open**: -- Wenn DB/Storage fehlschlägt, wird kein harter Lock erzeugt. -- Requests laufen weiter, um Verfügbarkeit nicht zu brechen. +Bei Block: -## API Rate Limiting +- HTTP `429` +- `Retry-After` +- Body `{"error":"rate_limit_exceeded"}` -Integration: -- `/lib/Http/ApiBootstrap.php` (vor Auth) +## Web-Login-Limits -Aktuelle Limits: +In `pages/auth/login().php`: -1. Bearer vorhanden (`token + ip`) -- Scope: `api.token_ip` -- Key: `|` -- Limit: `300` Hits pro `60` Sekunden -- Block: `120` Sekunden +1. Resolve-Phase pro IP (`login.resolve.ip`) + - 25 Versuche / 600s + - Block 300s +2. Passwortphase pro E-Mail+IP (`login.password.email_ip`) + - 5 Fehlversuche / 900s + - Block 900s -2. Kein Bearer (`ip-only`) -- Scope: `api.ip` -- Key: `` -- Limit: `120` Hits pro `60` Sekunden -- Block: `120` Sekunden +Erfolgreicher Passwort-Login setzt den Passwort-Scope zurück. -Bei überschreitung: -- Response: `429` -- Body: `{"error":"rate_limit_exceeded"}` -- Header: `Retry-After: ` +## API-Login-Limits -## Login Rate Limiting +In `pages/api/v1/auth/login().php` (zusätzlich zu globalen API-Limits): -Integration: -- `/pages/auth/login().php` -- `/pages/api/v1/auth/login().php` (public Login ohne Bearer) +1. IP (`api.auth.login.ip`) + - 10 Fehlversuche / 600s + - Block 300s +2. E-Mail+IP (`api.auth.login.email_ip`) + - 5 Fehlversuche / 900s + - Block 900s -Aktuelle Limits: +Erfolgreicher API-Login setzt `api.auth.login.email_ip` zurück. -1. Schritt `resolve_email` (IP) -- Scope: `login.resolve.ip` -- Key: `` -- Limit: `25` Hits pro `600` Sekunden -- Block: `300` Sekunden +## Smoke-Test -2. Schritt `login_password` (email + ip) -- Scope: `login.password.email_ip` -- Key: `|` -- Limit: `5` Fehlversuche pro `900` Sekunden -- Block: `900` Sekunden - -Verhalten: -- Bei Block: HTTP `429` + `Retry-After`. -- UI zeigt generische Meldung: `Too many login attempts. Please wait and try again.` -- Bei erfolgreichem Passwort-Login wird der Passwort-Scope für diesen Key zurückgesetzt. - -### API-Login (`/api/v1/auth/login`) - -Zusätzlich zum allgemeinen API-Limit in `ApiBootstrap` gelten eigene Login-Limits: - -1. Schritt `login_ip` (IP) -- Scope: `api.auth.login.ip` -- Key: `` -- Limit: `10` Fehlversuche pro `600` Sekunden -- Block: `300` Sekunden - -2. Schritt `login_email_ip` (email + ip) -- Scope: `api.auth.login.email_ip` -- Key: `|` -- Limit: `5` Fehlversuche pro `900` Sekunden -- Block: `900` Sekunden - -Verhalten: -- Endpoint bleibt public (kein Bearer erforderlich). -- Bei Block: HTTP `429` + `Retry-After`. -- Bei erfolgreichem API-Login wird `api.auth.login.email_ip` für den Key zurückgesetzt. - -## Datenfluss pro Request - -1. Key bilden (`scope` + Subject) -2. Subject hashen (`sha256`) -3. Bestehenden Datensatz lesen -4. Falls `blocked_until > now`: direkt blocken -5. Fenster prüfen (`window_started_at + windowSeconds`) -6. Hits erhöhen -7. Bei `hits > max`: `blocked_until = now + blockSeconds` -8. Ergebnis liefern (`allowed`, `retry_after`) - -## Operative Hinweise - -- Zeitbasis ist UTC (`gmdate`). -- Tabelle kann wachsen; in V1 gibt es noch keinen automatischen Cleanup für alte Rate-Limit-Zeilen. -- Bei Bedarf kann ein Wartungsjob alte/obsolete Zeilen periodisch löschen. - -## Smoke-Test (manuell) - -API: -1. Schnell >120 Requests/min ohne Bearer gegen `/api/v1/*` -2. Erwartung: `429` mit `Retry-After` - -Login: -1. Mehrere falsche Passwortversuche für dieselbe Email+IP -2. Erwartung nach 5 Fehlversuchen: `429` und Sperre für 15 Minuten - -## Erweiterungen (nächster Schritt) - -- Limits in Settings pflegbar machen (statt Hardcode). -- Optional stricter Mode in Production (fail-closed nur für API). -- Cleanup-Job für `request_rate_limits`. -- Optional Logging/Stats für geblockte Requests (z. B. im Admin-Stats Modul). +1. API ohne Token >120 Requests/min gegen `/api/v1/*` -> `429` erwartet. +2. Web-Login mit falschem Passwort >5 pro E-Mail+IP -> `429` erwartet. +3. API-Login mit falschen Credentials wiederholt -> `429` erwartet. diff --git a/docs/api.md b/docs/api.md index b39ec1b..06cca2a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ # REST-API (v1) -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-25 ## Single Source of Truth @@ -15,6 +15,19 @@ Diese Datei ist die verbindliche technische Referenz für: - Statuscodes - Auth-Header +## Fehlerhülle (API-Standard) + +Fehlerantworten nutzen ein einheitliches Schema: + +- `ok: false` +- `request_id: ` +- `error_code: ` +- `details: { ... }` +- kein Legacy-Feld `error` oder `errors` + +`request_id` wird zusaetzlich als Header `X-Request-Id` geliefert. +Die `request_id` kommt zentral aus `RequestContext` und korreliert API-Fehler mit Audit-Einträgen. + ## API-Doku im Admin - UI: `/admin/api-docs` diff --git a/docs/architektur.md b/docs/architektur.md index c6c427b..0dbf218 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -1,58 +1,95 @@ # Architektur-Kompass -Letzte Aktualisierung: 2026-02-23 +Letzte Aktualisierung: 2026-02-25 -Kurze, verbindliche Architekturübersicht für den aktuellen Stand. +## Ziel -## Begriffe (kurz) +Verbindliche Kurzregeln für Architekturentscheidungen. -- `Tenant`: Mandant, isolierter Daten- und Berechtigungsraum. -- `Department`: optionale organisatorische Untereinheit innerhalb eines Tenants. -- `Role`: Bündel von Berechtigungen. -- `Permission`: einzelne Berechtigung wie `users.view`. -- `Policy`: zentrale Autorisierungsregel für eine konkrete Fähigkeit. -- `Ability`: technischer Name einer Policy-Regel, z. B. `admin.users.edit.submit`. -- `AuthorizationDecision`: Ergebnis einer Policy (`allow/deny`, HTTP-Status, Fehlercode, Attribute). -- `Tenant-Scope`: Regel, ob ein User eine Ressource im Ziel-Tenant sehen oder ändern darf. -- `Request`: einzelner HTTP-Aufruf. -- `Action`: Datei unter `pages/**`, verarbeitet den Request. -- `Service`: Fachlogik unter `lib/Service/**`. -- `Repository`: SQL- und Mapping-Schicht unter `lib/Repository/**`. -- `UUID-first`: externe API gibt nur UUIDs aus, keine internen IDs. -- `Contract-Test`: Test für Architektur- und Schnittstellenregeln. +## Systembild -## Zielbild (Stand heute) +- Server-gerenderte Admin-App + REST-API unter `/api/v1`. +- Multi-Tenant-Isolation ist Standard. +- Autorisierung läuft zentral über Policies. +- Externe API-Verträge sind UUID-first. -- Server-gerenderte App plus REST-API unter `/api/v1`. -- Strikte Multi-Tenant-Isolation. -- Zentrale Autorisierung über `AuthorizationService` und Policies. -- API-Verträge sind UUID-first. +## Request-Flow -## Request-Flow (verbindlich) - -1. `web/index.php` lädt Bootstrap, Routing und Konfiguration. -2. Firewall, Session, Locale und Access-Control laufen. -3. Action unter `pages/**` nimmt Request an. -4. Action delegiert Fachlogik an Services. -5. Services nutzen Repositories für Datenzugriff. -6. Response wird als HTML, JSON oder Datei ausgegeben. +1. `web/index.php` bootstrappt Routing/Konfiguration und initialisiert den `AppContainer`. +2. Guard/Firewall/Session/Locale greifen. +3. Action in `pages/**` nimmt Input an. +4. Action delegiert an Service. +5. Service nutzt Repository für Datenzugriff. +6. Response wird als HTML/JSON/Datei ausgegeben. ## Schichtregeln (MUSS) -- `pages/**`: Input, Auth/AuthZ, Service-Aufruf, Response. Keine duplizierte Business-Logik. -- `lib/Service/**`: Fachlogik und Orchestrierung. Keine Superglobals, keine Header/Redirect-Ausgabe. -- `lib/Repository/**`: SQL, Filter, Paging, Mapping. Keine HTTP-, Session- oder Policy-Logik. -- `templates/**`: nur Rendering. +- `pages/**`: Input, Auth/AuthZ, Orchestrierung, Response. +- `lib/Service/**`: Fachlogik und Anwendungsfluss. +- `lib/Repository/**`: SQL, Filter, Paging, Mapping. +- `templates/**`: Rendering ohne Business-Logik. +- Templates konsumieren nur vom Action-Layer vorbereitete View-Daten (`$viewAuth`, Form/ViewModel), keine direkten Permission-Helper. +- Service-Auflösung in Actions/Helpers nur über `app(Foo::class)`. +- In Actions bevorzugt direkte Klassen-Bindings (`app(Service::class)`) statt Factory-Ketten. +- In `pages/admin/**` keine `Factory::class`-Referenzen. +- Request-Input in Actions nur über `requestInput()` lesen. +- Validierungsfehler über `FormErrors` führen und für APIs mit `ApiResponse::validationFromFormErrors(...)` ausgeben. +- `Flash` nur für Redirect-/Outcome-Meldungen verwenden, nicht als primäre Validation-Struktur. +- Listen-Endpoints in `pages/**/data().php` sind GET-only (`gridRequireGetRequest()`). +- Grid-Query-Parameter (`limit`, `offset`, `order`, `dir`) in `data().php` über `gridQueryLimitOffset()` und `gridQueryOrderDir()` normalisieren. -## Sicherheits- und API-Regeln (MUSS) +## Instanziierungsregeln (MUSS) -- Autorisierung nur zentral über Policies. -- Tenant-Scope nicht lokal nachbauen. -- Scope-Denials pro Endpunkt-Semantik als `403` oder `404`. -- API-Fehler einheitlich: `error`, optional `errors`. -- OpenAPI wird bei API-Änderungen im selben Merge aktualisiert. +- Composition Root: `web/index.php` + `lib/App/registerContainer.php` + (→ Details: `/docs/di-container.md`). +- `new ...Factory()` nur im Composition Root und in `*Factory.php`. +- In `lib/Service/**` außerhalb von `*Factory.php` keine direkten `new ...Factory()`. +- In `lib/Service/**` außerhalb von `*Factory.php` keine direkten `new ...Service()`, `new ...Gateway()` oder `new ...Repository()`. +- Legacy-Helper (`accessServicesFactory()`, `userServicesFactory()` usw.) sind entfernt und werden nicht mehr genutzt. -## Merge-Mindestchecks +## API- und Security-Regeln (MUSS) -- `docker compose exec php vendor/bin/phpunit` -- `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress` +- Tenant-Scope und Permission nur serverseitig erzwingen. +- Denials je Endpunkt als `403` oder `404` modellieren. +- API-Fehlerformat konsistent halten (`error`, optional `errors`). +- OpenAPI bei API-Änderungen im selben Merge aktualisieren. + +## RBAC-UI-Standard (MUSS) + +- Ability-Entscheidungen laufen serverseitig über `AuthorizationService`. +- Für Layout/Partials werden globale Capabilities zentral über `UiAccessService::layoutCapabilities()` + `UiCapabilityMap::LAYOUT` bereitgestellt. +- Für einzelne Views setzen Actions explizit `$viewAuth['page']` (bevorzugt über `UiCapabilityMap` + `UiAccessService::pageCapabilities()`). +- In `templates/**` keine direkten `can('...')`- oder Permission-Key-Checks. + +## Mindestchecks vor Merge + +```bash +docker compose exec php vendor/bin/phpunit +docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress +``` + +Bei JS-Änderungen zusätzlich Browser-Smoke mit DevTools-Console (keine JS-Errors). + +Struktur-Gates zusätzlich: + +```bash +(rg 'new\s+[^\s(]+Factory\(' lib/Service || true) | wc -l +(rg --glob '!**/*Factory.php' 'new\s+[^\s(]+(Service|Gateway|Repository)\(' lib/Service || true) | wc -l +(rg "\b(accessServicesFactory|directoryServicesFactory|userServicesFactory|authServicesFactory|tenantServicesFactory|settingServicesFactory|userRepositoryFactory|authRepositoryFactory|authGatewayFactory)\(" pages lib templates web || true) | wc -l +(rg -n 'new\s+[^\s(]+(Service|Gateway|Repository)\(' pages -g '*.php' || true) | wc -l +(rg -n '\bDB::' pages -g '*.php' || true) | wc -l +(rg -n 'app\([^\n]*Factory::class\)->create' pages/admin -g '*.php' || true) | wc -l +(rg -n 'Factory::class' pages/admin -g '*.php' || true) | wc -l +``` + +Architektur-Contract: + +```bash +docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php +``` + +## Vertiefung + +- `/docs/lib-standards.md` +- `/docs/sicherheitsmodell.md` +- `/docs/rbac-permissions-playbook.md` diff --git a/docs/betriebscheck-doctor.md b/docs/betriebscheck-doctor.md new file mode 100644 index 0000000..4b30dfe --- /dev/null +++ b/docs/betriebscheck-doctor.md @@ -0,0 +1,56 @@ +# Betriebscheck mit `bin/doctor.php` + +Letzte Aktualisierung: 2026-02-25 + +## Ziel + +`bin/doctor.php` ist der schnelle Gesundheitscheck fuer eine Instanz. + +Er prueft unter anderem: + +- ENV-Validierung (Pflichtkeys + Formate) +- AppContainer-Bootstrap +- Datenbankverbindung + Kern-Tabellen +- Schreibbarkeit von `APP_STORAGE_PATH` +- RBAC-Basisrechte +- Admin-Rollen-Zuweisung +- Scheduler-Heartbeat (Warnung, falls alt/fehlend) + +## Aufruf + +Lokal (wenn PHP + DB direkt erreichbar sind): + +```bash +php bin/doctor.php +``` + +Im Docker-Setup: + +```bash +docker compose exec php sh -lc "php bin/doctor.php" +``` + +## Exit-Code + +- `0`: keine harten Fehler +- `1`: mindestens ein `FAIL`-Check + +`WARN` fuehrt nicht zu Exit `1`, sollte aber geprueft werden. + +## Typischer Einsatz im Betrieb + +1. Direkt nach Neuinstallation / Deployment. +2. Bei 4xx/5xx-Symptomen als erster Schritt. +3. Nach Konfigurations- oder RBAC-Aenderungen. + +## Beispielausgabe + +```text +Minty Doctor Report +=================== +[OK] Environment validation: all required env keys and formats are valid +[OK] Database connectivity: connection established +[WARN] Scheduler heartbeat: last heartbeat 900s ago (result=ok) + +Summary: ok=7 warn=1 fail=0 +``` diff --git a/docs/codex-prompts.md b/docs/codex-prompts.md new file mode 100644 index 0000000..d02d728 --- /dev/null +++ b/docs/codex-prompts.md @@ -0,0 +1,65 @@ +# Codex Prompt-Standards + +Letzte Aktualisierung: 2026-03-04 + +## Ziel + +Wiederverwendbare Prompt-Muster fuer konsistente Planung und Implementierung mit den Skills `core-guardrails` und `starterkit-planner`. + +## Prompt: Implementierung (strict) + +```text +Nutze die Skills core-guardrails und starterkit-planner. + +Implementiere folgende Aufgabe strikt nach den Starterkit-Guardrails: + + +Pflicht: +1) Schichtgrenzen, Input/Validation, RBAC/UI-Contracts einhalten. +2) Minimalen Scope aendern, keine impliziten Nebenwirkungen. +3) Relevante Quality Gates ausfuehren und Ergebnis berichten. +4) Bei Regelkonflikt klar als Blocker markieren statt Workaround. +``` + +## Prompt: Refactor / Zentralisierung + +```text +Nutze die Skills core-guardrails und starterkit-planner. + +Ziel: Duplikate reduzieren und Standards zentralisieren, ohne Verhalten zu brechen. +Aufgabe: + + +Liefere: +1) Entscheidungsmatrix (Optionen + Tradeoffs) +2) Gewaehlten Ansatz mit Begruendung +3) Decision-complete Umsetzungsplan +4) Test- und Migrationsabsicherung +``` + +## Prompt: Architekturplan (decision complete) + +```text +Nutze die Skills core-guardrails und starterkit-planner. + +Erstelle einen decision-complete Plan fuer: + + +Format ist verpflichtend: +1) Ziel + Success Criteria +2) In/Out-of-Scope +3) Oeffentliche Interfaces / Contracts +4) Implementierungsschritte +5) Tests / Abnahme +6) Risiken / Migration +7) Annahmen / Defaults +``` + +## Out of Scope: Extensions + +Die Extension-Mechanik (Ablage, Hooks, Lifecycle) ist aktuell nicht standardisiert. + +Regel: +1. Keine impliziten Extension-Standards erfinden. +2. Erweiterungen explizit als Out-of-Scope kennzeichnen. +3. Nur dokumentieren, was bereits verbindlich im Repo festgelegt ist. diff --git a/docs/di-container.md b/docs/di-container.md new file mode 100644 index 0000000..725f2d7 --- /dev/null +++ b/docs/di-container.md @@ -0,0 +1,106 @@ +# Dependency Injection Container + +## Was ist das und wozu brauchen wir es? + +Statt dass jede Action-Datei ihre Services selbst zusammenbaut +(`new UserAccountService(new UserRepo(), new AuditRepo(), ...)`), +gibt es einen zentralen "Service-Speicher": den **AppContainer**. + +Der Container weiß, wie er jeden Service bauen muss — +aber er baut ihn erst, wenn er zum ersten Mal gebraucht wird (lazy). +Wird der gleiche Service in derselben Anfrage nochmal angefragt, +bekommt man dieselbe Instanz zurück (Singleton pro Request). + +## Services abrufen — der `app()` Helper + +In jeder Action-Datei (`pages/**/*.php`) steht der Container über +die globale Hilfsfunktion `app()` zur Verfügung: + +```php +$userService = app(\MintyPHP\Service\User\UserAccountService::class); +$authService = app(\MintyPHP\Service\Access\AuthorizationService::class); +``` + +Den vollständigen Klassennamen (FQN) als Schlüssel verwenden — +so gibt es keine Namenskollisionen und IDEs können Autocomplete nutzen. + +`app()` wirft eine `RuntimeException` wenn der Service nicht registriert ist. +Das ist absichtlich laut, damit fehlende Registrierungen sofort auffallen. + +## Aufbau: Registrars + +Beim Start der Anwendung läuft `lib/App/registerContainer.php`. +Es erstellt den Container und ruft 8 **Registrars** nacheinander auf: + +| Registrar | Was er registriert | +|---|---| +| `RepositoryFactoryRegistrar` | Alle Repository-Factories (SQL-Schicht) | +| `ServiceFactoryRegistrar` | Alle Service-Factories (Business-Logik, mit Abhängigkeiten) | +| `AccessRegistrar` | RBAC-spezifische Services (Roles, Permissions, Gateways) | +| `AuthRegistrar` | Auth-spezifische Services (Login, SSO, API-Tokens) | +| `DirectoryRegistrar` | LDAP/Entra-Directory-Services | +| `UserRegistrar` | User-spezifische Services und Gateways | +| `SettingsRegistrar` | Settings-Services und Gateways | +| `AppServicesRegistrar` | Konkrete Top-Level-Services, die direkt in Actions genutzt werden | + +Jeder Registrar implementiert das Interface `ContainerRegistrar::register(AppContainer $container)`. + +## Wie Abhängigkeiten aufgelöst werden + +```php +// In ServiceFactoryRegistrar: +$container->set(UserServicesFactory::class, static fn (AppContainer $c) => + new UserServicesFactory( + $c->get(AuditServicesFactory::class), // wird lazy aufgelöst + $c->get(UserRepositoryFactory::class), + $c->get(UserGatewayFactory::class), + $c->get(DatabaseSessionRepository::class) + ) +); +``` + +Der Container übergibt sich selbst als `$c` an die Factory-Funktion — +so werden alle Abhängigkeiten automatisch und lazy aus dem Container gezogen. +Zirkuläre Abhängigkeiten werden über `static fn (): Foo => $c->get(Foo::class)` aufgelöst +(der Aufruf wird verzögert, bis er wirklich gebraucht wird). + +## Neuen Service registrieren + +**1. Service und ggf. Factory erstellen** (wie bisher in `lib/Service/`) + +**2. Im passenden Registrar eintragen:** + +```php +// In AppServicesRegistrar (für Services, die direkt in Actions genutzt werden): +$container->set(MeinNeuerService::class, static fn (AppContainer $c) => + new MeinNeuerService( + $c->get(UserServicesFactory::class), + $c->get(SettingGateway::class) + ) +); +``` + +**3. In der Action nutzen:** + +```php +$meinService = app(\MintyPHP\Service\MeinNeuerService::class); +``` + +**Faustregel: welcher Registrar?** + +- Neue *Repository-Factory* → `RepositoryFactoryRegistrar` +- Neue *Service-Factory* (mit Abhängigkeiten) → `ServiceFactoryRegistrar` +- Konkreter *Service*, der direkt in Actions per `app()` abgerufen wird → `AppServicesRegistrar` +- Klar einer Domain zugeordnet (Auth, Access, Directory, User, Settings) → der passende Domain-Registrar + +## Wichtige Regeln + +- **Nie** `new SomeService(...)` direkt in Action-Dateien schreiben — + immer `app(SomeService::class)` nutzen. +- **Nie** Factory-Ketten in Actions (`app(SomeFactory::class)->createService()`) — + den konkreten Service direkt registrieren und per `app()` holen. +- Der Container lebt **pro HTTP-Anfrage** — er wird in `web/index.php` initialisiert + und über `setAppContainer()` im globalen State abgelegt. +- In CLI-Skripten (`bin/`) wird der Container separat initialisiert (s. `bin/scheduler-run.php`). +- In `lib/Service/**` außerhalb von `*Factory.php` niemals `new ...Factory()`, + `new ...Service()` oder `new ...Repository()` direkt instanziieren. diff --git a/docs/docker-betrieb.md b/docs/docker-betrieb.md index a1f1227..68828f9 100644 --- a/docs/docker-betrieb.md +++ b/docs/docker-betrieb.md @@ -16,3 +16,4 @@ Die Docker-Dokumentation ist in zwei klare Pfade getrennt: - Für tägliche Entwicklung immer mit `docker-compose.yml` arbeiten. - Für Serverbetrieb ausschließlich `docker-compose.prod.yml` verwenden. - Änderungen an Domain/TLS nur in der Produktivdoku und den Produktivdateien pflegen. +- Nach Start oder Deployment einmal `docker compose exec php sh -lc "php bin/doctor.php"` ausführen. diff --git a/docs/docker-lokal.md b/docs/docker-lokal.md index 02f14db..4df107a 100644 --- a/docs/docker-lokal.md +++ b/docs/docker-lokal.md @@ -1,6 +1,6 @@ # Docker lokal -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-03-03 ## Ziel @@ -35,6 +35,21 @@ docker compose up --build -d docker compose logs -f nginx php ``` +## Security Header in Dev + +Der lokale Nginx liefert bewusst dieselbe Security-Header-Baseline wie Produktion, +mit einer Ausnahme: + +- Kein `Strict-Transport-Security` (HSTS) im Dev-HTTP-Setup. + +Aktive Header in Dev: + +- `X-Content-Type-Options: nosniff` +- `X-Frame-Options: SAMEORIGIN` +- `Referrer-Policy: strict-origin-when-cross-origin` +- `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()` +- `X-Permitted-Cross-Domain-Policies: none` + ## Nützliche Befehle Neustart: diff --git a/docs/docker-produktiv.md b/docs/docker-produktiv.md index 8c609d3..9e823e6 100644 --- a/docs/docker-produktiv.md +++ b/docs/docker-produktiv.md @@ -1,6 +1,6 @@ # Docker produktiv -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-03-03 ## Ziel @@ -40,6 +40,22 @@ Zertifikate ablegen: - `/docker/nginx/certs/fullchain.pem` - `/docker/nginx/certs/privkey.pem` +### Security Header Baseline + +Die produktive Nginx-Konfiguration setzt folgende Header serverseitig: + +- `X-Content-Type-Options: nosniff` +- `X-Frame-Options: SAMEORIGIN` +- `Referrer-Policy: strict-origin-when-cross-origin` +- `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()` +- `X-Permitted-Cross-Domain-Policies: none` +- `Strict-Transport-Security: max-age=31536000` + +Hinweise: + +- HSTS wird nur im HTTPS-Serverblock gesetzt. +- `includeSubDomains` und `preload` sind absichtlich nicht gesetzt. + ## 3) Produktion starten ```bash diff --git a/docs/dokumentation-erweitern.md b/docs/dokumentation-erweitern.md index fb8b1a4..1c0c8e7 100644 --- a/docs/dokumentation-erweitern.md +++ b/docs/dokumentation-erweitern.md @@ -1,62 +1,61 @@ # Dokumentation erweitern -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-24 ## Ziel -Diese Seite beschreibt den Standardprozess, um die Projektdokumentation sauber, konsistent und dauerhaft wartbar zu erweitern. +Neue Seiten schnell, konsistent und langfristig wartbar ergänzen. -## Single Source of Truth +## Informationsarchitektur (verbindlich) -- Für Doku-Navigation und Doku-Suche ist **`/docs/index.md`** die einzige Quelle. -- Nur Dateien, die dort eingetragen sind, erscheinen in: - - `/admin/docs/...` - - globaler Suche (Kategorie `Documentation`) - - Vollsuche `/search` -- Der sichtbare Name im Docs-Aside und in der Suche kommt aus dem **H1-Titel** der Datei. +`/docs/index.md` ist die einzige Navigationsquelle für: -## Standardablauf für neue Doku-Dateien +- Admin-Doku (`/admin/docs/...`) +- Docs-Suche +- Vollsuche (`/search`) -1. Neue Datei unter `docs/*.md` anlegen (Slug nur `a-z`, `0-9`, `-`). -2. Erste Zeile als H1 setzen (`# Titel`). -3. Direkt darunter Datum setzen: - - `Letzte Aktualisierung: YYYY-MM-DD` -4. Datei in `docs/index.md` unter passender Kategorie eintragen: - - Format: ``/docs/.md`` -5. Kurzbeschreibung in `docs/index.md` ergänzen (1 Zeile, klarer Nutzen). -6. Relevante Querverweise in bestehenden Docs ergänzen. +Hauptbereiche in genau dieser Reihenfolge: -## Struktur- und Stilregeln +1. Lernpfad (chronologisch) +2. Konzepte (Explanation) +3. Aufgabenanleitungen (How-to) +4. Referenz (Reference) +5. Betrieb (Operations) +6. Mitwirken (Contributor Guide) -- Pro Datei genau ein H1. -- H1 ist der kanonische Anzeigename (Navigation + Suche). -- Abschnittsstruktur über H2/H3 (nicht direkt mit H4 starten). -- Ein Abschnitt = eine klare Aussage. -- Befehle in Codeblöcken mit realen, lauffähigen Beispielen. -- Dateipfade immer als klickbare Code-Referenz (`/path/to/file`). -- Keine veralteten Relikte stehen lassen ("Legacy", alte Namen, alte Routen). +## Format pro Seite (kurz und klar) -## Wichtig für die Suche +Jede neue Seite soll kompakt bleiben: -- Doku-Suche indexiert `docs/*.md` live. -- Treffer entstehen aus: - - Dokumenttitel - - Abschnittsüberschriften (H1/H2/H3) - - Abschnittstext -- Links springen auf `admin/docs/{slug}#anchor`. -- Aussagekräftige H2/H3-Überschriften verbessern Suchtreffer deutlich. +- genau ein H1 +- `Letzte Aktualisierung: YYYY-MM-DD` +- `## Ziel` (1-2 Sätze) +- maximal 3-6 H2-Abschnitte +- kurze Listen statt Fließtextblöcke +- konkrete Befehle oder Pfade + +Richtwert: zuerst kurz, Details als Verweis auf Referenzseiten. + +## Standardablauf + +1. Datei in `docs/*.md` anlegen (`a-z`, `0-9`, `-`). +2. Seite im passenden Bereich in `docs/index.md` eintragen. +3. Aussagekräftige H2/H3-Überschriften setzen (für Suche). +4. Relevante Querverweise ergänzen. +5. Datum aktualisieren. ## Qualitätscheck vor Merge -1. Ist die Datei in `docs/index.md` eingetragen? -2. Ist `Letzte Aktualisierung` vorhanden und korrekt? -3. Stimmen alle genannten Pfade/Routen/Permissions mit dem Code? -4. Gibt es mindestens einen konkreten, praxisnahen Beispielaufruf? -5. Sind neue Begriffe konsistent zu bestehender Terminologie? +1. Seite ist in `docs/index.md` eingetragen. +2. Struktur ist kurz, klar, ohne Redundanz. +3. Pfade, Routen, Permissions stimmen mit dem Code. +4. Instanziierungsregeln sind konsistent mit `/docs/architektur.md` und `/docs/lib-standards.md`. +5. Mindestens ein konkretes Beispiel ist enthalten. +6. Begriffe sind konsistent mit `/docs/02-domain-glossar.md`. ## Häufige Fehler -- Datei erstellt, aber nicht in `docs/index.md` registriert. -- Überschrift/Slug später umbenannt, alte Links nicht angepasst. -- Allgemeine Aussagen ohne konkrete Beispiele. -- API-/Permission-Änderung im Code, aber Doku nicht nachgezogen. +- Seite existiert, ist aber nicht im Index registriert. +- Lernpfad wird mit Referenztext überladen. +- Lange Detailtexte ohne klare Handlungsschritte. +- API-/Permission-Änderung im Code ohne Doku-Update. diff --git a/docs/entwickler-checkliste.md b/docs/entwickler-checkliste.md index 55b4423..bed1862 100644 --- a/docs/entwickler-checkliste.md +++ b/docs/entwickler-checkliste.md @@ -1,89 +1,90 @@ # Entwickler-Checkliste -Letzte Aktualisierung: 2026-02-23 +Letzte Aktualisierung: 2026-03-04 + +## Ziel + +Kurze Definition of Done vor Merge. ## Vor dem Coden -- [ ] Betroffene Schicht klar? (`Repository`, `Service`, `pages`, `templates`, `web/js`) -- [ ] Permission- und Tenant-Scope-Auswirkungen verstanden? -- [ ] Bestehendes Partial/Helper wiederverwendbar? -- [ ] Bei `lib/**`-Änderungen Regeln in `/docs/lib-standards.md` geprüft +- [ ] Betroffene Schichten klar (`Repository`, `Service`, `pages`, `templates`, `web/js`) +- [ ] Permission- und Tenant-Scope-Auswirkungen geklärt +- [ ] Relevante bestehende Partials/Helper geprüft ## Beim Implementieren -- [ ] Keine DB-Calls in Views (`*.phtml`) -- [ ] SQL nur im Repository -- [ ] Business-Regeln nur im Service -- [ ] Actions bleiben schlank (Input/Guard/Orchestrierung) -- [ ] Neue UI-Texte mit `t('...')` +- [ ] Kein SQL in `*.phtml` +- [ ] SQL nur in `lib/Repository/**` +- [ ] Business-Logik nur in `lib/Service/**` +- [ ] Neue UI-Texte über `t('...')` +- [ ] Listen-Filter konsistent: IDs/Enums als Select/MultiSelect statt Freitext +- [ ] Für persistierte Status/Outcomes nur zentrale Taxonomien aus `lib/Domain/Taxonomy/*` nutzen (keine lokalen String-Literale) +- [ ] Für neue/angepasste Liste: `filter-schema.php` angelegt/aktualisiert +- [ ] Listen-Titlebar über `templates/partials/app-list-titlebar.phtml` (kein inline `
`) +- [ ] Listen-Tabs über `templates/partials/app-list-tabs.phtml` (kein inline `
`) +- [ ] Purge-Titlebar-Action über `templates/partials/app-list-purge-action.phtml` (kein inline Purge-Form/Button) +- [ ] Detail-Formulare mit Standardverhalten markieren (`data-standard-detail-form="1"`) +- [ ] Detail-Titlebar nutzt Unsaved/Shortcut-Contract (`data-detail-unsaved-message`, `data-detail-save-primary`) +- [ ] Detail-Aktionen nutzen Action-Policy-Contract (`data-detail-action-policy`, `data-detail-action-kind`, `data-detail-confirm-message`) statt inline `confirm(...)` +- [ ] Detail-Validierungsfehler über `templates/partials/app-details-validation-summary.phtml` (kein inline Error-Loop) +- [ ] Für Detail-Validation-Summary strukturierte Feldfehler setzen (`$validationSummaryErrors = $errorBag->toArray()`) +- [ ] Toolbar über `renderGridFilterToolbar(...)`, kein manuelles Filter-HTML +- [ ] Listen über `initStandardListPage(...)` initialisiert (kein direkter `createServerGrid(...)`-Standardpfad) +- [ ] Für Listen `searchToolbarFilterSchema` + `drawerToolbarFilterSchema` + `filterChipMeta` aus `index().php` bereitgestellt +- [ ] Keine direkten `gridFiltersFromSchema(...)`- oder `initListFilterExperience(...)`-Aufrufe in Listen-Templates +- [ ] Bei Filter-Drawer-Seiten: `filters.mode: 'drawer'` im Wrapper gesetzt +- [ ] Bei Search-only-Listen: kein Drawer-Markup, Quick Search bleibt live +- [ ] Aktive Filter-Chips vorhanden (`data-active-filter-chips`) und Remove/Clear funktionieren +- [ ] Kein Legacy-Toggle in Listen (`data-toolbar-toggle`, `data-filter-overflow`, `data-filter-toggle`) +- [ ] Falls Save-Filter vorhanden: gespeicherter Zustand stammt aus Applied State (`gridConfig.baseUrl()`) +- [ ] Service-Auflösung in `pages/**`/`templates/**`/`lib/Support/**` nur via `app(Foo::class)` +- [ ] Keine `new ...Factory()` in `lib/Service/**` außerhalb `*Factory.php` +- [ ] Keine direkten `new ...Service()`, `new ...Gateway()`, `new ...Repository()` in `lib/Service/**` außerhalb `*Factory.php` -## UI/UX-Konsistenz +## Sicherheits- und Scope-Checks -- [ ] Edit-Seite nutzt `app-details-titlebar` -- [ ] Titelzeile enthält nur Primäraktionen (`Save`, `Save & close`) -- [ ] Sekundäraktionen liegen im Aside-Block `Actions` (nicht als verstecktes Titlebar-Dropdown) -- [ ] Erster Tab = `Master data` -- [ ] Status über `app-visibility-status-field` -- [ ] Delete über `app-danger-zone-delete-field` (falls relevant) -- [ ] Aside-Meta über Audit/IDs-Partial (falls relevant) -- [ ] Bei Tenant-Zusatzfeldern: Definitionen nur über eigene Actions, nicht über Tenant-Hauptsave -- [ ] Bei User-Zusatzfeldern: Felder tenantweise gruppiert, Scope serverseitig geprüft, Werte optional in V1 -- [ ] `is_filterable` nur für `select`, `multiselect`, `boolean`, `date` verwenden -- [ ] Bei Theme-Logik: Tenant-Overrides (`default_theme`, `allow_user_theme`) gegen globale Settings geprüft -- [ ] Bei Tenant-SSO: `tenants.sso_manage` geprüft und Secrets niemals im Klartext rendern/loggen -- [ ] Bei API-Audit: nur Metadaten loggen (keine Bodies/Tokens), Redaction aktiv, Permission `api_audit.view` geprüft +- [ ] Rechte serverseitig geprüft +- [ ] Tenant-Scope serverseitig geprüft +- [ ] Bei RBAC-Änderungen Playbook angewendet (`/docs/rbac-permissions-playbook.md`) +- [ ] Schreibaktionen mit POST + CSRF abgesichert +- [ ] Confirm-Dialoge über Datencontract (`data-confirm-message` / `data-detail-confirm-message`), keine inline `confirm(...)` +- [ ] Keine sensiblen Daten in Fehlern/Logs +- [ ] Für `pages/**/data().php`: GET-only + Query-Normalisierung (`gridRequireGetRequest`, `gridParseFilters`) +- [ ] Für `pages/**/data().php`: Filter über `gridParseFilters(...)` statt Inline-Parsing +- [ ] Bei MultiSelect-Filtern: CSV-Query (`foo=a,b`) + serverseitige `IN`-Filter mit Limit +- [ ] Keine Listen-Alias-Keys (`*_id` neben `*_ids`) neu einführen +- [ ] Input nur über `requestInput()` und Validation über `FormErrors` (`/docs/request-input-validation.md`) -## Assets +## Qualitätschecks -- [ ] Neue CSS-Datei im passenden Ordner (`components`, `layout`, `pages`, `vendor-overrides`) -- [ ] Falls nötig in `config/assets.php` als Gruppe registriert -- [ ] Seitenstil per `Buffer::set('style_groups', ...)` aktiviert - -## Tests und Checks - -- [ ] PHPUnit: - - [ ] `docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php` -- [ ] PHPStan: - - [ ] `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress` -- [ ] ESLint: - - [ ] `npx eslint web/js` -- [ ] i18n-Test: - - [ ] `docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php tests/I18n/TranslationKeysTest.php` -- [ ] Bei Microsoft-SSO-Änderungen: - - [ ] Für Bestandsumgebungen passendes idempotentes SQL-Update bereitgestellt und ausgeführt - - [ ] `APP_CRYPTO_KEY` in `.env` gesetzt (32 Byte key, hex oder base64) - - [ ] Wenn `phone`/`mobile`/`avatar` Sync genutzt wird: Graph `User.Read` in Entra verfügbar und consented - - [ ] Profil-Sync-Felder im Tenant-SSO-Tab geprüft (`sync_profile_on_login`, `sync_profile_fields`) - - [ ] Login Smoke-Test (`login?tenant=` -> Microsoft Start/Callback) -- [ ] Bei API-Audit-Änderungen: - - [ ] Für Bestandsumgebungen passendes idempotentes SQL-Update bereitgestellt und ausgeführt - - [ ] Audit-Logging für 2xx/4xx/5xx manuell geprüft - - [ ] Purge-Action (`admin/api-audit/purge`) prüft Permission + POST + CSRF -- [ ] Bei Global-Search-Änderungen: - - [ ] SQL-Resource in `lib/Support/Search/SearchSqlResourceProvider.php` ergänzt (`resources`, ggf. `tenantScopeFilters`) - - [ ] UI-Meta in `lib/Support/Search/SearchUiMetaProvider.php` ergänzt (`listUrl`, `iconForKey`) - - [ ] Mapping in `lib/Support/Search/SearchItemMapperProvider.php` ergänzt (`mapPreviewItem`, `mapResultItem`) - - [ ] Spezialressourcen in `lib/Support/Search/SearchSpecialResourceProvider.php` ergänzt (falls `docs`/`hotkeys`-ähnlich) - - [ ] `lib/Support/SearchConfig.php` nur als Fassade konsistent delegierend belassen - - [ ] Aside-Search-Eintrag in `templates/partials/app-main-aside.phtml` mit passendem `data-search-key` vorhanden - - [ ] Permission + Tenant-Scope geprüft (keine unerlaubten Treffer) - - [ ] Preview (`admin/search/data`) und Vollsuche (`search?search=...`) manuell geprüft +- [ ] `docker compose exec php vendor/bin/phpunit` +- [ ] `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress` +- [ ] `docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php` +- [ ] bei JS-Änderungen: Browser-Smoke mit DevTools-Console (keine JS-Errors) +- [ ] bei API-Änderungen: `docs/openapi.yaml` aktualisiert +- [ ] Struktur-Gates geprüft (`rg` für Instanziierungsregeln) +- [ ] `pages/**`: kein `new ...Service|Gateway|Repository`, kein `DB::` +- [ ] `pages/admin/**`: keine `app(...Factory::class)->create...`-Ketten +- [ ] `pages/admin/**`: keine `Factory::class`-Referenzen ## Manuelle Smoke-Tests - [ ] Login/Logout -- [ ] Eine Admin-Liste mit Filter + Sortierung -- [ ] Eine Edit-Seite mit Save + Danger-Zone-Aktion (falls vorhanden) -- [ ] Tenant-gebundene Sicht (Address Book / Users / Departments) -- [ ] Global Search +- [ ] betroffene Admin-Liste + Filter +- [ ] betroffene Edit-Seite + Save +- [ ] relevante Tenant-Sicht +- [ ] ggf. Global Search / API-Flow ## Abschluss -- [ ] Dokumentation aktualisiert (`README.md`, `docs/*` bei Bedarf, inkl. `docs/frontend-javascript.md`) -- [ ] Keine toten Imports/unused Code hinzugefügt -- [ ] Änderung ist für den nächsten Entwickler in 1-2 Minuten nachvollziehbar +- [ ] Doku aktualisiert (`README.md`, `docs/*`) +- [ ] keine toten Imports/ungenutzten Helfer +- [ ] Änderung in 1-2 Minuten nachvollziehbar -## Periodisch (bei Feature-Entfernung / vor Release) +## Vertiefung -- [ ] Ungenutzte Composer-Pakete prüfen: - - [ ] `docker compose exec php vendor/bin/composer-unused` +- `/docs/lib-standards.md` +- `/docs/sicherheitsmodell.md` +- `/docs/rbac-permissions-playbook.md` +- `/docs/dokumentation-erweitern.md` diff --git a/docs/erste-aenderung.md b/docs/erste-aenderung.md index 261c81c..b228575 100644 --- a/docs/erste-aenderung.md +++ b/docs/erste-aenderung.md @@ -1,6 +1,6 @@ # Leitfaden für die erste Änderung -Letzte Aktualisierung: 2026-02-22 +Letzte Aktualisierung: 2026-02-24 ## Ziel @@ -17,7 +17,9 @@ Ein neuer Entwickler soll den ersten Change strukturiert und reproduzierbar umse - Schreiboperation → `lib/Repository/User/UserWriteRepository.php` 3. Grid-Spalte in `pages/admin//index(default).phtml` ergänzen (z. B. `pages/admin/users/index(default).phtml`) 4. i18n-Key für Spaltennamen setzen -5. `php -l` + manueller UI-Test +5. Service-Auflösung in Action-Dateien nur über `app(Foo::class)` nutzen (kein `new ...Factory()` in `pages/**`) +6. In `data().php`: GET-only (`gridRequireGetRequest()`) und Grid-Querys über `gridQueryLimitOffset()`/`gridQueryOrderDir()` normalisieren +7. `php -l` + manueller UI-Test ### Done-Kriterien @@ -25,6 +27,7 @@ Ein neuer Entwickler soll den ersten Change strukturiert und reproduzierbar umse - Neue Werte im Endpoint dokumentiert/benannt - Sortierung und Mapping im Grid konsistent - Bei neuen Filtern: Query-Param, Repository-Filter und Grid-State konsistent +- Instanziierungsstandard eingehalten (`app(...)`, keine Legacy-Helper) ## Beispiel 2: API-Response erweitern @@ -32,7 +35,7 @@ Ein neuer Entwickler soll den ersten Change strukturiert und reproduzierbar umse 1. Endpoint in `pages/api/v1/...` anpassen (z. B. `pages/api/v1/me/index().php` oder `pages/api/v1/users/show($id).php`) 2. Permission- und Tenant-Scope-Prüfungen beibehalten -3. Optional Service/Repository ergänzen +3. Optional Service/Repository ergänzen, Instanziierung über `app(...)`/Factory-Standards 4. `docs/openapi.yaml` aktualisieren 5. `docs/api.md` mit Beispiel aktualisieren 6. Bei neuen Berechtigungen: `PermissionService` + `init.sql` synchron halten, für Bestandsumgebungen idempotentes SQL-Update in `db/updates/*.sql` bereitstellen diff --git a/docs/erste-schritte.md b/docs/erste-schritte.md index 3bae50c..cc91b5d 100644 --- a/docs/erste-schritte.md +++ b/docs/erste-schritte.md @@ -10,7 +10,6 @@ Diese Seite bringt neue Entwickler in unter 45 Minuten zu einem lauffähigen lok - Docker + Docker Compose - Git -- Node.js + npm (für ESLint) - Zugriff auf dieses Repository ## 1) Projekt lokal starten @@ -69,9 +68,10 @@ curl -H "Authorization: Bearer " \ ```bash docker compose exec php vendor/bin/phpunit docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress -npx eslint web/js ``` +Bei JS-Änderungen zusätzlich Browser-Smoke mit DevTools-Console (keine JS-Errors). + ## 6) Nächste Doku-Schritte - Täglicher Workflow: `/docs/lokale-entwicklung.md` diff --git a/docs/fehlerbehebung.md b/docs/fehlerbehebung.md index 9d0ebcf..9c8f213 100644 --- a/docs/fehlerbehebung.md +++ b/docs/fehlerbehebung.md @@ -14,6 +14,20 @@ UI zeigt alte Daten/Assets oder Verhalten wirkt stale. docker compose restart php nginx ``` +## Erstdiagnose mit Doctor + +### Symptom + +Unklar, ob das Problem aus ENV, DB, Storage, RBAC oder Scheduler kommt. + +### Lösung + +```bash +docker compose exec php sh -lc "php bin/doctor.php" +``` + +Bei `FAIL` zuerst diese Punkte beheben, danach erneut ausführen. + ## Login/API verhalten sich unerwartet ### Symptom diff --git a/docs/frontend-javascript.md b/docs/frontend-javascript.md index fb0a307..2e7a26e 100644 --- a/docs/frontend-javascript.md +++ b/docs/frontend-javascript.md @@ -1,6 +1,6 @@ # Frontend JavaScript -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-03-04 ## Ordnerstruktur @@ -84,18 +84,185 @@ Hinweise: - `order` nur bekannte Sort-Key-Spalten - Bei Search-/Filter-Änderung wird auf Seite 1 zurückgesetzt. -## Linting +### Filter UX 2.0 (Globaler Standard) -Prüfen mit: +Für Listen gilt standardmäßig: -```bash -npx eslint web/js +1. Quick Search sichtbar außerhalb des Drawers. +2. Restliche Filter im rechten Drawer (`data-filter-drawer-*`). +3. Drawer arbeitet `apply-first` (Draft bis `Apply`). +4. Aktive Filter werden als Chips angezeigt (`data-active-filter-chips`). +5. Filter-Reset erfolgt über Chips (`Clear all filters`), nicht im Drawer. + +JS-Verkabelung: + +- Standard-Entrypoint ist `initStandardListPage(...)` aus `web/js/core/app-grid-factory.js`. +- Der Wrapper kapselt intern `createServerGrid(...)` + (bei `mode: 'drawer'`) `initListFilterExperience(...)`. +- Bei Apply/Clear wird URL mit `pushState` fortgeschrieben. +- Save-Filter-Usecases lesen den angewandten Zustand über `gridConfig.baseUrl()`. +- Chip-State-Logik ist zentral im Wrapper verdrahtet (Default: `app-list-filter-state.js`). + +Search-only-Ausnahme: + +- Listen ohne zusätzliche Filter initialisieren über denselben Wrapper mit `mode: 'search-only'` (ohne Drawer/Chips). + +### Standard-List-API + +Für Listen-Templates gilt: + +- `initStandardListPage({ grid, filters, hooks })` verwenden. +- `grid.filterSchema` serverseitig aus `filter-schema.php` (`gridSchemaClientFilters(...)`) übergeben. +- Optional zusätzliche Spezialfilter über `grid.extraFilters`. +- Bei Drawer-Listen `filters.mode: 'drawer'`, bei Search-only `filters.mode: 'search-only'`. + +Regel: + +- keine direkten Aufrufe von `gridFiltersFromSchema(...)` in Listen-Templates +- keine direkten Aufrufe von `initListFilterExperience(...)` in Listen-Templates +- keine inline `filters: [...]`-Arrays in Listen-Templates +- MultiSelect-Filter immer als CSV-Key (`*_ids`, `actions`, `event_types` etc.) + +Serverseitig passendes Template-Markup: + +- normale Select-Filter über `selectFilter(...)` +- MultiSelect-Filter über `multiSelectFilter(...)` + +### List Titlebar Standard + +Für Listen-Titlebars gilt: + +- Titlebar über `templates/partials/app-list-titlebar.phtml` rendern. +- Titel über `$listTitle` setzen. +- Aktionen über `$listTitleActionsHtml` als Slot (captured HTML) übergeben. + +Beispiel: + +```php + + + ``` -Auto-fix (nur für sichere Rule-Fixes): +### List Tabs Standard -```bash -npx eslint web/js --fix +Für Tenant- oder Kontext-Tabs in Listen gilt: + +- Tabs über `templates/partials/app-list-tabs.phtml` rendern. +- Tab-Items über `$listTabsItems` mit `href`, `label`, `active` übergeben. +- Optional `id` über `$listTabsId` setzen. + +### List Purge Action Standard + +Für Purge-Buttons in Titlebar-Actions gilt: + +- Purge-Markup über `templates/partials/app-list-purge-action.phtml` rendern. +- Formular/CSRF/Button/Confirm werden zentral übergeben via: + - `$listPurgeEnabled` + - `$listPurgeFormId` + - `$listPurgeAction` + - `$listPurgeConfirmMessage` + - `$listPurgeButtonLabel` + +### Aside Grouped Navigation + Persisted Details State + +Für gruppierte Sidebar-Navigation mit einklappbaren `details` gilt: + +- Zentraler Storage-Helper: `web/js/core/app-details-open-state.js` (`initPersistedDetailsGroup(...)`). +- Standardgruppen über `details[data-details-key]`. +- Persistenz-Aktivierung über: + - `data-details-storage="..."` (allgemein, z. B. Detailseiten) + - `data-aside-details-storage="..."` (Aside-Panels) +- Optionales Aktiv-Verhalten im Aside über `data-aside-details-open-active="1"`: + - aktive Gruppe bleibt immer offen + - effektive Open-Keys = `persistedOpenKeys ∪ activeOpenKeys` + +Admin-Aside-Standard: + +- Storage-Key: `aside-admin-sections-v1` +- Gruppenkeys: + - `admin-organization` + - `admin-roles-permissions` + - `admin-automation` + - `admin-monitoring` + - `admin-logs` + - `admin-system` + +### Detail Page Standard + +Für Create/Edit-Detailseiten gilt: + +- Hauptformular über `data-standard-detail-form="1"` explizit opt-in markieren. +- Initialisierung läuft zentral über `initStandardDetailPage(...)` aus `web/js/core/app-detail-page-factory.js`. +- Auto-Init erfolgt über `web/js/components/app-standard-detail-page.js` (in `app-init.js` eingebunden). + +Standardverhalten: + +- Dirty-State über serialisierte `FormData`-Diffs (initial vs. aktuell). +- `beforeunload`-Warnung nur bei ungespeicherten Änderungen. +- Back/Cancel-Link in `.app-details-titlebar h1 a` fragt bei Dirty-State per Confirm. +- `Cmd/Ctrl+S` triggert primären Save/Create-Button (`data-detail-save-primary="1"`). +- Submit-Lock/Confirm laufen zentral über die Detail-Action-Policy. +- Bei Dirty-State wird in der Titlebar automatisch ein kleiner Hinweis mit Feldanzahl gezeigt. + +### Detail Action Policy + +Für standardisierte Detailseiten gilt zusätzlich: + +- `initStandardDetailPage(...)` bindet intern `initDetailActionPolicy(...)` aus `web/js/core/app-details-action-policy.js`. +- Aktivierung pro Seite über `data-detail-action-policy="1"` auf `.app-details-titlebar` (Default im Shared-Partial aktiv). +- Confirm nicht mehr inline per `onclick/onsubmit`, sondern über `data-detail-confirm-message`. +- Aktionssemantik über `data-detail-action-kind` (z. B. `save`, `save-close`, `delete`, `danger`). +- Optionaler Lock-Override über `data-detail-submit-lock="none"` (Default: `form`). + +### Global Confirm Actions + +Für allgemeine Form-/Button-Confirms außerhalb der Detail-Policy gilt: + +- zentral über `web/js/components/app-confirm-actions.js` +- Confirm-Text über `data-confirm-message` +- keine inline `onclick="return confirm(...)"` / `onsubmit="return confirm(...)"` verwenden + +### Detail Validation Summary Standard + +Für serverseitige Formularfehler in Create/Edit-Templates gilt: + +- Fehlerausgabe über `templates/partials/app-details-validation-summary.phtml`. +- Keine inline `
`-Blöcke mit `foreach ($errors as $error)` mehr. +- Standardtitel kommt aus `t('Please review the following errors')`. +- In Action-Handlern strukturierte Feld-Errors als `$validationSummaryErrors = $errorBag->toArray();` bereitstellen. + +Template-Beispiel: + +```php + ``` -Hinweis: `--fix` behebt format-/syntaxnahe Themen (z. B. fehlende Klammern), aber nicht automatisch alle Logikprobleme. +Optional strukturierte Einträge: + +- `['message' => '...', 'fieldName' => 'email']` +- `['message' => '...', 'fieldId' => 'email']` +- `['email' => ['Required']]` (FormErrors-ähnliche Map) + +JS-Verhalten über `initStandardDetailPage(...)`: + +- Klick auf Validation-Link fokussiert das passende Feld. +- Bei vorhandener Summary wird initial das erste betroffene Feld (oder Summary) fokussiert. +- Betroffene Felder werden zentral mit `aria-invalid="true"` und `aria-describedby` (Summary-ID) markiert. + +## Laufzeit-Check (ohne Node) + +Prüfen im Browser: + +1. DevTools öffnen (Console, optional Preserve log aktivieren). +2. Kernseiten laden (`/admin/users`, `/address-book`, Audit-Listen). +3. Filter setzen/zurücksetzen, Doppelklick-Navigation und Bulk-Aktionen testen. +4. Erwartung: keine JavaScript-Errors und keine Unhandled Promise Rejections. diff --git a/docs/geplante-aufgaben.md b/docs/geplante-aufgaben.md index 023cf41..57c2379 100644 --- a/docs/geplante-aufgaben.md +++ b/docs/geplante-aufgaben.md @@ -1,232 +1,74 @@ # Geplante Aufgaben -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-24 ## Ziel -Zentrale Verwaltung geplanter Aufgaben im Admin-Bereich. +Scheduler-Betrieb und Job-Erweiterung kurz und verlässlich dokumentieren. -- Ein zentraler Runner (`bin/scheduler-run.php`) läuft minütlich. - - Produktion typischerweise per Cron. - - Lokale Docker-Umgebung über den Compose-Service `scheduler`. -- Die konkreten Jobs sind Whitelist-basiert (kein frei ausführbarer Bash-Text aus DB). -- V1 startet mit `user_lifecycle_run`. +## Kernkomponenten -## Tabellen +- Runner: `bin/scheduler-run.php` +- Orchestrierung: `lib/Service/Scheduler/SchedulerRunService.php` +- Job-Konfiguration: `lib/Service/Scheduler/ScheduledJobService.php` +- Registry: `lib/Service/Scheduler/ScheduledJobRegistry.php` +- Schedule-Berechnung: `lib/Service/Scheduler/ScheduleCalculator.php` -- `scheduled_jobs` - - Konfiguration pro Job (enabled, schedule, timezone, next/last run, letzter Fehler). -- `scheduled_job_runs` - - Historie einzelner Runs (trigger, status, Dauer, error/result summary). -- `scheduler_runtime_status` - - Einzeilige Runtime-Health des globalen Runners (Heartbeat, letzter Runner-Status, letzter Fehlercode). +## Datenmodell -Run-Log-Retention: +- `scheduled_jobs`: Job-Konfiguration und letzter Laufstatus +- `scheduled_job_runs`: Historie einzelner Läufe +- `scheduler_runtime_status`: Heartbeat/Runner-Status (eine Zeile, `id = 1`) -- 90 Tage (Purge in Admin verfügbar). +Retention: + +- Run-Logs: 90 Tage (`ScheduledJobService::purgeRunsExpired`) +- System-Audit: konfigurierbar über `system_audit_retention_days` (Job `system_audit_purge`) + +## Laufmodell + +1. Runner startet und synchronisiert Registry-Jobs (`ensureSystemJobs`). +2. Globaler MySQL-Lock wird gesetzt (`scheduled_jobs_runner`). +3. Fällige Jobs (`enabled=1`, `next_run_at <= now`) werden abgearbeitet. +4. Jeder Lauf wird als `success`/`failed`/`skipped` protokolliert. +5. `next_run_at` wird neu berechnet. +6. Heartbeat wird aktualisiert, Lock wird freigegeben. + +## Scheduling + +Unterstützt: + +- `hourly` (1..24) +- `daily` (1..365 + Uhrzeit) +- `weekly` (1..52 + Uhrzeit + Wochentage) + +`catchup_once` steuert Nachholverhalten: + +- `1`: genau ein Nachhol-Lauf, danach normal weiter +- `0`: kein Backlog-Sturm, verpasste Fenster werden übersprungen ## Rechte -- `jobs.view`: Bereich ansehen -- `jobs.manage`: Job-Konfiguration speichern -- `jobs.run_now`: Job manuell ausführen +- `jobs.view`: Übersicht sehen +- `jobs.manage`: Job speichern +- `jobs.run_now`: Job manuell starten +- Purge der Run-Logs: `settings.update` -Purge der Run-Logs bleibt unter `settings.update`. +Registrierte Core-Jobs: -## Betriebsmodell +- `user_lifecycle_run` +- `system_audit_purge` -Cron-Eintrag (Beispiel): +## Neuen Job registrieren (kurz) -```bash -* * * * * docker compose exec php php bin/scheduler-run.php -``` +1. Handler in `lib/Service/Scheduler/Handler/*` erstellen (`ScheduledJobHandlerInterface`). +2. Job-Key + Handler in `ScheduledJobRegistry` ergänzen. +3. Abhängigkeiten in `SchedulerServicesFactory` verdrahten. -Der Runner: - -1. sichert sich globalen Lock (`GET_LOCK('scheduled_jobs_runner', 0)`) – nur ein Runner aktiv -2. holt globale Due-Jobs (`enabled=1`, `next_run_at <= now`) -3. führt Jobs kontrolliert aus -4. schreibt Heartbeat in `scheduler_runtime_status` -5. schreibt Run-Log und aktualisiert Job-Status -6. berechnet `next_run_at` neu -7. gibt den Lock frei - -## Runner-Heartbeat - -- `scheduler_runtime_status` enthält genau eine Zeile (`id = 1`). -- Jeder Aufruf von `bin/scheduler-run.php` aktualisiert diese Zeile: - - `last_heartbeat_at` - - `last_result` (`ok`, `lock_not_acquired`, `unexpected_error`) - - `last_error_code` (optional) -- Dadurch wächst die Tabelle nicht mit der Zeit; sie ist ein laufendes Statusfenster. - -Verwendung in Admin/Stats: - -- Kachel **Cron runner active** ist `Aktiv`, wenn `last_heartbeat_at` jünger als 3 Minuten ist. -- Ist der Heartbeat älter oder fehlt, zeigt die Kachel `Inaktiv`. -- Manuelle Runs aus der UI (`Run now`) zählen nicht als Cron-Liveness. - -## Scheduling (V1) - -Unterstützte Typen: - -- `hourly` (Intervall 1..24) -- `daily` (Intervall 1..365 + Uhrzeit) -- `weekly` (Intervall 1..52 + Uhrzeit + Wochentage) - -`catchup_once`: - -- Wenn ein Lauf verpasst wurde, wird genau ein Run nachgeholt. -- Danach wird normal in die Zukunft weitergeplant (kein Backlog-Sturm). - -## Neuen Job registrieren (Entwickler-Guide) - -### Architektur-überblick - -Jeder Job ist eine eigene Handler-Klasse, die `ScheduledJobHandlerInterface` implementiert. -Die Registry ist eine Map von `job_key` zu Handler-Instanz – sie enthält keine -job-spezifische Logik. - -``` -lib/Service/Scheduler/ - Handler/ - ScheduledJobHandlerInterface.php ← Vertrag für alle Handler - UserLifecycleJobHandler.php ← Referenz-Implementierung - ScheduledJobRegistry.php ← Handler-Map (einzige Datei die bei neuen Jobs angefasst wird) - SchedulerServicesFactory.php ← verdrahtet Abhängigkeiten zentral -``` - -### Schritt-für-Schritt - -**1. Handler-Klasse erstellen** - -Datei: `lib/Service/Scheduler/Handler/MeinJobHandler.php` - -```php - 'Mein Job', - 'description' => 'Kurze Beschreibung was der Job macht', - 'default_enabled' => 1, - 'default_timezone' => defined('APP_TIMEZONE') ? (string) APP_TIMEZONE : 'UTC', - 'default_schedule_type' => 'daily', - 'default_schedule_interval' => 1, - 'default_schedule_time' => '03:00', - 'default_schedule_weekdays_csv' => null, - 'default_catchup_once' => 1, - 'allowed_schedule_types' => ['daily', 'weekly'], - ]; - } - - public function execute(?int $actorUserId): array - { - $result = $this->meinService->run($actorUserId); - - if (!($result['ok'] ?? false)) { - $errorCode = (string) ($result['error'] ?? 'job_failed'); - $status = $errorCode === 'lock_not_acquired' ? 'skipped' : 'failed'; - return [ - 'status' => $status, - 'error_code' => $errorCode, - 'error_message' => null, - 'result' => [], - ]; - } - - return [ - 'status' => 'success', - 'error_code' => null, - 'error_message' => null, - 'result' => [ - 'processed_count' => (int) ($result['processed_count'] ?? 0), - 'duration_ms' => (int) ($result['duration_ms'] ?? 0), - ], - ]; - } -} -``` - -**2. Job in der Registry eintragen** - -Datei: `lib/Service/Scheduler/ScheduledJobRegistry.php` - -```php -// Konstante hinzufügen: -public const MEIN_JOB = 'mein_job'; - -// In __construct() eine Zeile hinzufügen: -$this->handlers = [ - self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService), - self::MEIN_JOB => new MeinJobHandler($meinService), // ← neu -]; -``` - -**3. Factory-Verdrahtung ergänzen** - -Datei: `lib/Service/Scheduler/SchedulerServicesFactory.php` - -- `MeinService` instanziieren bzw. bereitstellen -- beim Erzeugen von `ScheduledJobRegistry` den Service mit übergeben - -Danach legt `ensureSystemJobs()` den Job beim nächsten Scheduler-Aufruf automatisch -in der Datenbank an. - -### Regeln für Handler - -**`definition()`** -- Kein `job_key`-Feld – der Array-Key in der Registry-Map ist der Job-Key. -- `default_timezone`: Am besten `APP_TIMEZONE`-Konstante verwenden, Fallback `'UTC'`. -- `allowed_schedule_types`: Nur Typen erlauben, die der Job sinnvoll unterstützt. - Ein Job der stundlich keinen Sinn ergibt, sollte `hourly` nicht listen. - -**`execute()`** -- Muss immer ein Array mit `status`, `error_code`, `error_message`, `result` zurückgeben. -- `status` darf nur `'success'`, `'failed'` oder `'skipped'` sein. -- `error_message` darf maximal 255 Zeichen lang sein (VARCHAR-Limit in DB). -- `result` wird als JSON im Run-Log gespeichert – nur JSON-serialisierbare Werte. -- Wenn der zugrundeliegende Service einen eigenen Lock hat und `lock_not_acquired` meldet, - sollte der Handler `status = 'skipped'` zurückgeben (kein echter Fehler). - -**Namenskonvention** -- Dateiname: `MeinJobHandler.php` (PascalCase + `Handler`-Suffix) -- Konstante in Registry: `SCREAMING_SNAKE_CASE` (z.B. `AUDIT_PURGE_RUN`) -- Job-Key in DB: `snake_case` (z.B. `audit_purge_run`) - -### Referenz-Implementierung - -`lib/Service/Scheduler/Handler/UserLifecycleJobHandler.php` ist die vollständige -Referenz-Implementierung mit internem Service-Lock, Fehlerbehandlung und -job-spezifischem `result`-Payload. Der Objektgraph wird über -`lib/Service/Scheduler/SchedulerServicesFactory.php` gebaut. - ---- - -## Registrierte Jobs - -| job_key | Handler-Klasse | Default-Schedule | -|----------------------|-----------------------------|---------------------| -| `user_lifecycle_run` | `UserLifecycleJobHandler` | täglich 02:15 | - ---- +Danach wird der Job bei `ensureSystemJobs()` automatisch in `scheduled_jobs` angelegt. ## Troubleshooting -- `lock_not_acquired`: - - Ein anderer Runner läuft bereits. -- Job bleibt auf `running`: - - stale-running Schutz erlaubt nach 2 Stunden wieder neue Runs (Schwellwert in `ScheduledJobRepository->markRunning`). -- Kein fälliger Job: - - `next_run_at`, `enabled`, `timezone` und schedule Konfiguration prüfen. +- `lock_not_acquired`: ein anderer Runner läuft. +- Job bleibt `running`: stale-running Schutz greift nach 2 Stunden. +- Job läuft nicht: `enabled`, `next_run_at`, `timezone`, Schedule prüfen. diff --git a/docs/globale-suche.md b/docs/globale-suche.md index 1fe0e7a..11b0b01 100644 --- a/docs/globale-suche.md +++ b/docs/globale-suche.md @@ -1,149 +1,71 @@ # Globale Suche erweitern -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-24 -Diese Doku erklärt, wie neue Entitäten sauber in die globale Suche integriert werden. +## Ziel -## Überblick +Neue Search-Resource korrekt in Aside-Preview und Vollsuche integrieren. -Es gibt zwei Suchausgaben mit derselben Datenquelle: +## Architektur -1. **Aside-Preview** (`admin/search/data`) - - zeigt je Resource Count + bis zu 5 Preview-Einträge. -2. **Vollseite Suche** (`search/data` -> `/search`) - - zeigt gemischte Trefferliste über alle Resources. +Zwei Endpunkte teilen dieselbe Konfiguration: -Die zentrale Fassade liegt in: +- Aside-Preview: `pages/admin/search/data().php` +- Vollsuche: `pages/search/data().php` -- `/lib/Support/SearchConfig.php` +Zentrale Fassade: -Die eigentliche Logik ist in Provider aufgeteilt: +- `lib/Support/SearchConfig.php` +- Laufzeit-Orchestrierung: `lib/Service/Search/SearchDataService.php` +- SQL-Ausführung: `lib/Repository/Search/SearchQueryRepository.php` -- `/lib/Support/Search/SearchSqlResourceProvider.php` -- `/lib/Support/Search/SearchUiMetaProvider.php` -- `/lib/Support/Search/SearchItemMapperProvider.php` -- `/lib/Support/Search/SearchSpecialResourceProvider.php` -- `/lib/Support/Search/SearchQueryNormalizer.php` +Provider: -## Relevante Dateien +- SQL-Definitionen: `SearchSqlResourceProvider` +- UI-Meta (Icons/URLs): `SearchUiMetaProvider` +- Mapping: `SearchItemMapperProvider` +- Spezialquellen (`docs`, `hotkeys`): `SearchSpecialResourceProvider` -- `/lib/Support/SearchConfig.php` - - stabile Orchestrierungs-Fassade (kompatible Methoden nach außen) -- `/lib/Support/Search/SearchSqlResourceProvider.php` - - Resource-Definitionen (SQL + Permission + Label) + Tenant-Filter -- `/lib/Support/Search/SearchUiMetaProvider.php` - - URL- und Icon-Mapping pro Resource-Key -- `/lib/Support/Search/SearchItemMapperProvider.php` - - Mapping SQL-Row -> Preview-/Result-Item -- `/lib/Support/Search/SearchSpecialResourceProvider.php` - - Spezialressourcen (`docs`, `hotkeys`) -- `/lib/Support/Search/SearchQueryNormalizer.php` - - Query-Normalisierung für LIKE/Score -- `/pages/admin/search/data().php` - - Aside-Preview API (Counts + Preview) -- `/pages/search/data().php` - - Volltext-Treffer API -- `/templates/partials/app-main-aside.phtml` - - Sichtbarer Eintrag im Such-Panel (`data-search-key=...`) -- `/web/js/components/app-global-search.js` - - Render-Logik (generisch; meist keine Anpassung nötig) -- `/lib/Service/Docs/DocsCatalogService.php` - - Zentrale Katalog-/Anchor-Logik für Entwicklerdoku (Single Source of Truth) +## Laufzeitpfad -## Standard-Vorgehen für neue Resource +1. `pages/admin/search/data().php` oder `pages/search/data().php` +2. `SearchDataService` baut Scope/Permission + Resource-Iteration +3. `SearchQueryRepository` führt `countSql`/`previewSql`/`resultSql` aus +4. `SearchConfig` mapped auf API/UI-Items -### 1) Resource in `SearchSqlResourceProvider::resources()` anlegen +## Standardablauf für neue Resource -Neue Struktur mit: +1. Resource in `SearchSqlResourceProvider::resources()` ergänzen: + - `key`, `label`, `permission` + - `countSql`, `previewSql`, `resultSql` +2. Bei Tenant-Daten `tenantScopeFilters()` ergänzen. +3. UI ergänzen: + - `iconForKey()` + - `listUrl()` +4. Mapping ergänzen: + - `mapPreviewItem()` + - `mapResultItem()` +5. Aside-Eintrag in `templates/partials/app-main-aside.phtml` ergänzen (`data-search-key`). +6. i18n-Labels prüfen. -- `key` (z. B. `scheduled-jobs`) -- `label` (übersetzbar via `t(...)`) -- `permission` (z. B. `PermissionService::JOBS_VIEW`) -- `countSql` + `countParams` -- `previewSql` + `previewParams` -- `resultSql` + `resultParams` +## Wichtige Regeln -Regeln: +- Permission immer serverseitig in der Resource setzen. +- Tenant-Scope nicht nur im UI lösen. +- Preview immer mit `limit ?` und ohne sensitive Daten. -- SQL immer mit `... like ? escape '\\'`. -- Bei tenant-sensitiven Entitäten `{{tenantFilter}}` nutzen und in `SearchSqlResourceProvider::tenantScopeFilters()` ergänzen. -- Preview-Query immer mit `limit ?`. +## Docs-Resource -### 2) URL-/Icon-/Mapping ergänzen +Die Entwicklerdoku läuft als Spezialresource `docs`: -- `SearchUiMetaProvider::iconForKey()` erweitern -- `SearchUiMetaProvider::listUrl()` erweitern -- `SearchItemMapperProvider::mapPreviewItem()` ergänzen (falls Spezialformat nötig) -- `SearchItemMapperProvider::mapResultItem()` ergänzen (falls Spezialformat nötig) - -Ziel: - -- Preview und Volltreffer sollen sprechende Labels zeigen. -- URLs sollen direkt auf sinnvolle Zielseiten gehen. - -### 3) Eintrag im Aside-Search-Panel anlegen - -In `/templates/partials/app-main-aside.phtml`: - -- `
  • ` ergänzen -- Anzeige per Permission guarden (z. B. `$canViewJobs`) - -Wichtig: - -- `data-search-key` muss exakt dem `key` in `SearchConfig` entsprechen. - -### 4) i18n prüfen - -Neue Labels in: - -- `/i18n/default_de.json` -- `/i18n/default_en.json` - -## Sicherheits- und Qualitätsregeln - -- Permission in `SearchConfig` setzen, nicht nur im Aside. -- Tenant-Scope für tenant-bezogene Daten nicht vergessen. -- Keine sensiblen Daten im Preview-Label. -- Bei optionalen Modulen nur dann Resource aktivieren, wenn Schema vorhanden ist (oder Migration als Pflicht voraussetzen). - -## Kurzes Beispiel: `scheduled-jobs` - -Integration umfasst: - -- Resource in `SearchSqlResourceProvider::resources()` -- Mapping auf `admin/scheduled-jobs/edit/{id}` -- Icon `bi-calendar-check` -- Aside-Search-Item mit `data-search-key="scheduled-jobs"` -- Permission `jobs.view` - -## Doku-Resource (`docs`) im System - -Die Entwicklerdoku ist als eigene Search-Resource integriert: - -- Key: `docs` +- Quelle: `DocsCatalogService::search()` - Permission: `docs.view` -- Quelle: `docs/*.md` (über `DocsCatalogService`, kein `openapi.yaml`) -- Aside: eigener Block `Documentation` mit Count + Preview -- Vollsuche: Treffer vom Typ `Documentation` -- Ziel-URLs: `admin/docs/{slug}#anchor` (direkter Abschnitt) +- Ziel: `admin/docs/{slug}#anchor` -Wichtig: +## Check vor Merge -- Anchor-Ermittlung für Docs-Seite und Suche läuft über denselben Service. -- Dadurch bleiben Hash-Links stabil und konsistent. - -## Checkliste vor Merge - -1. `php -l /lib/Support/SearchConfig.php` -2. `php -l /lib/Support/Search/*.php` -3. `php -l /templates/partials/app-main-aside.phtml` -3. Global Search manuell testen: - - Sidebar-Suche zeigt Count + Preview - - Klick auf Resource öffnet richtige Liste - - `/search?search=...` zeigt Volltreffer -4. Permission-Test: - - ohne Permission kein Eintrag/keine Treffer -5. Tenant-Scope-Test (falls relevant) -6. Bei `docs` zusätzlich: - - Preview-Link springt auf richtigen Abschnitt - - Vollsuche zeigt `Documentation`-Treffer mit sinnvollem Snippet +1. `php -l lib/Support/SearchConfig.php` +2. `php -l lib/Support/Search/*.php` +3. Preview testen (`admin/search/data?q=...`) +4. Vollsuche testen (`/search?search=...`) +5. Permission/Scope mit reduziertem User prüfen diff --git a/docs/importe.md b/docs/importe.md index d7e1e8c..9d0dc4a 100644 --- a/docs/importe.md +++ b/docs/importe.md @@ -1,88 +1,63 @@ # Importe -Letzte Aktualisierung: 2026-02-22 +Letzte Aktualisierung: 2026-02-24 -Diese Seite beschreibt den V1 CSV-Import unter `/admin/imports`. +## Ziel -## Technischer Aufbau +CSV-Import (V1) für `users` und `departments` kurz und belastbar dokumentieren. -- Das Import-Modul ist instanzbasiert über `lib/Service/Import/ImportServicesFactory.php` verdrahtet. -- `ImportService` orchestriert Analyze/Preview/Commit. -- `ImportStateStoreService` kapselt den tokenbasierten Session-State (`admin_imports_v1`, TTL 2h). -- `CsvReaderService` und `ImportTempFileService` sind eigene Instanzservices. -- Import-Audit läuft über `ImportAuditService` + `ImportAuditRunRepository`. +## Architektur + +- Einstieg: `/admin/imports` +- Orchestrierung: `ImportService` +- Upload/Temp: `ImportTempFileService` +- CSV-Analyse/Stream: `CsvReaderService` +- State (tokenbasiert, Session): `ImportStateStoreService` +- Audit: `ImportAuditService` ## Scope (V1) -- Profile: `users`, `departments`. -- Create-only: bestehende Einträge werden übersprungen. -- CSV mit `,` oder `;`, UTF-8 (BOM wird toleriert). -- Limits: 10MB und 20.000 Datenzeilen. -- Fehlerausgabe nur in der UI (kein Download in V1). +- Profile: `users`, `departments` +- Create-only: bestehende Datensätze werden übersprungen +- CSV-Delimiter: `,` oder `;` +- BOM wird toleriert +- Limits: + - 10 MB Upload + - 20.000 Datenzeilen + - max. 500 Fehlerzeilen in der UI -## Permissions +## Berechtigungen -- `imports.view`: Zugriff auf die Import-Seite. -- `imports.audit.view`: Zugriff auf Import-Protokolle unter `/admin/import-audit`. -- `users.import`: User-Import ausführen. -- `users.import_assignments`: Tenant/Role/Department aus CSV verwenden. -- `departments.import`: Department-Import ausführen. +- `imports.view` +- `users.import` +- `departments.import` +- `users.import_assignments` (nur wenn Assignment-Spalten gemappt werden) +- `imports.audit.view` (Audit lesen) +- `settings.update` (Audit-Purge) ## Ablauf -1. Upload - - Entität wählen (`Users` oder `Departments`) und CSV hochladen. - - Header/Delimiter/Zeilenlimit werden direkt geprüft. -2. Mapping - - CSV-Header auf Zielfelder mappen. - - Pflichtziele sind profilabhängig. -3. Preview (Dry run) - - Keine DB-Schreibvorgänge. - - Summary + erste Fehler (maximal 500 Einträge). -4. Commit - - Zeilenweise Verarbeitung (`best effort`). - - Gültige Zeilen werden erstellt, fehlerhafte reportet. +1. Analyze: Upload + Header/Delimiter/Limit prüfen. +2. Mapping: CSV-Spalten auf Zielfelder mappen. +3. Preview: Dry-Run ohne DB-Write. +4. Commit: zeilenweise Verarbeitung mit Fehleraggregation. -## Assignment-Regeln +## Assignment-Regeln (wichtig) -- Users: optional `tenant`, `role`, `department` (jeweils 1:1). -- Departments: optional `tenant`. -- Identifier-Auflösung: - - Users: UUID zuerst, ID als Fallback. - - Departments: `tenant` ist UUID-only (kein ID-Fallback). -- Nur aktive Datensätze sind gültig. -- Department muss zum Tenant passen. -- Fehlen Assignment-Werte, greifen Defaults aus Settings. -- Ohne `users.import_assignments` wird ein Mapping mit Assignment-Spalten sofort abgelehnt. +- Users: optionale `tenant`/`role`/`department`-Zuordnung. +- Users: Auflösung über UUID, numerische ID als Fallback. +- Departments: `tenant` ist UUID-only. +- Ohne gültige Assignment-Werte greifen Defaults aus Settings. +- Out-of-scope Tenants werden blockiert (`assignment_out_of_scope`). -## Duplikate und Skip-Verhalten +## Temp-Dateien und State -- In-File-Duplikate: erste Zeile gewinnt, spätere `duplicate_in_file`. -- Users: - - bestehende E-Mail: `email_exists` -- Departments: - - bestehender Code: `code_exists` - - bestehende Kombination aus Tenant + Description: `department_exists` +- Temp-Pfad: `APP_STORAGE_PATH/imports/tmp` +- Alte Temp-Dateien werden opportunistisch abgeräumt (24h) +- Import-State-Token läuft nach 2h ab -## Temporäre Dateien +## Audit -- Uploads liegen unter `APP_STORAGE_PATH/imports/tmp`. -- Dateinamen werden randomisiert. -- Cleanup entfernt alte Temp-Dateien opportunistisch (älter als 24h). - -## Import-Audit (V1) - -- Pro Commit-Lauf wird ein Audit-Run gespeichert (`import_audit_runs`). -- Analyze/Preview werden bewusst nicht protokolliert. -- Gespeichert werden nur Metadaten: - - Profil (`users`/`departments`), Status (`success`/`partial`/`failed`) - - Laufzeit, Counter (`rows_total`, `created`, `skipped`, `failed`) - - User, aktueller Tenant, Dateiname (basename), gemappte Felder - - aggregierte Fehlercodes (kein Zeileninhalt) -- Keine CSV-Zeilenpayload oder PII-Details im Audit. -- Retention: 90 Tage, bereinigbar über `/admin/import-audit` (Purge-Action mit `settings.update`). - -## Fehlercodes (V1) - -- Struktur: `upload_missing`, `upload_too_large`, `invalid_file_type`, `invalid_csv_header`, `empty_csv`, `max_rows_exceeded`, `mapping_required_missing`, `assignment_permission_required` -- Zeilenvalidierung: `invalid_email`, `invalid_tenant_uuid`, `duplicate_in_file`, `email_exists`, `code_exists`, `department_exists`, `invalid_locale`, `invalid_active`, `invalid_hire_date`, `tenant_not_found`, `role_not_found`, `department_not_found`, `department_tenant_mismatch`, `assignment_out_of_scope`, `create_failed`, `unexpected_error` +- Nur Commit-Läufe werden protokolliert (nicht Analyze/Preview). +- Gespeichert: Metadaten, Counter, Fehlercodes, User/Tenant-Kontext. +- Retention: 90 Tage. diff --git a/docs/index.md b/docs/index.md index e9db475..ad38ec8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,75 +1,93 @@ # Dokumentation -Letzte Aktualisierung: 2026-02-23 +Letzte Aktualisierung: 2026-03-04 -Diese Dokumente sind für Entwickler gedacht, die die Anwendung warten oder erweitern. +Diese Dokumentation folgt einem klaren Lernpfad (einfach -> fortgeschritten) und trennt Lernen von Nachschlagen. -## Start hier (neue Entwickler) +## Lernpfad (chronologisch) -1. `/docs/erste-schritte.md` - - Setup in 30-45 Minuten, erster Login, erster Smoke-Test. -2. `/docs/lokale-entwicklung.md` - - Täglicher Workflow, Schema-Updates und Qualitätschecks. -3. `/docs/erste-aenderung.md` - - Schritt-für-Schritt für den ersten kleinen Code-Change. -4. `/docs/dokumentation-erweitern.md` - - Wie neue Doku-Dateien sauber erstellt, einsortiert und suchbar gemacht werden. -5. `/docs/entwickler-checkliste.md` - - Finale Checkliste vor Merge/Review. +1. `/docs/00-systemueberblick.md` + - Produktbild, Kernmodule und Lernziel in 10 Minuten. +2. `/docs/01-setup-und-erster-login.md` + - Lokales Setup, erster Login, erster Smoke-Test. +3. `/docs/02-domain-glossar.md` + - Zentrale Begriffe (Tenant, Role, Permission, Scope, Policy). +4. `/docs/03-architektur-request-flow.md` + - Request-Flow, Schichtmodell, Do/Don't. +5. `/docs/04-erste-aenderung-ui.md` + - Erste sichere UI-Änderung im Admin-Bereich. +6. `/docs/05-erste-aenderung-backend.md` + - Erste Backend-/Service-/Repository-Änderung. +7. `/docs/06-security-tenant-scope.md` + - Sicherheitsgrundlagen, Tenant-Scope, Pflichtchecks. +8. `/docs/07-api-erweitern.md` + - API-Änderung sauber mit OpenAPI und Tests umsetzen. +9. `/docs/08-advanced-scheduler-sso.md` + - Fortgeschrittene Themen: Scheduler, Lifecycle, SSO. -## Architektur und Regeln +## Konzepte (Explanation) - `/docs/architektur.md` - - Startpunkt mit Glossar, Zielbild, Schichtregeln und Sicherheitsleitplanken. + - Architektur-Kompass mit verbindlichen Leitplanken. +- `/docs/di-container.md` + - Wie der AppContainer funktioniert und neue Services registriert werden. - `/docs/sicherheitsmodell.md` - - Auth, Permissions, Tenant-Scope, Public/API-Zugriffe. -- `/docs/konventionen.md` - - Kurze, verbindliche Arbeitsregeln für Code, API und Qualität. -- `/docs/lib-standards.md` - - Konkrete Do/Don't-Regeln für `lib/**`. - -## Frontend - -- `/docs/frontend-javascript.md` - - Frontend-JS-Aufbau, Init-Flow, Grid-Integration und URL-State-Sync. -- `/docs/frontend-css.md` - - CSS-Struktur, Layering, Vendor-Overrides und Asset-Einbindung. - -## Fach- und Feature-Dokumentation - -- `/docs/benutzerdefinierte-felder.md` - - Tenant/User-Zusatzfelder (Typen, Pflege, Filterbarkeit). -- `/docs/importe.md` - - CSV-Import (V1), Mapping, Fehlercodes, Permission-Modell und Import-Audit. + - Vollständiges Sicherheitsmodell (Auth, Permission, Scope). - `/docs/einstellungen-speicherung.md` - - Source of truth für Settings (DB) und Datei-Cache-Verhalten. -- `/docs/anfragelimits.md` - - Login/API-Limits, Scopes, Block-Verhalten. -- `/docs/benutzer-lifecycle-policy.md` - - Automatische Benutzer-Deaktivierung/Löschung (Policy, Cron, manueller Run). -- `/docs/geplante-aufgaben.md` - - Zentrale geplante Aufgaben (Scheduler), Job-Konfiguration und Runner-Betrieb. -- `/docs/globale-suche.md` - - Globale Suche erweitern (Resource, Permission, Mapping, Aside-Integration). + - Warum Settings in DB + Datei-Cache koexistieren. -## API Referenz +## Aufgabenanleitungen (How-to) + +- `/docs/globale-suche.md` + - Neue Resource in der globalen Suche integrieren. +- `/docs/importe.md` + - Importfluss, Regeln und Fehlercodes. +- `/docs/benutzerdefinierte-felder.md` + - Tenant/User-Zusatzfelder korrekt erweitern. +- `/docs/geplante-aufgaben.md` + - Neue Scheduler-Jobs registrieren und betreiben. +- `/docs/auth-sso-smoke-test.md` + - SSO-Funktionalität manuell verifizieren. +- `/docs/anfragelimits.md` + - Rate-Limits verstehen und testen. +- `/docs/rbac-permissions-playbook.md` + - RBAC-Rechte sauber erweitern (neue Permission, neue Ability, UI/API-Fälle). +- `/docs/request-input-validation.md` + - Einheitliches Request/Input- und Validation-Muster für Web + API. + +## Referenz (Reference) - `/docs/api.md` - - API Quick Reference, Auth, Flows, Beispielaufrufe. -- `/docs/openapi.yaml` - - API Source of truth für Swagger UI. - -## Betriebswissen - + - API-Referenz-Einstieg. - `/docs/konfiguration.md` - - Alle ENV-Variablen mit Beschreibung, Standardwerten und Produktions-Checkliste. -- `/docs/fehlerbehebung.md` - - Häufige Fehlerbilder und schnelle Lösungen. -- `/docs/auth-sso-smoke-test.md` - - Manueller Smoke-Runbook für Login, Microsoft-Start/Callback, Tenant-SSO-Save und Logout. + - ENV-Variablen und Produktions-Defaults. +- `/docs/konventionen.md` + - Projektweite Konventionen (kurz und verbindlich). +- `/docs/lib-standards.md` + - Regeln speziell für `lib/**`. +- `/docs/benutzer-lifecycle-policy.md` + - Policy- und Laufzeitreferenz für User Lifecycle. + +## Betrieb (Operations) + - `/docs/docker-lokal.md` - - Schneller lokaler Docker-Start inkl. typischer Befehle und Troubleshooting. + - Lokaler Docker-Betrieb. - `/docs/docker-produktiv.md` - - Produktionsbetrieb mit Domain, TLS und `docker-compose.prod.yml`. + - Produktivbetrieb mit Docker. - `/docs/docker-betrieb.md` - - Einstieg und Navigation zwischen lokaler und produktiver Docker-Doku. + - Übersicht und Entscheidungshilfe für Dev/Prod. +- `/docs/betriebscheck-doctor.md` + - Schneller Systemcheck mit `bin/doctor.php`. +- `/docs/fehlerbehebung.md` + - Häufige Fehler und schnelle Lösungen. + +## Mitwirken (Contributor Guide) + +- `/docs/lokale-entwicklung.md` + - Tages-Workflow für Entwicklung und Checks. +- `/docs/entwickler-checkliste.md` + - Definition of Done vor Merge. +- `/docs/codex-prompts.md` + - Standard-Prompts für Skill-basierte Planung und Implementierung. +- `/docs/dokumentation-erweitern.md` + - Regeln für konsistente, wachsende Doku. diff --git a/docs/konfiguration.md b/docs/konfiguration.md index 9c3cb48..2954fa5 100644 --- a/docs/konfiguration.md +++ b/docs/konfiguration.md @@ -15,6 +15,7 @@ Vorlage: `.env.example` — niemals echte Credentials committen. | `APP_URL` | `http://localhost:8080` | Basis-URL der Anwendung (für Links in E-Mails und Redirects) | | `APP_ENV` | `dev` | Laufzeitumgebung (`dev` oder `prod`) — beeinflusst Fehlerausgabe und Caching | | `APP_DEBUG` | `true` | Debug-Modus aktivieren (`true`/`false`) — in Produktion auf `false` setzen | +| `APP_CONFIG_VALIDATE` | `true` | Boot-Validierung für ENV-Konfiguration aktivieren/deaktivieren (`true`/`false`) | | `APP_TIMEZONE` | `Europe/Berlin` | PHP-Zeitzone für `date_default_timezone_set()` | | `APP_STORAGE_PATH` | `./storage` | Pfad zum Storage-Verzeichnis für Uploads und Caches | | `APP_CRYPTO_KEY` | _(leer)_ | Schlüssel für symmetrische Verschlüsselung (64-stelliger Hex-Key **oder** Base64 mit 32 Byte) — **Pflichtfeld in Produktion** | @@ -101,3 +102,29 @@ Folgende Variablen **müssen** vor Go-Live angepasst werden: - [ ] `FIREWALL_REVERSE_PROXY=true` falls hinter Nginx/Traefik - [ ] Alle DB-Credentials auf sichere Werte setzen - [ ] Alle SMTP-Credentials auf echte Werte setzen + +--- + +## Boot-Validierung (Fail-Fast) + +Beim Start validiert die Anwendung die zentrale ENV-Konfiguration sofort in `config/config.php`. +Bei fehlenden oder ungültigen Pflichtwerten stoppt der Boot-Prozess mit klarer Fehlermeldung. + +Geprüft werden unter anderem: + +- Pflicht-Keys wie `APP_ENV`, `DB_*`, `CACHE_SERVERS`, `SESSION_NAME`, `FIREWALL_*` +- Typen/Formate (`bool`, `int`, `float`, `timezone`, `URL`) +- Konsistenz (`APP_LOCALE` muss in `APP_LOCALES` enthalten sein) +- Production-Regeln: + - `APP_URL` muss gesetzt sein + - `APP_CRYPTO_KEY` muss gesetzt und gültig sein (64 Hex oder Base64/32 Byte) + - `APP_DEBUG=false` + - `TENANT_SCOPE_STRICT=true` + +Nur für Notfälle kann die Validierung temporär abgeschaltet werden: + +```env +APP_CONFIG_VALIDATE=false +``` + +Empfehlung: im Normalbetrieb immer `true` lassen. diff --git a/docs/konventionen.md b/docs/konventionen.md index bcf04dc..d7d5fae 100644 --- a/docs/konventionen.md +++ b/docs/konventionen.md @@ -1,6 +1,6 @@ # Konventionen (kurz und verbindlich) -Letzte Aktualisierung: 2026-02-23 +Letzte Aktualisierung: 2026-02-26 ## 1) Code @@ -14,6 +14,18 @@ Letzte Aktualisierung: 2026-02-23 - Fachlogik nur in `lib/Service/**`. - `pages/**` bleibt dünn: Input, Auth/AuthZ, Service-Aufruf, Response. - Neue Domain-Logik als Instanz-Service mit Factory-Verdrahtung. +- Service-Instanzen in Actions/Helpern nur via `app(Foo::class)`. +- `new ...Factory()` nur im Composition Root (`web/index.php` + `lib/App/registerContainer.php`) oder in `*Factory.php`. +- In `lib/Service/**` außerhalb von `*Factory.php` keine direkten `new ...Factory()`, `new ...Service()`, `new ...Gateway()` oder `new ...Repository()`. +- Alte Service-Helper (`accessServicesFactory()`, `userServicesFactory()` usw.) nicht mehr verwenden. +- `pages/**/data().php` nur GET (`gridRequireGetRequest()`), Query-Normalisierung ausschließlich über `gridParseFilters(...)`. +- Für jede Liste ein `filter-schema.php` im selben Verzeichnis; `data().php`, `index().php` und `index(default).phtml` verwenden dieses Schema. +- Filter-Toolbar nur über `renderGridFilterToolbar(...)`, keine manuellen Filter-Inputs/-Selects. +- Client-Filterbindung nur über `gridFiltersFromSchema(...)` (kein inline `filters: [...]`). +- Für Listen gilt Filter UX 2.0: Quick Search sichtbar, restliche Filter im Drawer, aktive Filter-Chips. +- Search-only-Listen (nur Quick Search) verwenden keinen Drawer und bleiben im Live-Modus. +- `data-filter-overflow`, `data-filter-toggle` und `data-toolbar-toggle` sind Legacy und in Listen-Templates nicht mehr erlaubt. +- Listen-IDs nur als CSV-Parameter mit pluralem Key (`*_ids`), keine Alias-Fallbacks. ## 3) Autorisierung @@ -38,4 +50,4 @@ Letzte Aktualisierung: 2026-02-23 - `docker compose exec php vendor/bin/phpunit` - `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress` -- bei JS-Änderungen: `npx eslint web/js` +- bei JS-Änderungen: Browser-Smoke mit DevTools-Console (keine JS-Errors) diff --git a/docs/lib-standards.md b/docs/lib-standards.md index 6cb725b..cf427d8 100644 --- a/docs/lib-standards.md +++ b/docs/lib-standards.md @@ -1,6 +1,6 @@ # lib-Standards -Letzte Aktualisierung: 2026-02-23 +Letzte Aktualisierung: 2026-03-03 Praktische Regeln nur für `lib/**`. Ergänzt `docs/architektur.md`. @@ -14,26 +14,114 @@ Praktische Regeln nur für `lib/**`. Ergänzt `docs/architektur.md`. - Verantwortlich für Fachlogik und Orchestrierung. - Keine Superglobals (`$_POST`, `$_GET`, `$_SESSION`). - Keine Header- oder Redirect-Ausgabe. +- Keine direkten `DB::...`-Aufrufe im Service. - Abhängigkeiten per Constructor Injection. +- DB-Locks und Transaktionen über Repository-Schicht (z. B. `DatabaseSessionRepository`). +- Audit-Hooks für fachliche Schreibaktionen laufen in Services (`SystemAuditService`), nicht in Templates. +- API-Request-System-Audit läuft zentral über `ApiSystemAuditReporter` (Lifecycle in `ApiBootstrap` + `ApiResponse`), nicht pro Endpoint. +- Scheduler-Run-/Job-Ereignisse werden zentral im `SchedulerRunService` in `SystemAuditService` geschrieben. +- CLI-System-Audit für zentrale Commands wird am Entrypoint (`bin/**`) geschrieben, mit minimaler Metadata (ohne Argumentwerte). ## 3) Instanziierung -- Domain-Abhängigkeiten mit `new` nur in Factories/Bootstrap. +- Composition Root ist `web/index.php` + `lib/App/registerContainer.php` + (→ Details: `/docs/di-container.md`). +- Domain-Abhängigkeiten mit `new` nur im Composition Root und in `*Factory.php`. +- In `pages/**`, `templates/**`, `lib/Support/**` und Gateway-/Service-Fallbacks nur über `app(Foo::class)` auflösen. +- In Actions bevorzugt direkte Service/Gateway-Bindings (`app(Service::class)`) statt `app(...Factory::class)->create...()`. +- In `pages/admin/**` keine `Factory::class`-Referenzen; nur direkte `app(Service::class)` / `app(Repository::class)`. +- In `lib/Service/**` außerhalb von `*Factory.php` keine direkten `new ...Factory()`. +- In `lib/Service/**` außerhalb von `*Factory.php` keine direkten `new ...Service()`, `new ...Gateway()` oder `new ...Repository()`. +- Legacy-Service-Helper (`accessServicesFactory()`, `userServicesFactory()` usw.) werden nicht mehr verwendet. - Für neue Domain-Logik keine statischen Utility-Klassen. +### 3.1) Schnell-Gates (lokal) + +```bash +(rg 'new\s+[^\s(]+Factory\(' lib/Service || true) | wc -l +(rg --glob '!**/*Factory.php' 'new\s+[^\s(]+(Service|Gateway|Repository)\(' lib/Service || true) | wc -l +(rg "\b(accessServicesFactory|directoryServicesFactory|userServicesFactory|authServicesFactory|tenantServicesFactory|settingServicesFactory|userRepositoryFactory|authRepositoryFactory|authGatewayFactory)\(" pages lib templates web || true) | wc -l +(rg -n 'new\s+[^\s(]+(Service|Gateway|Repository)\(' pages -g '*.php' || true) | wc -l +(rg -n '\bDB::' pages -g '*.php' || true) | wc -l +(rg -n 'app\([^\n]*Factory::class\)->create' pages/admin -g '*.php' || true) | wc -l +(rg -n 'Factory::class' pages/admin -g '*.php' || true) | wc -l +``` + +Erwartung jeweils: `0`. + +### 3.2) Architektur-Contract-Test + +- Verbindlicher Guardrail: `tests/Architecture/CoreStarterkitContractTest.php` +- Deckt die Hard-Cut-Regeln ab: + - keine direkten `new ...Service|Gateway|Repository` in `pages/**` + - kein `DB::` in `pages/**` + - keine `app(...Factory::class)->create...`-Ketten in `pages/admin/**` + - keine Input-Superglobals in `pages/**` + - kein `ApiResponse::readJsonBody(...)` in `pages/api/v1/**` + ## 4) Autorisierung und Scope - Zentral über `AuthorizationService` und Policy-Abilities. - Keine verteilten Einzelchecks als primäre Zugriffslogik. +- UI-Capabilities zentral über `UiAccessService` auflösen. +- `$viewAuth['layout']` wird zentral im Front Controller aufgebaut (`web/index.php`), nicht in Templates. +- Templates nutzen nur `$viewAuth['layout']` bzw. `$viewAuth['page']`, keine `can('...')`-Helper. -## 5) API-Contract +### 4.1) Erweiterung neuer Permissions (Starter-Kit) + +1. Permission-Key in `PermissionService` + Migration/Seed ergänzen. +2. Ability in passender Policy ergänzen (oder neue Operation-Ability). +3. Bei UI-Bedarf Mapping zentral in `UiCapabilityMap` ergänzen. +4. Action setzt benötigte `$viewAuth['page']`-Flags. +5. Template konsumiert nur `$viewAuth`. +6. Architektur-/Service-Tests und Doku aktualisieren. + +Details und Sonderfälle: `/docs/rbac-permissions-playbook.md` + +## 5) `data().php`-Standard + +- `pages/**/data().php` sind ausschließlich GET-Endpunkte (`gridRequireGetRequest()`). +- Query-Normalisierung zentral über `gridParseFilters($query, gridSchemaQuery($filterSchema))`. +- Pro Liste gibt es ein `filter-schema.php` im selben Verzeichnis (Single Source of Truth). +- Schema-Typen: `int`, `number`, `bool`, `string`, `enum`, `uuid`, `date`, `csv_ids`, `csv_uuid`, `csv_strings`, `order`, `dir`. +- Keine Alias-Parameter (`*_id`) in Listen-Querys; IDs immer als CSV über `*_ids`. +- Bei JSON-Listenantworten immer konsistent: `{'data': [...], 'total': }`. +- Für MultiSelect-Filter in Listen: + - Toolbar-Rendering zentral über `renderGridFilterToolbar(...)`. + - Select-/MultiSelect-/Text-/Date-/Hidden-Filter werden aus dem Schema gerendert (kein dupliziertes HTML in Templates). + - Grid-Filterbindung clientseitig zentral über `gridFiltersFromSchema(...)`. + - Grid-Filter verwenden CSV-Parameter (z. B. `event_types`, `actor_user_ids`), keine `[]`-Query-Arrays. + - Repository-Filter über `IN (???)` und serverseitige Limitierung/Validierung. + +## 5.1) Status/Outcome-Taxonomien (Backed Enums) + +- Persistierte Status-/Outcome-Domänen liegen zentral in `lib/Domain/Taxonomy/*`. +- Jede Taxonomie liefert einheitlich: `values()`, `tryNormalize()`, `normalizeOr()`, `labelToken()`, `badgeVariant()`. +- Filter-Schemas lesen Allowed-Listen aus `Enum::values()` oder `gridEnumSanitizer(Enum::class)`. +- Data-Endpunkte erzeugen Badge/Label über Enum-Cases, nicht über lokale `if/elseif`-Mappings. +- In Services/Repositories keine verstreuten Literal-Listen für diese Domänen (`success`, `failed`, ...). +- DB-seitig sind die gleichen Domänen per `CHECK`-Constraints abgesichert (`db/init/init.sql` + `db/updates/2026-03-03-status-taxonomy-hard-cut.sql`). + +## 6) API-Contract - Extern UUID-first. - Interne IDs nur intern auflösen und mappen. -## 6) Definition of Done für `lib/**` +## 7) Input + Validation in `pages/**` + +- Request-Daten nur über `requestInput()` lesen. +- Kein direktes `$_POST`, `$_GET`, `$_FILES`, `$_SERVER['REQUEST_METHOD']` in `pages/**`. +- Validierungsfehler intern über `FormErrors` aufbauen (`formErrors()`, `formErrorsFrom()`). +- API-Validation-Antworten einheitlich über `ApiResponse::validationFromFormErrors(...)`. +- Web-Templates bekommen weiterhin kompakte Listen (`$errors`) aus `FormErrors::toFlatList()`. +- `Flash` nicht als primäre Validation-Struktur verwenden; nur für Redirect-/Outcome-Meldungen. + +Kurz-How-to: `/docs/request-input-validation.md` + +## 8) Definition of Done für `lib/**` 1. Schichtgrenzen eingehalten. 2. AuthZ zentral. -3. Unit-Tests ergänzt, bei Strukturänderung zusätzlich Contract-Test. -4. `phpunit` und `phpstan` grün. +3. Instanziierungsregeln aus Abschnitt 3 eingehalten (inkl. Gate-Checks). +4. Unit-Tests ergänzt, bei Strukturänderung zusätzlich Contract-Test. +5. `phpunit` und `phpstan` grün. diff --git a/docs/lokale-entwicklung.md b/docs/lokale-entwicklung.md index 70983ae..fef68aa 100644 --- a/docs/lokale-entwicklung.md +++ b/docs/lokale-entwicklung.md @@ -1,10 +1,10 @@ # Lokale Entwicklung -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-24 ## Ziel -Pragmatischer Tages-Workflow für Entwicklung, Tests und Migrationen. +Kompakter Tagesworkflow für Entwicklung, Tests und schema-nahe Änderungen. ## Start/Stop @@ -13,58 +13,52 @@ docker compose up -d docker compose down ``` -Hinweis: `docker compose up -d` startet auch den Service `scheduler` (globaler Minutely-Runner). +Hinweis: `up -d` startet auch den Scheduler-Service. -Bei größeren Refactors oder merkwürdigem Laufzeitverhalten: +Bei inkonsistentem Laufzeitverhalten: ```bash docker compose restart php nginx ``` -## Datenbank und Schema-Updates +## Schema-Stand - Basis-Schema + Seeds: `db/init/init.sql` -- Das Repo nutzt aktuell ein konsolidiertes Init-Schema (keine separaten Migrationsdateien in `db/init/`). -- Für Bestandsumgebungen Schema-/Permission-Änderungen als idempotentes SQL-Update in `db/updates/*.sql` bereitstellen und manuell ausführen. +- Für Bestandsumgebungen: idempotente SQL-Updates in `db/updates/*.sql` -Beispiel (bestehende Umgebung, SQL-Update manuell ausführen): - -```bash -docker compose exec -T db sh -lc \ - 'mariadb -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" < /tmp/release-update.sql' -``` - -## Typischer Implementierungsablauf +## Umsetzungsablauf 1. Requirement klären 2. Repository anpassen (SQL) 3. Service anpassen (Business-Regeln) 4. Action/Page anpassen -5. i18n-Keys ergänzen (`i18n/default_de.json`, `i18n/default_en.json`) -6. Doku anpassen (`docs/*`) +5. i18n ergänzen +6. Doku aktualisieren -## Pflicht-Checks vor Commit +## Pflichtchecks vor Commit ```bash docker compose exec php vendor/bin/phpunit docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress -npx eslint web/js ``` -Optional gezielt: +Bei JS-Änderungen zusätzlich Browser-Smoke mit DevTools-Console (keine JS-Errors). + +## Struktur-Gates (empfohlen) ```bash -docker compose exec php vendor/bin/phpunit tests/I18n/TranslationKeysTest.php +(rg 'new\s+[^\s(]+Factory\(' lib/Service || true) | wc -l +(rg --glob '!**/*Factory.php' 'new\s+[^\s(]+(Service|Gateway|Repository)\(' lib/Service || true) | wc -l +(rg "\b(accessServicesFactory|directoryServicesFactory|userServicesFactory|authServicesFactory|tenantServicesFactory|settingServicesFactory|userRepositoryFactory|authRepositoryFactory|authGatewayFactory)\(" pages lib templates web || true) | wc -l ``` ## API-spezifisch -- OpenAPI aktualisieren: `docs/openapi.yaml` -- API-Quickref aktualisieren: `docs/api.md` -- Bei Security-Änderungen auch `docs/sicherheitsmodell.md` aktualisieren +- `docs/openapi.yaml` aktualisieren +- bei Bedarf `docs/api.md` ergänzen +- bei Security-Änderungen `docs/sicherheitsmodell.md` aktualisieren -## Bei Unsicherheit +## Vertiefung -- Architektur zuerst lesen: `/docs/architektur.md` -- Sicherheitsregeln prüfen: `/docs/sicherheitsmodell.md` -- Abschluss immer gegen `/docs/entwickler-checkliste.md` +- `/docs/entwickler-checkliste.md` +- `/docs/03-architektur-request-flow.md` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b85a97e..420fda4 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -62,10 +62,16 @@ paths: examples: invalid_credentials: value: - error: invalid_credentials + ok: false + request_id: 550e8400-e29b-41d4-a716-446655440000 + error_code: invalid_credentials + details: {} account_inactive: value: - error: account_inactive + ok: false + request_id: 550e8400-e29b-41d4-a716-446655440000 + error_code: account_inactive + details: {} '403': $ref: '#/components/responses/Forbidden' '409': @@ -1108,7 +1114,10 @@ components: schema: $ref: '#/components/schemas/ErrorResponse' example: - error: unauthorized + ok: false + request_id: 550e8400-e29b-41d4-a716-446655440000 + error_code: unauthorized + details: {} Forbidden: description: Forbidden content: @@ -1122,7 +1131,10 @@ components: schema: $ref: '#/components/schemas/ErrorResponse' example: - error: not_found + ok: false + request_id: 550e8400-e29b-41d4-a716-446655440000 + error_code: not_found + details: {} ValidationError: description: Validation failed content: @@ -1130,10 +1142,13 @@ components: schema: $ref: '#/components/schemas/ErrorResponse' example: - error: validation_error - errors: - field: - - invalid_value + ok: false + request_id: 550e8400-e29b-41d4-a716-446655440000 + error_code: validation_error + details: + errors: + field: + - invalid_value Conflict: description: Conflict content: @@ -1151,7 +1166,11 @@ components: schema: $ref: '#/components/schemas/ErrorResponse' example: - error: rate_limit_exceeded + ok: false + request_id: 550e8400-e29b-41d4-a716-446655440000 + error_code: rate_limit_exceeded + details: + retry_after: 60 ServerError: description: Internal server error content: @@ -1167,16 +1186,19 @@ components: schemas: ErrorResponse: type: object - required: [error] + required: [ok, request_id, error_code, details] properties: - error: + ok: + type: boolean + example: false + request_id: type: string - errors: + format: uuid + error_code: + type: string + details: type: object - additionalProperties: - type: array - items: - type: string + additionalProperties: true GenericDataResponse: type: object required: [data] @@ -1333,6 +1355,11 @@ components: user_inactivity_delete_days: type: integer nullable: true + system_audit_enabled: + type: boolean + system_audit_retention_days: + type: integer + nullable: true microsoft_shared_client_id: type: string microsoft_authority: @@ -1395,6 +1422,11 @@ components: user_inactivity_delete_days: type: integer nullable: true + system_audit_enabled: + type: boolean + system_audit_retention_days: + type: integer + nullable: true microsoft_shared_client_id: type: string microsoft_authority: diff --git a/docs/rbac-permissions-playbook.md b/docs/rbac-permissions-playbook.md new file mode 100644 index 0000000..5b012bd --- /dev/null +++ b/docs/rbac-permissions-playbook.md @@ -0,0 +1,78 @@ +# RBAC Permission Playbook + +Letzte Aktualisierung: 2026-02-25 + +Ziel: Neue Rechte konsistent und ohne Legacy-Pfade ergänzen. + +## Entscheidungshilfe + +1. Nur UI an bestehende Ability hängen: 3 Schritte. +2. Neue Ability auf bestehende Permission: 4 Schritte. +3. Ganz neue Permission (inkl. DB): 7 Schritte. + +## Fall A: Ganz neue Permission + +1. Permission-Key in `PermissionService` ergänzen. + Datei: `lib/Service/Access/PermissionService.php` +2. DB-Update für Bestandsinstanzen anlegen (idempotent). + Datei: `db/updates/YYYY-MM-DD-*.sql` +3. `db/init/init.sql` für Neuinstallationen synchron ergänzen. +4. Ability in passender Policy ergänzen: + `ABILITY_*`, `supports()`, Mapping in `authorize()`. +5. Action/API auf Ability umstellen: + Web: `Guard::requireAbility(...)` oder `AuthorizationService`. + API: `ApiResponse::requireAbility(...)`. +6. Falls UI betroffen: `UiCapabilityMap` ergänzen und in Action nach `$viewAuth` auflösen. +7. Tests + Doku aktualisieren. + +Beispiel (Core): `system_audit.view` + `system_audit.purge` mit Abilities +`ops.admin.system_audit.view` und `ops.admin.system_audit.purge`. + +## Fall B: Neue Ability auf bestehende Permission + +1. Ability in passender Policy ergänzen und auf vorhandenen Permission-Key mappen. +2. Action/API auf neue Ability ziehen. +3. Optional UI-Mapping in `UiCapabilityMap` + `$viewAuth`. +4. Tests/Doku nachziehen. + +## Fall C: Nur UI-Freigabe erweitern (ohne neue Permission) + +1. Capability-Key in `UiCapabilityMap` ergänzen. +2. In Action mit `UiAccessService::pageCapabilities(...)` oder `layoutCapabilities()` auflösen. +3. Template nur über `$viewAuth['page']` bzw. `$viewAuth['layout']` steuern. + +## Fall D: API-only Endpoint + +1. Ability festlegen (Policy/OperationsPolicy). +2. Endpoint mit `ApiResponse::requireAbility(...)` schützen. +3. OpenAPI aktualisieren. +4. API-Tests ergänzen. + +## Fall E: Scope-sensitive Ability (Ressourcenbezug) + +1. Immer Kontext übergeben, z. B.: + `actor_user_id`, `target_user_id`, `target_tenant_id`, `input`. +2. Wenn Capabilities benötigt werden: + `AuthorizationDecision::attribute('capabilities', [])` nutzen. +3. Für reine Bool-Checks `AuthorizationService::allow(...)` verwenden. + +## Verbindliche Regeln + +1. Keine Permission-Checks in Templates (`can(...)` verboten). +2. Keine alten Wrapper (`Guard::requirePermission*`, `ApiResponse::requirePermission`). +3. UI nur über `$viewAuth`. +4. Policy-/Ability-Namen sind der technische Vertrag, nicht der Permission-Key im Template. + +## Minimal-Checkliste vor Merge + +```bash +rg -n "(?isMethod('POST')) { + if (!Session::checkCsrfToken()) { + $errorBag->addGlobal(t('Form expired, please try again')); + } + + if (!$errorBag->hasAny()) { + $result = $service->save($request->bodyAll()); + $errorBag->merge($result['errors'] ?? []); + } +} + +$errors = $errorBag->toFlatList(); +``` + +## API-Beispiel (Endpoint) + +```php +$request = requestInput(); +$errors = formErrors(); + +if ($request->bodyString('name') === '') { + $errors->add('name', 'required'); +} + +if ($errors->hasAny()) { + ApiResponse::validationFromFormErrors($errors); +} +``` + +## Migration-Gates + +```bash +rg -n '\\$_POST|\\$_GET|\\$_FILES|REQUEST_METHOD' pages -g '*.php' +rg -n 'ApiResponse::readJsonBody\\(' pages/api/v1 -g '*.php' +``` + +Erwartung jeweils: `0` Treffer. diff --git a/docs/sicherheitsmodell.md b/docs/sicherheitsmodell.md index 1b8ea66..964e615 100644 --- a/docs/sicherheitsmodell.md +++ b/docs/sicherheitsmodell.md @@ -1,141 +1,113 @@ # Sicherheitsmodell -Letzte Aktualisierung: 2026-02-23 +Letzte Aktualisierung: 2026-03-03 -## 1) Grundprinzip +## Ziel -Die Anwendung kombiniert mehrere Sicherheitslayer: +Kurze, verbindliche Übersicht der Sicherheitsmechanik für Web und API. -1. Request-Grenze (Nginx + Minty Firewall) -2. Session/Auth-Guard -3. Permission-Checks -4. Tenant-Scope-Checks -5. Endpoint-spezifische Schutzlogik (z. B. Avatar-Dateien, Exporte, Actions) +## Sicherheitskette pro Request -Kein einzelner Layer ist ausreichend; die Kombination ist der Schutz. +1. Firewall/Request-Grenze +2. Authentifizierung +3. Permission-Check +4. Tenant-Scope-Check +5. Endpoint-spezifische Regeln (z. B. Exporte, Media, Audit) -## 2) Authentifizierung +Kein Layer allein reicht aus. -- Session-basierter Login. -- Optionales Remember-Me über `user_remember_tokens`. -- Auto-Login aus Cookie erfolgt früh in `/web/index.php`. -- Zentraler Login-Entry ist `login` (optional `login?tenant=` für Tenant-Vorauswahl). -- Unknown-Email-Verhalten im Login bleibt generisch (keine Tenant-/Account-Details vor erfolgreicher Auflösung). -- Auth-/SSO-Kern ist instanzbasiert verdrahtet über `lib/Service/Auth/AuthServicesFactory.php` - (`AuthService`, `RememberMeService`, `SsoUserLinkService`, `TenantSsoService`, `MicrosoftOidcService`). +## Public vs. Protected -Kritisch: +- Public-Routen kommen aus `config/routes.php` (`public => true`). +- Zusätzlich sind Präfixe technisch immer public (`branding/`, `flash/`, `auth/microsoft/`, `api/`) in `lib/Http/AccessControl.php`. +- Alles andere erfordert Login. -- Tokens sind gehasht gespeichert. -- Tokens können zentral ablaufen gelassen werden (Settings-Gefahrenzone). +API-Sonderfall: -## 3) Public vs. Protected Routes +- `pages/api/v1/auth/login().php` nutzt `ApiBootstrap::init(false)` (public Login-Endpunkt). +- Geschützte API-Endpunkte nutzen `ApiBootstrap::init()`. -- Public Routes werden in `/config/routes.php` mit `public => true` definiert. -- Daraus wird zur Laufzeit `APP_PUBLIC_PATHS` aufgebaut. -- Zusätzlich gibt es technische Always-Public-Präfixe in `lib/Http/AccessControl.php` - (u. a. `api/`, `auth/microsoft/`, `branding/`, `flash/`). -- Alles andere ist loginpflichtig. +## Auth und Login -API-Besonderheit: +- Web-Login ist session-basiert (`pages/auth/login().php`). +- Remember-Me-Autologin läuft früh in `web/index.php`. +- Multi-Tenant-Login wählt Tenant-Kontext serverseitig. +- Microsoft-SSO läuft über OIDC (`auth/microsoft/start` + `auth/microsoft/callback`) mit `state`, `nonce`, PKCE und ID-Token-Validierung. -- `/api/v1/auth/login` ist bewusst public (ohne Bearer-Header). -- Laufzeitverhalten: `pages/api/v1/auth/login().php` nutzt `ApiBootstrap::init(false)`. -- Alle anderen geschützten API-Endpunkte bleiben bei `ApiBootstrap::init()` (Auth erforderlich). +## Permission und Tenant-Scope -## 4) Permission-System +- Rechte werden serverseitig geprüft (`PermissionService` + Policies). +- UI-Sichtbarkeit wird ebenfalls serverseitig vorbereitet (`UiAccessService` + `$viewAuth`), nicht im Template selbst entschieden. +- Globaler Scope-Bypass ist explizit: `tenant.scope.global`. +- Beispiel für harte Feature-Gates: + - `users.access_pdf` + - `custom_fields.manage`, `custom_fields.edit_values` + - `tenants.sso_manage` + - `api_audit.view` -- Berechtigungen sind feingranular (`permissions` + `role_permissions`). -- Rollen vergeben Berechtigungen an User (`user_roles`). -- Actions prüfen Berechtigungen explizit. +## API-Schutz -Empfehlung: +- Zentrale API-Rate-Limits laufen in `lib/Http/ApiBootstrap.php`. +- API-Audit läuft zentral über `ApiAuditService`: + - loggt Metadaten (Pfad, Status, Dauer, Kontext) + - redigiert sensible Query-Parameter + - speichert keine Request-/Response-Bodies -- Rechte für View/Edit/Delete getrennt halten. -- Admin-only Endpunkte immer serverseitig absichern, nicht nur im UI verstecken. -- Onboarding-PDFs für Benutzer laufen über eigenes Recht `users.access_pdf` (Single + Bulk). -- Tenant-Zusatzfelder sind getrennt abgesichert: - - `custom_fields.manage` für Definitionen/Optionen im Tenant - - `custom_fields.edit_values` für Wertepflege am User. - - `field_key` wird serverseitig erzeugt/stabil gehalten (kein editierbares UI-Feld), damit tenant-weite Eindeutigkeit erhalten bleibt. -- Theme-Wechsel für User wird tenant-spezifisch abgesichert: - - effektive Regel kommt aus `current_tenant.allow_user_theme` (Fallback global `app_theme_user`) - - Endpoint `admin/users/theme` blockt bei deaktivierter Regel mit `403` (`theme_disabled`). -- Globaler Tenant-Scope-Bypass ist explizit und separat: - - nur Permission `tenant.scope.global`. - - `settings.update` oder `tenants.update` alleine geben **keinen** Scope-Bypass. -- Tenant-Microsoft-SSO ist separat abgesichert: - - `tenants.sso_manage` für Pflege der Tenant-SSO-Konfiguration. - - Shared-App-Credentials bleiben unter `settings.update`. - - Secrets werden nur verschlüsselt gespeichert (`APP_CRYPTO_KEY` + AES-256-GCM), nie im Klartext gerendert. - - Profil-Sync ist tenant-spezifisch konfigurierbar (`sync_profile_on_login`, `sync_profile_fields`). - - Erlaubte Sync-Felder: `first_name`, `last_name`, `phone`, `mobile`, `avatar`. - - Graph-basierte Felder (`phone/mobile/avatar`) laufen fail-open: Login bleibt erfolgreich, auch wenn Graph nicht verfügbar ist. +## Edge-Hardening (Nginx) -## 5) Tenant Scope +- Security-Header werden zentral im Nginx gesetzt. +- Baseline: + - `X-Content-Type-Options: nosniff` + - `X-Frame-Options: SAMEORIGIN` + - `Referrer-Policy: strict-origin-when-cross-origin` + - `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()` + - `X-Permitted-Cross-Domain-Policies: none` +- Produktion ergänzt: + - `Strict-Transport-Security: max-age=31536000` +- Lokale Entwicklung setzt bewusst kein HSTS, um HTTP-/localhost-Workflows nicht zu beeinflussen. -- Datenzugriff für tenant-gebundene Inhalte erfolgt nur bei Tenant-Match. -- Departments sind genau einem Tenant zugeordnet (`departments.tenant_id`). -- Beim Speichern von User-Zuordnungen werden Departments serverseitig gegen die aktuell zugewiesenen User-Tenants gefiltert; ungültige Department-Zuordnungen werden entfernt. -- User-Zusatzfeldwerte werden auf Tenant-Wechsel bereinigt (Werte außerhalb der aktuell zugewiesenen Tenants werden gelöscht). -- Filterbare Zusatzfelder sind auf sichere Typen begrenzt (`select`, `multiselect`, `boolean`, `date`); freie Texttypen werden nicht als dynamische Filter zugelassen. -- Theme-Auflösung ist tenant-spezifisch: `default_theme` und `allow_user_theme` werden je `current_tenant` aufgelöst; ohne Tenant-Snapshot greifen globale Defaults. -- SSO-Mapping bleibt tenant-scoped: - - Primär über externe Identität (`provider + tid + oid`). - - Fallback einmalig über E-Mail (create-or-link), danach feste Identity-Linkung. - - Domain-Allowlist (falls gesetzt) wird im Callback serverseitig erzwungen. - - Profil-Sync überschreibt nur konfigurierte Felder und ignoriert leere Quelle-Werte; - `email` bleibt nach JIT-Create unverändert (insert-only). -- Lokaler/SSO-Login ist nur mit mindestens einem aktiven User-Tenant erlaubt (kein Sonderfall ohne Tenant). -- Strict/Non-Strict Verhalten über `TENANT_SCOPE_STRICT`. -- Inaktiv-Status von Tenants beeinflusst Sichtbarkeit (je nach Kontext). +## System-Audit (fachliche Aktionen) -## 6) Verbotene Zugriffe +- Separater Audit-Stream: `system_audit_log` (unabhängig von `api_audit_log`). +- Scope V2: + - Web: Auth-Events + Admin-Write-Aktionen. + - API: selektive `api.request`-Events (Write-Requests, `>=500`, Security-Endpunkte bei `>=400`). + - Scheduler: `scheduler.run` (Runner-Zusammenfassung) und `scheduler.job.run` (pro Joblauf). + - CLI: `cli.command` für zentrale Betriebsbefehle (z. B. `bin/doctor.php`). +- Privacy-Default: + - keine Request-/Response-Bodies + - keine Klartext-Secrets/Passwörter/Tokens/E-Mails + - IP/User-Agent nur gehasht (HMAC) +- Globaler Schalter über Settings: + - `system_audit_enabled` (hard-off: keine neuen Events) + - `system_audit_retention_days` (Retention/Purge) +- RBAC: + - `system_audit.view` + - `system_audit.purge` -- Bei fehlender Permission/Tenant-Bindung: forbidden flow (403 Seite) statt stilles Durchrutschen. -- Keine sensitiven Details in Fehlermeldungen leaken. +## Schreibschutz -## 7) Datei-/Media-Endpunkte +- Web-Schreibaktionen laufen mit CSRF-Token. +- Kritische Actions sind POST-only + serverseitige Validierung. +- API nutzt Bearer-Auth und eigenes Schutzmodell (kein Form-CSRF). -- Avatar/Favicon/Logo Endpunkte prüfen Session + Zugriffskontext. -- Storage liegt außerhalb direkter Business-Logik; Zugriff nur über kontrollierte Endpunkte/Symlinks. +## Betriebschecks -## 8) CSRF und Form-Schutz +- Permission-Set und Rollen regelmäßig prüfen. +- Tenant-Entzug/Scope-Verhalten testen. +- Rate-Limits und `429`-Verhalten bei Login/API testen. +- Audit-Streams bewusst getrennt nutzen: + - `system_audit_log`: fachliche/sicherheitsrelevante Ereignisse (summary-level) + - `api_audit_log`: API-Request-Telemetrie (Status, Dauer, Pfad) + - `import_audit_runs`, `user_lifecycle_audit_log`, `scheduled_job_runs`: Domänen-/Betriebsdetails +- Audit-Retention prüfen: + - API-Audit: 90 Tage + - Import-Audit: 90 Tage + - User-Lifecycle-Audit: 365 Tage + - System-Audit: konfigurierbar (Default 365 Tage) -- Schreibaktionen nutzen CSRF-Token. -- Delete/Status-Aktionen nur per POST und serverseitiger Validierung. -- Bulk-Download für Onboarding-PDF (`admin/users/access-pdf-bulk`) ist POST+CSRF, Scope bleibt aktiv. -- OIDC-Loginfluss nutzt `state`, `nonce` und PKCE; Callback validiert zusätzlich `aud`, `iss`, `tid`, Signatur (JWKS), `exp/nbf`. +## Vertiefung -## 9) Operative Checks - -Regelmäßig prüfen: - -- Inaktive Rollen/Permissions werden nicht mehr aktiv zugewiesen/ausgewertet. -- Tenant-Entzüge invalidieren Sichtkontext erwartungsgemäß. -- Remember-Tokens können zentral invalidiert werden. -- Mail-Log enthält Fehlerdaten für Auditing. -- User-Lifecycle-Policy (falls aktiv) läuft täglich und setzt inaktive Accounts automatisch auf `inactive` - bzw. löscht sie später (2-stufig), privilegierte Admins sind ausgenommen. - - Delete schreibt vorher zwingend ein Audit-Snapshot (fail-closed). - - Snapshot ist verschlüsselt gespeichert (`snapshot_enc`) und enthält keine Secrets/Tokens. - - Restore ist separat berechtigt (`users.lifecycle_restore`) und stellt bewusst keine Assignments wieder her. -- Geplante Aufgaben (Scheduler) sind Whitelist-basiert: - - keine frei ausführbaren Shell-Kommandos aus der Datenbank. - - zentrale Rechte: `jobs.view`, `jobs.manage`, `jobs.run_now`. - - Runner-Lock verhindert parallele Mehrfach-Ausführung. - -## 10) API-Audit - -- Alle `/api/v1/*` Requests werden zentral mit Metadaten in `api_audit_log` protokolliert. -- V1 speichert keine Request-/Response-Bodies, nur Metadaten (Pfad, Status, Dauer, Scope-Kontext). -- Query-Parameter werden redacted gespeichert (z. B. Token/Secret/Password-Felder). -- Zugriff auf die Audit-UI ist über Permission `api_audit.view` abgesichert. -- Retention ist auf 90 Tage ausgelegt (Bereinigung per Admin-Action oder Cron). - -## 11) User-Lifecycle-Audit - -- Lifecycle-Audit-UI ist über `user_lifecycle_audit.view` abgesichert. -- Restore aus Delete-Snapshot ist getrennt über `users.lifecycle_restore` abgesichert. -- Ohne gültigen Snapshot wird kein Auto-Delete ausgeführt (fail-closed). -- Retention für Lifecycle-Audit-Einträge: 365 Tage. +- `/docs/anfragelimits.md` +- `/docs/06-security-tenant-scope.md` +- `/docs/rbac-permissions-playbook.md` diff --git a/eslint.config.cjs b/eslint.config.cjs deleted file mode 100644 index 8c82eab..0000000 --- a/eslint.config.cjs +++ /dev/null @@ -1,56 +0,0 @@ -/** @type {import('eslint').Linter.FlatConfig[]} */ -module.exports = [ - { - ignores: [ - 'web/vendor/**', - 'vendor/**', - 'node_modules/**', - ], - }, - { - files: ['web/js/**/*.js'], - languageOptions: { - ecmaVersion: 2022, - sourceType: 'module', - globals: { - window: 'readonly', - document: 'readonly', - navigator: 'readonly', - location: 'readonly', - history: 'readonly', - localStorage: 'readonly', - sessionStorage: 'readonly', - console: 'readonly', - URL: 'readonly', - URLSearchParams: 'readonly', - FormData: 'readonly', - MutationObserver: 'readonly', - CustomEvent: 'readonly', - Event: 'readonly', - fetch: 'readonly', - setTimeout: 'readonly', - clearTimeout: 'readonly', - setInterval: 'readonly', - clearInterval: 'readonly', - }, - }, - linterOptions: { - reportUnusedDisableDirectives: 'error', - }, - rules: { - 'no-debugger': 'error', - 'no-redeclare': 'error', - 'no-unused-vars': [ - 'error', - { - args: 'none', - caughtErrors: 'none', - ignoreRestSiblings: true, - }, - ], - 'no-console': ['warn', { allow: ['warn', 'error'] }], - eqeqeq: ['error', 'always'], - curly: ['error', 'all'], - }, - }, -]; diff --git a/i18n/default_de.json b/i18n/default_de.json index 4559b77..9f12596 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -225,6 +225,17 @@ "No account yet": "Noch kein Konto", "Inactive": "Inaktiv", "Filter": "Filter", + "Apply filters": "Filter anwenden", + "Discard changes": "Änderungen verwerfen", + "Active filters": "Aktive Filter", + "Clear all filters": "Alle Filter zurücksetzen", + "No active filters": "Keine aktiven Filter", + "Close filters": "Filter schließen", + "Remove filter": "Filter entfernen", + "Filters applied": "Filter angewendet", + "Filter removed": "Filter entfernt", + "All filters cleared": "Alle Filter entfernt", + "%d changes not applied": "%d Änderungen nicht angewendet", "UUID": "UUID", "Profile image": "Profilbild", "Profile": "Profil", @@ -661,6 +672,12 @@ "Last used": "Zuletzt verwendet", "Keyboard shortcuts": "Tastenkürzel", "Open search": "Suche öffnen", + "Save in edit pages": "Auf Bearbeitungsseiten speichern", + "Unsaved changes": "Ungespeicherte Änderungen", + "Unsaved change (%d field)": "Ungespeicherte Änderung (%d geändertes Feld)", + "Unsaved changes (%d fields)": "Ungespeicherte Änderungen (%d geänderte Felder)", + "Please review the following errors": "Bitte prüfen Sie die folgenden Fehler", + "You have unsaved changes. Leave without saving?": "Sie haben ungespeicherte Änderungen. Seite ohne Speichern verlassen?", "Switch sidebar section": "Sidebar-Bereich wechseln", "Action": "Aktion", "Mac": "Mac", @@ -1088,5 +1105,63 @@ "Last heartbeat": "Letzter Heartbeat", "Last runner result": "Letztes Runner-Ergebnis", "Runner lock not acquired": "Runner-Lock nicht erhalten", - "Locale": "Sprache" + "Locale": "Sprache", + "Learning path": "Lernpfad", + "Concepts": "Konzepte", + "How-to guides": "Aufgabenanleitungen", + "Contributor guide": "Mitwirken", + "System audit": "System-Protokolle", + "System audit logs": "System-Protokolle", + "View system audit entry": "System-Protokoll-Eintrag anzeigen", + "System audit entry not found": "System-Protokoll-Eintrag nicht gefunden", + "Enable system audit log": "System-Protokolle aktivieren", + "Purge system audit logs": "System-Protokolle bereinigen", + "Purge expired system audit entries?": "Abgelaufene System-Protokoll-Einträge bereinigen?", + "%d system audit entries purged": "%d System-Protokoll-Einträge bereinigt", + "All outcomes": "Alle Ergebnisse", + "All channels": "Alle Kanäle", + "Event type": "Ereignistyp", + "Outcome": "Ergebnis", + "Actor": "Akteur", + "Actor tenant": "Akteur-Mandant", + "Event details": "Ereignisdetails", + "Target type": "Zieltyp", + "Request ID": "Request-ID", + "IP hash": "IP-Hash", + "User agent hash": "User-Agent-Hash", + "Channel": "Kanal", + "Denied": "Verweigert", + "Event": "Ereignis", + "Metadata": "Metadaten", + "Actor user ID": "Akteur-Benutzer-ID", + "Select users": "Benutzer auswählen", + "Select actions": "Aktionen auswählen", + "Select statuses": "Status auswählen", + "Select triggers": "Auslöser auswählen", + "Tenant #%d": "Mandant #%d", + "Tenant #%d (deleted)": "Mandant #%d (gelöscht)", + "Select event types": "Ereignistypen auswählen", + "Select actor users": "Akteure auswählen", + "User #%d": "Benutzer #%d", + "User #%d (deleted)": "Benutzer #%d (gelöscht)", + "Retention (days)": "Aufbewahrung (Tage)", + "setting.system_audit_enabled": "System-Protokolle aktivieren/deaktivieren", + "setting.system_audit_retention_days": "Aufbewahrungsdauer in Tagen für System-Protokoll-Einträge", + "System audit events (24h)": "System-Protokoll-Ereignisse (24h)", + "System audit risks (24h)": "System-Protokoll-Risiken (24h)", + "Failed outcomes (24h)": "Fehlgeschlagene Ergebnisse (24h)", + "Denied outcomes (24h)": "Verweigerte Ergebnisse (24h)", + "Trend vs previous 24h": "Trend vs. vorherige 24h", + "Top system audit event types (24h)": "Top-System-Protokoll-Ereignistypen (24h)", + "Failed or denied": "Fehlgeschlagen oder verweigert", + "Recent system audit risks (24h)": "Aktuelle System-Protokoll-Risiken (24h)", + "User lifecycle audit": "Benutzer-Lifecycle-Protokoll", + "Lifecycle runs (7d)": "Lifecycle-Läufe (7d)", + "Lifecycle risks (7d)": "Lifecycle-Risiken (7d)", + "Failed runs (7d)": "Fehlgeschlagene Läufe (7d)", + "Skipped runs (7d)": "Übersprungene Läufe (7d)", + "Restore actions (7d)": "Wiederherstellungsaktionen (7d)", + "Trend vs previous 7d": "Trend vs. vorherige 7d", + "Top lifecycle reason codes (7d)": "Top-Lifecycle-Fehlercodes (7d)", + "Recent lifecycle risks (7d)": "Aktuelle Lifecycle-Risiken (7d)" } diff --git a/i18n/default_en.json b/i18n/default_en.json index 8704023..b024ab9 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -225,6 +225,17 @@ "No account yet": "No account yet", "Inactive": "Inactive", "Filter": "Filter", + "Apply filters": "Apply filters", + "Discard changes": "Discard changes", + "Active filters": "Active filters", + "Clear all filters": "Clear all filters", + "No active filters": "No active filters", + "Close filters": "Close filters", + "Remove filter": "Remove filter", + "Filters applied": "Filters applied", + "Filter removed": "Filter removed", + "All filters cleared": "All filters cleared", + "%d changes not applied": "%d changes not applied", "UUID": "UUID", "Profile image": "Profile image", "Profile": "Profile", @@ -661,6 +672,12 @@ "Last used": "Last used", "Keyboard shortcuts": "Keyboard shortcuts", "Open search": "Open search", + "Save in edit pages": "Save in edit pages", + "Unsaved changes": "Unsaved changes", + "Unsaved change (%d field)": "Unsaved change (%d field)", + "Unsaved changes (%d fields)": "Unsaved changes (%d fields)", + "Please review the following errors": "Please review the following errors", + "You have unsaved changes. Leave without saving?": "You have unsaved changes. Leave without saving?", "Switch sidebar section": "Switch sidebar section", "Action": "Action", "Mac": "Mac", @@ -1088,5 +1105,63 @@ "Last heartbeat": "Last heartbeat", "Last runner result": "Last runner result", "Runner lock not acquired": "Runner lock not acquired", - "Locale": "Locale" + "Locale": "Locale", + "Learning path": "Learning path", + "Concepts": "Concepts", + "How-to guides": "How-to guides", + "Contributor guide": "Contributor guide", + "System audit": "System audit", + "System audit logs": "System audit logs", + "View system audit entry": "View system audit entry", + "System audit entry not found": "System audit entry not found", + "Enable system audit log": "Enable system audit log", + "Purge system audit logs": "Purge system audit logs", + "Purge expired system audit entries?": "Purge expired system audit entries?", + "%d system audit entries purged": "%d system audit entries purged", + "All outcomes": "All outcomes", + "All channels": "All channels", + "Event type": "Event type", + "Outcome": "Outcome", + "Actor": "Actor", + "Actor tenant": "Actor tenant", + "Event details": "Event details", + "Target type": "Target type", + "Request ID": "Request ID", + "IP hash": "IP hash", + "User agent hash": "User agent hash", + "Channel": "Channel", + "Denied": "Denied", + "Event": "Event", + "Metadata": "Metadata", + "Actor user ID": "Actor user ID", + "Select users": "Select users", + "Select actions": "Select actions", + "Select statuses": "Select statuses", + "Select triggers": "Select triggers", + "Tenant #%d": "Tenant #%d", + "Tenant #%d (deleted)": "Tenant #%d (deleted)", + "Select event types": "Select event types", + "Select actor users": "Select actor users", + "User #%d": "User #%d", + "User #%d (deleted)": "User #%d (deleted)", + "Retention (days)": "Retention (days)", + "setting.system_audit_enabled": "Enable/disable system audit logging", + "setting.system_audit_retention_days": "Retention in days for system audit entries", + "System audit events (24h)": "System audit events (24h)", + "System audit risks (24h)": "System audit risks (24h)", + "Failed outcomes (24h)": "Failed outcomes (24h)", + "Denied outcomes (24h)": "Denied outcomes (24h)", + "Trend vs previous 24h": "Trend vs previous 24h", + "Top system audit event types (24h)": "Top system audit event types (24h)", + "Failed or denied": "Failed or denied", + "Recent system audit risks (24h)": "Recent system audit risks (24h)", + "User lifecycle audit": "User lifecycle audit", + "Lifecycle runs (7d)": "Lifecycle runs (7d)", + "Lifecycle risks (7d)": "Lifecycle risks (7d)", + "Failed runs (7d)": "Failed runs (7d)", + "Skipped runs (7d)": "Skipped runs (7d)", + "Restore actions (7d)": "Restore actions (7d)", + "Trend vs previous 7d": "Trend vs previous 7d", + "Top lifecycle reason codes (7d)": "Top lifecycle reason codes (7d)", + "Recent lifecycle risks (7d)": "Recent lifecycle risks (7d)" } diff --git a/lib/App/AppContainer.php b/lib/App/AppContainer.php new file mode 100644 index 0000000..dfc5bf7 --- /dev/null +++ b/lib/App/AppContainer.php @@ -0,0 +1,38 @@ + */ + private array $bindings = []; + + /** @var array */ + private array $instances = []; + + public function set(string $id, callable $factory): void + { + $this->bindings[$id] = $factory; + } + + public function has(string $id): bool + { + return array_key_exists($id, $this->instances) || array_key_exists($id, $this->bindings); + } + + public function get(string $id): mixed + { + if (array_key_exists($id, $this->instances)) { + return $this->instances[$id]; + } + + if (!array_key_exists($id, $this->bindings)) { + throw new RuntimeException('Service not bound: ' . $id); + } + + $this->instances[$id] = ($this->bindings[$id])($this); + return $this->instances[$id]; + } +} diff --git a/lib/App/Bootstrap/EnvValidator.php b/lib/App/Bootstrap/EnvValidator.php new file mode 100644 index 0000000..2391ace --- /dev/null +++ b/lib/App/Bootstrap/EnvValidator.php @@ -0,0 +1,330 @@ + */ + private const REQUIRED_KEYS = [ + 'APP_NAME', + 'APP_ENV', + 'APP_DEBUG', + 'APP_TIMEZONE', + 'APP_STORAGE_PATH', + 'APP_LOCALE', + 'APP_LOCALES', + 'APP_I18N_DOMAIN', + 'SESSION_NAME', + 'TENANT_SCOPE_STRICT', + 'DB_HOST', + 'DB_PORT', + 'DB_NAME', + 'DB_USER', + 'DB_PASS', + 'CACHE_SERVERS', + 'FIREWALL_CONCURRENCY', + 'FIREWALL_SPINLOCK_SECONDS', + 'FIREWALL_INTERVAL_SECONDS', + 'FIREWALL_CACHE_PREFIX', + 'FIREWALL_REVERSE_PROXY', + ]; + + public static function validate(): void + { + if (!self::isValidationEnabled()) { + return; + } + + $env = []; + foreach (array_merge(self::REQUIRED_KEYS, ['APP_URL', 'APP_CRYPTO_KEY']) as $key) { + $env[$key] = getenv($key); + } + + $errors = self::validateArray($env); + if ($errors === []) { + return; + } + + throw new \RuntimeException( + "Configuration validation failed:\n - " + . implode("\n - ", $errors) + . "\nPlease verify your .env based on .env.example" + ); + } + + public static function isValidationEnabled(): bool + { + $raw = getenv('APP_CONFIG_VALIDATE'); + if ($raw === false || trim((string) $raw) === '') { + return true; + } + + return self::parseBool((string) $raw) ?? true; + } + + /** + * @param array $env + * @return list + */ + public static function validateArray(array $env): array + { + $errors = []; + + foreach (self::REQUIRED_KEYS as $key) { + if (!self::hasEnvKey($env, $key)) { + $errors[] = "Missing required env key '{$key}'"; + continue; + } + + if (self::value($env, $key) === '') { + $errors[] = "Env key '{$key}' must not be empty"; + } + } + + $appEnv = strtolower(self::value($env, 'APP_ENV')); + if ($appEnv !== '' && !in_array($appEnv, ['dev', 'prod', 'test'], true)) { + $errors[] = "APP_ENV must be one of: dev, prod, test"; + } + + $appDebug = self::value($env, 'APP_DEBUG'); + if ($appDebug !== '' && self::parseBool($appDebug) === null) { + $errors[] = "APP_DEBUG must be a boolean value (true/false/1/0)"; + } + + $tenantScopeStrict = self::value($env, 'TENANT_SCOPE_STRICT'); + if ($tenantScopeStrict !== '' && self::parseBool($tenantScopeStrict) === null) { + $errors[] = "TENANT_SCOPE_STRICT must be a boolean value (true/false/1/0)"; + } + + $reverseProxy = self::value($env, 'FIREWALL_REVERSE_PROXY'); + if ($reverseProxy !== '' && self::parseBool($reverseProxy) === null) { + $errors[] = "FIREWALL_REVERSE_PROXY must be a boolean value (true/false/1/0)"; + } + + $timezone = self::value($env, 'APP_TIMEZONE'); + if ($timezone !== '' && !in_array($timezone, \DateTimeZone::listIdentifiers(), true)) { + $errors[] = "APP_TIMEZONE is invalid: '{$timezone}'"; + } + + $locales = self::parseCsv(self::value($env, 'APP_LOCALES')); + if ($locales === []) { + $errors[] = 'APP_LOCALES must contain at least one locale'; + } + + $locale = self::value($env, 'APP_LOCALE'); + if ($locale !== '' && $locales !== [] && !in_array($locale, $locales, true)) { + $errors[] = "APP_LOCALE '{$locale}' is not part of APP_LOCALES"; + } + + $dbPort = self::parseInt(self::value($env, 'DB_PORT')); + if ($dbPort === null || $dbPort < 1 || $dbPort > 65535) { + $errors[] = 'DB_PORT must be an integer between 1 and 65535'; + } + + $firewallConcurrency = self::parseInt(self::value($env, 'FIREWALL_CONCURRENCY')); + if ($firewallConcurrency === null || $firewallConcurrency < 1) { + $errors[] = 'FIREWALL_CONCURRENCY must be an integer >= 1'; + } + + $firewallInterval = self::parseInt(self::value($env, 'FIREWALL_INTERVAL_SECONDS')); + if ($firewallInterval === null || $firewallInterval < 1) { + $errors[] = 'FIREWALL_INTERVAL_SECONDS must be an integer >= 1'; + } + + $firewallSpinLock = self::parseFloat(self::value($env, 'FIREWALL_SPINLOCK_SECONDS')); + if ($firewallSpinLock === null || $firewallSpinLock <= 0) { + $errors[] = 'FIREWALL_SPINLOCK_SECONDS must be a float > 0'; + } + + $cacheServers = self::parseCsv(self::value($env, 'CACHE_SERVERS')); + if ($cacheServers === []) { + $errors[] = 'CACHE_SERVERS must contain at least one host[:port] entry'; + } else { + foreach ($cacheServers as $server) { + if (!preg_match('/^[a-z0-9.-]+(?::\d{1,5})?$/i', $server)) { + $errors[] = "CACHE_SERVERS contains invalid entry '{$server}'"; + continue; + } + + $parts = explode(':', $server, 2); + if (count($parts) !== 2) { + continue; + } + + $port = self::parseInt($parts[1]); + if ($port === null || $port < 1 || $port > 65535) { + $errors[] = "CACHE_SERVERS entry '{$server}' has invalid port"; + } + } + } + + $storagePath = self::value($env, 'APP_STORAGE_PATH'); + if ($storagePath !== '') { + $absoluteStoragePath = self::absolutePath($storagePath); + if (!is_dir($absoluteStoragePath)) { + $errors[] = "APP_STORAGE_PATH does not exist: '{$storagePath}'"; + } elseif (!is_writable($absoluteStoragePath)) { + $errors[] = "APP_STORAGE_PATH is not writable: '{$storagePath}'"; + } + } + + $cryptoKey = self::value($env, 'APP_CRYPTO_KEY'); + if ($cryptoKey !== '' && !self::isValidCryptoKey($cryptoKey)) { + $errors[] = 'APP_CRYPTO_KEY must be 64-char hex or base64 encoded 32-byte key'; + } + + $appUrl = self::value($env, 'APP_URL'); + $appUrlIsValid = true; + if ($appUrl !== '' && !self::isValidHttpUrl($appUrl)) { + $errors[] = "APP_URL is invalid: '{$appUrl}'"; + $appUrlIsValid = false; + } + + if (in_array($appEnv, ['dev', 'prod'], true) && $appUrl === '') { + if ($appEnv === 'prod') { + $errors[] = 'APP_URL must be set in production'; + } else { + $errors[] = 'APP_URL must be set in development'; + } + } + + if ($appEnv === 'prod') { + if ($appUrl !== '' && $appUrlIsValid) { + $appUrlScheme = strtolower((string) parse_url($appUrl, PHP_URL_SCHEME)); + if ($appUrlScheme !== 'https') { + $errors[] = 'APP_URL must use https in production'; + } + + $appUrlHost = strtolower((string) parse_url($appUrl, PHP_URL_HOST)); + if (self::isLocalOrIpHost($appUrlHost)) { + $errors[] = 'APP_URL must not use localhost or an IP host in production'; + } + } + if ($cryptoKey === '') { + $errors[] = 'APP_CRYPTO_KEY must be set in production'; + } + if (self::parseBool($appDebug) !== false) { + $errors[] = 'APP_DEBUG must be false in production'; + } + if (self::parseBool($tenantScopeStrict) !== true) { + $errors[] = 'TENANT_SCOPE_STRICT must be true in production'; + } + } + + return $errors; + } + + /** + * @param array $env + */ + private static function hasEnvKey(array $env, string $key): bool + { + return array_key_exists($key, $env) && $env[$key] !== false && $env[$key] !== null; + } + + /** + * @param array $env + */ + private static function value(array $env, string $key): string + { + if (!self::hasEnvKey($env, $key)) { + return ''; + } + + return trim((string) $env[$key]); + } + + private static function parseBool(string $value): ?bool + { + $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + return $parsed; + } + + private static function parseInt(string $value): ?int + { + if ($value === '') { + return null; + } + $parsed = filter_var($value, FILTER_VALIDATE_INT); + return $parsed !== false ? (int) $parsed : null; + } + + private static function parseFloat(string $value): ?float + { + if ($value === '') { + return null; + } + $parsed = filter_var($value, FILTER_VALIDATE_FLOAT); + return $parsed !== false ? (float) $parsed : null; + } + + /** + * @return list + */ + private static function parseCsv(string $value): array + { + if ($value === '') { + return []; + } + + return array_values(array_filter(array_map('trim', explode(',', $value)))); + } + + private static function isValidCryptoKey(string $value): bool + { + if (preg_match('/^[a-f0-9]{64}$/i', $value) === 1) { + return true; + } + + $decoded = base64_decode($value, true); + return is_string($decoded) && strlen($decoded) === 32; + } + + private static function isValidHttpUrl(string $value): bool + { + if (filter_var($value, FILTER_VALIDATE_URL) === false) { + return false; + } + + $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME)); + return in_array($scheme, ['http', 'https'], true); + } + + private static function isLocalOrIpHost(string $host): bool + { + $host = strtolower(trim($host)); + if ($host === '') { + return true; + } + + if ($host === 'localhost' || str_ends_with($host, '.localhost')) { + return true; + } + + $host = trim($host, '[]'); + return filter_var($host, FILTER_VALIDATE_IP) !== false; + } + + private static function absolutePath(string $path): string + { + if (self::isAbsolutePath($path)) { + return $path; + } + + $projectRoot = dirname(__DIR__, 3); + return $projectRoot . '/' . ltrim($path, '/'); + } + + private static function isAbsolutePath(string $path): bool + { + if ($path === '') { + return false; + } + + if ($path[0] === '/' || $path[0] === '\\') { + return true; + } + + return preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1; + } +} diff --git a/lib/App/Container/ContainerRegistrar.php b/lib/App/Container/ContainerRegistrar.php new file mode 100644 index 0000000..75ef6fb --- /dev/null +++ b/lib/App/Container/ContainerRegistrar.php @@ -0,0 +1,10 @@ +set(PermissionGateway::class, static fn (AppContainer $c): PermissionGateway => $c->get(AccessServicesFactory::class)->createPermissionGateway()); + $container->set(AuthorizationService::class, static fn (AppContainer $c): AuthorizationService => $c->get(AccessServicesFactory::class)->createAuthorizationService()); + $container->set(UiAccessService::class, static fn (AppContainer $c): UiAccessService => new UiAccessService( + $c->get(AuthorizationService::class) + )); + $container->set(PermissionService::class, static fn (AppContainer $c): PermissionService => $c->get(AccessServicesFactory::class)->createPermissionService()); + $container->set(RoleService::class, static fn (AppContainer $c): RoleService => $c->get(DirectoryServicesFactory::class)->createRoleService()); + $container->set(PermissionRepository::class, static fn (AppContainer $c): PermissionRepository => $c->get(AccessServicesFactory::class)->createPermissionRepository()); + $container->set(RolePermissionRepository::class, static fn (AppContainer $c): RolePermissionRepository => $c->get(AccessServicesFactory::class)->createRolePermissionRepository()); + $container->set(RoleRepository::class, static fn (AppContainer $c): RoleRepository => $c->get(DirectoryServicesFactory::class)->createRoleRepository()); + } +} diff --git a/lib/App/Container/Registrars/AppServicesRegistrar.php b/lib/App/Container/Registrars/AppServicesRegistrar.php new file mode 100644 index 0000000..b3087bb --- /dev/null +++ b/lib/App/Container/Registrars/AppServicesRegistrar.php @@ -0,0 +1,66 @@ +set(AdminStatsViewDataService::class, static fn (AppContainer $c): AdminStatsViewDataService => new AdminStatsViewDataService( + $c->get(AdminStatsRepository::class) + )); + $container->set(SearchDataService::class, static fn (AppContainer $c): SearchDataService => new SearchDataService( + $c->get(PermissionGateway::class), + $c->get(UserTenantRepository::class), + $c->get(SearchQueryRepository::class) + )); + $container->set(RateLimiterService::class, static fn (AppContainer $c): RateLimiterService => $c->get(SecurityServicesFactory::class)->createRateLimiterService()); + $container->set(MailService::class, static fn (AppContainer $c): MailService => $c->get(MailServicesFactory::class)->createMailService()); + $container->set(MailLogService::class, static fn (AppContainer $c): MailLogService => $c->get(MailServicesFactory::class)->createMailLogService()); + $container->set(ApiAuditService::class, static fn (AppContainer $c): ApiAuditService => $c->get(AuditServicesFactory::class)->createApiAuditService()); + $container->set(UserLifecycleAuditService::class, static fn (AppContainer $c): UserLifecycleAuditService => $c->get(AuditServicesFactory::class)->createUserLifecycleAuditService()); + $container->set(SystemAuditService::class, static fn (AppContainer $c): SystemAuditService => $c->get(AuditServicesFactory::class)->createSystemAuditService()); + $container->set(ApiSystemAuditReporter::class, static fn (AppContainer $c): ApiSystemAuditReporter => new ApiSystemAuditReporter( + $c->get(SystemAuditService::class) + )); + $container->set(ImportService::class, static fn (AppContainer $c): ImportService => $c->get(ImportServicesFactory::class)->createImportService()); + $container->set(ImportAuditService::class, static fn (AppContainer $c): ImportAuditService => $c->get(ImportServicesFactory::class)->createImportAuditService()); + $container->set(ScheduledJobService::class, static fn (AppContainer $c): ScheduledJobService => $c->get(SchedulerServicesFactory::class)->createScheduledJobService()); + $container->set(SchedulerRunService::class, static fn (AppContainer $c): SchedulerRunService => $c->get(SchedulerServicesFactory::class)->createSchedulerRunService()); + $container->set(AddressBookService::class, static fn (AppContainer $c): AddressBookService => $c->get(AddressBookServicesFactory::class)->createAddressBookService()); + $container->set(TenantCustomFieldService::class, static fn (AppContainer $c): TenantCustomFieldService => $c->get(CustomFieldServicesFactory::class)->createTenantCustomFieldService()); + $container->set(UserCustomFieldValueService::class, static fn (AppContainer $c): UserCustomFieldValueService => $c->get(CustomFieldServicesFactory::class)->createUserCustomFieldValueService()); + $container->set(RequestInputFactory::class, static fn (): RequestInputFactory => new RequestInputFactory()); + } +} diff --git a/lib/App/Container/Registrars/AuthRegistrar.php b/lib/App/Container/Registrars/AuthRegistrar.php new file mode 100644 index 0000000..c418539 --- /dev/null +++ b/lib/App/Container/Registrars/AuthRegistrar.php @@ -0,0 +1,48 @@ +set(AuthService::class, static fn (AppContainer $c): AuthService => $c->get(AuthServicesFactory::class)->createAuthService()); + $container->set(RememberMeService::class, static fn (AppContainer $c): RememberMeService => $c->get(AuthServicesFactory::class)->createRememberMeService()); + $container->set(ApiTokenService::class, static fn (AppContainer $c): ApiTokenService => $c->get(AuthServicesFactory::class)->createApiTokenService()); + $container->set(ApiTokenEndpointService::class, static fn (AppContainer $c): ApiTokenEndpointService => new ApiTokenEndpointService( + $c->get(AuthRepositoryFactory::class)->createApiTokenRepository(), + $c->get(TenantRepository::class), + $c->get(AuthScopeGateway::class) + )); + $container->set(PasswordResetService::class, static fn (AppContainer $c): PasswordResetService => $c->get(AuthServicesFactory::class)->createPasswordResetService()); + $container->set(EmailVerificationService::class, static fn (AppContainer $c): EmailVerificationService => $c->get(AuthServicesFactory::class)->createEmailVerificationService()); + $container->set(TenantSsoService::class, static fn (AppContainer $c): TenantSsoService => $c->get(AuthServicesFactory::class)->createTenantSsoService()); + $container->set(MicrosoftOidcService::class, static fn (AppContainer $c): MicrosoftOidcService => $c->get(AuthServicesFactory::class)->createMicrosoftOidcService()); + $container->set(SsoUserLinkService::class, static fn (AppContainer $c): SsoUserLinkService => $c->get(AuthServicesFactory::class)->createSsoUserLinkService()); + $container->set(AuthScopeGateway::class, static fn (AppContainer $c): AuthScopeGateway => $c->get(AuthServicesFactory::class)->createAuthScopeGateway()); + $container->set(TenantMicrosoftAuthRepository::class, static fn (AppContainer $c): TenantMicrosoftAuthRepository => $c->get(AuthRepositoryFactory::class)->createTenantMicrosoftAuthRepository()); + $container->set(ApiTokenRepository::class, static fn (AppContainer $c): ApiTokenRepository => $c->get(AuthRepositoryFactory::class)->createApiTokenRepository()); + $container->set(RememberTokenRepository::class, static fn (AppContainer $c): RememberTokenRepository => $c->get(AuthRepositoryFactory::class)->createRememberTokenRepository()); + $container->set(PasswordResetRepository::class, static fn (AppContainer $c): PasswordResetRepository => $c->get(AuthRepositoryFactory::class)->createPasswordResetRepository()); + } +} diff --git a/lib/App/Container/Registrars/DirectoryRegistrar.php b/lib/App/Container/Registrars/DirectoryRegistrar.php new file mode 100644 index 0000000..126a1eb --- /dev/null +++ b/lib/App/Container/Registrars/DirectoryRegistrar.php @@ -0,0 +1,33 @@ +set(TenantService::class, static fn (AppContainer $c): TenantService => $c->get(DirectoryServicesFactory::class)->createTenantService()); + $container->set(TenantScopeService::class, static fn (AppContainer $c): TenantScopeService => $c->get(TenantServicesFactory::class)->createTenantScopeService()); + $container->set(DepartmentService::class, static fn (AppContainer $c): DepartmentService => $c->get(DirectoryServicesFactory::class)->createDepartmentService()); + $container->set(DirectoryScopeGateway::class, static fn (AppContainer $c): DirectoryScopeGateway => $c->get(DirectoryServicesFactory::class)->createDirectoryScopeGateway()); + $container->set(DirectorySettingsGateway::class, static fn (AppContainer $c): DirectorySettingsGateway => $c->get(DirectoryServicesFactory::class)->createDirectorySettingsGateway()); + $container->set(TenantAvatarService::class, static fn (AppContainer $c): TenantAvatarService => $c->get(TenantServicesFactory::class)->createTenantAvatarService()); + $container->set(TenantFaviconService::class, static fn (AppContainer $c): TenantFaviconService => $c->get(TenantServicesFactory::class)->createTenantFaviconService()); + $container->set(TenantRepository::class, static fn (AppContainer $c): TenantRepository => $c->get(TenantServicesFactory::class)->createTenantRepository()); + $container->set(DepartmentRepository::class, static fn (AppContainer $c): DepartmentRepository => $c->get(DirectoryServicesFactory::class)->createDepartmentRepository()); + } +} diff --git a/lib/App/Container/Registrars/RepositoryFactoryRegistrar.php b/lib/App/Container/Registrars/RepositoryFactoryRegistrar.php new file mode 100644 index 0000000..b5de229 --- /dev/null +++ b/lib/App/Container/Registrars/RepositoryFactoryRegistrar.php @@ -0,0 +1,41 @@ +set(SettingRepositoryFactory::class, static fn (): SettingRepositoryFactory => new SettingRepositoryFactory()); + $container->set(AuditRepositoryFactory::class, static fn (): AuditRepositoryFactory => new AuditRepositoryFactory()); + $container->set(SecurityRepositoryFactory::class, static fn (): SecurityRepositoryFactory => new SecurityRepositoryFactory()); + $container->set(MailRepositoryFactory::class, static fn (): MailRepositoryFactory => new MailRepositoryFactory()); + $container->set(ImportRepositoryFactory::class, static fn (): ImportRepositoryFactory => new ImportRepositoryFactory()); + $container->set(SchedulerRepositoryFactory::class, static fn (): SchedulerRepositoryFactory => new SchedulerRepositoryFactory()); + $container->set(TenantRepositoryFactory::class, static fn (): TenantRepositoryFactory => new TenantRepositoryFactory()); + $container->set(DirectoryRepositoryFactory::class, static fn (): DirectoryRepositoryFactory => new DirectoryRepositoryFactory()); + $container->set(AccessRepositoryFactory::class, static fn (): AccessRepositoryFactory => new AccessRepositoryFactory()); + $container->set(UserRepositoryFactory::class, static fn (): UserRepositoryFactory => new UserRepositoryFactory()); + $container->set(AuthRepositoryFactory::class, static fn (): AuthRepositoryFactory => new AuthRepositoryFactory()); + $container->set(AdminStatsRepository::class, static fn (): AdminStatsRepository => new AdminStatsRepository()); + $container->set(SearchQueryRepository::class, static fn (): SearchQueryRepository => new SearchQueryRepository()); + $container->set(DatabaseSessionRepository::class, static fn (): DatabaseSessionRepository => new DatabaseSessionRepository()); + } +} diff --git a/lib/App/Container/Registrars/ServiceFactoryRegistrar.php b/lib/App/Container/Registrars/ServiceFactoryRegistrar.php new file mode 100644 index 0000000..4d4e3a3 --- /dev/null +++ b/lib/App/Container/Registrars/ServiceFactoryRegistrar.php @@ -0,0 +1,144 @@ +set(SettingServicesFactory::class, static fn (AppContainer $c): SettingServicesFactory => new SettingServicesFactory( + $c->get(SettingRepositoryFactory::class) + )); + $container->set(AuditServicesFactory::class, static fn (AppContainer $c): AuditServicesFactory => new AuditServicesFactory( + $c->get(AuditRepositoryFactory::class), + $c->get(SettingGateway::class) + )); + $container->set(SecurityServicesFactory::class, static fn (AppContainer $c): SecurityServicesFactory => new SecurityServicesFactory( + $c->get(SecurityRepositoryFactory::class) + )); + $container->set(MailServicesFactory::class, static fn (AppContainer $c): MailServicesFactory => new MailServicesFactory( + $c->get(MailRepositoryFactory::class), + $c->get(SettingGateway::class) + )); + $container->set(BrandingServicesFactory::class, static fn (AppContainer $c): BrandingServicesFactory => new BrandingServicesFactory( + $c->get(SettingGateway::class) + )); + $container->set(CustomFieldServicesFactory::class, static fn (AppContainer $c): CustomFieldServicesFactory => new CustomFieldServicesFactory( + $c->get(TenantRepository::class), + $c->get(UserScopeGateway::class) + )); + $container->set(TenantServicesFactory::class, static fn (AppContainer $c): TenantServicesFactory => new TenantServicesFactory( + $c->get(PermissionGateway::class), + $c->get(TenantRepositoryFactory::class) + )); + $container->set(ImportServicesFactory::class, static fn (AppContainer $c): ImportServicesFactory => new ImportServicesFactory( + $c->get(UserServicesFactory::class), + $c->get(AccessServicesFactory::class), + $c->get(SettingServicesFactory::class), + $c->get(UserRepositoryFactory::class), + $c->get(DirectoryServicesFactory::class), + $c->get(ImportRepositoryFactory::class) + )); + $container->set(SchedulerServicesFactory::class, static fn (AppContainer $c): SchedulerServicesFactory => new SchedulerServicesFactory( + $c->get(UserServicesFactory::class), + $c->get(AuditServicesFactory::class), + $c->get(DatabaseSessionRepository::class), + $c->get(SchedulerRepositoryFactory::class) + )); + $container->set(AddressBookServicesFactory::class, static fn (AppContainer $c): AddressBookServicesFactory => new AddressBookServicesFactory( + $c->get(UserServicesFactory::class), + $c->get(DirectoryServicesFactory::class), + $c->get(CustomFieldServicesFactory::class) + )); + $container->set(DirectoryServicesFactory::class, static fn (AppContainer $c): DirectoryServicesFactory => new DirectoryServicesFactory( + $c->get(UserServicesFactory::class), + $c->get(AuditServicesFactory::class), + $c->get(DirectoryRepositoryFactory::class), + $c->get(DirectoryGatewayFactory::class) + )); + $container->set(DirectoryGatewayFactory::class, static fn (AppContainer $c): DirectoryGatewayFactory => new DirectoryGatewayFactory( + $c->get(SettingServicesFactory::class), + $c->get(TenantScopeService::class) + )); + $container->set(AccessServicesFactory::class, static fn (AppContainer $c): AccessServicesFactory => new AccessServicesFactory( + $c->get(AccessRepositoryFactory::class), + $c->get(AccessGatewayFactory::class), + static fn (): AccessPolicyFactory => $c->get(AccessPolicyFactory::class) + )); + $container->set(AccessGatewayFactory::class, static fn (AppContainer $c): AccessGatewayFactory => new AccessGatewayFactory( + $c->get(AccessRepositoryFactory::class), + $c->get(AuditServicesFactory::class) + )); + $container->set(AccessPolicyFactory::class, static fn (AppContainer $c): AccessPolicyFactory => new AccessPolicyFactory( + $c->get(PermissionGateway::class), + $c->get(DirectoryScopeGateway::class) + )); + $container->set(UserGatewayFactory::class, static fn (AppContainer $c): UserGatewayFactory => new UserGatewayFactory( + $c->get(UserRepositoryFactory::class), + $c->get(AccessServicesFactory::class), + $c->get(SettingServicesFactory::class), + $c->get(TenantScopeService::class) + )); + $container->set(UserServicesFactory::class, static fn (AppContainer $c): UserServicesFactory => new UserServicesFactory( + $c->get(AuditServicesFactory::class), + $c->get(UserRepositoryFactory::class), + $c->get(UserGatewayFactory::class), + $c->get(DatabaseSessionRepository::class) + )); + $container->set(AuthGatewayFactory::class, static fn (AppContainer $c): AuthGatewayFactory => new AuthGatewayFactory( + $c->get(AuthRepositoryFactory::class), + $c->get(AccessServicesFactory::class), + $c->get(UserServicesFactory::class), + $c->get(SettingServicesFactory::class), + $c->get(TenantScopeService::class) + )); + $container->set(AuthServicesFactory::class, static fn (AppContainer $c): AuthServicesFactory => new AuthServicesFactory( + $c->get(UserServicesFactory::class), + $c->get(AuditServicesFactory::class), + $c->get(MailServicesFactory::class), + $c->get(AuthRepositoryFactory::class), + $c->get(AuthGatewayFactory::class), + $c->get(DatabaseSessionRepository::class) + )); + } +} diff --git a/lib/App/Container/Registrars/SettingsRegistrar.php b/lib/App/Container/Registrars/SettingsRegistrar.php new file mode 100644 index 0000000..e0f8aef --- /dev/null +++ b/lib/App/Container/Registrars/SettingsRegistrar.php @@ -0,0 +1,44 @@ +set(SettingService::class, static fn (AppContainer $c): SettingService => $c->get(SettingServicesFactory::class)->createSettingService()); + $container->set(SettingGateway::class, static fn (AppContainer $c): SettingGateway => $c->get(SettingServicesFactory::class)->createSettingGateway()); + $container->set(SettingCacheService::class, static fn (AppContainer $c): SettingCacheService => $c->get(SettingServicesFactory::class)->createSettingCacheService()); + $container->set(AdminSettingsService::class, static fn (AppContainer $c): AdminSettingsService => new AdminSettingsService( + $c->get(SettingGateway::class), + $c->get(SettingCacheService::class), + $c->get(TenantService::class), + $c->get(RoleService::class), + $c->get(DepartmentService::class), + $c->get(RememberTokenRepository::class), + $c->get(ApiTokenRepository::class), + $c->get(SystemAuditService::class) + )); + $container->set(ThemeConfigService::class, static fn (AppContainer $c): ThemeConfigService => $c->get(SettingServicesFactory::class)->createThemeConfigService()); + $container->set(BrandingLogoService::class, static fn (AppContainer $c): BrandingLogoService => $c->get(BrandingServicesFactory::class)->createBrandingLogoService()); + $container->set(BrandingFaviconService::class, static fn (AppContainer $c): BrandingFaviconService => $c->get(BrandingServicesFactory::class)->createBrandingFaviconService()); + } +} diff --git a/lib/App/Container/Registrars/UserRegistrar.php b/lib/App/Container/Registrars/UserRegistrar.php new file mode 100644 index 0000000..cc58302 --- /dev/null +++ b/lib/App/Container/Registrars/UserRegistrar.php @@ -0,0 +1,66 @@ +set(UserAccountService::class, static fn (AppContainer $c): UserAccountService => $c->get(UserServicesFactory::class)->createUserAccountService()); + $container->set(UserAssignmentService::class, static fn (AppContainer $c): UserAssignmentService => $c->get(UserServicesFactory::class)->createUserAssignmentService()); + $container->set(UserPasswordService::class, static fn (AppContainer $c): UserPasswordService => $c->get(UserServicesFactory::class)->createUserPasswordService()); + $container->set(UserAvatarService::class, static fn (AppContainer $c): UserAvatarService => $c->get(UserServicesFactory::class)->createUserAvatarService()); + $container->set(UserDirectoryGateway::class, static fn (AppContainer $c): UserDirectoryGateway => $c->get(UserServicesFactory::class)->createUserDirectoryGateway()); + $container->set(UserScopeGateway::class, static fn (AppContainer $c): UserScopeGateway => $c->get(UserServicesFactory::class)->createUserScopeGateway()); + $container->set(UserAccessTemplateService::class, static fn (AppContainer $c): UserAccessTemplateService => new UserAccessTemplateService( + $c->get(TenantSsoService::class), + $c->get(UserDirectoryGateway::class), + $c->get(UserTenantRepository::class) + )); + $container->set(UserAccessPdfService::class, static fn (AppContainer $c): UserAccessPdfService => new UserAccessPdfService( + $c->get(UserAccessTemplateService::class), + $c->get(BrandingLogoService::class) + )); + $container->set(UserSavedFilterService::class, static fn (AppContainer $c): UserSavedFilterService => $c->get(UserServicesFactory::class)->createUserSavedFilterService()); + $container->set(UserTenantContextService::class, static fn (AppContainer $c): UserTenantContextService => $c->get(UserServicesFactory::class)->createUserTenantContextService()); + $container->set(UserLifecycleService::class, static fn (AppContainer $c): UserLifecycleService => $c->get(UserServicesFactory::class)->createUserLifecycleService()); + $container->set(UserPasswordPolicyService::class, static fn (AppContainer $c): UserPasswordPolicyService => $c->get(UserServicesFactory::class)->createUserPasswordPolicyService()); + $container->set(UserLifecycleRestoreService::class, static fn (AppContainer $c): UserLifecycleRestoreService => $c->get(UserServicesFactory::class)->createUserLifecycleRestoreService()); + $container->set(UserApiWriteInputMapper::class, static fn (AppContainer $c): UserApiWriteInputMapper => new UserApiWriteInputMapper( + $c->get(\MintyPHP\Service\Tenant\TenantService::class), + $c->get(\MintyPHP\Service\Access\RoleService::class), + $c->get(\MintyPHP\Service\Org\DepartmentService::class) + )); + $container->set(UserReadRepository::class, static fn (AppContainer $c): UserReadRepository => $c->get(UserRepositoryFactory::class)->createUserReadRepository()); + $container->set(UserWriteRepository::class, static fn (AppContainer $c): UserWriteRepository => $c->get(UserRepositoryFactory::class)->createUserWriteRepository()); + $container->set(UserTenantRepository::class, static fn (AppContainer $c): UserTenantRepository => $c->get(UserRepositoryFactory::class)->createUserTenantRepository()); + $container->set(UserRoleRepository::class, static fn (AppContainer $c): UserRoleRepository => $c->get(UserRepositoryFactory::class)->createUserRoleRepository()); + $container->set(UserDepartmentRepository::class, static fn (AppContainer $c): UserDepartmentRepository => $c->get(UserRepositoryFactory::class)->createUserDepartmentRepository()); + } +} diff --git a/lib/App/registerContainer.php b/lib/App/registerContainer.php new file mode 100644 index 0000000..05f470e --- /dev/null +++ b/lib/App/registerContainer.php @@ -0,0 +1,30 @@ +register($container); +} + +return $container; diff --git a/lib/Domain/Taxonomy/ImportAuditStatus.php b/lib/Domain/Taxonomy/ImportAuditStatus.php new file mode 100644 index 0000000..1dace8d --- /dev/null +++ b/lib/Domain/Taxonomy/ImportAuditStatus.php @@ -0,0 +1,33 @@ + 'Running', + self::Success => 'Success', + self::Partial => 'Partial', + self::Failed => 'Failed', + }; + } + + public function badgeVariant(): string + { + return match ($this) { + self::Running => 'info', + self::Success => 'success', + self::Partial => 'warning', + self::Failed => 'danger', + }; + } +} diff --git a/lib/Domain/Taxonomy/MailLogStatus.php b/lib/Domain/Taxonomy/MailLogStatus.php new file mode 100644 index 0000000..4e2bc13 --- /dev/null +++ b/lib/Domain/Taxonomy/MailLogStatus.php @@ -0,0 +1,30 @@ + 'Queued', + self::Sent => 'Sent', + self::Failed => 'Failed', + }; + } + + public function badgeVariant(): string + { + return match ($this) { + self::Queued => 'info', + self::Sent => 'success', + self::Failed => 'danger', + }; + } +} diff --git a/lib/Domain/Taxonomy/ScheduledJobRunStatus.php b/lib/Domain/Taxonomy/ScheduledJobRunStatus.php new file mode 100644 index 0000000..b28d49a --- /dev/null +++ b/lib/Domain/Taxonomy/ScheduledJobRunStatus.php @@ -0,0 +1,30 @@ + 'Success', + self::Failed => 'Failed', + self::Skipped => 'Skipped', + }; + } + + public function badgeVariant(): string + { + return match ($this) { + self::Success => 'success', + self::Failed => 'danger', + self::Skipped => 'warning', + }; + } +} diff --git a/lib/Domain/Taxonomy/ScheduledJobStatus.php b/lib/Domain/Taxonomy/ScheduledJobStatus.php new file mode 100644 index 0000000..52727d7 --- /dev/null +++ b/lib/Domain/Taxonomy/ScheduledJobStatus.php @@ -0,0 +1,33 @@ + 'Running', + self::Success => 'Success', + self::Failed => 'Failed', + self::Skipped => 'Skipped', + }; + } + + public function badgeVariant(): string + { + return match ($this) { + self::Running => 'info', + self::Success => 'success', + self::Failed => 'danger', + self::Skipped => 'warning', + }; + } +} diff --git a/lib/Domain/Taxonomy/ScheduledJobTriggerType.php b/lib/Domain/Taxonomy/ScheduledJobTriggerType.php new file mode 100644 index 0000000..98b564a --- /dev/null +++ b/lib/Domain/Taxonomy/ScheduledJobTriggerType.php @@ -0,0 +1,27 @@ + 'Scheduler', + self::Manual => 'Manual', + }; + } + + public function badgeVariant(): string + { + return match ($this) { + self::Scheduler => 'neutral', + self::Manual => 'info', + }; + } +} diff --git a/lib/Domain/Taxonomy/SchedulerRuntimeResult.php b/lib/Domain/Taxonomy/SchedulerRuntimeResult.php new file mode 100644 index 0000000..a48cdad --- /dev/null +++ b/lib/Domain/Taxonomy/SchedulerRuntimeResult.php @@ -0,0 +1,30 @@ + 'OK', + self::LockNotAcquired => 'Lock not acquired', + self::UnexpectedError => 'Unexpected error', + }; + } + + public function badgeVariant(): string + { + return match ($this) { + self::Ok => 'success', + self::LockNotAcquired => 'warning', + self::UnexpectedError => 'danger', + }; + } +} diff --git a/lib/Domain/Taxonomy/SupportsStringTaxonomy.php b/lib/Domain/Taxonomy/SupportsStringTaxonomy.php new file mode 100644 index 0000000..9f5bae9 --- /dev/null +++ b/lib/Domain/Taxonomy/SupportsStringTaxonomy.php @@ -0,0 +1,38 @@ + + */ + public static function values(): array + { + return array_map( + static fn (self $case): string => $case->value, + self::cases() + ); + } + + public static function tryNormalize(string $value): ?self + { + $value = strtolower(trim($value)); + if ($value === '') { + return null; + } + + foreach (self::cases() as $case) { + if (strtolower($case->value) === $value) { + return $case; + } + } + + return null; + } + + public static function normalizeOr(string $value, self $fallback): self + { + return self::tryNormalize($value) ?? $fallback; + } +} diff --git a/lib/Domain/Taxonomy/SystemAuditChannel.php b/lib/Domain/Taxonomy/SystemAuditChannel.php new file mode 100644 index 0000000..99956c0 --- /dev/null +++ b/lib/Domain/Taxonomy/SystemAuditChannel.php @@ -0,0 +1,33 @@ + 'Web', + self::Api => 'API', + self::Scheduler => 'Scheduler', + self::Cli => 'CLI', + }; + } + + public function badgeVariant(): string + { + return match ($this) { + self::Web => 'neutral', + self::Api => 'info', + self::Scheduler => 'warning', + self::Cli => 'neutral', + }; + } +} diff --git a/lib/Domain/Taxonomy/SystemAuditOutcome.php b/lib/Domain/Taxonomy/SystemAuditOutcome.php new file mode 100644 index 0000000..446cb71 --- /dev/null +++ b/lib/Domain/Taxonomy/SystemAuditOutcome.php @@ -0,0 +1,30 @@ + 'Success', + self::Failed => 'Failed', + self::Denied => 'Denied', + }; + } + + public function badgeVariant(): string + { + return match ($this) { + self::Success => 'success', + self::Failed => 'danger', + self::Denied => 'warning', + }; + } +} diff --git a/lib/Domain/Taxonomy/TenantStatus.php b/lib/Domain/Taxonomy/TenantStatus.php new file mode 100644 index 0000000..7affc24 --- /dev/null +++ b/lib/Domain/Taxonomy/TenantStatus.php @@ -0,0 +1,27 @@ + 'Active', + self::Inactive => 'Inactive', + }; + } + + public function badgeVariant(): string + { + return match ($this) { + self::Active => 'success', + self::Inactive => 'danger', + }; + } +} diff --git a/lib/Domain/Taxonomy/UserLifecycleAction.php b/lib/Domain/Taxonomy/UserLifecycleAction.php new file mode 100644 index 0000000..51152cf --- /dev/null +++ b/lib/Domain/Taxonomy/UserLifecycleAction.php @@ -0,0 +1,30 @@ + 'Deactivate', + self::Delete => 'Delete', + self::Restore => 'Restore', + }; + } + + public function badgeVariant(): string + { + return match ($this) { + self::Deactivate => 'warning', + self::Delete => 'danger', + self::Restore => 'success', + }; + } +} diff --git a/lib/Domain/Taxonomy/UserLifecycleStatus.php b/lib/Domain/Taxonomy/UserLifecycleStatus.php new file mode 100644 index 0000000..1e0380c --- /dev/null +++ b/lib/Domain/Taxonomy/UserLifecycleStatus.php @@ -0,0 +1,30 @@ + 'Success', + self::Skipped => 'Skipped', + self::Failed => 'Failed', + }; + } + + public function badgeVariant(): string + { + return match ($this) { + self::Success => 'success', + self::Skipped => 'warning', + self::Failed => 'danger', + }; + } +} diff --git a/lib/Domain/Taxonomy/UserLifecycleTriggerType.php b/lib/Domain/Taxonomy/UserLifecycleTriggerType.php new file mode 100644 index 0000000..442f763 --- /dev/null +++ b/lib/Domain/Taxonomy/UserLifecycleTriggerType.php @@ -0,0 +1,30 @@ + 'Manual', + self::Cron => 'Cron', + self::System => 'System', + }; + } + + public function badgeVariant(): string + { + return match ($this) { + self::Manual => 'info', + self::Cron => 'neutral', + self::System => 'warning', + }; + } +} diff --git a/lib/Http/ApiAuth.php b/lib/Http/ApiAuth.php index 140c687..60bd78a 100644 --- a/lib/Http/ApiAuth.php +++ b/lib/Http/ApiAuth.php @@ -2,13 +2,32 @@ namespace MintyPHP\Http; -use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Repository\Access\RolePermissionRepository; +use MintyPHP\Repository\Access\UserRoleRepository; +use MintyPHP\Service\Access\AuthorizationService; +use MintyPHP\Service\Auth\ApiTokenService; use MintyPHP\Service\Auth\AuthScopeGateway; -use MintyPHP\Service\Auth\AuthServicesFactory; -use MintyPHP\Service\User\UserServicesFactory; +use MintyPHP\Service\User\UserTenantContextService; class ApiAuth { + /** @var (callable(): AuthScopeGateway)|null */ + private static $authScopeGatewayResolver = null; + + /** @var (callable(): ApiTokenService)|null */ + private static $apiTokenServiceResolver = null; + + /** @var (callable(): UserRoleRepository)|null */ + private static $userRoleRepositoryResolver = null; + + /** @var (callable(): RolePermissionRepository)|null */ + private static $rolePermissionRepositoryResolver = null; + + /** @var (callable(): UserTenantContextService)|null */ + private static $userTenantContextServiceResolver = null; + /** @var (callable(): AuthorizationService)|null */ + private static $authorizationServiceResolver = null; + private static ?array $currentUser = null; private static ?array $currentPermissions = null; private static ?int $currentTenantId = null; @@ -16,6 +35,22 @@ class ApiAuth private static ?array $currentTokenRecord = null; private static bool $authenticated = false; + public static function configure( + callable $authScopeGatewayResolver, + callable $apiTokenServiceResolver, + callable $userRoleRepositoryResolver, + callable $rolePermissionRepositoryResolver, + callable $userTenantContextServiceResolver, + callable $authorizationServiceResolver + ): void { + self::$authScopeGatewayResolver = $authScopeGatewayResolver; + self::$apiTokenServiceResolver = $apiTokenServiceResolver; + self::$userRoleRepositoryResolver = $userRoleRepositoryResolver; + self::$rolePermissionRepositoryResolver = $rolePermissionRepositoryResolver; + self::$userTenantContextServiceResolver = $userTenantContextServiceResolver; + self::$authorizationServiceResolver = $authorizationServiceResolver; + } + /** * Extract the full Bearer token from the Authorization header. */ @@ -49,19 +84,17 @@ class ApiAuth return false; } - $authServicesFactory = new AuthServicesFactory(); - $scopeGateway = $authServicesFactory->createAuthScopeGateway(); - $result = $authServicesFactory->createApiTokenService()->validate($bearerToken); + $scopeGateway = self::scopeGateway(); + $result = self::apiTokenService()->validate($bearerToken); if ($result === null) { return false; } $user = $result['user']; $userId = (int) ($user['id'] ?? 0); - $userServicesFactory = new UserServicesFactory(); - $userRoleRepository = $userServicesFactory->createUserRoleRepository(); - $rolePermissionRepository = (new AccessServicesFactory())->createRolePermissionRepository(); - $userTenantContextService = $userServicesFactory->createUserTenantContextService(); + $userRoleRepository = self::userRoleRepository(); + $rolePermissionRepository = self::rolePermissionRepository(); + $userTenantContextService = self::userTenantContextService(); // Load permissions directly (bypass session cache) $roleIds = $userRoleRepository->listRoleIdsByUserId($userId); @@ -110,9 +143,14 @@ class ApiAuth return self::$currentPermissions ?? []; } - public static function hasPermission(string $key): bool + public static function can(string $ability, array $context = []): bool { - return in_array($key, self::$currentPermissions ?? [], true); + $decision = self::authorizationService()->authorize($ability, [ + 'actor_user_id' => self::userId(), + 'scoped_tenant_id' => self::scopedTenantId(), + ...$context, + ]); + return $decision->isAllowed(); } public static function tenantId(): ?int @@ -146,7 +184,7 @@ class ApiAuth */ public static function requireResourceAccess(string $resource, int $resourceId): void { - $scopeGateway = (new AuthServicesFactory())->createAuthScopeGateway(); + $scopeGateway = self::scopeGateway(); if (!$scopeGateway->canAccess($resource, $resourceId, self::userId())) { ApiResponse::notFound(); } @@ -164,7 +202,7 @@ class ApiAuth ApiResponse::forbidden(); } - $scopeGateway = (new AuthServicesFactory())->createAuthScopeGateway(); + $scopeGateway = self::scopeGateway(); if (!$scopeGateway->resourceBelongsToTenant($resource, $resourceId, $tenantId)) { ApiResponse::notFound(); } @@ -175,8 +213,14 @@ class ApiAuth */ public static function canSelfManageTokens(): bool { - return self::hasPermission(\MintyPHP\Service\Access\PermissionService::USERS_SELF_UPDATE) - || self::hasPermission(\MintyPHP\Service\Access\PermissionService::API_TOKENS_MANAGE); + $decision = self::authorizationService()->authorize( + \MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_API_TOKENS_SELF_MANAGE, + [ + 'actor_user_id' => self::userId(), + 'scoped_tenant_id' => self::scopedTenantId(), + ] + ); + return $decision->isAllowed(); } public static function requireSelfManageTokens(): void @@ -185,4 +229,49 @@ class ApiAuth ApiResponse::forbidden('api_tokens_self_manage_forbidden'); } } + + private static function scopeGateway(): AuthScopeGateway + { + return self::resolveDependency(self::$authScopeGatewayResolver, AuthScopeGateway::class, 'ApiAuth'); + } + + private static function apiTokenService(): ApiTokenService + { + return self::resolveDependency(self::$apiTokenServiceResolver, ApiTokenService::class, 'ApiAuth'); + } + + private static function userRoleRepository(): UserRoleRepository + { + return self::resolveDependency(self::$userRoleRepositoryResolver, UserRoleRepository::class, 'ApiAuth'); + } + + private static function rolePermissionRepository(): RolePermissionRepository + { + return self::resolveDependency(self::$rolePermissionRepositoryResolver, RolePermissionRepository::class, 'ApiAuth'); + } + + private static function userTenantContextService(): UserTenantContextService + { + return self::resolveDependency(self::$userTenantContextServiceResolver, UserTenantContextService::class, 'ApiAuth'); + } + + private static function authorizationService(): AuthorizationService + { + return self::resolveDependency(self::$authorizationServiceResolver, AuthorizationService::class, 'ApiAuth'); + } + + private static function resolveDependency(mixed $resolver, string $expectedClass, string $consumer): mixed + { + if (!is_callable($resolver)) { + throw new \RuntimeException($consumer . ' is not configured for dependency: ' . $expectedClass); + } + + $service = $resolver(); + if (!$service instanceof $expectedClass) { + throw new \RuntimeException($consumer . ' resolver returned invalid dependency: ' . $expectedClass); + } + + return $service; + } + } diff --git a/lib/Http/ApiBootstrap.php b/lib/Http/ApiBootstrap.php index 619ea88..65f1397 100644 --- a/lib/Http/ApiBootstrap.php +++ b/lib/Http/ApiBootstrap.php @@ -2,10 +2,10 @@ namespace MintyPHP\Http; +use MintyPHP\Http\RequestContext; +use MintyPHP\Service\Audit\ApiAuditService; use MintyPHP\Service\Security\RateLimiterService; -use MintyPHP\Service\Security\SecurityServicesFactory; use MintyPHP\Service\Settings\SettingGateway; -use MintyPHP\Service\Settings\SettingServicesFactory; class ApiBootstrap { @@ -18,10 +18,26 @@ class ApiBootstrap private static bool $initialized = false; private static bool $shutdownRegistered = false; - private static ?SecurityServicesFactory $securityServicesFactory = null; - private static ?RateLimiterService $rateLimiterService = null; - private static ?SettingServicesFactory $settingServicesFactory = null; - private static ?SettingGateway $settingGateway = null; + /** @var (callable(): ApiAuditService)|null */ + private static $apiAuditServiceResolver = null; + /** @var (callable(): SettingGateway)|null */ + private static $settingGatewayResolver = null; + /** @var (callable(): RateLimiterService)|null */ + private static $rateLimiterServiceResolver = null; + /** @var (callable(): ApiSystemAuditReporter)|null */ + private static $apiSystemAuditReporterResolver = null; + + public static function configure( + callable $apiAuditServiceResolver, + callable $settingGatewayResolver, + callable $rateLimiterServiceResolver, + callable $apiSystemAuditReporterResolver + ): void { + self::$apiAuditServiceResolver = $apiAuditServiceResolver; + self::$settingGatewayResolver = $settingGatewayResolver; + self::$rateLimiterServiceResolver = $rateLimiterServiceResolver; + self::$apiSystemAuditReporterResolver = $apiSystemAuditReporterResolver; + } /** * Initialize the API request context. @@ -40,8 +56,16 @@ class ApiBootstrap define('MINTY_ALLOW_OUTPUT', true); } + RequestContext::start([ + 'channel' => 'api', + 'method' => strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')), + 'path' => (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH), + ]); + header('X-Request-Id: ' . RequestContext::requestHeaderValue()); + self::registerShutdownHandler(); - \auditServicesFactory()->createApiAuditService()->startRequestContext(); + self::apiAuditService()->startRequestContext(); + self::startSystemAuditReporter(); self::setCorsHeaders(); if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { @@ -96,7 +120,8 @@ class ApiBootstrap if ($statusCode <= 0) { $statusCode = 200; } - \auditServicesFactory()->createApiAuditService()->finish($statusCode); + self::apiAuditService()->finish($statusCode); + self::finishSystemAuditReporter($statusCode); return; } @@ -107,7 +132,8 @@ class ApiBootstrap if ($statusCode <= 0) { $statusCode = 200; } - \auditServicesFactory()->createApiAuditService()->finish($statusCode); + self::apiAuditService()->finish($statusCode); + self::finishSystemAuditReporter($statusCode); return; } @@ -115,7 +141,8 @@ class ApiBootstrap if ($statusCode < 400) { $statusCode = 500; } - \auditServicesFactory()->createApiAuditService()->finish($statusCode, 'fatal_error'); + self::apiAuditService()->finish($statusCode, 'fatal_error'); + self::finishSystemAuditReporter($statusCode, 'fatal_error'); }); } @@ -208,29 +235,53 @@ class ApiBootstrap private static function settings(): SettingGateway { - if (self::$settingGateway instanceof SettingGateway) { - return self::$settingGateway; - } - - if (!(self::$settingServicesFactory instanceof SettingServicesFactory)) { - self::$settingServicesFactory = new SettingServicesFactory(); - } - - self::$settingGateway = self::$settingServicesFactory->createSettingGateway(); - return self::$settingGateway; + return self::resolveDependency(self::$settingGatewayResolver, SettingGateway::class, 'ApiBootstrap'); } private static function rateLimiter(): RateLimiterService { - if (self::$rateLimiterService instanceof RateLimiterService) { - return self::$rateLimiterService; + return self::resolveDependency(self::$rateLimiterServiceResolver, RateLimiterService::class, 'ApiBootstrap'); + } + + private static function apiAuditService(): ApiAuditService + { + return self::resolveDependency(self::$apiAuditServiceResolver, ApiAuditService::class, 'ApiBootstrap'); + } + + private static function systemAuditReporter(): ApiSystemAuditReporter + { + return self::resolveDependency(self::$apiSystemAuditReporterResolver, ApiSystemAuditReporter::class, 'ApiBootstrap'); + } + + private static function startSystemAuditReporter(): void + { + try { + self::systemAuditReporter()->start(); + } catch (\Throwable) { + // fail-open + } + } + + private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void + { + try { + self::systemAuditReporter()->finish($statusCode, $errorCode); + } catch (\Throwable) { + // fail-open + } + } + + private static function resolveDependency(mixed $resolver, string $expectedClass, string $consumer): mixed + { + if (!is_callable($resolver)) { + throw new \RuntimeException($consumer . ' is not configured for dependency: ' . $expectedClass); } - if (!(self::$securityServicesFactory instanceof SecurityServicesFactory)) { - self::$securityServicesFactory = new SecurityServicesFactory(); + $service = $resolver(); + if (!$service instanceof $expectedClass) { + throw new \RuntimeException($consumer . ' resolver returned invalid dependency: ' . $expectedClass); } - self::$rateLimiterService = self::$securityServicesFactory->createRateLimiterService(); - return self::$rateLimiterService; + return $service; } } diff --git a/lib/Http/ApiResponse.php b/lib/Http/ApiResponse.php index 893e241..025a7da 100644 --- a/lib/Http/ApiResponse.php +++ b/lib/Http/ApiResponse.php @@ -2,8 +2,31 @@ namespace MintyPHP\Http; +use MintyPHP\Http\Input\FormErrors; +use MintyPHP\Http\RequestContext; +use MintyPHP\Service\Access\AuthorizationService; +use MintyPHP\Service\Audit\ApiAuditService; + class ApiResponse { + /** @var (callable(): ApiAuditService)|null */ + private static $apiAuditServiceResolver = null; + /** @var (callable(): AuthorizationService)|null */ + private static $authorizationServiceResolver = null; + /** @var (callable(): ApiSystemAuditReporter)|null */ + private static $apiSystemAuditReporterResolver = null; + + public static function configure( + callable $apiAuditServiceResolver, + callable $authorizationServiceResolver, + callable $apiSystemAuditReporterResolver + ): void + { + self::$apiAuditServiceResolver = $apiAuditServiceResolver; + self::$authorizationServiceResolver = $authorizationServiceResolver; + self::$apiSystemAuditReporterResolver = $apiSystemAuditReporterResolver; + } + public static function success(array $data = [], int $status = 200): never { self::send($data, $status); @@ -21,7 +44,7 @@ class ApiResponse public static function error(string $error, int $status = 400, array $extra = []): never { - $body = array_merge(['error' => $error], $extra); + $body = self::buildErrorBody($error, $extra); self::send($body, $status, $error); } @@ -50,10 +73,16 @@ class ApiResponse self::error('validation_error', 422, ['errors' => $errors]); } + public static function validationFromFormErrors(FormErrors $errors): never + { + self::validationError($errors->toArray()); + } + public static function tooManyRequests(int $retryAfter = 60): never { + $retryAfter = max(1, $retryAfter); self::send( - ['error' => 'rate_limit_exceeded'], + self::buildErrorBody('rate_limit_exceeded', ['retry_after' => $retryAfter]), 429, 'rate_limit_exceeded', ['Retry-After: ' . $retryAfter] @@ -118,39 +147,147 @@ class ApiResponse } } - /** - * Require a specific permission key. - */ - public static function requirePermission(string $key): void + public static function requireAbility(string $ability, array $context = []): void { self::requireAuth(); - if (!ApiAuth::hasPermission($key)) { + $decision = self::authorizationService()->authorize($ability, [ + 'actor_user_id' => ApiAuth::userId(), + 'scoped_tenant_id' => ApiAuth::scopedTenantId(), + ...$context, + ]); + if (!$decision->isAllowed()) { self::forbidden(); } } private static function send(?array $body, int $status, ?string $errorCode = null, array $headers = []): never { + $requestId = self::requestId(); http_response_code($status); + header('X-Request-Id: ' . $requestId); foreach ($headers as $headerLine) { header($headerLine); } if ($body === null) { - \auditServicesFactory()->createApiAuditService()->finish($status, $errorCode); + self::finishSystemAuditReporter($status, $errorCode); + self::apiAuditService()->finish($status, $errorCode); die(); } + if (!array_key_exists('request_id', $body)) { + $body['request_id'] = $requestId; + } + $json = json_encode($body, JSON_UNESCAPED_UNICODE); if (!is_string($json)) { $status = 500; http_response_code($status); $errorCode = $errorCode ?: 'serialization_error'; - $json = json_encode(['error' => 'serialization_error'], JSON_UNESCAPED_UNICODE) ?: '{"error":"serialization_error"}'; + $fallbackBody = self::buildErrorBody('serialization_error'); + $fallbackBody['request_id'] = $requestId; + $json = json_encode($fallbackBody, JSON_UNESCAPED_UNICODE) + ?: '{"ok":false,"error_code":"serialization_error","request_id":"unknown","details":{}}'; } header('Content-Type: application/json; charset=utf-8'); - \auditServicesFactory()->createApiAuditService()->finish($status, $errorCode); + self::finishSystemAuditReporter($status, $errorCode); + self::apiAuditService()->finish($status, $errorCode); die($json); } + + private static function requestId(): string + { + $requestId = RequestContext::currentId(); + if (is_string($requestId) && trim($requestId) !== '') { + return trim($requestId); + } + + $requestId = self::apiAuditService()->currentRequestId(); + if (is_string($requestId) && trim($requestId) !== '') { + return trim($requestId); + } + + return RequestContext::id(); + } + + private static function buildErrorBody(string $errorCode, array $details = []): array + { + $normalizedErrorCode = trim($errorCode); + if ($normalizedErrorCode === '') { + $normalizedErrorCode = 'unknown_error'; + } + + $normalizedDetails = self::normalizeDetails($details); + + return [ + 'ok' => false, + 'error_code' => $normalizedErrorCode, + 'details' => $normalizedDetails, + ]; + } + + private static function normalizeDetails(array $details): array|\stdClass + { + if ($details === []) { + return new \stdClass(); + } + + if (array_is_list($details)) { + return ['items' => $details]; + } + + return $details; + } + + private static function apiAuditService(): ApiAuditService + { + if (!is_callable(self::$apiAuditServiceResolver)) { + throw new \RuntimeException('ApiResponse is not configured for dependency: ' . ApiAuditService::class); + } + + $service = (self::$apiAuditServiceResolver)(); + if (!$service instanceof ApiAuditService) { + throw new \RuntimeException('ApiResponse resolver returned invalid dependency: ' . ApiAuditService::class); + } + + return $service; + } + + private static function authorizationService(): AuthorizationService + { + if (!is_callable(self::$authorizationServiceResolver)) { + throw new \RuntimeException('ApiResponse is not configured for dependency: ' . AuthorizationService::class); + } + + $service = (self::$authorizationServiceResolver)(); + if (!$service instanceof AuthorizationService) { + throw new \RuntimeException('ApiResponse resolver returned invalid dependency: ' . AuthorizationService::class); + } + + return $service; + } + + private static function systemAuditReporter(): ApiSystemAuditReporter + { + if (!is_callable(self::$apiSystemAuditReporterResolver)) { + throw new \RuntimeException('ApiResponse is not configured for dependency: ' . ApiSystemAuditReporter::class); + } + + $service = (self::$apiSystemAuditReporterResolver)(); + if (!$service instanceof ApiSystemAuditReporter) { + throw new \RuntimeException('ApiResponse resolver returned invalid dependency: ' . ApiSystemAuditReporter::class); + } + + return $service; + } + + private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void + { + try { + self::systemAuditReporter()->finish($statusCode, $errorCode); + } catch (\Throwable) { + // fail-open + } + } } diff --git a/lib/Http/ApiSystemAuditReporter.php b/lib/Http/ApiSystemAuditReporter.php new file mode 100644 index 0000000..6c7186b --- /dev/null +++ b/lib/Http/ApiSystemAuditReporter.php @@ -0,0 +1,211 @@ +context !== null) { + return; + } + + RequestContext::ensureStarted(); + $requestContext = RequestContext::context(); + + $method = strtoupper(trim((string) ($requestContext['method'] ?? ($_SERVER['REQUEST_METHOD'] ?? 'GET')))); + if ($method === '') { + $method = 'GET'; + } + + $path = trim((string) ($requestContext['path'] ?? '')); + if ($path === '') { + $fallbackPath = parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH); + $path = is_string($fallbackPath) ? trim($fallbackPath) : ''; + } + + $this->context = [ + 'started_at' => microtime(true), + 'method' => $method, + 'path' => $path, + 'finished' => false, + ]; + } + + public function finish(int $statusCode, ?string $errorCode = null): void + { + if (!is_array($this->context) || !empty($this->context['finished'])) { + return; + } + + $this->context['finished'] = true; + + $method = strtoupper(trim((string) $this->context['method'])); + $path = trim((string) $this->context['path']); + $statusCode = $this->normalizeStatusCode($statusCode); + + if (!$this->shouldRecord($method, $path, $statusCode)) { + return; + } + + $durationMs = max(0, (int) round((microtime(true) - (float) $this->context['started_at']) * 1000)); + $securityEndpoint = $this->isSecurityEndpoint($path); + $writeMethod = $this->isWriteMethod($method); + $normalizedErrorCode = trim((string) ($errorCode ?? '')); + + $metadata = [ + 'endpoint_key' => $this->endpointKey($path), + 'status_code' => $statusCode, + 'status_class' => $this->statusClass($statusCode), + 'duration_ms' => $durationMs, + 'auth_mode' => $this->authMode(), + 'write_method' => $writeMethod, + 'security_endpoint' => $securityEndpoint, + ]; + + $this->systemAuditService->record( + 'api.request', + $this->outcomeFromStatusCode($statusCode), + [ + 'actor_user_id' => ApiAuth::isAuthenticated() ? ApiAuth::userId() : null, + 'error_code' => $normalizedErrorCode !== '' ? substr($normalizedErrorCode, 0, 100) : null, + 'metadata' => $metadata, + ] + ); + } + + private function shouldRecord(string $method, string $path, int $statusCode): bool + { + if ($method === 'OPTIONS') { + return false; + } + + if ($this->isWriteMethod($method)) { + return true; + } + + if ($statusCode >= 500) { + return true; + } + + return $this->isSecurityEndpoint($path) && $statusCode >= 400; + } + + private function isWriteMethod(string $method): bool + { + return in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true); + } + + private function isSecurityEndpoint(string $path): bool + { + $path = '/' . ltrim(strtolower(trim($path)), '/'); + if ($path === '/api/v1/auth/login' || $path === '/api/v1/me/password') { + return true; + } + + return str_starts_with($path, '/api/v1/me/tokens'); + } + + private function endpointKey(string $path): string + { + $path = '/' . ltrim(trim($path), '/'); + if ($path === '/') { + return '/'; + } + + $segments = array_values(array_filter(explode('/', trim($path, '/')), static fn (string $segment): bool => $segment !== '')); + if ($segments === []) { + return '/'; + } + + $normalized = []; + foreach ($segments as $segment) { + $value = strtolower(trim($segment)); + if ($value === '') { + continue; + } + + if (ctype_digit($value)) { + $normalized[] = '{id}'; + continue; + } + + if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $value) === 1) { + $normalized[] = '{uuid}'; + continue; + } + + if (preg_match('/^[a-f0-9]{24}$/i', $value) === 1) { + $normalized[] = '{token_selector}'; + continue; + } + + $normalized[] = substr($value, 0, 64); + } + + return '/' . implode('/', $normalized); + } + + private function outcomeFromStatusCode(int $statusCode): string + { + if ($statusCode >= 500) { + return SystemAuditOutcome::Failed->value; + } + if ($statusCode >= 400) { + return SystemAuditOutcome::Denied->value; + } + return SystemAuditOutcome::Success->value; + } + + private function statusClass(int $statusCode): string + { + if ($statusCode >= 100 && $statusCode <= 199) { + return '1xx'; + } + if ($statusCode >= 200 && $statusCode <= 299) { + return '2xx'; + } + if ($statusCode >= 300 && $statusCode <= 399) { + return '3xx'; + } + if ($statusCode >= 400 && $statusCode <= 499) { + return '4xx'; + } + if ($statusCode >= 500 && $statusCode <= 599) { + return '5xx'; + } + + return 'unknown'; + } + + private function authMode(): string + { + if (ApiAuth::isAuthenticated() || ApiAuth::extractBearerToken() !== '') { + return 'token'; + } + + return 'public'; + } + + private function normalizeStatusCode(int $statusCode): int + { + return ($statusCode >= 100 && $statusCode <= 999) ? $statusCode : 500; + } +} diff --git a/lib/Http/Input/FormErrors.php b/lib/Http/Input/FormErrors.php new file mode 100644 index 0000000..34eef89 --- /dev/null +++ b/lib/Http/Input/FormErrors.php @@ -0,0 +1,101 @@ +> */ + private array $errors = []; + + public function add(string $field, string $message): self + { + $field = trim($field); + if ($field === '') { + $field = 'input'; + } + + $message = trim($message); + if ($message === '') { + return $this; + } + + $this->errors[$field] ??= []; + $this->errors[$field][] = $message; + + return $this; + } + + /** + * @param array $messages + */ + public function addMany(string $field, array $messages): self + { + foreach ($messages as $message) { + $this->add($field, (string) $message); + } + + return $this; + } + + public function addGlobal(string $message): self + { + return $this->add('input', $message); + } + + /** + * @param array|string>|array|self $errors + */ + public function merge(array|self $errors): self + { + if ($errors instanceof self) { + $errors = $errors->toArray(); + } + + if (array_is_list($errors)) { + foreach ($errors as $message) { + $this->addGlobal((string) $message); + } + + return $this; + } + + foreach ($errors as $field => $messages) { + if (is_array($messages)) { + $this->addMany((string) $field, array_values(array_map('strval', $messages))); + continue; + } + + $this->add((string) $field, (string) $messages); + } + + return $this; + } + + public function hasAny(): bool + { + return $this->errors !== []; + } + + /** + * @return array> + */ + public function toArray(): array + { + return $this->errors; + } + + /** + * @return array + */ + public function toFlatList(): array + { + $flat = []; + foreach ($this->errors as $messages) { + foreach ($messages as $message) { + $flat[] = $message; + } + } + + return $flat; + } +} diff --git a/lib/Http/Input/RequestInput.php b/lib/Http/Input/RequestInput.php new file mode 100644 index 0000000..9e8cf9e --- /dev/null +++ b/lib/Http/Input/RequestInput.php @@ -0,0 +1,137 @@ + $query + * @param array $body + * @param array $files + */ + public function __construct( + private readonly string $method, + private readonly array $query, + private readonly array $body, + private readonly array $files + ) { + } + + public function method(): string + { + return $this->method; + } + + public function isMethod(string ...$methods): bool + { + foreach ($methods as $method) { + if ($this->method === strtoupper(trim($method))) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + public function queryAll(): array + { + return $this->query; + } + + public function query(string $key, mixed $default = null): mixed + { + return array_key_exists($key, $this->query) ? $this->query[$key] : $default; + } + + public function hasQuery(string $key): bool + { + return array_key_exists($key, $this->query); + } + + public function queryString(string $key, string $default = ''): string + { + return trim((string) $this->query($key, $default)); + } + + public function queryInt(string $key, int $default = 0): int + { + return (int) $this->query($key, $default); + } + + /** + * @return array + */ + public function queryArray(string $key): array + { + $value = $this->query($key, []); + return is_array($value) ? $value : []; + } + + /** + * @return array + */ + public function bodyAll(): array + { + return $this->body; + } + + public function body(string $key, mixed $default = null): mixed + { + return array_key_exists($key, $this->body) ? $this->body[$key] : $default; + } + + public function hasBody(string $key): bool + { + return array_key_exists($key, $this->body); + } + + public function bodyString(string $key, string $default = ''): string + { + return trim((string) $this->body($key, $default)); + } + + public function bodyInt(string $key, int $default = 0): int + { + return (int) $this->body($key, $default); + } + + /** + * @return array + */ + public function bodyArray(string $key): array + { + $value = $this->body($key, []); + return is_array($value) ? $value : []; + } + + /** + * @return array + */ + public function filesAll(): array + { + return $this->files; + } + + /** + * @return mixed + */ + public function file(string $key, mixed $default = null): mixed + { + return array_key_exists($key, $this->files) ? $this->files[$key] : $default; + } + + public function hasFile(string $key): bool + { + return array_key_exists($key, $this->files); + } + + public function wantsJson(): bool + { + return Request::wantsJson(); + } +} diff --git a/lib/Http/Input/RequestInputFactory.php b/lib/Http/Input/RequestInputFactory.php new file mode 100644 index 0000000..c7256c0 --- /dev/null +++ b/lib/Http/Input/RequestInputFactory.php @@ -0,0 +1,47 @@ +|null */ + private ?array $cachedApiBody = null; + + public function create(): RequestInput + { + $method = strtoupper(trim((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'))); + if ($method === '') { + $method = 'GET'; + } + + $query = $_GET; + $files = $_FILES; + + if (defined('MINTY_API_REQUEST')) { + return new RequestInput($method, $query, $this->readApiBody(), $files); + } + + $body = $_POST; + return new RequestInput($method, $query, $body, $files); + } + + /** + * @return array + */ + private function readApiBody(): array + { + if ($this->cachedApiBody !== null) { + return $this->cachedApiBody; + } + + $raw = file_get_contents('php://input'); + if (!is_string($raw) || $raw === '') { + $this->cachedApiBody = []; + return $this->cachedApiBody; + } + + $decoded = json_decode($raw, true); + $this->cachedApiBody = is_array($decoded) ? $decoded : []; + return $this->cachedApiBody; + } +} diff --git a/lib/Http/RequestContext.php b/lib/Http/RequestContext.php new file mode 100644 index 0000000..3629a07 --- /dev/null +++ b/lib/Http/RequestContext.php @@ -0,0 +1,226 @@ +|null */ + private static ?array $context = null; + + public static function start(array $overrides = []): void + { + if (self::$context === null) { + self::$context = self::buildDefaultContext(); + } + + self::applyOverrides($overrides); + } + + public static function ensureStarted(): void + { + if (self::$context !== null) { + return; + } + + self::$context = self::buildDefaultContext(); + } + + public static function id(): string + { + self::ensureStarted(); + $requestId = trim((string) (self::$context['request_id'] ?? '')); + if ($requestId === '') { + $requestId = RepoQuery::uuidV4(); + self::$context['request_id'] = $requestId; + } + return $requestId; + } + + public static function currentId(): ?string + { + if (self::$context === null) { + return null; + } + + $requestId = trim((string) (self::$context['request_id'] ?? '')); + return $requestId !== '' ? $requestId : null; + } + + public static function setChannel(string $channel): void + { + self::ensureStarted(); + $normalized = self::normalizeChannel($channel); + if ($normalized !== '') { + self::$context['channel'] = $normalized; + } + } + + public static function setPath(string $path): void + { + self::ensureStarted(); + $path = trim($path); + if ($path === '') { + return; + } + + self::$context['path'] = substr($path, 0, 255); + } + + public static function context(): array + { + self::ensureStarted(); + + return self::$context ?? []; + } + + public static function requestHeaderValue(): string + { + return self::id(); + } + + public static function hashValue(string $value): string + { + $secret = defined('APP_CRYPTO_KEY') ? trim((string) APP_CRYPTO_KEY) : ''; + if ($secret === '') { + return hash('sha256', $value); + } + + return hash_hmac('sha256', $value, $secret); + } + + public static function resetForTests(): void + { + self::$context = null; + } + + /** + * @return array + */ + private static function buildDefaultContext(): array + { + $method = strtoupper(trim((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'))); + if ($method === '') { + $method = PHP_SAPI === 'cli' ? 'CLI' : 'GET'; + } + + $uri = (string) ($_SERVER['REQUEST_URI'] ?? ''); + $path = parse_url($uri, PHP_URL_PATH); + $path = is_string($path) ? trim($path) : ''; + if ($path === '' && PHP_SAPI === 'cli') { + $path = (string) ($_SERVER['argv'][0] ?? 'cli'); + } + + $requestId = self::requestIdFromHeader(); + if ($requestId === null) { + $requestId = RepoQuery::uuidV4(); + } + + return [ + 'request_id' => $requestId, + 'channel' => self::detectChannel($path), + 'method' => substr($method, 0, 8), + 'path' => substr($path, 0, 255), + 'ip' => self::normalizeIp((string) ($_SERVER['REMOTE_ADDR'] ?? '')), + 'user_agent' => self::normalizeUserAgent((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')), + ]; + } + + private static function applyOverrides(array $overrides): void + { + foreach ($overrides as $key => $value) { + if (!is_string($key)) { + continue; + } + + if ($key === 'request_id') { + $requestId = trim((string) $value); + if (self::isValidUuid($requestId)) { + self::$context['request_id'] = $requestId; + } + continue; + } + + if ($key === 'channel') { + $channel = self::normalizeChannel((string) $value); + if ($channel !== '') { + self::$context['channel'] = $channel; + } + continue; + } + + if ($key === 'method') { + $method = strtoupper(trim((string) $value)); + if ($method !== '') { + self::$context['method'] = substr($method, 0, 8); + } + continue; + } + + if ($key === 'path') { + $path = trim((string) $value); + if ($path !== '') { + self::$context['path'] = substr($path, 0, 255); + } + continue; + } + } + } + + private static function detectChannel(string $path): string + { + if (PHP_SAPI === 'cli') { + return 'cli'; + } + if (defined('MINTY_API_REQUEST')) { + return 'api'; + } + + $normalizedPath = ltrim(strtolower(trim($path)), '/'); + if (str_starts_with($normalizedPath, 'api/')) { + return 'api'; + } + + return 'web'; + } + + private static function normalizeChannel(string $channel): string + { + $channel = strtolower(trim($channel)); + return in_array($channel, ['web', 'api', 'scheduler', 'cli'], true) ? $channel : ''; + } + + private static function requestIdFromHeader(): ?string + { + $header = trim((string) ($_SERVER['HTTP_X_REQUEST_ID'] ?? '')); + if (self::isValidUuid($header)) { + return strtolower($header); + } + + return null; + } + + private static function isValidUuid(string $value): bool + { + return preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $value) === 1; + } + + private static function normalizeIp(string $ip): string + { + $ip = trim($ip); + if ($ip === '') { + return ''; + } + return substr($ip, 0, 45); + } + + private static function normalizeUserAgent(string $userAgent): string + { + $userAgent = trim($userAgent); + if ($userAgent === '') { + return ''; + } + return substr($userAgent, 0, 255); + } +} diff --git a/lib/Repository/Access/PermissionRepository.php b/lib/Repository/Access/PermissionRepository.php index dcc2b58..95065d4 100644 --- a/lib/Repository/Access/PermissionRepository.php +++ b/lib/Repository/Access/PermissionRepository.php @@ -76,7 +76,7 @@ class PermissionRepository $list[] = $data; } } - return ['data' => $list, 'total' => (int) $total]; + return ['rows' => $list, 'total' => (int) $total]; } public function find(int $id): ?array diff --git a/lib/Repository/Access/UserRoleRepository.php b/lib/Repository/Access/UserRoleRepository.php index 2eb3002..ebee508 100644 --- a/lib/Repository/Access/UserRoleRepository.php +++ b/lib/Repository/Access/UserRoleRepository.php @@ -40,6 +40,34 @@ class UserRoleRepository return true; } + public function listUserIdsByRoleIds(array $roleIds): array + { + $ids = array_values(array_unique(array_map('intval', $roleIds))); + $ids = array_values(array_filter($ids, static fn ($id) => $id > 0)); + if (!$ids) { + return []; + } + + $rows = DB::select( + 'select distinct user_id from user_roles where role_id in (???)', + array_map('strval', $ids) + ); + if (!is_array($rows)) { + return []; + } + + $userIds = []; + foreach ($rows as $row) { + $data = $row['user_roles'] ?? $row; + if (!is_array($data) || !isset($data['user_id'])) { + continue; + } + $userIds[] = (int) $data['user_id']; + } + + return array_values(array_unique(array_filter($userIds, static fn ($id) => $id > 0))); + } + public function countUsersByRoleIds(array $roleIds): array { return $this->countByRoleIds($roleIds, false); diff --git a/lib/Repository/Audit/ApiAuditLogRepository.php b/lib/Repository/Audit/ApiAuditLogRepository.php index c6150f4..eac9b68 100644 --- a/lib/Repository/Audit/ApiAuditLogRepository.php +++ b/lib/Repository/Audit/ApiAuditLogRepository.php @@ -7,6 +7,8 @@ use MintyPHP\Repository\Support\RepoQuery; class ApiAuditLogRepository { + private const FILTER_OPTIONS_LIMIT_MAX = 200; + public function create(array $data): int|false { $id = DB::insert( @@ -39,8 +41,16 @@ class ApiAuditLogRepository $method = strtoupper(trim((string) ($filters['method'] ?? ''))); $createdFrom = trim((string) ($filters['created_from'] ?? '')); $createdTo = trim((string) ($filters['created_to'] ?? '')); - $tenantId = (int) ($filters['tenant_id'] ?? 0); - $userId = (int) ($filters['user_id'] ?? 0); + $tenantIds = array_slice( + RepoQuery::normalizeIdList($filters['tenant_ids'] ?? ''), + 0, + self::FILTER_OPTIONS_LIMIT_MAX + ); + $userIds = array_slice( + RepoQuery::normalizeIdList($filters['user_ids'] ?? ''), + 0, + self::FILTER_OPTIONS_LIMIT_MAX + ); [$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0); [$order, $dir] = RepoQuery::sanitizeOrder( @@ -86,13 +96,13 @@ class ApiAuditLogRepository $where[] = 'api_audit_log.created_at <= ?'; $params[] = $createdTo . ' 23:59:59'; } - if ($tenantId > 0) { - $where[] = 'api_audit_log.tenant_id = ?'; - $params[] = (string) $tenantId; + if ($tenantIds !== []) { + $where[] = 'api_audit_log.tenant_id in (???)'; + $params[] = $tenantIds; } - if ($userId > 0) { - $where[] = 'api_audit_log.user_id = ?'; - $params[] = (string) $userId; + if ($userIds !== []) { + $where[] = 'api_audit_log.user_id in (???)'; + $params[] = $userIds; } $whereSql = $where ? (' where ' . implode(' and ', $where)) : ''; @@ -147,6 +157,109 @@ class ApiAuditLogRepository ]; } + public function listFilterOptions(int $limit = self::FILTER_OPTIONS_LIMIT_MAX): array + { + $limit = max(1, min(self::FILTER_OPTIONS_LIMIT_MAX, $limit)); + + $methodRows = DB::select( + 'select + api_audit_log.method, + max(api_audit_log.created_at) as last_used_at + from api_audit_log + where api_audit_log.method is not null + and api_audit_log.method <> \'\' + group by api_audit_log.method + order by last_used_at desc + limit ?', + (string) $limit + ); + + $methods = []; + if (is_array($methodRows)) { + foreach ($methodRows as $row) { + $flat = self::flattenRow($row); + $method = strtoupper(trim((string) ($flat['method'] ?? ''))); + if ($method === '') { + continue; + } + $methods[] = $method; + } + } + $methods = array_values(array_unique($methods)); + + $userRows = DB::select( + 'select + api_audit_log.user_id, + max(api_audit_log.created_at) as last_used_at, + max(users.id) as user_exists_id, + max(users.display_name) as user_display_name, + max(users.email) as user_email + from api_audit_log + left join users on users.id = api_audit_log.user_id + where api_audit_log.user_id is not null + and api_audit_log.user_id > 0 + group by api_audit_log.user_id + order by last_used_at desc + limit ?', + (string) $limit + ); + + $users = []; + if (is_array($userRows)) { + foreach ($userRows as $row) { + $flat = self::flattenRow($row); + $id = (int) ($flat['user_id'] ?? 0); + if ($id <= 0) { + continue; + } + $users[] = [ + 'id' => $id, + 'display_name' => trim((string) ($flat['user_display_name'] ?? '')), + 'email' => trim((string) ($flat['user_email'] ?? '')), + 'exists' => (int) ($flat['user_exists_id'] ?? 0) > 0, + ]; + } + } + + $tenantRows = DB::select( + 'select + api_audit_log.tenant_id, + max(api_audit_log.created_at) as last_used_at, + max(tenants.id) as tenant_exists_id, + max(tenants.description) as tenant_description + from api_audit_log + left join tenants on tenants.id = api_audit_log.tenant_id + where api_audit_log.tenant_id is not null + and api_audit_log.tenant_id > 0 + group by api_audit_log.tenant_id + order by last_used_at desc + limit ?', + (string) $limit + ); + + $tenants = []; + if (is_array($tenantRows)) { + foreach ($tenantRows as $row) { + $flat = self::flattenRow($row); + $id = (int) ($flat['tenant_id'] ?? 0); + if ($id <= 0) { + continue; + } + $tenants[] = [ + 'id' => $id, + 'description' => trim((string) ($flat['tenant_description'] ?? '')), + 'exists' => (int) ($flat['tenant_exists_id'] ?? 0) > 0, + ]; + } + } + + return [ + 'methods' => $methods, + 'users' => $users, + 'tenants' => $tenants, + ]; + } + public function find(int $id): ?array { $row = DB::selectOne( @@ -219,4 +332,25 @@ class ApiAuditLogRepository return $log; } + + /** + * @return array + */ + private static function flattenRow(mixed $row): array + { + if (!is_array($row)) { + return []; + } + + $flat = []; + foreach ($row as $key => $value) { + if (is_array($value)) { + $flat = array_merge($flat, $value); + } elseif (is_string($key)) { + $flat[$key] = $value; + } + } + + return $flat; + } } diff --git a/lib/Repository/Audit/ImportAuditRunRepository.php b/lib/Repository/Audit/ImportAuditRunRepository.php index 8cc2c29..4587b54 100644 --- a/lib/Repository/Audit/ImportAuditRunRepository.php +++ b/lib/Repository/Audit/ImportAuditRunRepository.php @@ -2,11 +2,14 @@ namespace MintyPHP\Repository\Audit; +use MintyPHP\Domain\Taxonomy\ImportAuditStatus; use MintyPHP\DB; use MintyPHP\Repository\Support\RepoQuery; class ImportAuditRunRepository { + private const FILTER_OPTIONS_LIMIT_MAX = 200; + public function createRunning(array $data): int|false { $id = DB::insert( @@ -15,7 +18,7 @@ class ImportAuditRunRepository ) values (?,?,?,?,?,NOW(),?,?)', (string) ($data['run_uuid'] ?? ''), (string) ($data['profile_key'] ?? ''), - (string) ($data['status'] ?? 'running'), + (string) ($data['status'] ?? ImportAuditStatus::Running->value), $data['source_filename'] ?? null, $data['mapped_targets_csv'] ?? null, $data['user_id'] !== null ? (string) ((int) $data['user_id']) : null, @@ -42,7 +45,7 @@ class ImportAuditRunRepository duration_ms = ?, finished_at = NOW() where id = ?', - (string) ($data['status'] ?? 'failed'), + (string) ($data['status'] ?? ImportAuditStatus::Failed->value), (string) ((int) ($data['rows_total'] ?? 0)), (string) ((int) ($data['created_count'] ?? 0)), (string) ((int) ($data['skipped_count'] ?? 0)), @@ -62,7 +65,11 @@ class ImportAuditRunRepository $status = strtolower(trim((string) ($filters['status'] ?? ''))); $createdFrom = trim((string) ($filters['created_from'] ?? '')); $createdTo = trim((string) ($filters['created_to'] ?? '')); - $userId = (int) ($filters['user_id'] ?? 0); + $userIds = array_slice( + RepoQuery::normalizeIdList($filters['user_ids'] ?? ''), + 0, + self::FILTER_OPTIONS_LIMIT_MAX + ); [$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0); [$order, $dir] = RepoQuery::sanitizeOrder( @@ -101,7 +108,7 @@ class ImportAuditRunRepository $where[] = 'import_audit_runs.profile_key = ?'; $params[] = $profileKey; } - if (in_array($status, ['running', 'success', 'partial', 'failed'], true)) { + if (ImportAuditStatus::tryNormalize($status) !== null) { $where[] = 'import_audit_runs.status = ?'; $params[] = $status; } @@ -113,9 +120,9 @@ class ImportAuditRunRepository $where[] = 'import_audit_runs.started_at <= ?'; $params[] = $createdTo . ' 23:59:59'; } - if ($userId > 0) { - $where[] = 'import_audit_runs.user_id = ?'; - $params[] = (string) $userId; + if ($userIds !== []) { + $where[] = 'import_audit_runs.user_id in (???)'; + $params[] = $userIds; } $whereSql = $where ? (' where ' . implode(' and ', $where)) : ''; @@ -170,6 +177,47 @@ class ImportAuditRunRepository ]; } + public function listFilterOptions(int $limit = self::FILTER_OPTIONS_LIMIT_MAX): array + { + $limit = max(1, min(self::FILTER_OPTIONS_LIMIT_MAX, $limit)); + + $userRows = DB::select( + 'select + import_audit_runs.user_id, + max(import_audit_runs.started_at) as last_used_at, + max(users.id) as user_exists_id, + max(users.display_name) as user_display_name, + max(users.email) as user_email + from import_audit_runs + left join users on users.id = import_audit_runs.user_id + where import_audit_runs.user_id is not null + and import_audit_runs.user_id > 0 + group by import_audit_runs.user_id + order by last_used_at desc + limit ?', + (string) $limit + ); + + $users = []; + if (is_array($userRows)) { + foreach ($userRows as $row) { + $flat = self::flattenRow($row); + $id = (int) ($flat['user_id'] ?? 0); + if ($id <= 0) { + continue; + } + $users[] = [ + 'id' => $id, + 'display_name' => trim((string) ($flat['user_display_name'] ?? '')), + 'email' => trim((string) ($flat['user_email'] ?? '')), + 'exists' => (int) ($flat['user_exists_id'] ?? 0) > 0, + ]; + } + } + + return ['users' => $users]; + } + public function find(int $id): ?array { if ($id <= 0) { @@ -248,4 +296,25 @@ class ImportAuditRunRepository return $item; } + + /** + * @return array + */ + private static function flattenRow(mixed $row): array + { + if (!is_array($row)) { + return []; + } + + $flat = []; + foreach ($row as $key => $value) { + if (is_array($value)) { + $flat = array_merge($flat, $value); + } elseif (is_string($key)) { + $flat[$key] = $value; + } + } + + return $flat; + } } diff --git a/lib/Repository/Audit/SystemAuditLogRepository.php b/lib/Repository/Audit/SystemAuditLogRepository.php new file mode 100644 index 0000000..0381a35 --- /dev/null +++ b/lib/Repository/Audit/SystemAuditLogRepository.php @@ -0,0 +1,357 @@ +value), + $data['error_code'] ?? null, + $data['actor_user_id'] !== null ? (string) ((int) $data['actor_user_id']) : null, + $data['actor_tenant_id'] !== null ? (string) ((int) $data['actor_tenant_id']) : null, + $data['target_type'] ?? null, + $data['target_id'] !== null ? (string) ((int) $data['target_id']) : null, + $data['target_uuid'] ?? null, + $data['method'] ?? null, + $data['path'] ?? null, + $data['ip_hash'] ?? null, + $data['user_agent_hash'] ?? null, + $data['metadata_json'] ?? null + ); + + return $id ? (int) $id : false; + } + + public function listPaged(array $filters): array + { + $search = trim((string) ($filters['search'] ?? '')); + $eventTypes = RepoQuery::normalizeStringList( + $filters['event_types'] ?? '', + self::FILTER_OPTIONS_LIMIT_MAX, + static function (string $value): string { + $value = strtolower(trim($value)); + if ($value === '' || strlen($value) > 64) { + return ''; + } + return preg_match('/^[a-z0-9._-]+$/', $value) === 1 ? $value : ''; + } + ); + $outcome = strtolower(trim((string) ($filters['outcome'] ?? ''))); + $channel = strtolower(trim((string) ($filters['channel'] ?? ''))); + $createdFrom = trim((string) ($filters['created_from'] ?? '')); + $createdTo = trim((string) ($filters['created_to'] ?? '')); + $actorUserIds = array_slice( + RepoQuery::normalizeIdList($filters['actor_user_ids'] ?? ''), + 0, + self::FILTER_OPTIONS_LIMIT_MAX + ); + $targetType = trim((string) ($filters['target_type'] ?? '')); + $requestId = trim((string) ($filters['request_id'] ?? '')); + + [$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0); + [$order, $dir] = RepoQuery::sanitizeOrder( + $filters, + ['id', 'created_at', 'event_type', 'outcome', 'channel', 'actor_user_id'], + 'created_at', + 'desc' + ); + + $where = []; + $params = []; + + RepoQuery::addLikeFilter( + $where, + $params, + ['system_audit_log.event_type', 'system_audit_log.path', 'system_audit_log.error_code', 'system_audit_log.request_id'], + $search + ); + + if ($eventTypes !== []) { + $where[] = 'system_audit_log.event_type in (???)'; + $params[] = $eventTypes; + } + + $normalizedOutcome = SystemAuditOutcome::tryNormalize($outcome); + if ($normalizedOutcome !== null) { + $where[] = 'system_audit_log.outcome = ?'; + $params[] = $normalizedOutcome->value; + } + + $normalizedChannel = SystemAuditChannel::tryNormalize($channel); + if ($normalizedChannel !== null) { + $where[] = 'system_audit_log.channel = ?'; + $params[] = $normalizedChannel->value; + } + + if ($actorUserIds !== []) { + $where[] = 'system_audit_log.actor_user_id in (???)'; + $params[] = $actorUserIds; + } + + if ($targetType !== '') { + $where[] = 'system_audit_log.target_type = ?'; + $params[] = $targetType; + } + + if ($requestId !== '') { + if (preg_match('/^[a-f0-9-]{36}$/i', $requestId)) { + $where[] = 'system_audit_log.request_id = ?'; + $params[] = strtolower($requestId); + } else { + RepoQuery::addLikeFilter($where, $params, ['system_audit_log.request_id'], $requestId); + } + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) { + $where[] = 'system_audit_log.created_at >= ?'; + $params[] = $createdFrom . ' 00:00:00'; + } + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) { + $where[] = 'system_audit_log.created_at <= ?'; + $params[] = $createdTo . ' 23:59:59'; + } + + $whereSql = $where ? (' where ' . implode(' and ', $where)) : ''; + $fromSql = ' from system_audit_log ' . + 'left join users actor_user on actor_user.id = system_audit_log.actor_user_id ' . + 'left join tenants actor_tenant on actor_tenant.id = system_audit_log.actor_tenant_id '; + + $total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0); + + $rows = DB::select( + 'select + system_audit_log.id, + system_audit_log.event_uuid, + system_audit_log.request_id, + system_audit_log.channel, + system_audit_log.event_type, + system_audit_log.outcome, + system_audit_log.error_code, + system_audit_log.actor_user_id, + system_audit_log.actor_tenant_id, + system_audit_log.target_type, + system_audit_log.target_id, + system_audit_log.target_uuid, + system_audit_log.method, + system_audit_log.path, + system_audit_log.ip_hash, + system_audit_log.user_agent_hash, + system_audit_log.metadata_json, + system_audit_log.created_at, + actor_user.id, + actor_user.uuid, + actor_user.display_name, + actor_user.email, + actor_tenant.id, + actor_tenant.uuid, + actor_tenant.description + ' . $fromSql . $whereSql . + sprintf(' order by system_audit_log.`%s` %s limit ? offset ?', $order, $dir), + ...array_merge($params, [(string) $limit, (string) $offset]) + ); + + $normalized = []; + if (is_array($rows)) { + foreach ($rows as $row) { + $item = $this->normalizeRow($row); + if ($item !== null) { + $normalized[] = $item; + } + } + } + + return [ + 'total' => $total, + 'rows' => $normalized, + ]; + } + + public function listFilterOptions(int $limit = self::FILTER_OPTIONS_LIMIT_MAX): array + { + $limit = max(1, min(self::FILTER_OPTIONS_LIMIT_MAX, $limit)); + + $eventRows = DB::select( + 'select + system_audit_log.event_type, + max(system_audit_log.created_at) as last_used_at + from system_audit_log + where system_audit_log.event_type is not null + and system_audit_log.event_type <> \'\' + group by system_audit_log.event_type + order by last_used_at desc + limit ?', + (string) $limit + ); + + $eventTypes = []; + if (is_array($eventRows)) { + foreach ($eventRows as $row) { + $flat = self::flattenRow($row); + $eventType = strtolower(trim((string) ($flat['event_type'] ?? ''))); + if ($eventType === '' || strlen($eventType) > 64) { + continue; + } + if (preg_match('/^[a-z0-9._-]+$/', $eventType) !== 1) { + continue; + } + $eventTypes[] = $eventType; + } + } + $eventTypes = array_values(array_unique($eventTypes)); + + $actorRows = DB::select( + 'select + system_audit_log.actor_user_id, + max(system_audit_log.created_at) as last_used_at, + max(actor_user.id) as actor_user_exists_id, + max(actor_user.display_name) as actor_user_display_name, + max(actor_user.email) as actor_user_email + from system_audit_log + left join users actor_user on actor_user.id = system_audit_log.actor_user_id + where system_audit_log.actor_user_id is not null + and system_audit_log.actor_user_id > 0 + group by system_audit_log.actor_user_id + order by last_used_at desc + limit ?', + (string) $limit + ); + + $actors = []; + if (is_array($actorRows)) { + foreach ($actorRows as $row) { + $flat = self::flattenRow($row); + $id = (int) ($flat['actor_user_id'] ?? 0); + if ($id <= 0) { + continue; + } + $actors[] = [ + 'id' => $id, + 'display_name' => trim((string) ($flat['actor_user_display_name'] ?? '')), + 'email' => trim((string) ($flat['actor_user_email'] ?? '')), + 'exists' => (int) ($flat['actor_user_exists_id'] ?? 0) > 0, + ]; + } + } + + return [ + 'event_types' => $eventTypes, + 'actors' => $actors, + ]; + } + + public function find(int $id): ?array + { + $row = DB::selectOne( + 'select + system_audit_log.id, + system_audit_log.event_uuid, + system_audit_log.request_id, + system_audit_log.channel, + system_audit_log.event_type, + system_audit_log.outcome, + system_audit_log.error_code, + system_audit_log.actor_user_id, + system_audit_log.actor_tenant_id, + system_audit_log.target_type, + system_audit_log.target_id, + system_audit_log.target_uuid, + system_audit_log.method, + system_audit_log.path, + system_audit_log.ip_hash, + system_audit_log.user_agent_hash, + system_audit_log.metadata_json, + system_audit_log.created_at, + actor_user.id, + actor_user.uuid, + actor_user.display_name, + actor_user.email, + actor_tenant.id, + actor_tenant.uuid, + actor_tenant.description + from system_audit_log + left join users actor_user on actor_user.id = system_audit_log.actor_user_id + left join tenants actor_tenant on actor_tenant.id = system_audit_log.actor_tenant_id + where system_audit_log.id = ? + limit 1', + (string) $id + ); + + return $this->normalizeRow($row); + } + + public function purgeOlderThanDays(int $days): int + { + if ($days <= 0) { + return 0; + } + + $cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC'))) + ->modify('-' . $days . ' days') + ->format('Y-m-d H:i:s'); + + $deleted = DB::delete('delete from system_audit_log where created_at < ?', $cutoff); + return is_int($deleted) ? $deleted : 0; + } + + private function normalizeRow(mixed $row): ?array + { + if (!is_array($row)) { + return null; + } + + $log = $row['system_audit_log'] ?? []; + if (!is_array($log) || !isset($log['id'])) { + return null; + } + + $user = is_array($row['actor_user'] ?? null) ? $row['actor_user'] : []; + $tenant = is_array($row['actor_tenant'] ?? null) ? $row['actor_tenant'] : []; + + $log['actor_user_uuid'] = (string) ($user['uuid'] ?? ''); + $log['actor_user_display_name'] = (string) ($user['display_name'] ?? ''); + $log['actor_user_email'] = (string) ($user['email'] ?? ''); + $log['actor_tenant_uuid'] = (string) ($tenant['uuid'] ?? ''); + $log['actor_tenant_description'] = (string) ($tenant['description'] ?? ''); + + return $log; + } + + /** + * @return array + */ + private static function flattenRow(mixed $row): array + { + if (!is_array($row)) { + return []; + } + + $flat = []; + foreach ($row as $key => $value) { + if (is_array($value)) { + $flat = array_merge($flat, $value); + } elseif (is_string($key)) { + $flat[$key] = $value; + } + } + + return $flat; + } +} diff --git a/lib/Repository/Audit/UserLifecycleAuditRepository.php b/lib/Repository/Audit/UserLifecycleAuditRepository.php index 669b6d4..a112984 100644 --- a/lib/Repository/Audit/UserLifecycleAuditRepository.php +++ b/lib/Repository/Audit/UserLifecycleAuditRepository.php @@ -2,11 +2,16 @@ namespace MintyPHP\Repository\Audit; +use MintyPHP\Domain\Taxonomy\UserLifecycleAction; +use MintyPHP\Domain\Taxonomy\UserLifecycleStatus; +use MintyPHP\Domain\Taxonomy\UserLifecycleTriggerType; use MintyPHP\DB; use MintyPHP\Repository\Support\RepoQuery; class UserLifecycleAuditRepository { + private const FILTER_OPTIONS_LIMIT_MAX = 200; + public function create(array $row): int|false { $id = DB::insert( @@ -38,14 +43,14 @@ class UserLifecycleAuditRepository if ($id <= 0) { return false; } - $status = trim(strtolower($status)); - if (!in_array($status, ['success', 'skipped', 'failed'], true)) { + $normalizedStatus = UserLifecycleStatus::tryNormalize($status); + if ($normalizedStatus === null) { return false; } $updated = DB::update( 'update user_lifecycle_audit_log set status = ?, reason_code = ? where id = ?', - $status, + $normalizedStatus->value, $reasonCode, (string) $id ); @@ -55,9 +60,26 @@ class UserLifecycleAuditRepository public function listPaged(array $filters): array { $search = trim((string) ($filters['search'] ?? '')); - $action = strtolower(trim((string) ($filters['action'] ?? ''))); - $status = strtolower(trim((string) ($filters['status'] ?? ''))); - $triggerType = strtolower(trim((string) ($filters['trigger_type'] ?? ''))); + $actions = RepoQuery::normalizeStringList( + $filters['actions'] ?? '', + self::FILTER_OPTIONS_LIMIT_MAX, + static fn (string $value): string => UserLifecycleAction::tryNormalize($value)?->value ?? '' + ); + $statuses = RepoQuery::normalizeStringList( + $filters['statuses'] ?? '', + self::FILTER_OPTIONS_LIMIT_MAX, + static fn (string $value): string => UserLifecycleStatus::tryNormalize($value)?->value ?? '' + ); + $triggerTypes = RepoQuery::normalizeStringList( + $filters['trigger_types'] ?? '', + self::FILTER_OPTIONS_LIMIT_MAX, + static fn (string $value): string => UserLifecycleTriggerType::tryNormalize($value)?->value ?? '' + ); + $actorUserIds = array_slice( + RepoQuery::normalizeIdList($filters['actor_user_ids'] ?? ''), + 0, + self::FILTER_OPTIONS_LIMIT_MAX + ); $createdFrom = trim((string) ($filters['created_from'] ?? '')); $createdTo = trim((string) ($filters['created_to'] ?? '')); @@ -82,17 +104,21 @@ class UserLifecycleAuditRepository ], $search ); - if (in_array($action, ['deactivate', 'delete', 'restore'], true)) { - $where[] = 'user_lifecycle_audit_log.action = ?'; - $params[] = $action; + if ($actions !== []) { + $where[] = 'user_lifecycle_audit_log.action in (???)'; + $params[] = $actions; } - if (in_array($status, ['success', 'skipped', 'failed'], true)) { - $where[] = 'user_lifecycle_audit_log.status = ?'; - $params[] = $status; + if ($statuses !== []) { + $where[] = 'user_lifecycle_audit_log.status in (???)'; + $params[] = $statuses; } - if (in_array($triggerType, ['manual', 'cron', 'system'], true)) { - $where[] = 'user_lifecycle_audit_log.trigger_type = ?'; - $params[] = $triggerType; + if ($triggerTypes !== []) { + $where[] = 'user_lifecycle_audit_log.trigger_type in (???)'; + $params[] = $triggerTypes; + } + if ($actorUserIds !== []) { + $where[] = 'user_lifecycle_audit_log.actor_user_id in (???)'; + $params[] = $actorUserIds; } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) { $where[] = 'user_lifecycle_audit_log.created_at >= ?'; @@ -157,6 +183,130 @@ class UserLifecycleAuditRepository return ['total' => $total, 'rows' => $normalized]; } + public function listFilterOptions(int $limit = self::FILTER_OPTIONS_LIMIT_MAX): array + { + $limit = max(1, min(self::FILTER_OPTIONS_LIMIT_MAX, $limit)); + + $actionRows = DB::select( + 'select + user_lifecycle_audit_log.action, + max(user_lifecycle_audit_log.created_at) as last_used_at + from user_lifecycle_audit_log + where user_lifecycle_audit_log.action is not null + and user_lifecycle_audit_log.action <> \'\' + group by user_lifecycle_audit_log.action + order by last_used_at desc + limit ?', + (string) $limit + ); + + $actions = []; + if (is_array($actionRows)) { + foreach ($actionRows as $row) { + $flat = self::flattenRow($row); + $action = strtolower(trim((string) ($flat['action'] ?? ''))); + if (UserLifecycleAction::tryNormalize($action) === null) { + continue; + } + $actions[] = $action; + } + } + $actions = array_values(array_unique($actions)); + + $statusRows = DB::select( + 'select + user_lifecycle_audit_log.status, + max(user_lifecycle_audit_log.created_at) as last_used_at + from user_lifecycle_audit_log + where user_lifecycle_audit_log.status is not null + and user_lifecycle_audit_log.status <> \'\' + group by user_lifecycle_audit_log.status + order by last_used_at desc + limit ?', + (string) $limit + ); + + $statuses = []; + if (is_array($statusRows)) { + foreach ($statusRows as $row) { + $flat = self::flattenRow($row); + $status = strtolower(trim((string) ($flat['status'] ?? ''))); + if (UserLifecycleStatus::tryNormalize($status) === null) { + continue; + } + $statuses[] = $status; + } + } + $statuses = array_values(array_unique($statuses)); + + $triggerRows = DB::select( + 'select + user_lifecycle_audit_log.trigger_type, + max(user_lifecycle_audit_log.created_at) as last_used_at + from user_lifecycle_audit_log + where user_lifecycle_audit_log.trigger_type is not null + and user_lifecycle_audit_log.trigger_type <> \'\' + group by user_lifecycle_audit_log.trigger_type + order by last_used_at desc + limit ?', + (string) $limit + ); + + $triggerTypes = []; + if (is_array($triggerRows)) { + foreach ($triggerRows as $row) { + $flat = self::flattenRow($row); + $triggerType = strtolower(trim((string) ($flat['trigger_type'] ?? ''))); + if (UserLifecycleTriggerType::tryNormalize($triggerType) === null) { + continue; + } + $triggerTypes[] = $triggerType; + } + } + $triggerTypes = array_values(array_unique($triggerTypes)); + + $actorRows = DB::select( + 'select + user_lifecycle_audit_log.actor_user_id, + max(user_lifecycle_audit_log.created_at) as last_used_at, + max(actor_user.id) as actor_user_exists_id, + max(actor_user.display_name) as actor_user_display_name, + max(actor_user.email) as actor_user_email + from user_lifecycle_audit_log + left join users actor_user on actor_user.id = user_lifecycle_audit_log.actor_user_id + where user_lifecycle_audit_log.actor_user_id is not null + and user_lifecycle_audit_log.actor_user_id > 0 + group by user_lifecycle_audit_log.actor_user_id + order by last_used_at desc + limit ?', + (string) $limit + ); + + $actors = []; + if (is_array($actorRows)) { + foreach ($actorRows as $row) { + $flat = self::flattenRow($row); + $id = (int) ($flat['actor_user_id'] ?? 0); + if ($id <= 0) { + continue; + } + $actors[] = [ + 'id' => $id, + 'display_name' => trim((string) ($flat['actor_user_display_name'] ?? '')), + 'email' => trim((string) ($flat['actor_user_email'] ?? '')), + 'exists' => (int) ($flat['actor_user_exists_id'] ?? 0) > 0, + ]; + } + } + + return [ + 'actions' => $actions, + 'statuses' => $statuses, + 'trigger_types' => $triggerTypes, + 'actors' => $actors, + ]; + } + public function find(int $id): ?array { if ($id <= 0) { @@ -293,4 +443,25 @@ class UserLifecycleAuditRepository } return $item; } + + /** + * @return array + */ + private static function flattenRow(mixed $row): array + { + if (!is_array($row)) { + return []; + } + + $flat = []; + foreach ($row as $key => $value) { + if (is_array($value)) { + $flat = array_merge($flat, $value); + } elseif (is_string($key)) { + $flat[$key] = $value; + } + } + + return $flat; + } } diff --git a/lib/Repository/Mail/MailLogRepository.php b/lib/Repository/Mail/MailLogRepository.php index 24b64e0..9812166 100644 --- a/lib/Repository/Mail/MailLogRepository.php +++ b/lib/Repository/Mail/MailLogRepository.php @@ -2,6 +2,7 @@ namespace MintyPHP\Repository\Mail; +use MintyPHP\Domain\Taxonomy\MailLogStatus; use MintyPHP\DB; use MintyPHP\Repository\Support\RepoQuery; @@ -14,7 +15,7 @@ class MailLogRepository $data['to_email'], $data['subject'], $data['template'] ?? null, - $data['status'] ?? 'queued' + $data['status'] ?? MailLogStatus::Queued->value ); return $id ? (int) $id : null; } @@ -23,7 +24,7 @@ class MailLogRepository { $result = DB::update( 'update mail_log set status = ?, sent_at = NOW(), provider_message_id = ?, error_message = NULL where id = ?', - 'sent', + MailLogStatus::Sent->value, $providerMessageId, (string) $id ); @@ -34,7 +35,7 @@ class MailLogRepository { $result = DB::update( 'update mail_log set status = ?, error_message = ? where id = ?', - 'failed', + MailLogStatus::Failed->value, $errorMessage, (string) $id ); @@ -44,7 +45,7 @@ class MailLogRepository public function listPaged(array $options): array { $search = trim((string) ($options['search'] ?? '')); - $status = trim((string) ($options['status'] ?? '')); + $status = MailLogStatus::tryNormalize((string) ($options['status'] ?? ''))?->value ?? ''; $createdFrom = trim((string) ($options['created_from'] ?? '')); $createdTo = trim((string) ($options['created_to'] ?? '')); diff --git a/lib/Repository/Scheduler/ScheduledJobRepository.php b/lib/Repository/Scheduler/ScheduledJobRepository.php index 19d0829..c06bd80 100644 --- a/lib/Repository/Scheduler/ScheduledJobRepository.php +++ b/lib/Repository/Scheduler/ScheduledJobRepository.php @@ -2,6 +2,7 @@ namespace MintyPHP\Repository\Scheduler; +use MintyPHP\Domain\Taxonomy\ScheduledJobStatus; use MintyPHP\DB; use MintyPHP\Repository\Support\RepoQuery; @@ -75,7 +76,7 @@ class ScheduledJobRepository $where[] = 'enabled = ?'; $params[] = $enabled; } - if (in_array($status, ['success', 'failed', 'running', 'skipped'], true)) { + if (ScheduledJobStatus::tryNormalize($status) !== null) { $where[] = 'last_run_status = ?'; $params[] = $status; } @@ -197,10 +198,10 @@ class ScheduledJobRepository and (last_run_status is null or last_run_status <> ? or (last_run_started_at is not null and last_run_started_at < ?))', - 'running', + ScheduledJobStatus::Running->value, $startedAtUtc, (string) $id, - 'running', + ScheduledJobStatus::Running->value, $staleBefore ); return (int) $updated > 0; diff --git a/lib/Repository/Scheduler/ScheduledJobRunRepository.php b/lib/Repository/Scheduler/ScheduledJobRunRepository.php index c7c5192..1537d2e 100644 --- a/lib/Repository/Scheduler/ScheduledJobRunRepository.php +++ b/lib/Repository/Scheduler/ScheduledJobRunRepository.php @@ -2,6 +2,8 @@ namespace MintyPHP\Repository\Scheduler; +use MintyPHP\Domain\Taxonomy\ScheduledJobRunStatus; +use MintyPHP\Domain\Taxonomy\ScheduledJobTriggerType; use MintyPHP\DB; use MintyPHP\Repository\Support\RepoQuery; @@ -17,8 +19,8 @@ class ScheduledJobRunRepository (string) ($data['run_uuid'] ?? ''), (string) ((int) ($data['job_id'] ?? 0)), (string) ($data['job_key'] ?? ''), - (string) ($data['trigger_type'] ?? 'scheduler'), - (string) ($data['status'] ?? 'failed'), + (string) ($data['trigger_type'] ?? ScheduledJobTriggerType::Scheduler->value), + (string) ($data['status'] ?? ScheduledJobRunStatus::Failed->value), ($data['actor_user_id'] ?? null) !== null ? (string) ((int) $data['actor_user_id']) : null, (string) ($data['started_at'] ?? ''), $data['finished_at'] ?? null, @@ -56,11 +58,11 @@ class ScheduledJobRunRepository ['scheduled_job_runs.run_uuid', 'scheduled_job_runs.error_code', 'scheduled_job_runs.error_message'], $search ); - if (in_array($status, ['success', 'failed', 'skipped'], true)) { + if (ScheduledJobRunStatus::tryNormalize($status) !== null) { $where[] = 'scheduled_job_runs.status = ?'; $params[] = $status; } - if (in_array($triggerType, ['scheduler', 'manual'], true)) { + if (ScheduledJobTriggerType::tryNormalize($triggerType) !== null) { $where[] = 'scheduled_job_runs.trigger_type = ?'; $params[] = $triggerType; } diff --git a/lib/Repository/Scheduler/SchedulerRuntimeRepository.php b/lib/Repository/Scheduler/SchedulerRuntimeRepository.php index 2efdd64..0926323 100644 --- a/lib/Repository/Scheduler/SchedulerRuntimeRepository.php +++ b/lib/Repository/Scheduler/SchedulerRuntimeRepository.php @@ -2,6 +2,7 @@ namespace MintyPHP\Repository\Scheduler; +use MintyPHP\Domain\Taxonomy\SchedulerRuntimeResult; use MintyPHP\DB; class SchedulerRuntimeRepository @@ -13,10 +14,7 @@ class SchedulerRuntimeRepository $heartbeatAtUtc = gmdate('Y-m-d H:i:s'); } - $result = trim($result); - if ($result === '') { - $result = 'ok'; - } + $result = SchedulerRuntimeResult::normalizeOr($result, SchedulerRuntimeResult::Ok)->value; $errorCode = $this->nullableTrim($errorCode); $updated = DB::update( diff --git a/lib/Repository/Search/SearchQueryRepository.php b/lib/Repository/Search/SearchQueryRepository.php new file mode 100644 index 0000000..b9039fc --- /dev/null +++ b/lib/Repository/Search/SearchQueryRepository.php @@ -0,0 +1,37 @@ + $params + * @return array + */ + public function selectRows(string $sql, array $params = []): array + { + if (trim($sql) === '') { + return []; + } + + $rows = DB::select($sql, ...$params); + + return is_array($rows) ? $rows : []; + } + + /** + * @param array $params + */ + public function selectCount(string $sql, array $params = []): int + { + if (trim($sql) === '') { + return 0; + } + + $value = DB::selectValue($sql, ...$params); + + return (int) ($value ?? 0); + } +} diff --git a/lib/Repository/Stats/AdminStatsRepository.php b/lib/Repository/Stats/AdminStatsRepository.php new file mode 100644 index 0000000..586b706 --- /dev/null +++ b/lib/Repository/Stats/AdminStatsRepository.php @@ -0,0 +1,1090 @@ + + */ + public function buildViewData(): array + { + $tenantActiveStatus = TenantStatus::Active->value; + $tenantInactiveStatus = TenantStatus::Inactive->value; + $importStatusRunning = ImportAuditStatus::Running->value; + $importStatusSuccess = ImportAuditStatus::Success->value; + $importStatusPartial = ImportAuditStatus::Partial->value; + $importStatusFailed = ImportAuditStatus::Failed->value; + $systemAuditOutcomeFailed = SystemAuditOutcome::Failed->value; + $systemAuditOutcomeDenied = SystemAuditOutcome::Denied->value; + $lifecycleStatusFailed = UserLifecycleStatus::Failed->value; + $lifecycleStatusSkipped = UserLifecycleStatus::Skipped->value; + $lifecycleActionRestore = UserLifecycleAction::Restore->value; + $schedulerTriggerScheduler = ScheduledJobTriggerType::Scheduler->value; + $schedulerRunStatusFailed = ScheduledJobRunStatus::Failed->value; + $mailStatusFailed = MailLogStatus::Failed->value; + $mailStatusSent = MailLogStatus::Sent->value; + + $activeUserCount = (int) (DB::selectValue('select count(*) from users where active = 1') ?? 0); + $inactiveUserCount = (int) (DB::selectValue('select count(*) from users where active = 0') ?? 0); + $activeTenantCount = (int) (DB::selectValue('select count(*) from tenants where status = ?', $tenantActiveStatus) ?? 0); + $inactiveTenantCount = (int) (DB::selectValue('select count(*) from tenants where status = ?', $tenantInactiveStatus) ?? 0); + $activeDepartmentCount = (int) (DB::selectValue('select count(*) from departments where active = 1') ?? 0); + $inactiveDepartmentCount = (int) (DB::selectValue('select count(*) from departments where active = 0') ?? 0); + $activeRoleCount = (int) (DB::selectValue('select count(*) from roles where active = 1') ?? 0); + $inactiveRoleCount = (int) (DB::selectValue('select count(*) from roles where active = 0') ?? 0); + + $rolesWithoutPermissionsCount = (int) (DB::selectValue( + 'select count(*) from roles r left join role_permissions rp on rp.role_id = r.id where r.active = 1 and rp.role_id is null' + ) ?? 0); + $permissionsWithoutRolesCount = (int) (DB::selectValue( + 'select count(*) from permissions p left join role_permissions rp on rp.permission_id = p.id where p.active = 1 and rp.permission_id is null' + ) ?? 0); + $usersWithoutRolesCount = (int) (DB::selectValue( + 'select count(*) from users u left join user_roles ur on ur.user_id = u.id where u.active = 1 and ur.user_id is null' + ) ?? 0); + $usersWithoutTenantsCount = (int) (DB::selectValue( + 'select count(*) from users u left join user_tenants ut on ut.user_id = u.id where u.active = 1 and ut.user_id is null' + ) ?? 0); + + $flattenStatsRow = static function ($row): array { + if (!is_array($row)) { + return []; + } + if (isset($row['']) && is_array($row[''])) { + return $row['']; + } + $flat = []; + foreach ($row as $key => $value) { + if (is_array($value)) { + $flat = array_merge($flat, $value); + } elseif (is_string($key)) { + $flat[$key] = $value; + } + } + return $flat; + }; + + $ssoStatsAvailable = (int) (DB::selectValue( + "select count(*) from information_schema.tables where table_schema = database() and table_name = 'tenant_auth_microsoft'" + ) ?? 0) === 1; + $ssoEnabledTenantCount = 0; + $ssoEnforcedTenantCount = 0; + $ssoEnabledTenantPercent = 0.0; + $usersMicrosoftOnlyCount = 0; + $usersMicrosoftSyncGapsCount = 0; + $ssoTenantRows = []; + $usersMicrosoftOnlyRows = []; + $usersMicrosoftSyncGapRows = []; + + if ($ssoStatsAvailable) { + $ssoEnabledTenantCount = (int) (DB::selectValue( + "select count(*) from tenants t " . + "left join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + "where t.status = ? and coalesce(tam.enabled, 0) = 1", + $tenantActiveStatus + ) ?? 0); + $ssoEnforcedTenantCount = (int) (DB::selectValue( + "select count(*) from tenants t " . + "left join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + "where t.status = ? and coalesce(tam.enabled, 0) = 1 and coalesce(tam.enforce_microsoft_login, 0) = 1", + $tenantActiveStatus + ) ?? 0); + if ($activeTenantCount > 0) { + $ssoEnabledTenantPercent = round(((float) $ssoEnabledTenantCount * 100) / (float) $activeTenantCount, 1); + } + + $usersMicrosoftOnlyCount = (int) (DB::selectValue( + "select count(*) from users u " . + "where u.active = 1 " . + "and exists ( " . + " select 1 from user_tenants ut " . + " join tenants t on t.id = ut.tenant_id and t.status = ? " . + " where ut.user_id = u.id " . + ") " . + "and not exists ( " . + " select 1 from user_tenants ut " . + " join tenants t on t.id = ut.tenant_id and t.status = ? " . + " left join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + " where ut.user_id = u.id " . + " and (coalesce(tam.enabled, 0) = 0 or coalesce(tam.enforce_microsoft_login, 0) = 0) " . + ")", + $tenantActiveStatus, + $tenantActiveStatus + ) ?? 0); + + $usersMicrosoftSyncGapsCount = (int) (DB::selectValue( + "select count(*) from ( " . + " select u.id, " . + " trim(coalesce(u.first_name, '')) as first_name_value, " . + " trim(coalesce(u.last_name, '')) as last_name_value, " . + " trim(coalesce(u.phone, '')) as phone_value, " . + " trim(coalesce(u.mobile, '')) as mobile_value, " . + " max(find_in_set('first_name', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_first_name, " . + " max(find_in_set('last_name', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_last_name, " . + " max(find_in_set('phone', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_phone, " . + " max(find_in_set('mobile', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_mobile " . + " from users u " . + " join user_tenants ut on ut.user_id = u.id " . + " join tenants t on t.id = ut.tenant_id and t.status = ? " . + " join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + " where u.active = 1 and u.last_login_provider = 'microsoft' and tam.enabled = 1 and tam.sync_profile_on_login = 1 " . + " group by u.id, u.first_name, u.last_name, u.phone, u.mobile " . + ") x " . + "where (x.need_first_name = 1 and x.first_name_value = '') " . + " or (x.need_last_name = 1 and x.last_name_value = '') " . + " or (x.need_phone = 1 and x.phone_value = '') " . + " or (x.need_mobile = 1 and x.mobile_value = '')", + $tenantActiveStatus + ) ?? 0); + + $ssoTenantRowsRaw = DB::select( + "select " . + " t.uuid as tenant_uuid, " . + " t.description as tenant_description, " . + " coalesce(tam.enabled, 0) as sso_enabled, " . + " coalesce(tam.enforce_microsoft_login, 0) as enforce_microsoft_login, " . + " coalesce(tam.sync_profile_on_login, 0) as sync_profile_on_login, " . + " coalesce(nullif(tam.sync_profile_fields, ''), '-') as sync_profile_fields " . + "from tenants t " . + "left join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + "where t.status = ? " . + "order by sso_enabled desc, enforce_microsoft_login desc, t.description asc " . + "limit 10", + $tenantActiveStatus + ); + $ssoTenantRows = is_array($ssoTenantRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $tenantUuid = trim((string) ($flat['tenant_uuid'] ?? ($flat['uuid'] ?? ''))); + $tenantDescription = trim((string) ($flat['tenant_description'] ?? ($flat['description'] ?? ''))); + if ($tenantUuid === '' || $tenantDescription === '') { + return null; + } + return [ + 'tenant_uuid' => $tenantUuid, + 'tenant_description' => $tenantDescription, + 'sso_enabled' => (int) ($flat['sso_enabled'] ?? ($flat['enabled'] ?? 0)), + 'enforce_microsoft_login' => (int) ($flat['enforce_microsoft_login'] ?? 0), + 'sync_profile_on_login' => (int) ($flat['sync_profile_on_login'] ?? 0), + 'sync_profile_fields' => trim((string) ($flat['sync_profile_fields'] ?? '-')), + ]; + }, + $ssoTenantRowsRaw + ))) : []; + + $usersMicrosoftOnlyRowsRaw = DB::select( + "select " . + " u.uuid as user_uuid, " . + " u.display_name as display_name, " . + " u.email as email, " . + " group_concat(distinct t.description order by t.description separator ', ') as tenant_labels " . + "from users u " . + "join user_tenants ut on ut.user_id = u.id " . + "join tenants t on t.id = ut.tenant_id and t.status = ? " . + "left join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + "where u.active = 1 " . + "group by u.id, u.uuid, u.display_name, u.email " . + "having sum(case when coalesce(tam.enabled, 0) = 0 or coalesce(tam.enforce_microsoft_login, 0) = 0 then 1 else 0 end) = 0 " . + "order by u.display_name asc " . + "limit 10", + $tenantActiveStatus + ); + $usersMicrosoftOnlyRows = is_array($usersMicrosoftOnlyRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $userUuid = trim((string) ($flat['user_uuid'] ?? ($flat['uuid'] ?? ''))); + $email = trim((string) ($flat['user_email'] ?? ($flat['email'] ?? ''))); + if ($userUuid === '' || $email === '') { + return null; + } + return [ + 'user_uuid' => $userUuid, + 'display_name' => trim((string) ($flat['user_display_name'] ?? ($flat['display_name'] ?? ''))), + 'email' => $email, + 'tenant_labels' => trim((string) ($flat['tenant_labels'] ?? ($flat['description'] ?? ''))), + ]; + }, + $usersMicrosoftOnlyRowsRaw + ))) : []; + + $usersMicrosoftSyncGapRowsRaw = DB::select( + "select " . + " x.user_uuid, x.user_display_name, x.user_email, " . + " trim(concat_ws(', ', " . + " if(x.need_first_name = 1, 'first_name', null), " . + " if(x.need_last_name = 1, 'last_name', null), " . + " if(x.need_phone = 1, 'phone', null), " . + " if(x.need_mobile = 1, 'mobile', null) " . + " )) as configured_fields, " . + " trim(concat_ws(', ', " . + " if(x.need_first_name = 1 and x.first_name_value = '', 'first_name', null), " . + " if(x.need_last_name = 1 and x.last_name_value = '', 'last_name', null), " . + " if(x.need_phone = 1 and x.phone_value = '', 'phone', null), " . + " if(x.need_mobile = 1 and x.mobile_value = '', 'mobile', null) " . + " )) as missing_fields " . + "from ( " . + " select " . + " u.id, u.uuid as user_uuid, u.display_name as user_display_name, u.email as user_email, " . + " trim(coalesce(u.first_name, '')) as first_name_value, " . + " trim(coalesce(u.last_name, '')) as last_name_value, " . + " trim(coalesce(u.phone, '')) as phone_value, " . + " trim(coalesce(u.mobile, '')) as mobile_value, " . + " max(find_in_set('first_name', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_first_name, " . + " max(find_in_set('last_name', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_last_name, " . + " max(find_in_set('phone', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_phone, " . + " max(find_in_set('mobile', coalesce(nullif(tam.sync_profile_fields, ''), 'first_name,last_name')) > 0) as need_mobile " . + " from users u " . + " join user_tenants ut on ut.user_id = u.id " . + " join tenants t on t.id = ut.tenant_id and t.status = ? " . + " join tenant_auth_microsoft tam on tam.tenant_id = t.id " . + " where u.active = 1 and u.last_login_provider = 'microsoft' and tam.enabled = 1 and tam.sync_profile_on_login = 1 " . + " group by u.id, u.uuid, u.display_name, u.email, u.first_name, u.last_name, u.phone, u.mobile " . + ") x " . + "where (x.need_first_name = 1 and x.first_name_value = '') " . + " or (x.need_last_name = 1 and x.last_name_value = '') " . + " or (x.need_phone = 1 and x.phone_value = '') " . + " or (x.need_mobile = 1 and x.mobile_value = '') " . + "order by x.user_display_name asc " . + "limit 10", + $tenantActiveStatus + ); + $usersMicrosoftSyncGapRows = is_array($usersMicrosoftSyncGapRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $userUuid = trim((string) ($flat['user_uuid'] ?? ($flat['uuid'] ?? ''))); + $email = trim((string) ($flat['user_email'] ?? ($flat['email'] ?? ''))); + if ($userUuid === '' || $email === '') { + return null; + } + return [ + 'user_uuid' => $userUuid, + 'display_name' => trim((string) ($flat['user_display_name'] ?? ($flat['display_name'] ?? ''))), + 'email' => $email, + 'configured_fields' => trim((string) ($flat['configured_fields'] ?? '')), + 'missing_fields' => trim((string) ($flat['missing_fields'] ?? '')), + ]; + }, + $usersMicrosoftSyncGapRowsRaw + ))) : []; + } + + $customFieldStatsAvailable = (int) (DB::selectValue( + "select count(*) from information_schema.tables " . + "where table_schema = database() " . + "and table_name in ('tenant_custom_field_definitions', 'tenant_custom_field_options', 'user_custom_field_values')" + ) ?? 0) === 3; + $customFieldsSelectionWithoutOptionsCount = 0; + $customFieldTenantsWithSelectionWithoutOptionsCount = 0; + $customFieldsInactiveWithValuesCount = 0; + $customFieldsUnusedActiveCount = 0; + $customFieldsSelectionWithoutOptionsHref = 'admin/tenants'; + $customFieldTenantsWithSelectionWithoutOptionsHref = 'admin/tenants'; + $customFieldsInactiveWithValuesHref = 'admin/tenants'; + $customFieldsUnusedActiveHref = 'admin/tenants'; + $customFieldIssueTenantRows = []; + + if ($customFieldStatsAvailable) { + $loadCustomFieldIssueTenants = static function (string $query): array { + $rows = DB::select($query); + if (!is_array($rows)) { + return []; + } + $items = []; + foreach ($rows as $row) { + $tenant = $row['tenants'] ?? []; + $meta = $row[''] ?? []; + $tenantUuid = trim((string) ($tenant['uuid'] ?? '')); + $tenantDescription = trim((string) ($tenant['description'] ?? '')); + $issueCount = (int) ($meta['issue_count'] ?? 0); + if ($tenantUuid === '' || $tenantDescription === '' || $issueCount <= 0) { + continue; + } + $items[] = [ + 'tenant_uuid' => $tenantUuid, + 'tenant_description' => $tenantDescription, + 'issue_count' => $issueCount, + ]; + } + return $items; + }; + + $customFieldsSelectionWithoutOptionsCount = (int) (DB::selectValue( + "select count(*) from ( " . + "select d.id " . + "from tenant_custom_field_definitions d " . + "left join tenant_custom_field_options o on o.definition_id = d.id and o.active = 1 " . + "where d.active = 1 and d.type in ('select', 'multiselect') " . + "group by d.id " . + "having count(o.id) = 0 " . + ') missing_options' + ) ?? 0); + + $customFieldTenantsWithSelectionWithoutOptionsCount = (int) (DB::selectValue( + "select count(distinct missing_options.tenant_id) from ( " . + "select d.tenant_id " . + "from tenant_custom_field_definitions d " . + "left join tenant_custom_field_options o on o.definition_id = d.id and o.active = 1 " . + "where d.active = 1 and d.type in ('select', 'multiselect') " . + "group by d.id, d.tenant_id " . + "having count(o.id) = 0 " . + ') missing_options' + ) ?? 0); + + $customFieldsInactiveWithValuesCount = (int) (DB::selectValue( + 'select count(distinct d.id) from tenant_custom_field_definitions d ' . + 'inner join user_custom_field_values v on v.definition_id = d.id ' . + 'where d.active = 0' + ) ?? 0); + + $customFieldsUnusedActiveCount = (int) (DB::selectValue( + "select count(*) from ( " . + "select d.id " . + "from tenant_custom_field_definitions d " . + "left join user_custom_field_values v on v.definition_id = d.id " . + "where d.active = 1 " . + "group by d.id " . + "having count(v.id) = 0 " . + ') unused_active' + ) ?? 0); + + $selectionWithoutOptionsTenantUuid = (string) (DB::selectValue( + "select t.uuid from tenants t " . + "inner join ( " . + "select d.tenant_id " . + "from tenant_custom_field_definitions d " . + "left join tenant_custom_field_options o on o.definition_id = d.id and o.active = 1 " . + "where d.active = 1 and d.type in ('select', 'multiselect') " . + "group by d.id, d.tenant_id " . + "having count(o.id) = 0 " . + "order by d.tenant_id asc " . + "limit 1" . + ') issue on issue.tenant_id = t.id ' . + 'limit 1' + ) ?? ''); + + $inactiveWithValuesTenantUuid = (string) (DB::selectValue( + "select t.uuid from tenants t " . + "inner join ( " . + "select d.tenant_id " . + "from tenant_custom_field_definitions d " . + "inner join user_custom_field_values v on v.definition_id = d.id " . + "where d.active = 0 " . + "group by d.tenant_id " . + "order by d.tenant_id asc " . + "limit 1" . + ') issue on issue.tenant_id = t.id ' . + 'limit 1' + ) ?? ''); + + $unusedActiveTenantUuid = (string) (DB::selectValue( + "select t.uuid from tenants t " . + "inner join ( " . + "select d.tenant_id " . + "from tenant_custom_field_definitions d " . + "left join user_custom_field_values v on v.definition_id = d.id " . + "where d.active = 1 " . + "group by d.id, d.tenant_id " . + "having count(v.id) = 0 " . + "order by d.tenant_id asc " . + "limit 1" . + ') issue on issue.tenant_id = t.id ' . + 'limit 1' + ) ?? ''); + + if ($selectionWithoutOptionsTenantUuid !== '') { + $customFieldsSelectionWithoutOptionsHref = 'admin/tenants/edit/' . $selectionWithoutOptionsTenantUuid . '?tab=custom_fields'; + $customFieldTenantsWithSelectionWithoutOptionsHref = $customFieldsSelectionWithoutOptionsHref; + } + if ($inactiveWithValuesTenantUuid !== '') { + $customFieldsInactiveWithValuesHref = 'admin/tenants/edit/' . $inactiveWithValuesTenantUuid . '?tab=custom_fields'; + } + if ($unusedActiveTenantUuid !== '') { + $customFieldsUnusedActiveHref = 'admin/tenants/edit/' . $unusedActiveTenantUuid . '?tab=custom_fields'; + } + + $selectionIssueTenants = $loadCustomFieldIssueTenants( + "select t.uuid, t.description, count(*) as issue_count " . + "from tenants t " . + "inner join ( " . + "select d.id, d.tenant_id " . + "from tenant_custom_field_definitions d " . + "left join tenant_custom_field_options o on o.definition_id = d.id and o.active = 1 " . + "where d.active = 1 and d.type in ('select', 'multiselect') " . + "group by d.id, d.tenant_id " . + "having count(o.id) = 0 " . + ") issue on issue.tenant_id = t.id " . + "group by t.id, t.uuid, t.description " . + "order by issue_count desc, t.description asc " . + "limit 5" + ); + foreach ($selectionIssueTenants as $row) { + $customFieldIssueTenantRows[] = [ + 'issue' => t('Selection fields without options'), + 'tenant_uuid' => $row['tenant_uuid'], + 'tenant_description' => $row['tenant_description'], + 'issue_count' => $row['issue_count'], + ]; + } + + $inactiveIssueTenants = $loadCustomFieldIssueTenants( + "select t.uuid, t.description, count(distinct d.id) as issue_count " . + "from tenants t " . + "inner join tenant_custom_field_definitions d on d.tenant_id = t.id and d.active = 0 " . + "inner join user_custom_field_values v on v.definition_id = d.id " . + "group by t.id, t.uuid, t.description " . + "order by issue_count desc, t.description asc " . + "limit 5" + ); + foreach ($inactiveIssueTenants as $row) { + $customFieldIssueTenantRows[] = [ + 'issue' => t('Inactive custom fields with values'), + 'tenant_uuid' => $row['tenant_uuid'], + 'tenant_description' => $row['tenant_description'], + 'issue_count' => $row['issue_count'], + ]; + } + + $unusedIssueTenants = $loadCustomFieldIssueTenants( + "select t.uuid, t.description, count(*) as issue_count " . + "from tenants t " . + "inner join ( " . + "select d.id, d.tenant_id " . + "from tenant_custom_field_definitions d " . + "left join user_custom_field_values v on v.definition_id = d.id " . + "where d.active = 1 " . + "group by d.id, d.tenant_id " . + "having count(v.id) = 0 " . + ") issue on issue.tenant_id = t.id " . + "group by t.id, t.uuid, t.description " . + "order by issue_count desc, t.description asc " . + "limit 5" + ); + foreach ($unusedIssueTenants as $row) { + $customFieldIssueTenantRows[] = [ + 'issue' => t('Unused active custom fields'), + 'tenant_uuid' => $row['tenant_uuid'], + 'tenant_description' => $row['tenant_description'], + 'issue_count' => $row['issue_count'], + ]; + } + } + + $apiStatsAvailable = (int) (DB::selectValue( + "select count(*) from information_schema.tables " . + "where table_schema = database() and table_name in ('api_audit_log', 'user_api_tokens')" + ) ?? 0) === 2; + $apiRequests24hCount = 0; + $apiRequestsPrev24hCount = 0; + $apiRequestTrendPercent = 0.0; + $apiErrors4xx24hCount = 0; + $apiErrors5xx24hCount = 0; + $apiP95DurationMs = 0; + $apiTokensExpiring7dCount = 0; + $apiTopErrorRows = []; + $apiSlowEndpointRows = []; + + if ($apiStatsAvailable) { + $apiRequests24hCount = (int) (DB::selectValue( + 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR)' + ) ?? 0); + $apiRequestsPrev24hCount = (int) (DB::selectValue( + 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 48 HOUR) and created_at < (NOW() - INTERVAL 24 HOUR)' + ) ?? 0); + if ($apiRequestsPrev24hCount > 0) { + $apiRequestTrendPercent = round((($apiRequests24hCount - $apiRequestsPrev24hCount) * 100) / $apiRequestsPrev24hCount, 1); + } + + $apiErrors4xx24hCount = (int) (DB::selectValue( + 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 400 and 499' + ) ?? 0); + $apiErrors5xx24hCount = (int) (DB::selectValue( + 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 500 and 599' + ) ?? 0); + + $durationCount = (int) (DB::selectValue( + 'select count(*) from api_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 200 and 299 and duration_ms is not null' + ) ?? 0); + if ($durationCount > 0) { + $offset = (int) floor(($durationCount - 1) * 0.95); + $apiP95DurationMs = (int) (DB::selectValue( + 'select duration_ms from api_audit_log ' . + 'where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 200 and 299 and duration_ms is not null ' . + 'order by duration_ms asc limit ' . $offset . ', 1' + ) ?? 0); + } + + $apiTokensExpiring7dCount = (int) (DB::selectValue( + 'select count(*) from user_api_tokens ' . + 'where revoked_at is null and expires_at is not null and expires_at > NOW() and expires_at <= (NOW() + INTERVAL 7 DAY)' + ) ?? 0); + + $apiTopErrorRowsRaw = DB::select( + "select " . + " coalesce(nullif(error_code, ''), concat('http_', status_code)) as error_label, " . + " count(*) as hit_count " . + "from api_audit_log " . + "where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code >= 400 " . + "group by error_label " . + "order by hit_count desc, error_label asc " . + "limit 5" + ); + $apiTopErrorRows = is_array($apiTopErrorRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $label = trim((string) ($flat['error_label'] ?? '')); + $hits = (int) ($flat['hit_count'] ?? 0); + if ($label === '' || $hits <= 0) { + return null; + } + return [ + 'error_label' => $label, + 'hit_count' => $hits, + ]; + }, + $apiTopErrorRowsRaw + ))) : []; + + $apiSlowEndpointRowsRaw = DB::select( + "select " . + " path, " . + " count(*) as hit_count, " . + " round(avg(duration_ms), 1) as avg_duration_ms, " . + " max(duration_ms) as max_duration_ms " . + "from api_audit_log " . + "where created_at >= (NOW() - INTERVAL 24 HOUR) and status_code between 200 and 299 and duration_ms is not null " . + "group by path " . + "having count(*) >= 3 " . + "order by avg(duration_ms) desc, max(duration_ms) desc " . + "limit 5" + ); + $apiSlowEndpointRows = is_array($apiSlowEndpointRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $path = trim((string) ($flat['path'] ?? '')); + $hits = (int) ($flat['hit_count'] ?? 0); + if ($path === '' || $hits <= 0) { + return null; + } + return [ + 'path' => $path, + 'hit_count' => $hits, + 'avg_duration_ms' => (float) ($flat['avg_duration_ms'] ?? 0), + 'max_duration_ms' => (int) ($flat['max_duration_ms'] ?? 0), + ]; + }, + $apiSlowEndpointRowsRaw + ))) : []; + } + + $importStatsAvailable = (int) (DB::selectValue( + "select count(*) from information_schema.tables " . + "where table_schema = database() and table_name = 'import_audit_runs'" + ) ?? 0) === 1; + $importRuns7dCount = 0; + $importRunsPrev7dCount = 0; + $importRunTrendPercent = 0.0; + $importRowsCreated7dCount = 0; + $importRowsFailed7dCount = 0; + $importPartialOrFailedRuns7dCount = 0; + $importSuccessRate7dPercent = 0.0; + $importAverageDurationMs7d = 0; + $importRecentRunRows = []; + $importProfileRows = []; + $importTopErrorCodeRows = []; + + if ($importStatsAvailable) { + $importRuns7dCount = (int) (DB::selectValue( + "select count(*) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY)" + ) ?? 0); + $importRunsPrev7dCount = (int) (DB::selectValue( + "select count(*) from import_audit_runs where started_at >= (NOW() - INTERVAL 14 DAY) and started_at < (NOW() - INTERVAL 7 DAY)" + ) ?? 0); + if ($importRunsPrev7dCount > 0) { + $importRunTrendPercent = round((($importRuns7dCount - $importRunsPrev7dCount) * 100) / $importRunsPrev7dCount, 1); + } + + $importRowsCreated7dCount = (int) (DB::selectValue( + "select coalesce(sum(created_count), 0) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY)" + ) ?? 0); + $importRowsFailed7dCount = (int) (DB::selectValue( + "select coalesce(sum(failed_count), 0) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY)" + ) ?? 0); + $importPartialOrFailedRuns7dCount = (int) (DB::selectValue( + "select count(*) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY) and status in (???)", + [$importStatusPartial, $importStatusFailed] + ) ?? 0); + $importAverageDurationMs7d = (int) (DB::selectValue( + "select round(avg(duration_ms), 0) from import_audit_runs " . + "where started_at >= (NOW() - INTERVAL 7 DAY) and duration_ms is not null" + ) ?? 0); + $importSuccessRuns7dCount = (int) (DB::selectValue( + "select count(*) from import_audit_runs where started_at >= (NOW() - INTERVAL 7 DAY) and status = ?", + $importStatusSuccess + ) ?? 0); + if ($importRuns7dCount > 0) { + $importSuccessRate7dPercent = round(($importSuccessRuns7dCount * 100) / $importRuns7dCount, 1); + } + + $importRecentRunRowsRaw = DB::select( + "select " . + " import_audit_runs.id, " . + " import_audit_runs.started_at, " . + " import_audit_runs.profile_key, " . + " import_audit_runs.status, " . + " import_audit_runs.rows_total, " . + " import_audit_runs.created_count, " . + " import_audit_runs.failed_count, " . + " import_audit_runs.duration_ms, " . + " users.uuid as user_uuid, " . + " users.display_name as user_display_name, " . + " users.email as user_email " . + "from import_audit_runs " . + "left join users on users.id = import_audit_runs.user_id " . + "order by import_audit_runs.started_at desc " . + "limit 10" + ); + $importRecentRunRows = is_array($importRecentRunRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $id = (int) ($flat['id'] ?? 0); + $startedAt = trim((string) ($flat['started_at'] ?? '')); + $profileKey = trim((string) ($flat['profile_key'] ?? '')); + $status = trim((string) ($flat['status'] ?? '')); + if ($id <= 0 || $startedAt === '' || $profileKey === '' || $status === '') { + return null; + } + return [ + 'id' => $id, + 'started_at' => $startedAt, + 'profile_key' => $profileKey, + 'status' => $status, + 'rows_total' => (int) ($flat['rows_total'] ?? 0), + 'created_count' => (int) ($flat['created_count'] ?? 0), + 'failed_count' => (int) ($flat['failed_count'] ?? 0), + 'duration_ms' => (int) ($flat['duration_ms'] ?? 0), + 'user_uuid' => trim((string) ($flat['user_uuid'] ?? '')), + 'user_display_name' => trim((string) ($flat['user_display_name'] ?? '')), + 'user_email' => trim((string) ($flat['user_email'] ?? '')), + ]; + }, + $importRecentRunRowsRaw + ))) : []; + + $importProfileRowsRaw = DB::select( + "select " . + " profile_key, " . + " count(*) as run_count, " . + " sum(case when status = ? then 1 else 0 end) as success_count, " . + " coalesce(sum(created_count), 0) as created_count_sum, " . + " coalesce(sum(failed_count), 0) as failed_count_sum " . + "from import_audit_runs " . + "where started_at >= (NOW() - INTERVAL 7 DAY) " . + "group by profile_key " . + "order by run_count desc, profile_key asc " . + "limit 10", + $importStatusSuccess + ); + $importProfileRows = is_array($importProfileRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $profileKey = trim((string) ($flat['profile_key'] ?? '')); + $runCount = (int) ($flat['run_count'] ?? 0); + if ($profileKey === '' || $runCount <= 0) { + return null; + } + $successCount = (int) ($flat['success_count'] ?? 0); + $successRate = round(($successCount * 100) / $runCount, 1); + return [ + 'profile_key' => $profileKey, + 'run_count' => $runCount, + 'created_count_sum' => (int) ($flat['created_count_sum'] ?? 0), + 'failed_count_sum' => (int) ($flat['failed_count_sum'] ?? 0), + 'success_rate' => $successRate, + ]; + }, + $importProfileRowsRaw + ))) : []; + + $importTopErrorRowsRaw = DB::select( + "select error_codes_json from import_audit_runs " . + "where started_at >= (NOW() - INTERVAL 7 DAY) " . + "and error_codes_json is not null and error_codes_json <> '' " . + "order by started_at desc " . + "limit 1000" + ); + $errorCodeCounts = []; + if (is_array($importTopErrorRowsRaw)) { + foreach ($importTopErrorRowsRaw as $row) { + $flat = $flattenStatsRow($row); + $json = (string) ($flat['error_codes_json'] ?? ''); + if ($json === '') { + continue; + } + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + continue; + } + foreach ($decoded as $code => $count) { + $codeKey = trim((string) $code); + $countValue = (int) $count; + if ($codeKey === '' || $countValue <= 0) { + continue; + } + $errorCodeCounts[$codeKey] = (int) ($errorCodeCounts[$codeKey] ?? 0) + $countValue; + } + } + } + if (!empty($errorCodeCounts)) { + arsort($errorCodeCounts); + foreach (array_slice($errorCodeCounts, 0, 5, true) as $code => $count) { + $importTopErrorCodeRows[] = [ + 'error_code' => $code, + 'count' => (int) $count, + ]; + } + } + } + + $auditNow = new \DateTimeImmutable('now'); + $systemAuditCreatedFrom = $auditNow->modify('-1 day')->format('Y-m-d'); + $systemAuditCreatedTo = $auditNow->format('Y-m-d'); + $lifecycleCreatedFrom = $auditNow->modify('-7 days')->format('Y-m-d'); + $lifecycleCreatedTo = $auditNow->format('Y-m-d'); + + $systemAuditStatsAvailable = (int) (DB::selectValue( + "select count(*) from information_schema.tables " . + "where table_schema = database() and table_name = 'system_audit_log'" + ) ?? 0) === 1; + $userLifecycleAuditStatsAvailable = (int) (DB::selectValue( + "select count(*) from information_schema.tables " . + "where table_schema = database() and table_name = 'user_lifecycle_audit_log'" + ) ?? 0) === 1; + $auditStatsAvailable = $systemAuditStatsAvailable || $userLifecycleAuditStatsAvailable; + + $systemAuditEvents24hCount = 0; + $systemAuditFailed24hCount = 0; + $systemAuditDenied24hCount = 0; + $systemAuditEventsPrev24hCount = 0; + $systemAuditTrendPercent = 0.0; + $systemAuditTopEventTypeRows = []; + $systemAuditRecentRiskRows = []; + $systemAuditAll24hHref = 'admin/system-audit'; + $systemAuditFailed24hHref = 'admin/system-audit'; + $systemAuditDenied24hHref = 'admin/system-audit'; + + if ($systemAuditStatsAvailable) { + $systemAuditEvents24hCount = (int) (DB::selectValue( + "select count(*) from system_audit_log where created_at >= (NOW() - INTERVAL 24 HOUR)" + ) ?? 0); + $systemAuditFailed24hCount = (int) (DB::selectValue( + "select count(*) from system_audit_log " . + "where created_at >= (NOW() - INTERVAL 24 HOUR) and outcome = ?", + $systemAuditOutcomeFailed + ) ?? 0); + $systemAuditDenied24hCount = (int) (DB::selectValue( + "select count(*) from system_audit_log " . + "where created_at >= (NOW() - INTERVAL 24 HOUR) and outcome = ?", + $systemAuditOutcomeDenied + ) ?? 0); + $systemAuditEventsPrev24hCount = (int) (DB::selectValue( + "select count(*) from system_audit_log " . + "where created_at >= (NOW() - INTERVAL 48 HOUR) and created_at < (NOW() - INTERVAL 24 HOUR)" + ) ?? 0); + if ($systemAuditEventsPrev24hCount > 0) { + $systemAuditTrendPercent = round( + (($systemAuditEvents24hCount - $systemAuditEventsPrev24hCount) * 100) / $systemAuditEventsPrev24hCount, + 1 + ); + } + + $systemAuditAll24hHref = 'admin/system-audit?' . http_build_query([ + 'created_from' => $systemAuditCreatedFrom, + 'created_to' => $systemAuditCreatedTo, + ]); + $systemAuditFailed24hHref = 'admin/system-audit?' . http_build_query([ + 'outcome' => $systemAuditOutcomeFailed, + 'created_from' => $systemAuditCreatedFrom, + 'created_to' => $systemAuditCreatedTo, + ]); + $systemAuditDenied24hHref = 'admin/system-audit?' . http_build_query([ + 'outcome' => $systemAuditOutcomeDenied, + 'created_from' => $systemAuditCreatedFrom, + 'created_to' => $systemAuditCreatedTo, + ]); + + $systemAuditTopEventTypeRowsRaw = DB::select( + "select " . + " event_type, " . + " count(*) as hit_count, " . + " sum(case when outcome in (???) then 1 else 0 end) as failed_or_denied_count " . + "from system_audit_log " . + "where created_at >= (NOW() - INTERVAL 24 HOUR) " . + "group by event_type " . + "order by failed_or_denied_count desc, hit_count desc, event_type asc " . + "limit 5", + [$systemAuditOutcomeFailed, $systemAuditOutcomeDenied] + ); + $systemAuditTopEventTypeRows = is_array($systemAuditTopEventTypeRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $eventType = trim((string) ($flat['event_type'] ?? '')); + $hitCount = (int) ($flat['hit_count'] ?? 0); + if ($eventType === '' || $hitCount <= 0) { + return null; + } + return [ + 'event_type' => $eventType, + 'hit_count' => $hitCount, + 'failed_or_denied_count' => (int) ($flat['failed_or_denied_count'] ?? 0), + ]; + }, + $systemAuditTopEventTypeRowsRaw + ))) : []; + + $systemAuditRecentRiskRowsRaw = DB::select( + "select " . + " id, event_type, outcome, channel, created_at " . + "from system_audit_log " . + "where created_at >= (NOW() - INTERVAL 24 HOUR) and outcome in (???) " . + "order by created_at desc " . + "limit 10", + [$systemAuditOutcomeFailed, $systemAuditOutcomeDenied] + ); + $systemAuditRecentRiskRows = is_array($systemAuditRecentRiskRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $id = (int) ($flat['id'] ?? 0); + $eventType = trim((string) ($flat['event_type'] ?? '')); + $outcome = trim((string) ($flat['outcome'] ?? '')); + if ($id <= 0 || $eventType === '' || $outcome === '') { + return null; + } + return [ + 'id' => $id, + 'event_type' => $eventType, + 'outcome' => $outcome, + 'channel' => trim((string) ($flat['channel'] ?? '')), + 'created_at' => trim((string) ($flat['created_at'] ?? '')), + ]; + }, + $systemAuditRecentRiskRowsRaw + ))) : []; + } + + $lifecycleRuns7dCount = 0; + $lifecycleRunsPrev7dCount = 0; + $lifecycleRunTrendPercent = 0.0; + $lifecycleFailed7dCount = 0; + $lifecycleSkipped7dCount = 0; + $lifecycleRestore7dCount = 0; + $lifecycleTopReasonRows = []; + $lifecycleRecentRiskRows = []; + $lifecycleAll7dHref = 'admin/user-lifecycle-audit'; + $lifecycleFailed7dHref = 'admin/user-lifecycle-audit'; + $lifecycleRestore7dHref = 'admin/user-lifecycle-audit'; + + if ($userLifecycleAuditStatsAvailable) { + $lifecycleRuns7dCount = (int) (DB::selectValue( + "select count(*) from user_lifecycle_audit_log where created_at >= (NOW() - INTERVAL 7 DAY)" + ) ?? 0); + $lifecycleRunsPrev7dCount = (int) (DB::selectValue( + "select count(*) from user_lifecycle_audit_log " . + "where created_at >= (NOW() - INTERVAL 14 DAY) and created_at < (NOW() - INTERVAL 7 DAY)" + ) ?? 0); + if ($lifecycleRunsPrev7dCount > 0) { + $lifecycleRunTrendPercent = round( + (($lifecycleRuns7dCount - $lifecycleRunsPrev7dCount) * 100) / $lifecycleRunsPrev7dCount, + 1 + ); + } + $lifecycleFailed7dCount = (int) (DB::selectValue( + "select count(*) from user_lifecycle_audit_log " . + "where created_at >= (NOW() - INTERVAL 7 DAY) and status = ?", + $lifecycleStatusFailed + ) ?? 0); + $lifecycleSkipped7dCount = (int) (DB::selectValue( + "select count(*) from user_lifecycle_audit_log " . + "where created_at >= (NOW() - INTERVAL 7 DAY) and status = ?", + $lifecycleStatusSkipped + ) ?? 0); + $lifecycleRestore7dCount = (int) (DB::selectValue( + "select count(*) from user_lifecycle_audit_log " . + "where created_at >= (NOW() - INTERVAL 7 DAY) and action = ?", + $lifecycleActionRestore + ) ?? 0); + + $lifecycleAll7dHref = 'admin/user-lifecycle-audit?' . http_build_query([ + 'created_from' => $lifecycleCreatedFrom, + 'created_to' => $lifecycleCreatedTo, + ]); + $lifecycleFailed7dHref = 'admin/user-lifecycle-audit?' . http_build_query([ + 'statuses' => $lifecycleStatusFailed . ',' . $lifecycleStatusSkipped, + 'created_from' => $lifecycleCreatedFrom, + 'created_to' => $lifecycleCreatedTo, + ]); + $lifecycleRestore7dHref = 'admin/user-lifecycle-audit?' . http_build_query([ + 'actions' => $lifecycleActionRestore, + 'created_from' => $lifecycleCreatedFrom, + 'created_to' => $lifecycleCreatedTo, + ]); + + $lifecycleTopReasonRowsRaw = DB::select( + "select " . + " coalesce(nullif(reason_code, ''), '-') as reason_code, " . + " count(*) as hit_count " . + "from user_lifecycle_audit_log " . + "where created_at >= (NOW() - INTERVAL 7 DAY) " . + "group by reason_code " . + "order by hit_count desc, reason_code asc " . + "limit 5" + ); + $lifecycleTopReasonRows = is_array($lifecycleTopReasonRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $reasonCode = trim((string) ($flat['reason_code'] ?? '-')); + $hitCount = (int) ($flat['hit_count'] ?? 0); + if ($hitCount <= 0) { + return null; + } + if ($reasonCode === '') { + $reasonCode = '-'; + } + return [ + 'reason_code' => $reasonCode, + 'hit_count' => $hitCount, + ]; + }, + $lifecycleTopReasonRowsRaw + ))) : []; + + $lifecycleRecentRiskRowsRaw = DB::select( + "select " . + " id, action, status, trigger_type, target_user_uuid, target_user_email, created_at " . + "from user_lifecycle_audit_log " . + "where created_at >= (NOW() - INTERVAL 7 DAY) " . + " and (status in (???) or action = ?) " . + "order by created_at desc " . + "limit 10", + [$lifecycleStatusFailed, $lifecycleStatusSkipped], + $lifecycleActionRestore + ); + $lifecycleRecentRiskRows = is_array($lifecycleRecentRiskRowsRaw) ? array_values(array_filter(array_map( + static function ($row) use ($flattenStatsRow): ?array { + $flat = $flattenStatsRow($row); + $id = (int) ($flat['id'] ?? 0); + $action = trim((string) ($flat['action'] ?? '')); + $status = trim((string) ($flat['status'] ?? '')); + if ($id <= 0 || $action === '' || $status === '') { + return null; + } + return [ + 'id' => $id, + 'action' => $action, + 'status' => $status, + 'trigger_type' => trim((string) ($flat['trigger_type'] ?? '')), + 'target_user_uuid' => trim((string) ($flat['target_user_uuid'] ?? '')), + 'target_user_email' => trim((string) ($flat['target_user_email'] ?? '')), + 'created_at' => trim((string) ($flat['created_at'] ?? '')), + ]; + }, + $lifecycleRecentRiskRowsRaw + ))) : []; + } + + $scheduledStatsAvailable = (int) (DB::selectValue( + "select count(*) from information_schema.tables " . + "where table_schema = database() and table_name in ('scheduled_jobs', 'scheduled_job_runs', 'scheduler_runtime_status')" + ) ?? 0) === 3; + $scheduledEnabledJobsCount = 0; + $scheduledOverdueJobsCount = 0; + $scheduledRuns24hCount = 0; + $scheduledFailedRuns24hCount = 0; + $schedulerRunnerLastHeartbeatAt = ''; + $schedulerRunnerLastResult = ''; + $schedulerRunnerLastErrorCode = ''; + $schedulerRunnerIsActive = false; + + if ($scheduledStatsAvailable) { + $scheduledEnabledJobsCount = (int) (DB::selectValue( + "select count(*) from scheduled_jobs where enabled = 1" + ) ?? 0); + $scheduledOverdueJobsCount = (int) (DB::selectValue( + "select count(*) from scheduled_jobs " . + "where enabled = 1 and next_run_at is not null and next_run_at <= NOW()" + ) ?? 0); + $scheduledRuns24hCount = (int) (DB::selectValue( + "select count(*) from scheduled_job_runs " . + "where trigger_type = ? and started_at >= (NOW() - INTERVAL 24 HOUR)", + $schedulerTriggerScheduler + ) ?? 0); + $scheduledFailedRuns24hCount = (int) (DB::selectValue( + "select count(*) from scheduled_job_runs " . + "where trigger_type = ? and status = ? and started_at >= (NOW() - INTERVAL 24 HOUR)", + $schedulerTriggerScheduler, + $schedulerRunStatusFailed + ) ?? 0); + + $schedulerRunnerIsActive = (int) (DB::selectValue( + "select case when last_heartbeat_at >= (NOW() - INTERVAL 3 MINUTE) then 1 else 0 end " . + "from scheduler_runtime_status where id = 1" + ) ?? 0) === 1; + + $schedulerRuntimeRowRaw = DB::selectOne( + "select last_heartbeat_at, last_result, last_error_code from scheduler_runtime_status where id = 1 limit 1" + ); + $schedulerRuntimeRow = $flattenStatsRow($schedulerRuntimeRowRaw); + $schedulerRunnerLastHeartbeatAt = trim((string) ($schedulerRuntimeRow['last_heartbeat_at'] ?? '')); + $schedulerRunnerLastResult = trim((string) ($schedulerRuntimeRow['last_result'] ?? '')); + $schedulerRunnerLastErrorCode = trim((string) ($schedulerRuntimeRow['last_error_code'] ?? '')); + } + + $usersUnverifiedCount = (int) (DB::selectValue( + 'select count(*) from users where active = 1 and email_verified_at is null' + ) ?? 0); + $usersNeverLoggedCount = (int) (DB::selectValue( + 'select count(*) from users where active = 1 and last_login_at is null' + ) ?? 0); + $recentLogins = DB::select( + 'select id, uuid, first_name, last_name, email, last_login_at from users where active = 1 and last_login_at is not null order by last_login_at desc limit 10' + ); + $recentLogins = is_array($recentLogins) ? array_map( + static fn ($row) => $row['users'] ?? $row, + $recentLogins + ) : []; + $neverLoggedUsers = DB::select( + 'select id, uuid, first_name, last_name, email, created from users where active = 1 and last_login_at is null order by created desc limit 10' + ); + $neverLoggedUsers = is_array($neverLoggedUsers) ? array_map( + static fn ($row) => $row['users'] ?? $row, + $neverLoggedUsers + ) : []; + + $mailLogFailedCount = (int) (DB::selectValue('select count(*) from mail_log where status = ?', $mailStatusFailed) ?? 0); + $mailLogSentCount = (int) (DB::selectValue('select count(*) from mail_log where status = ?', $mailStatusSent) ?? 0); + $mailLogLastSentAt = (string) (DB::selectValue('select max(sent_at) from mail_log where status = ?', $mailStatusSent) ?? ''); + $mailLogRecentFailed = DB::select( + 'select id, to_email, subject, error_message, created_at from mail_log where status = ? order by created_at desc limit 5', + $mailStatusFailed + ); + $mailLogRecentFailed = is_array($mailLogRecentFailed) ? array_map( + static fn ($row) => $row['mail_log'] ?? $row, + $mailLogRecentFailed + ) : []; + + + $vars = get_defined_vars(); + unset($vars['this']); + return $vars; + } +} diff --git a/lib/Repository/Support/DatabaseSessionRepository.php b/lib/Repository/Support/DatabaseSessionRepository.php new file mode 100644 index 0000000..06672fb --- /dev/null +++ b/lib/Repository/Support/DatabaseSessionRepository.php @@ -0,0 +1,50 @@ +begin_transaction(); + } + + public function commitTransaction(): void + { + DB::handle()->commit(); + } + + public function rollbackTransaction(): void + { + DB::handle()->rollback(); + } +} diff --git a/lib/Repository/Support/RepoQuery.php b/lib/Repository/Support/RepoQuery.php index 506cb18..0b35c2f 100644 --- a/lib/Repository/Support/RepoQuery.php +++ b/lib/Repository/Support/RepoQuery.php @@ -148,4 +148,50 @@ class RepoQuery sort($ids, SORT_NUMERIC); return $ids; } + + public static function normalizeStringList(mixed $value, int $max = 200, ?callable $sanitizer = null): array + { + if ($max <= 0) { + return []; + } + + if (is_string($value)) { + $value = array_filter(array_map('trim', explode(',', $value))); + } elseif (!is_array($value)) { + return []; + } + + $flat = []; + array_walk_recursive($value, static function ($item) use (&$flat): void { + $flat[] = $item; + }); + + $items = []; + $seen = []; + foreach ($flat as $item) { + $text = trim((string) $item); + if ($text === '') { + continue; + } + + if ($sanitizer !== null) { + $text = trim((string) $sanitizer($text)); + if ($text === '') { + continue; + } + } + + if (isset($seen[$text])) { + continue; + } + + $seen[$text] = true; + $items[] = $text; + if (count($items) >= $max) { + break; + } + } + + return $items; + } } diff --git a/lib/Repository/Tenant/TenantRepository.php b/lib/Repository/Tenant/TenantRepository.php index 9894f9f..ebfcf91 100644 --- a/lib/Repository/Tenant/TenantRepository.php +++ b/lib/Repository/Tenant/TenantRepository.php @@ -2,6 +2,7 @@ namespace MintyPHP\Repository\Tenant; +use MintyPHP\Domain\Taxonomy\TenantStatus; use MintyPHP\DB; use MintyPHP\Repository\Support\RepoQuery; @@ -81,7 +82,7 @@ class TenantRepository $rows = call_user_func_array( ['MintyPHP\\DB', 'select'], array_merge( - ["select id from tenants where status = 'active' and id in ($placeholders)"], + ["select id from tenants where status = ? and id in ($placeholders)", TenantStatus::Active->value], $params ) ); @@ -101,6 +102,7 @@ class TenantRepository public function listPaged(array $options): array { $search = trim((string) ($options['search'] ?? '')); + $status = TenantStatus::tryNormalize((string) ($options['status'] ?? '')); $allowedOrder = ['id', 'uuid', 'description', 'status', 'created', 'modified']; [$limit, $offset] = RepoQuery::sanitizeLimitOffset($options); [$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder); @@ -108,6 +110,10 @@ class TenantRepository $where = []; $params = []; RepoQuery::addLikeFilter($where, $params, ['description', 'uuid'], $search); + if ($status !== null) { + $where[] = 'tenants.status = ?'; + $params[] = $status->value; + } if (!empty($options['tenantUserId'])) { $tenantUserId = (int) $options['tenantUserId']; if ($tenantUserId > 0) { @@ -187,7 +193,7 @@ class TenantRepository $data['primary_color'] ?? null, $data['default_theme'] ?? null, $data['allow_user_theme'] ?? null, - $data['status'] ?? 'active', + $data['status'] ?? TenantStatus::Active->value, $data['status_changed_at'] ?? null, $data['status_changed_by'] ?? null, $data['created_by'] ?? null @@ -217,7 +223,7 @@ class TenantRepository 'primary_color' => $data['primary_color'] ?? null, 'default_theme' => $data['default_theme'] ?? null, 'allow_user_theme' => $data['allow_user_theme'] ?? null, - 'status' => $data['status'] ?? 'active', + 'status' => $data['status'] ?? TenantStatus::Active->value, ]; if (array_key_exists('modified_by', $data)) { $fields['modified_by'] = $data['modified_by']; diff --git a/lib/Service/Access/AccessGatewayFactory.php b/lib/Service/Access/AccessGatewayFactory.php new file mode 100644 index 0000000..df3c8d4 --- /dev/null +++ b/lib/Service/Access/AccessGatewayFactory.php @@ -0,0 +1,33 @@ +permissionService ??= new PermissionService( + $this->accessRepositoryFactory->createPermissionRepository(), + $this->accessRepositoryFactory->createRolePermissionRepository(), + $this->accessRepositoryFactory->createUserRoleRepository(), + $this->auditServicesFactory->createSystemAuditService() + ); + } + + public function createPermissionGateway(): PermissionGateway + { + return $this->permissionGateway ??= new PermissionGateway($this->createPermissionService()); + } +} diff --git a/lib/Service/Access/AccessPolicyFactory.php b/lib/Service/Access/AccessPolicyFactory.php new file mode 100644 index 0000000..9d484fc --- /dev/null +++ b/lib/Service/Access/AccessPolicyFactory.php @@ -0,0 +1,80 @@ +userAuthorizationPolicy ??= new UserAuthorizationPolicy( + $this->permissionGateway, + $this->directoryScopeGateway + ); + } + + public function createRoleAuthorizationPolicy(): RoleAuthorizationPolicy + { + return $this->roleAuthorizationPolicy ??= new RoleAuthorizationPolicy($this->permissionGateway); + } + + public function createPermissionAuthorizationPolicy(): PermissionAuthorizationPolicy + { + return $this->permissionAuthorizationPolicy ??= new PermissionAuthorizationPolicy($this->permissionGateway); + } + + public function createOperationsAuthorizationPolicy(): OperationsAuthorizationPolicy + { + return $this->operationsAuthorizationPolicy ??= new OperationsAuthorizationPolicy($this->permissionGateway); + } + + public function createSettingsAuthorizationPolicy(): SettingsAuthorizationPolicy + { + return $this->settingsAuthorizationPolicy ??= new SettingsAuthorizationPolicy($this->permissionGateway); + } + + public function createTenantAuthorizationPolicy(): TenantAuthorizationPolicy + { + return $this->tenantAuthorizationPolicy ??= new TenantAuthorizationPolicy( + $this->permissionGateway, + $this->directoryScopeGateway + ); + } + + public function createDepartmentAuthorizationPolicy(): DepartmentAuthorizationPolicy + { + return $this->departmentAuthorizationPolicy ??= new DepartmentAuthorizationPolicy( + $this->permissionGateway, + $this->directoryScopeGateway + ); + } + + public function createAuthorizationService(): AuthorizationService + { + return $this->authorizationService ??= new AuthorizationService([ + $this->createUserAuthorizationPolicy(), + $this->createRoleAuthorizationPolicy(), + $this->createPermissionAuthorizationPolicy(), + $this->createOperationsAuthorizationPolicy(), + $this->createSettingsAuthorizationPolicy(), + $this->createTenantAuthorizationPolicy(), + $this->createDepartmentAuthorizationPolicy(), + ]); + } +} diff --git a/lib/Service/Access/AccessRepositoryFactory.php b/lib/Service/Access/AccessRepositoryFactory.php new file mode 100644 index 0000000..efe5c85 --- /dev/null +++ b/lib/Service/Access/AccessRepositoryFactory.php @@ -0,0 +1,29 @@ +permissionRepository ??= new PermissionRepository(); + } + + public function createRolePermissionRepository(): RolePermissionRepository + { + return $this->rolePermissionRepository ??= new RolePermissionRepository(); + } + + public function createUserRoleRepository(): UserRoleRepository + { + return $this->userRoleRepository ??= new UserRoleRepository(); + } +} diff --git a/lib/Service/Access/AccessServicesFactory.php b/lib/Service/Access/AccessServicesFactory.php index 1507564..fe342c8 100644 --- a/lib/Service/Access/AccessServicesFactory.php +++ b/lib/Service/Access/AccessServicesFactory.php @@ -5,119 +5,90 @@ namespace MintyPHP\Service\Access; use MintyPHP\Repository\Access\PermissionRepository; use MintyPHP\Repository\Access\RolePermissionRepository; use MintyPHP\Repository\Access\UserRoleRepository; -use MintyPHP\Service\Directory\DirectoryScopeGateway; -use MintyPHP\Service\Directory\DirectoryServicesFactory; class AccessServicesFactory { - private ?PermissionRepository $permissionRepository = null; - private ?RolePermissionRepository $rolePermissionRepository = null; - private ?UserRoleRepository $userRoleRepository = null; - private ?PermissionService $permissionService = null; - private ?PermissionGateway $permissionGateway = null; - private ?DirectoryServicesFactory $directoryServicesFactory = null; - private ?DirectoryScopeGateway $directoryScopeGateway = null; - private ?UserAuthorizationPolicy $userAuthorizationPolicy = null; - private ?RoleAuthorizationPolicy $roleAuthorizationPolicy = null; - private ?PermissionAuthorizationPolicy $permissionAuthorizationPolicy = null; - private ?SettingsAuthorizationPolicy $settingsAuthorizationPolicy = null; - private ?TenantAuthorizationPolicy $tenantAuthorizationPolicy = null; - private ?DepartmentAuthorizationPolicy $departmentAuthorizationPolicy = null; - private ?AuthorizationService $authorizationService = null; + private ?AccessPolicyFactory $accessPolicyFactory = null; + private \Closure $accessPolicyFactoryResolver; + + public function __construct( + private readonly AccessRepositoryFactory $accessRepositoryFactory, + private readonly AccessGatewayFactory $accessGatewayFactory, + callable $accessPolicyFactoryResolver + ) { + $this->accessPolicyFactoryResolver = \Closure::fromCallable($accessPolicyFactoryResolver); + } public function createPermissionRepository(): PermissionRepository { - return $this->permissionRepository ??= new PermissionRepository(); + return $this->accessRepositoryFactory->createPermissionRepository(); } public function createRolePermissionRepository(): RolePermissionRepository { - return $this->rolePermissionRepository ??= new RolePermissionRepository(); + return $this->accessRepositoryFactory->createRolePermissionRepository(); } public function createUserRoleRepository(): UserRoleRepository { - return $this->userRoleRepository ??= new UserRoleRepository(); + return $this->accessRepositoryFactory->createUserRoleRepository(); } public function createPermissionService(): PermissionService { - return $this->permissionService ??= new PermissionService( - $this->createPermissionRepository(), - $this->createRolePermissionRepository(), - $this->createUserRoleRepository() - ); + return $this->accessGatewayFactory->createPermissionService(); } public function createPermissionGateway(): PermissionGateway { - return $this->permissionGateway ??= new PermissionGateway($this->createPermissionService()); + return $this->accessGatewayFactory->createPermissionGateway(); } public function createUserAuthorizationPolicy(): UserAuthorizationPolicy { - return $this->userAuthorizationPolicy ??= new UserAuthorizationPolicy( - $this->createPermissionGateway(), - $this->createDirectoryScopeGateway() - ); + return $this->accessPolicyFactory()->createUserAuthorizationPolicy(); } public function createRoleAuthorizationPolicy(): RoleAuthorizationPolicy { - return $this->roleAuthorizationPolicy ??= new RoleAuthorizationPolicy( - $this->createPermissionGateway() - ); + return $this->accessPolicyFactory()->createRoleAuthorizationPolicy(); } public function createPermissionAuthorizationPolicy(): PermissionAuthorizationPolicy { - return $this->permissionAuthorizationPolicy ??= new PermissionAuthorizationPolicy( - $this->createPermissionGateway() - ); + return $this->accessPolicyFactory()->createPermissionAuthorizationPolicy(); } public function createSettingsAuthorizationPolicy(): SettingsAuthorizationPolicy { - return $this->settingsAuthorizationPolicy ??= new SettingsAuthorizationPolicy( - $this->createPermissionGateway() - ); + return $this->accessPolicyFactory()->createSettingsAuthorizationPolicy(); } public function createTenantAuthorizationPolicy(): TenantAuthorizationPolicy { - return $this->tenantAuthorizationPolicy ??= new TenantAuthorizationPolicy( - $this->createPermissionGateway(), - $this->createDirectoryScopeGateway() - ); + return $this->accessPolicyFactory()->createTenantAuthorizationPolicy(); } public function createDepartmentAuthorizationPolicy(): DepartmentAuthorizationPolicy { - return $this->departmentAuthorizationPolicy ??= new DepartmentAuthorizationPolicy( - $this->createPermissionGateway(), - $this->createDirectoryScopeGateway() - ); + return $this->accessPolicyFactory()->createDepartmentAuthorizationPolicy(); } public function createAuthorizationService(): AuthorizationService { - return $this->authorizationService ??= new AuthorizationService([ - $this->createUserAuthorizationPolicy(), - $this->createRoleAuthorizationPolicy(), - $this->createPermissionAuthorizationPolicy(), - $this->createSettingsAuthorizationPolicy(), - $this->createTenantAuthorizationPolicy(), - $this->createDepartmentAuthorizationPolicy(), - ]); + return $this->accessPolicyFactory()->createAuthorizationService(); } - private function createDirectoryScopeGateway(): DirectoryScopeGateway + private function accessPolicyFactory(): AccessPolicyFactory { - return $this->directoryScopeGateway ??= $this->directoryServicesFactory()->createDirectoryScopeGateway(); - } + if ($this->accessPolicyFactory !== null) { + return $this->accessPolicyFactory; + } - private function directoryServicesFactory(): DirectoryServicesFactory - { - return $this->directoryServicesFactory ??= new DirectoryServicesFactory(); + $factory = ($this->accessPolicyFactoryResolver)(); + if (!$factory instanceof AccessPolicyFactory) { + throw new \RuntimeException('Access policy resolver must return AccessPolicyFactory'); + } + return $this->accessPolicyFactory = $factory; } } diff --git a/lib/Service/Access/AuthorizationService.php b/lib/Service/Access/AuthorizationService.php index 639b43a..74978bc 100644 --- a/lib/Service/Access/AuthorizationService.php +++ b/lib/Service/Access/AuthorizationService.php @@ -37,5 +37,9 @@ class AuthorizationService return AuthorizationDecision::deny(500, 'authorization_policy_missing'); } -} + public function allow(string $ability, array $context = []): bool + { + return $this->authorize($ability, $context)->isAllowed(); + } +} diff --git a/lib/Service/Access/OperationsAuthorizationPolicy.php b/lib/Service/Access/OperationsAuthorizationPolicy.php new file mode 100644 index 0000000..136a416 --- /dev/null +++ b/lib/Service/Access/OperationsAuthorizationPolicy.php @@ -0,0 +1,168 @@ + $this->authorizeUsersCreateCustomFields($actorUserId), + self::ABILITY_API_TOKENS_SELF_MANAGE => $this->authorizeApiTokensSelfManage($actorUserId), + self::ABILITY_ADDRESS_BOOK_VIEW => $this->allowIfHas($actorUserId, PermissionService::ADDRESS_BOOK_VIEW), + self::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::USER_LIFECYCLE_AUDIT_VIEW), + self::ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE => $this->allowIfHas($actorUserId, PermissionService::USERS_LIFECYCLE_RESTORE), + self::ABILITY_ADMIN_API_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::API_AUDIT_VIEW), + self::ABILITY_ADMIN_SYSTEM_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::SYSTEM_AUDIT_VIEW), + self::ABILITY_ADMIN_SYSTEM_AUDIT_PURGE => $this->allowIfHas($actorUserId, PermissionService::SYSTEM_AUDIT_PURGE), + self::ABILITY_ADMIN_DOCS_VIEW => $this->allowIfHas($actorUserId, PermissionService::DOCS_VIEW), + self::ABILITY_ADMIN_IMPORTS_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::IMPORTS_AUDIT_VIEW), + self::ABILITY_ADMIN_IMPORTS_VIEW => $this->allowIfHas($actorUserId, PermissionService::IMPORTS_VIEW), + self::ABILITY_ADMIN_JOBS_RUN_NOW => $this->allowIfHas($actorUserId, PermissionService::JOBS_RUN_NOW), + self::ABILITY_ADMIN_JOBS_VIEW => $this->allowIfHas($actorUserId, PermissionService::JOBS_VIEW), + self::ABILITY_ADMIN_JOBS_MANAGE => $this->allowIfHas($actorUserId, PermissionService::JOBS_MANAGE), + self::ABILITY_ADMIN_MAIL_LOG_VIEW => $this->allowIfHas($actorUserId, PermissionService::MAIL_LOG_VIEW), + self::ABILITY_ADMIN_STATS_VIEW => $this->allowIfHas($actorUserId, PermissionService::STATS_VIEW), + self::ABILITY_ADMIN_API_DOCS_VIEW => $this->allowIfHas($actorUserId, PermissionService::API_DOCS_VIEW), + self::ABILITY_ADMIN_IMPORTS_TYPE_USERS => $this->allowIfHas($actorUserId, PermissionService::USERS_IMPORT), + self::ABILITY_ADMIN_IMPORTS_TYPE_DEPARTMENTS => $this->allowIfHas($actorUserId, PermissionService::DEPARTMENTS_IMPORT), + self::ABILITY_ADMIN_USERS_ASSIGNMENTS_MANAGE => $this->allowIfHas($actorUserId, PermissionService::USERS_UPDATE_ASSIGNMENTS), + self::ABILITY_API_PERMISSIONS_VIEW => $this->allowIfHas($actorUserId, PermissionService::PERMISSIONS_VIEW), + self::ABILITY_API_SETTINGS_VIEW => $this->allowIfHas($actorUserId, PermissionService::SETTINGS_VIEW), + self::ABILITY_API_SETTINGS_UPDATE => $this->allowIfHas($actorUserId, PermissionService::SETTINGS_UPDATE), + self::ABILITY_API_ROLES_VIEW => $this->allowIfHas($actorUserId, PermissionService::ROLES_VIEW), + self::ABILITY_API_ROLES_CREATE => $this->allowIfHas($actorUserId, PermissionService::ROLES_CREATE), + self::ABILITY_API_ROLES_UPDATE => $this->allowIfHas($actorUserId, PermissionService::ROLES_UPDATE), + self::ABILITY_API_ROLES_DELETE => $this->allowIfHas($actorUserId, PermissionService::ROLES_DELETE), + self::ABILITY_API_DEPARTMENTS_VIEW => $this->allowIfHas($actorUserId, PermissionService::DEPARTMENTS_VIEW), + self::ABILITY_API_DEPARTMENTS_CREATE => $this->allowIfHas($actorUserId, PermissionService::DEPARTMENTS_CREATE), + self::ABILITY_API_DEPARTMENTS_UPDATE => $this->allowIfHas($actorUserId, PermissionService::DEPARTMENTS_UPDATE), + self::ABILITY_API_DEPARTMENTS_DELETE => $this->allowIfHas($actorUserId, PermissionService::DEPARTMENTS_DELETE), + self::ABILITY_API_TENANTS_VIEW => $this->allowIfHas($actorUserId, PermissionService::TENANTS_VIEW), + self::ABILITY_API_TENANTS_CREATE => $this->allowIfHas($actorUserId, PermissionService::TENANTS_CREATE), + self::ABILITY_API_TENANTS_UPDATE => $this->allowIfHas($actorUserId, PermissionService::TENANTS_UPDATE), + self::ABILITY_API_TENANTS_DELETE => $this->allowIfHas($actorUserId, PermissionService::TENANTS_DELETE), + self::ABILITY_API_ME_SELF_UPDATE => $this->allowIfHas($actorUserId, PermissionService::USERS_SELF_UPDATE), + default => AuthorizationDecision::deny(500, 'authorization_ability_not_supported'), + }; + } + + private function authorizeUsersCreateCustomFields(int $actorUserId): AuthorizationDecision + { + if (!$this->permissionGateway->userHas($actorUserId, PermissionService::CUSTOM_FIELDS_EDIT_VALUES)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + if (!$this->permissionGateway->userHas($actorUserId, PermissionService::USERS_UPDATE_ASSIGNMENTS)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + return AuthorizationDecision::allow(); + } + + private function authorizeApiTokensSelfManage(int $actorUserId): AuthorizationDecision + { + if ($this->permissionGateway->userHas($actorUserId, PermissionService::USERS_SELF_UPDATE)) { + return AuthorizationDecision::allow(); + } + if ($this->permissionGateway->userHas($actorUserId, PermissionService::API_TOKENS_MANAGE)) { + return AuthorizationDecision::allow(); + } + return AuthorizationDecision::deny(403, 'forbidden'); + } + + private function allowIfHas(int $actorUserId, string $permissionKey): AuthorizationDecision + { + if (!$this->permissionGateway->userHas($actorUserId, $permissionKey)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + return AuthorizationDecision::allow(); + } +} diff --git a/lib/Service/Access/PermissionService.php b/lib/Service/Access/PermissionService.php index 679d9f7..f225eae 100644 --- a/lib/Service/Access/PermissionService.php +++ b/lib/Service/Access/PermissionService.php @@ -5,6 +5,7 @@ namespace MintyPHP\Service\Access; use MintyPHP\Repository\Access\PermissionRepository; use MintyPHP\Repository\Access\RolePermissionRepository; use MintyPHP\Repository\Access\UserRoleRepository; +use MintyPHP\Service\Audit\SystemAuditService; class PermissionService { @@ -13,7 +14,8 @@ class PermissionService public function __construct( private readonly PermissionRepository $permissionRepository, private readonly RolePermissionRepository $rolePermissionRepository, - private readonly UserRoleRepository $userRoleRepository + private readonly UserRoleRepository $userRoleRepository, + private readonly SystemAuditService $systemAuditService ) { } @@ -63,6 +65,8 @@ class PermissionService public const DOCS_VIEW = 'docs.view'; public const MAIL_LOG_VIEW = 'mail_log.view'; public const API_AUDIT_VIEW = 'api_audit.view'; + public const SYSTEM_AUDIT_VIEW = 'system_audit.view'; + public const SYSTEM_AUDIT_PURGE = 'system_audit.purge'; public const STATS_VIEW = 'stats.view'; public const API_TOKENS_MANAGE = 'api_tokens.manage'; @@ -187,6 +191,15 @@ class PermissionService if (!$createdId) { return ['ok' => false, 'errors' => [t('Permission can not be created')], 'form' => $form]; } + $this->systemAuditService->record('admin.permissions.create', 'success', [ + 'target_type' => 'permission', + 'target_id' => (int) $createdId, + 'metadata' => [ + 'key' => $form['key'], + 'active' => (int) ($form['active'] ?? 0), + 'is_system' => (int) ($form['is_system'] ?? 0), + ], + ]); return ['ok' => true, 'form' => $form, 'id' => (int) $createdId]; } @@ -201,10 +214,22 @@ class PermissionService if ($existing && (int) ($existing['id'] ?? 0) !== $id) { return ['ok' => false, 'errors' => [t('Permission key already exists')], 'form' => $form]; } + $before = $this->permissionRepository->find($id) ?? []; $updated = $this->permissionRepository->update($id, $form); if (!$updated) { return ['ok' => false, 'errors' => [t('Permission can not be updated')], 'form' => $form]; } + $this->systemAuditService->record('admin.permissions.update', 'success', [ + 'target_type' => 'permission', + 'target_id' => $id, + 'before' => [ + 'active' => $before['active'] ?? null, + ], + 'after' => [ + 'active' => $form['active'] ?? null, + ], + 'metadata' => ['key' => $form['key']], + ]); return ['ok' => true, 'form' => $form]; } @@ -221,6 +246,11 @@ class PermissionService if (!$deleted) { return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; } + $this->systemAuditService->record('admin.permissions.delete', 'success', [ + 'target_type' => 'permission', + 'target_id' => $id, + 'metadata' => ['key' => (string) ($permission['key'] ?? '')], + ]); return ['ok' => true, 'permission' => $permission]; } diff --git a/lib/Service/Access/RoleService.php b/lib/Service/Access/RoleService.php index 3a8d389..4d2ff83 100644 --- a/lib/Service/Access/RoleService.php +++ b/lib/Service/Access/RoleService.php @@ -3,40 +3,41 @@ namespace MintyPHP\Service\Access; use MintyPHP\Repository\Access\RoleRepository; +use MintyPHP\Service\Audit\SystemAuditService; use MintyPHP\Service\Directory\DirectorySettingsGateway; -use MintyPHP\Service\Settings\SettingServicesFactory; class RoleService { public function __construct( - private readonly ?RoleRepository $roleRepository = null, - private readonly ?DirectorySettingsGateway $settingsGateway = null + private readonly RoleRepository $roleRepository, + private readonly DirectorySettingsGateway $settingsGateway, + private readonly SystemAuditService $systemAuditService ) { } public function list(): array { - return $this->roleRepository()->list(); + return $this->roleRepository->list(); } public function listActive(): array { - return $this->roleRepository()->listActive(); + return $this->roleRepository->listActive(); } public function listPaged(array $options): array { - return $this->roleRepository()->listPaged($options); + return $this->roleRepository->listPaged($options); } public function findByUuid(string $uuid): ?array { - return $this->roleRepository()->findByUuid($uuid); + return $this->roleRepository->findByUuid($uuid); } public function findById(int $id): ?array { - return $this->roleRepository()->find($id); + return $this->roleRepository->find($id); } public function createFromAdmin(array $input, int $currentUserId = 0): array @@ -48,7 +49,7 @@ class RoleService return ['ok' => false, 'errors' => $errors, 'form' => $form]; } - $createdId = $this->roleRepository()->create([ + $createdId = $this->roleRepository->create([ 'description' => $form['description'], 'code' => $form['code'] ?: null, 'active' => $form['active'], @@ -59,11 +60,20 @@ class RoleService return ['ok' => false, 'errors' => [t('Role can not be created')], 'form' => $form]; } - $createdRole = $this->roleRepository()->find((int) $createdId); + $createdRole = $this->roleRepository->find((int) $createdId); $uuid = $createdRole['uuid'] ?? null; if (!empty($input['is_default'])) { - $this->settingsGateway()->setDefaultRoleId((int) $createdId); + $this->settingsGateway->setDefaultRoleId((int) $createdId); } + + $this->systemAuditService->record('admin.roles.create', 'success', [ + 'actor_user_id' => $currentUserId > 0 ? $currentUserId : null, + 'target_type' => 'role', + 'target_id' => (int) $createdId, + 'target_uuid' => is_string($uuid) ? $uuid : '', + 'metadata' => ['active' => $form['active']], + ]); + return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId]; } @@ -76,7 +86,9 @@ class RoleService return ['ok' => false, 'errors' => $errors, 'form' => $form]; } - $updated = $this->roleRepository()->update($roleId, [ + $existingBefore = $this->roleRepository->find($roleId) ?? []; + + $updated = $this->roleRepository->update($roleId, [ 'description' => $form['description'], 'code' => $form['code'] ?: null, 'active' => $form['active'], @@ -87,6 +99,15 @@ class RoleService return ['ok' => false, 'errors' => [t('Role can not be updated')], 'form' => $form]; } + $this->systemAuditService->record('admin.roles.update', 'success', [ + 'actor_user_id' => $currentUserId > 0 ? $currentUserId : null, + 'target_type' => 'role', + 'target_id' => $roleId, + 'target_uuid' => (string) ($existingBefore['uuid'] ?? ''), + 'before' => ['active' => $existingBefore['active'] ?? null], + 'after' => ['active' => $form['active']], + ]); + return ['ok' => true, 'form' => $form]; } @@ -97,7 +118,7 @@ class RoleService return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } - $role = $this->roleRepository()->findByUuid($uuid); + $role = $this->roleRepository->findByUuid($uuid); if (!$role || !isset($role['id'])) { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } @@ -106,11 +127,17 @@ class RoleService return ['ok' => false, 'status' => 403, 'error' => 'admin_role_protected']; } - $deleted = $this->roleRepository()->delete((int) $role['id']); + $deleted = $this->roleRepository->delete((int) $role['id']); if (!$deleted) { return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; } + $this->systemAuditService->record('admin.roles.delete', 'success', [ + 'target_type' => 'role', + 'target_id' => (int) $role['id'], + 'target_uuid' => (string) ($role['uuid'] ?? ''), + ]); + return ['ok' => true, 'role' => $role]; } @@ -130,7 +157,7 @@ class RoleService $errors[] = t('Description cannot be empty'); } $code = $form['code'] ?? ''; - if ($code !== '' && $this->roleRepository()->existsByCode($code, $excludeId)) { + if ($code !== '' && $this->roleRepository->existsByCode($code, $excludeId)) { $errors[] = t('Role code already exists'); } return $errors; @@ -145,19 +172,4 @@ class RoleService return 1; } - private function roleRepository(): RoleRepository - { - return $this->roleRepository ?? new RoleRepository(); - } - - private function settingsGateway(): DirectorySettingsGateway - { - if ($this->settingsGateway instanceof DirectorySettingsGateway) { - return $this->settingsGateway; - } - - return new DirectorySettingsGateway( - (new SettingServicesFactory())->createSettingGateway() - ); - } } diff --git a/lib/Service/Access/UiAccessService.php b/lib/Service/Access/UiAccessService.php new file mode 100644 index 0000000..756fd2f --- /dev/null +++ b/lib/Service/Access/UiAccessService.php @@ -0,0 +1,119 @@ + */ + private array $decisionCache = []; + + public function __construct( + private readonly AuthorizationService $authorizationService + ) { + } + + public function allow(int $actorUserId, string $ability, array $context = []): bool + { + $ability = trim($ability); + if ($actorUserId <= 0 || $ability === '') { + return false; + } + + $normalizedContext = $this->normalizeContext($context); + $cacheKey = $actorUserId . '|' . $ability . '|' . sha1(json_encode($normalizedContext, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: ''); + if (array_key_exists($cacheKey, $this->decisionCache)) { + return $this->decisionCache[$cacheKey]; + } + + $decision = $this->authorizationService->authorize($ability, [ + 'actor_user_id' => $actorUserId, + ...$normalizedContext, + ]); + + return $this->decisionCache[$cacheKey] = $decision->isAllowed(); + } + + /** + * @param array}> $map + * @param array $baseContext + * @return array + */ + public function resolveMap(int $actorUserId, array $map, array $baseContext = []): array + { + $resolved = []; + $normalizedBaseContext = $this->normalizeContext($baseContext); + + foreach ($map as $key => $definition) { + if (is_string($definition)) { + $resolved[$key] = $this->allow($actorUserId, $definition, $normalizedBaseContext); + continue; + } + + if (!is_array($definition)) { + $resolved[$key] = false; + continue; + } + + $ability = $definition['ability']; + $context = []; + if (array_key_exists('context', $definition) && is_array($definition['context'])) { + $context = $this->normalizeContext($definition['context']); + } + $resolved[$key] = $this->allow($actorUserId, $ability, [ + ...$normalizedBaseContext, + ...$context, + ]); + } + + return $resolved; + } + + /** + * @param array}> $map + * @param array $baseContext + * @return array + */ + public function pageCapabilities(int $actorUserId, array $map, array $baseContext = []): array + { + return $this->resolveMap($actorUserId, $map, $baseContext); + } + + /** + * @return array + */ + public function layoutCapabilities(int $actorUserId): array + { + return $this->resolveMap($actorUserId, UiCapabilityMap::LAYOUT); + } + + /** + * @param array $context + * @return array + */ + private function normalizeContext(array $context): array + { + $normalized = $this->normalizeValue($context); + return is_array($normalized) ? $normalized : []; + } + + private function normalizeValue(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + $isList = array_is_list($value); + if ($isList) { + foreach ($value as $idx => $entry) { + $value[$idx] = $this->normalizeValue($entry); + } + return $value; + } + + ksort($value); + foreach ($value as $key => $entry) { + $value[$key] = $this->normalizeValue($entry); + } + return $value; + } +} diff --git a/lib/Service/Access/UiCapabilityMap.php b/lib/Service/Access/UiCapabilityMap.php new file mode 100644 index 0000000..cd80574 --- /dev/null +++ b/lib/Service/Access/UiCapabilityMap.php @@ -0,0 +1,80 @@ + + */ + public const LAYOUT = [ + 'can_view_tenants' => TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW, + 'can_view_departments' => DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_VIEW, + 'can_view_users' => UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW, + 'can_view_roles' => RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW, + 'can_view_permissions' => PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW, + 'can_view_settings' => SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_VIEW, + 'can_view_imports' => OperationsAuthorizationPolicy::ABILITY_ADMIN_IMPORTS_VIEW, + 'can_view_jobs' => OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_VIEW, + 'can_view_api_docs' => OperationsAuthorizationPolicy::ABILITY_ADMIN_API_DOCS_VIEW, + 'can_view_docs' => OperationsAuthorizationPolicy::ABILITY_ADMIN_DOCS_VIEW, + 'can_view_mail_log' => OperationsAuthorizationPolicy::ABILITY_ADMIN_MAIL_LOG_VIEW, + 'can_view_api_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_API_AUDIT_VIEW, + 'can_view_system_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_SYSTEM_AUDIT_VIEW, + 'can_view_imports_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_IMPORTS_AUDIT_VIEW, + 'can_view_user_lifecycle_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW, + 'can_view_stats' => OperationsAuthorizationPolicy::ABILITY_ADMIN_STATS_VIEW, + 'can_view_address_book' => OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW, + ]; + + /** + * @var array + */ + public const PAGE_USERS_INDEX = [ + 'can_create_user' => UserAuthorizationPolicy::ABILITY_ADMIN_USERS_CREATE, + 'can_update_users' => UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACTIVATE, + 'can_delete_users' => UserAuthorizationPolicy::ABILITY_ADMIN_USERS_DELETE, + 'can_access_pdf' => UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACCESS_PDF, + ]; + + /** + * @var array + */ + public const PAGE_USERS_CREATE = [ + 'can_manage_assignments' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USERS_ASSIGNMENTS_MANAGE, + 'can_edit_custom_field_values' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USERS_CREATE_EDIT_CUSTOM_FIELDS, + ]; + + /** + * @var array + */ + public const PAGE_STATS_INDEX = [ + 'can_view_mail_log' => OperationsAuthorizationPolicy::ABILITY_ADMIN_MAIL_LOG_VIEW, + 'can_view_users' => UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW, + 'can_view_tenants' => TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW, + 'can_view_imports_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_IMPORTS_AUDIT_VIEW, + 'can_view_system_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_SYSTEM_AUDIT_VIEW, + 'can_view_user_lifecycle_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW, + ]; + + /** + * @var array + */ + public const PAGE_AUDIT_PURGE = [ + 'can_purge' => SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE, + ]; + + /** + * @var array + */ + public const PAGE_SYSTEM_AUDIT = [ + 'can_purge_system_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_SYSTEM_AUDIT_PURGE, + ]; + + /** + * @var array + */ + public const PAGE_USER_LIFECYCLE_VIEW = [ + 'can_restore' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE, + ]; +} diff --git a/lib/Service/AddressBook/AddressBookCustomFieldGateway.php b/lib/Service/AddressBook/AddressBookCustomFieldGateway.php index 389683d..4d4f130 100644 --- a/lib/Service/AddressBook/AddressBookCustomFieldGateway.php +++ b/lib/Service/AddressBook/AddressBookCustomFieldGateway.php @@ -7,13 +7,19 @@ use MintyPHP\Service\CustomField\UserCustomFieldValueService; class AddressBookCustomFieldGateway { + public function __construct( + private readonly UserCustomFieldValueService $userCustomFieldValueService + ) { + } + public function extractFilterSpec(array $query, int $currentUserId): array { - return UserCustomFieldValueService::extractAddressBookFilterSpec($query, $currentUserId); + return $this->userCustomFieldValueService->extractAddressBookFilterSpec($query, $currentUserId); } public function listOptionsByDefinitionIds(array $definitionIds, bool $activeOnly = true): array { return TenantCustomFieldOptionRepository::listByDefinitionIds($definitionIds, $activeOnly); } + } diff --git a/lib/Service/AddressBook/AddressBookDirectoryGateway.php b/lib/Service/AddressBook/AddressBookDirectoryGateway.php index 68960d9..8090d9b 100644 --- a/lib/Service/AddressBook/AddressBookDirectoryGateway.php +++ b/lib/Service/AddressBook/AddressBookDirectoryGateway.php @@ -10,10 +10,10 @@ use MintyPHP\Service\Tenant\TenantService; class AddressBookDirectoryGateway { public function __construct( - private readonly ?TenantService $tenantService = null, - private readonly ?DepartmentService $departmentService = null, - private readonly ?RoleService $roleService = null, - private readonly ?DirectoryScopeGateway $scopeGateway = null + private readonly TenantService $tenantService, + private readonly DepartmentService $departmentService, + private readonly RoleService $roleService, + private readonly DirectoryScopeGateway $scopeGateway ) { } @@ -54,21 +54,21 @@ class AddressBookDirectoryGateway private function tenantService(): TenantService { - return $this->tenantService ?? new TenantService(); + return $this->tenantService; } private function departmentService(): DepartmentService { - return $this->departmentService ?? new DepartmentService(); + return $this->departmentService; } private function roleService(): RoleService { - return $this->roleService ?? new RoleService(); + return $this->roleService; } private function scopeGateway(): DirectoryScopeGateway { - return $this->scopeGateway ?? new DirectoryScopeGateway(); + return $this->scopeGateway; } } diff --git a/lib/Service/AddressBook/AddressBookService.php b/lib/Service/AddressBook/AddressBookService.php index 0b9f3df..737af87 100644 --- a/lib/Service/AddressBook/AddressBookService.php +++ b/lib/Service/AddressBook/AddressBookService.php @@ -104,28 +104,27 @@ class AddressBookService unset($definition); $tenantDepartmentMap = []; - if ($tenantIds) { - $departmentMapByTenantId = []; - foreach ($departments as $department) { - $departmentId = (int) ($department['id'] ?? 0); - $departmentTenantId = (int) ($department['tenant_id'] ?? 0); - if ($departmentId <= 0 || $departmentTenantId <= 0) { - continue; - } - $departmentMapByTenantId[$departmentTenantId] ??= []; - $departmentMapByTenantId[$departmentTenantId][] = $departmentId; + $departmentMapByTenantId = []; + foreach ($departments as $department) { + $departmentId = (int) ($department['id'] ?? 0); + $departmentTenantId = (int) ($department['tenant_id'] ?? 0); + if ($departmentId <= 0 || $departmentTenantId <= 0) { + continue; } + $departmentMapByTenantId[$departmentTenantId] ??= []; + $departmentMapByTenantId[$departmentTenantId][] = $departmentId; + } - foreach ($tenants as $tenant) { - $tenantId = (int) ($tenant['id'] ?? 0); - $tenantUuid = (string) ($tenant['uuid'] ?? ''); - if ($tenantId > 0 && $tenantUuid !== '') { - $tenantDepartmentMap[$tenantUuid] = array_map( - 'strval', - $departmentMapByTenantId[$tenantId] ?? [] - ); - } + foreach ($tenants as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + $tenantUuid = (string) ($tenant['uuid'] ?? ''); + if ($tenantId <= 0 || $tenantUuid === '') { + continue; } + $tenantDepartmentMap[$tenantUuid] = array_map( + 'strval', + $departmentMapByTenantId[$tenantId] ?? [] + ); } return [ diff --git a/lib/Service/AddressBook/AddressBookServicesFactory.php b/lib/Service/AddressBook/AddressBookServicesFactory.php index dda601e..50b0f67 100644 --- a/lib/Service/AddressBook/AddressBookServicesFactory.php +++ b/lib/Service/AddressBook/AddressBookServicesFactory.php @@ -2,6 +2,7 @@ namespace MintyPHP\Service\AddressBook; +use MintyPHP\Service\CustomField\CustomFieldServicesFactory; use MintyPHP\Service\Directory\DirectoryServicesFactory; use MintyPHP\Service\User\UserServicesFactory; @@ -11,8 +12,12 @@ class AddressBookServicesFactory private ?AddressBookCustomFieldGateway $addressBookCustomFieldGateway = null; private ?AddressBookAvatarGateway $addressBookAvatarGateway = null; private ?AddressBookService $addressBookService = null; - private ?UserServicesFactory $userServicesFactory = null; - private ?DirectoryServicesFactory $directoryServicesFactory = null; + + public function __construct( + private readonly UserServicesFactory $userServicesFactory, + private readonly DirectoryServicesFactory $directoryServicesFactory, + private readonly CustomFieldServicesFactory $customFieldServicesFactory + ) {} public function createAddressBookService(): AddressBookService { @@ -20,7 +25,7 @@ class AddressBookServicesFactory return $this->addressBookService; } - $userFactory = $this->userServicesFactory(); + $userFactory = $this->userServicesFactory; return $this->addressBookService = new AddressBookService( $userFactory->createUserAccountService(), $userFactory->createUserAssignmentService(), @@ -36,7 +41,7 @@ class AddressBookServicesFactory return $this->addressBookDirectoryGateway; } - $directoryFactory = $this->directoryServicesFactory(); + $directoryFactory = $this->directoryServicesFactory; return $this->addressBookDirectoryGateway = new AddressBookDirectoryGateway( $directoryFactory->createTenantService(), $directoryFactory->createDepartmentService(), @@ -47,23 +52,15 @@ class AddressBookServicesFactory public function createAddressBookCustomFieldGateway(): AddressBookCustomFieldGateway { - return $this->addressBookCustomFieldGateway ??= new AddressBookCustomFieldGateway(); + return $this->addressBookCustomFieldGateway ??= new AddressBookCustomFieldGateway( + $this->customFieldServicesFactory->createUserCustomFieldValueService() + ); } public function createAddressBookAvatarGateway(): AddressBookAvatarGateway { return $this->addressBookAvatarGateway ??= new AddressBookAvatarGateway( - $this->userServicesFactory()->createUserAvatarService() + $this->userServicesFactory->createUserAvatarService() ); } - - private function userServicesFactory(): UserServicesFactory - { - return $this->userServicesFactory ??= new UserServicesFactory(); - } - - private function directoryServicesFactory(): DirectoryServicesFactory - { - return $this->directoryServicesFactory ??= new DirectoryServicesFactory(); - } } diff --git a/lib/Service/Audit/ApiAuditService.php b/lib/Service/Audit/ApiAuditService.php index dde4366..4ce9835 100644 --- a/lib/Service/Audit/ApiAuditService.php +++ b/lib/Service/Audit/ApiAuditService.php @@ -3,8 +3,8 @@ namespace MintyPHP\Service\Audit; use MintyPHP\Http\ApiAuth; +use MintyPHP\Http\RequestContext; use MintyPHP\Repository\Audit\ApiAuditLogRepository; -use MintyPHP\Repository\Support\RepoQuery; class ApiAuditService { @@ -25,6 +25,10 @@ class ApiAuditService return; } + RequestContext::ensureStarted(); + RequestContext::setChannel('api'); + $requestContext = RequestContext::context(); + $method = strtoupper(trim((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'))); if ($method === 'OPTIONS') { $this->context = ['active' => false, 'finished' => true]; @@ -42,13 +46,23 @@ class ApiAuditService 'active' => true, 'finished' => false, 'started_at' => microtime(true), - 'request_id' => RepoQuery::uuidV4(), - 'method' => substr($method !== '' ? $method : 'GET', 0, 8), - 'path' => substr($path, 0, 255), + 'request_id' => (string) ($requestContext['request_id'] ?? RequestContext::id()), + 'method' => (string) ($requestContext['method'] ?? substr($method !== '' ? $method : 'GET', 0, 8)), + 'path' => (string) ($requestContext['path'] ?? substr($path, 0, 255)), 'query_json' => $this->buildRedactedQueryJson($_GET), ]; } + public function currentRequestId(): ?string + { + if (!is_array($this->context)) { + return null; + } + + $requestId = trim((string) ($this->context['request_id'] ?? '')); + return $requestId !== '' ? $requestId : null; + } + public function finish(int $statusCode, ?string $errorCode = null): void { if (!is_array($this->context)) { @@ -79,14 +93,16 @@ class ApiAuditService $normalizedErrorCode = substr($normalizedErrorCode, 0, 100); } - $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? '')); + $requestContext = RequestContext::context(); + + $ip = trim((string) ($requestContext['ip'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''))); if ($ip === '') { $ip = null; } elseif (strlen($ip) > 45) { $ip = substr($ip, 0, 45); } - $userAgent = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); + $userAgent = trim((string) ($requestContext['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? ''))); if ($userAgent === '') { $userAgent = null; } elseif (strlen($userAgent) > self::MAX_USER_AGENT_LENGTH) { @@ -99,7 +115,7 @@ class ApiAuditService $tokenTenantId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::scopedTenantId() ?? 0) : 0; $payload = [ - 'request_id' => (string) ($this->context['request_id'] ?? RepoQuery::uuidV4()), + 'request_id' => (string) ($this->context['request_id'] ?? RequestContext::id()), 'method' => (string) ($this->context['method'] ?? ''), 'path' => (string) ($this->context['path'] ?? ''), 'query_json' => $this->context['query_json'] ?? null, @@ -131,6 +147,11 @@ class ApiAuditService return $this->apiAuditLogRepository->find($id); } + public function filterOptions(int $limit = 200): array + { + return $this->apiAuditLogRepository->listFilterOptions($limit); + } + public function purgeExpired(): int { return $this->apiAuditLogRepository->purgeOlderThanDays(self::RETENTION_DAYS); diff --git a/lib/Service/Audit/AuditRepositoryFactory.php b/lib/Service/Audit/AuditRepositoryFactory.php new file mode 100644 index 0000000..add3daa --- /dev/null +++ b/lib/Service/Audit/AuditRepositoryFactory.php @@ -0,0 +1,29 @@ +apiAuditLogRepository ??= new ApiAuditLogRepository(); + } + + public function createUserLifecycleAuditRepository(): UserLifecycleAuditRepository + { + return $this->userLifecycleAuditRepository ??= new UserLifecycleAuditRepository(); + } + + public function createSystemAuditLogRepository(): SystemAuditLogRepository + { + return $this->systemAuditLogRepository ??= new SystemAuditLogRepository(); + } +} diff --git a/lib/Service/Audit/AuditServicesFactory.php b/lib/Service/Audit/AuditServicesFactory.php index b8ac05e..b06264d 100644 --- a/lib/Service/Audit/AuditServicesFactory.php +++ b/lib/Service/Audit/AuditServicesFactory.php @@ -3,23 +3,35 @@ namespace MintyPHP\Service\Audit; use MintyPHP\Repository\Audit\ApiAuditLogRepository; +use MintyPHP\Repository\Audit\SystemAuditLogRepository; use MintyPHP\Repository\Audit\UserLifecycleAuditRepository; +use MintyPHP\Service\Settings\SettingGateway; class AuditServicesFactory { - private ?ApiAuditLogRepository $apiAuditLogRepository = null; - private ?UserLifecycleAuditRepository $userLifecycleAuditRepository = null; private ?ApiAuditService $apiAuditService = null; private ?UserLifecycleAuditService $userLifecycleAuditService = null; + private ?SystemAuditRedactionService $systemAuditRedactionService = null; + private ?SystemAuditService $systemAuditService = null; + + public function __construct( + private readonly AuditRepositoryFactory $auditRepositoryFactory, + private readonly SettingGateway $settingGateway + ) {} public function createApiAuditLogRepository(): ApiAuditLogRepository { - return $this->apiAuditLogRepository ??= new ApiAuditLogRepository(); + return $this->auditRepositoryFactory->createApiAuditLogRepository(); } public function createUserLifecycleAuditRepository(): UserLifecycleAuditRepository { - return $this->userLifecycleAuditRepository ??= new UserLifecycleAuditRepository(); + return $this->auditRepositoryFactory->createUserLifecycleAuditRepository(); + } + + public function createSystemAuditLogRepository(): SystemAuditLogRepository + { + return $this->auditRepositoryFactory->createSystemAuditLogRepository(); } public function createApiAuditService(): ApiAuditService @@ -33,4 +45,18 @@ class AuditServicesFactory $this->createUserLifecycleAuditRepository() ); } + + public function createSystemAuditRedactionService(): SystemAuditRedactionService + { + return $this->systemAuditRedactionService ??= new SystemAuditRedactionService(); + } + + public function createSystemAuditService(): SystemAuditService + { + return $this->systemAuditService ??= new SystemAuditService( + $this->createSystemAuditLogRepository(), + $this->createSystemAuditRedactionService(), + $this->settingGateway + ); + } } diff --git a/lib/Service/Audit/ImportAuditService.php b/lib/Service/Audit/ImportAuditService.php index b374d7c..136972f 100644 --- a/lib/Service/Audit/ImportAuditService.php +++ b/lib/Service/Audit/ImportAuditService.php @@ -2,6 +2,7 @@ namespace MintyPHP\Service\Audit; +use MintyPHP\Domain\Taxonomy\ImportAuditStatus; use MintyPHP\Repository\Audit\ImportAuditRunRepository; use MintyPHP\Repository\Support\RepoQuery; @@ -34,7 +35,7 @@ class ImportAuditService $runId = $this->importAuditRunRepository->createRunning([ 'run_uuid' => RepoQuery::uuidV4(), 'profile_key' => $profileKey, - 'status' => 'running', + 'status' => ImportAuditStatus::Running->value, 'source_filename' => $this->normalizeSourceFilename($sourceFilename), 'mapped_targets_csv' => $this->normalizeMappedTargetsCsv($mappedTargets), 'user_id' => $userId > 0 ? $userId : null, @@ -67,13 +68,13 @@ class ImportAuditService $status = $this->normalizeStatus($forcedStatus); if ($status === null) { if (array_key_exists('ok', $result) && !($result['ok'] ?? false)) { - $status = 'failed'; + $status = ImportAuditStatus::Failed->value; } elseif ($failedCount <= 0) { - $status = 'success'; + $status = ImportAuditStatus::Success->value; } elseif ($createdCount > 0 || $skippedCount > 0) { - $status = 'partial'; + $status = ImportAuditStatus::Partial->value; } else { - $status = 'failed'; + $status = ImportAuditStatus::Failed->value; } } @@ -113,6 +114,11 @@ class ImportAuditService return $this->importAuditRunRepository->find($id); } + public function filterOptions(int $limit = 200): array + { + return $this->importAuditRunRepository->listFilterOptions($limit); + } + public function purgeExpired(): int { return $this->importAuditRunRepository->purgeOlderThanDays(self::RETENTION_DAYS); @@ -156,11 +162,7 @@ class ImportAuditService private function normalizeStatus(?string $status): ?string { - $status = strtolower(trim((string) ($status ?? ''))); - if ($status === '') { - return null; - } - return in_array($status, ['running', 'success', 'partial', 'failed'], true) ? $status : null; + return ImportAuditStatus::tryNormalize((string) ($status ?? ''))?->value; } private function encodeErrorCounts(array $result): ?string @@ -204,4 +206,3 @@ class ImportAuditService return is_string($encoded) ? $encoded : null; } } - diff --git a/lib/Service/Audit/SystemAuditRedactionService.php b/lib/Service/Audit/SystemAuditRedactionService.php new file mode 100644 index 0000000..92f0ff0 --- /dev/null +++ b/lib/Service/Audit/SystemAuditRedactionService.php @@ -0,0 +1,191 @@ + */ + private const SENSITIVE_KEYS = [ + 'password', + 'password2', + 'secret', + 'token', + 'access_token', + 'refresh_token', + 'id_token', + 'authorization', + 'api_key', + 'client_secret', + 'smtp_password', + 'microsoft_shared_client_secret', + 'email', + 'to_email', + ]; + + /** @var array */ + private const CHANGE_VALUE_ALLOWLIST = [ + 'active', + 'status', + 'default_tenant_id', + 'default_role_id', + 'default_department_id', + 'app_locale', + 'app_theme', + 'app_theme_user', + 'app_registration', + 'app_primary_color', + 'api_token_default_ttl_days', + 'api_token_max_ttl_days', + 'user_inactivity_deactivate_days', + 'user_inactivity_delete_days', + 'system_audit_enabled', + 'system_audit_retention_days', + ]; + + public function redactMetadata(array $metadata): array + { + return $this->redactArray($metadata); + } + + /** + * @return array{changed_fields: list, changes: array} + */ + public function buildChangeSet(array $before, array $after): array + { + $changedFields = []; + $changes = []; + + $allFields = array_values(array_unique(array_merge(array_keys($before), array_keys($after)))); + sort($allFields, SORT_STRING); + + foreach ($allFields as $field) { + if (!is_string($field) || trim($field) === '') { + continue; + } + + $beforeValue = $before[$field] ?? null; + $afterValue = $after[$field] ?? null; + + if ($this->valuesEqual($beforeValue, $afterValue)) { + continue; + } + + $changedFields[] = $field; + if ($this->isAllowlistedChangeField($field)) { + $changes[$field] = [ + 'before' => $this->normalizeValue($beforeValue), + 'after' => $this->normalizeValue($afterValue), + ]; + continue; + } + + $changes[$field] = [ + 'before' => '[REDACTED]', + 'after' => '[REDACTED]', + ]; + } + + return [ + 'changed_fields' => $changedFields, + 'changes' => $changes, + ]; + } + + public function hashValue(string $value): string + { + return RequestContext::hashValue($value); + } + + private function redactArray(array $data): array + { + $result = []; + $index = 0; + + foreach ($data as $rawKey => $value) { + if ($index >= self::MAX_ARRAY_ITEMS) { + break; + } + + $key = trim((string) $rawKey); + if ($key === '') { + continue; + } + + if ($this->isSensitiveKey($key)) { + $result[$key] = '[REDACTED]'; + $index++; + continue; + } + + if (is_array($value)) { + $result[$key] = $this->redactArray($value); + $index++; + continue; + } + + $result[$key] = $this->normalizeValue($value); + $index++; + } + + return $result; + } + + private function normalizeValue(mixed $value): mixed + { + if (is_null($value) || is_bool($value) || is_int($value) || is_float($value)) { + return $value; + } + + if (is_array($value)) { + return $this->redactArray($value); + } + + $string = trim((string) $value); + if ($string === '') { + return ''; + } + + if (strlen($string) > self::MAX_STRING_LENGTH) { + return substr($string, 0, self::MAX_STRING_LENGTH); + } + + return $string; + } + + private function valuesEqual(mixed $left, mixed $right): bool + { + if (is_array($left) || is_array($right)) { + return json_encode($left) === json_encode($right); + } + + return (string) $left === (string) $right; + } + + private function isSensitiveKey(string $key): bool + { + $key = strtolower(trim($key)); + if ($key === '') { + return false; + } + + if (in_array($key, self::SENSITIVE_KEYS, true)) { + return true; + } + + return str_contains($key, 'password') + || str_contains($key, 'secret') + || str_contains($key, 'token') + || str_contains($key, 'authorization') + || str_ends_with($key, '_key'); + } + + private function isAllowlistedChangeField(string $field): bool + { + return in_array(strtolower(trim($field)), self::CHANGE_VALUE_ALLOWLIST, true); + } +} diff --git a/lib/Service/Audit/SystemAuditService.php b/lib/Service/Audit/SystemAuditService.php new file mode 100644 index 0000000..0d3447f --- /dev/null +++ b/lib/Service/Audit/SystemAuditService.php @@ -0,0 +1,189 @@ +value, + array $context = [] + ): ?int + { + if (!$this->isEnabled()) { + return null; + } + + $eventType = $this->normalizeEventType($eventType); + if ($eventType === '') { + return null; + } + + $outcome = $this->normalizeOutcome($outcome); + $requestContext = RequestContext::context(); + $session = is_array($_SESSION ?? null) ? $_SESSION : []; + + $fallbackActorUserId = !empty($session['user']['id']) + ? (int) $session['user']['id'] + : (ApiAuth::isAuthenticated() ? ApiAuth::userId() : 0); + $fallbackActorTenantId = !empty($session['current_tenant']['id']) + ? (int) $session['current_tenant']['id'] + : (ApiAuth::isAuthenticated() ? (int) (ApiAuth::tenantId() ?? 0) : 0); + + $actorUserId = (int) ($context['actor_user_id'] ?? $fallbackActorUserId); + $actorTenantId = (int) ($context['actor_tenant_id'] ?? $fallbackActorTenantId); + $targetId = (int) ($context['target_id'] ?? 0); + + $targetType = trim((string) ($context['target_type'] ?? '')); + $targetUuid = trim((string) ($context['target_uuid'] ?? '')); + $errorCode = trim((string) ($context['error_code'] ?? '')); + $metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : []; + + $before = is_array($context['before'] ?? null) ? $context['before'] : []; + $after = is_array($context['after'] ?? null) ? $context['after'] : []; + if ($before !== [] || $after !== []) { + $metadata = [ + ...$metadata, + ...$this->systemAuditRedactionService->buildChangeSet($before, $after), + ]; + } + + $metadata = $this->systemAuditRedactionService->redactMetadata($metadata); + $metadataJson = $metadata !== [] ? json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null; + if (!is_string($metadataJson)) { + $metadataJson = null; + } + + $ip = trim((string) ($requestContext['ip'] ?? '')); + $userAgent = trim((string) ($requestContext['user_agent'] ?? '')); + + $payload = [ + 'event_uuid' => RepoQuery::uuidV4(), + 'request_id' => $this->normalizeRequestId((string) ($requestContext['request_id'] ?? '')), + 'channel' => $this->normalizeChannel((string) ($requestContext['channel'] ?? '')), + 'event_type' => $eventType, + 'outcome' => $outcome, + 'error_code' => $errorCode !== '' ? substr($errorCode, 0, 100) : null, + 'actor_user_id' => $actorUserId > 0 ? $actorUserId : null, + 'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null, + 'target_type' => $targetType !== '' ? substr($targetType, 0, 64) : null, + 'target_id' => $targetId > 0 ? $targetId : null, + 'target_uuid' => $targetUuid !== '' ? substr($targetUuid, 0, 36) : null, + 'method' => $this->normalizeMethod((string) ($requestContext['method'] ?? '')), + 'path' => $this->normalizePath((string) ($requestContext['path'] ?? '')), + 'ip_hash' => $ip !== '' ? $this->systemAuditRedactionService->hashValue($ip) : null, + 'user_agent_hash' => $userAgent !== '' ? $this->systemAuditRedactionService->hashValue($userAgent) : null, + 'metadata_json' => $metadataJson, + ]; + + try { + $id = $this->systemAuditLogRepository->create($payload); + return is_int($id) ? $id : null; + } catch (\Throwable) { + // Fail-open by design: audit logging must never break business flow. + return null; + } + } + + public function listPaged(array $filters): array + { + return $this->systemAuditLogRepository->listPaged($filters); + } + + public function find(int $id): ?array + { + return $this->systemAuditLogRepository->find($id); + } + + public function filterOptions(int $limit = 200): array + { + return $this->systemAuditLogRepository->listFilterOptions($limit); + } + + public function purgeExpired(): int + { + return $this->systemAuditLogRepository->purgeOlderThanDays($this->retentionDays()); + } + + public function retentionDays(): int + { + $days = (int) $this->settingGateway->getSystemAuditRetentionDays(); + if ($days < self::RETENTION_DAYS_MIN || $days > self::RETENTION_DAYS_MAX) { + return self::RETENTION_DAYS_FALLBACK; + } + return $days; + } + + public function isEnabled(): bool + { + return $this->settingGateway->isSystemAuditEnabled(); + } + + private function normalizeEventType(string $eventType): string + { + $eventType = trim($eventType); + if ($eventType === '') { + return ''; + } + return substr($eventType, 0, 64); + } + + private function normalizeOutcome(string $outcome): string + { + return SystemAuditOutcome::normalizeOr($outcome, SystemAuditOutcome::Success)->value; + } + + private function normalizeChannel(string $channel): string + { + return SystemAuditChannel::normalizeOr($channel, SystemAuditChannel::Web)->value; + } + + private function normalizeMethod(string $method): ?string + { + $method = strtoupper(trim($method)); + if ($method === '') { + return null; + } + return substr($method, 0, 8); + } + + private function normalizePath(string $path): ?string + { + $path = trim($path); + if ($path === '') { + return null; + } + return substr($path, 0, 255); + } + + private function normalizeRequestId(string $requestId): ?string + { + $requestId = strtolower(trim($requestId)); + if ($requestId === '') { + return null; + } + if (preg_match('/^[a-f0-9-]{36}$/', $requestId) !== 1) { + return null; + } + return $requestId; + } +} diff --git a/lib/Service/Audit/UserLifecycleAuditService.php b/lib/Service/Audit/UserLifecycleAuditService.php index 73f065f..a20fd80 100644 --- a/lib/Service/Audit/UserLifecycleAuditService.php +++ b/lib/Service/Audit/UserLifecycleAuditService.php @@ -2,6 +2,9 @@ namespace MintyPHP\Service\Audit; +use MintyPHP\Domain\Taxonomy\UserLifecycleAction; +use MintyPHP\Domain\Taxonomy\UserLifecycleStatus; +use MintyPHP\Domain\Taxonomy\UserLifecycleTriggerType; use MintyPHP\Repository\Audit\UserLifecycleAuditRepository; use MintyPHP\Support\Crypto; @@ -49,13 +52,13 @@ class UserLifecycleAuditService array $policy, ?int $actorUserId, array $targetUser, - string $status = 'success', + string $status = UserLifecycleStatus::Success->value, ?string $reasonCode = null ): bool { try { return $this->userLifecycleAuditRepository->create($this->buildBaseRow( $runUuid, - 'deactivate', + UserLifecycleAction::Deactivate->value, $triggerType, $status, $reasonCode, @@ -83,9 +86,9 @@ class UserLifecycleAuditService } $row = $this->buildBaseRow( $runUuid, - 'delete', + UserLifecycleAction::Delete->value, $triggerType, - 'success', + UserLifecycleStatus::Success->value, null, $policy, $actorUserId, @@ -110,9 +113,9 @@ class UserLifecycleAuditService try { return $this->userLifecycleAuditRepository->create($this->buildBaseRow( $runUuid, - 'delete', + UserLifecycleAction::Delete->value, $triggerType, - 'failed', + UserLifecycleStatus::Failed->value, $reasonCode, $policy, $actorUserId, @@ -129,13 +132,13 @@ class UserLifecycleAuditService array $policy, ?int $actorUserId, array $targetUser, - string $status = 'success', + string $status = UserLifecycleStatus::Success->value, ?string $reasonCode = null ): bool { try { return $this->userLifecycleAuditRepository->create($this->buildBaseRow( $runUuid, - 'restore', + UserLifecycleAction::Restore->value, $triggerType, $status, $reasonCode, @@ -167,6 +170,11 @@ class UserLifecycleAuditService return $this->userLifecycleAuditRepository->find($id); } + public function filterOptions(int $limit = 200): array + { + return $this->userLifecycleAuditRepository->listFilterOptions($limit); + } + public function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array { return $this->userLifecycleAuditRepository->findDeleteEventForRestore($id, $forUpdate); @@ -211,14 +219,9 @@ class UserLifecycleAuditService ?int $actorUserId, array $targetUser ): array { - $triggerType = strtolower(trim($triggerType)); - if (!in_array($triggerType, ['manual', 'cron', 'system'], true)) { - $triggerType = 'system'; - } - $status = strtolower(trim($status)); - if (!in_array($status, ['success', 'skipped', 'failed'], true)) { - $status = 'failed'; - } + $action = UserLifecycleAction::normalizeOr($action, UserLifecycleAction::Deactivate)->value; + $triggerType = UserLifecycleTriggerType::normalizeOr($triggerType, UserLifecycleTriggerType::System)->value; + $status = UserLifecycleStatus::normalizeOr($status, UserLifecycleStatus::Failed)->value; return [ 'run_uuid' => trim($runUuid), diff --git a/lib/Service/Auth/ApiTokenEndpointService.php b/lib/Service/Auth/ApiTokenEndpointService.php new file mode 100644 index 0000000..88952f4 --- /dev/null +++ b/lib/Service/Auth/ApiTokenEndpointService.php @@ -0,0 +1,277 @@ + $max) { + return $max; + } + return $limit; + } + + /** + * @return array> + */ + public function listSelfTokens(int $userId, int $limit, ?int $scopedTenantId, ?int $currentTokenId): array + { + if ($userId <= 0) { + return []; + } + + $tokens = $this->apiTokenRepository->listByUserId($userId, $limit); + $tenantUuidById = $this->buildTenantUuidByIdMap($tokens); + $rows = []; + + foreach ($tokens as $token) { + $tokenTenantId = $this->normalizeTenantId($token['tenant_id'] ?? null); + if ($scopedTenantId !== null && $scopedTenantId > 0 && $tokenTenantId !== $scopedTenantId) { + continue; + } + + $rows[] = [ + 'uuid' => (string) ($token['uuid'] ?? ''), + 'name' => (string) ($token['name'] ?? ''), + 'selector_prefix' => substr((string) ($token['selector'] ?? ''), 0, 8), + 'tenant_uuid' => $tokenTenantId !== null ? ($tenantUuidById[$tokenTenantId] ?? null) : null, + 'last_used_at' => $token['last_used_at'] ?? null, + 'last_ip' => (string) ($token['last_ip'] ?? ''), + 'expires_at' => $token['expires_at'] ?? null, + 'revoked_at' => $token['revoked_at'] ?? null, + 'created' => $token['created'] ?? null, + 'is_current_token' => (int) ($token['id'] ?? 0) === (int) ($currentTokenId ?? 0), + ]; + } + + return $rows; + } + + /** + * @return array|null + */ + public function findSelfToken(string $tokenUuid, int $userId, ?int $scopedTenantId, ?int $currentTokenId): ?array + { + $tokenUuid = trim($tokenUuid); + if ($userId <= 0 || !$this->apiTokenRepository->isUuid($tokenUuid)) { + return null; + } + + $token = $this->apiTokenRepository->findByUuidForUser($tokenUuid, $userId); + if (!$token) { + return null; + } + + $tokenTenantId = $this->normalizeTenantId($token['tenant_id'] ?? null); + if ($scopedTenantId !== null && $scopedTenantId > 0 && $tokenTenantId !== $scopedTenantId) { + return null; + } + + return [ + 'uuid' => (string) ($token['uuid'] ?? ''), + 'name' => (string) ($token['name'] ?? ''), + 'selector_prefix' => substr((string) ($token['selector'] ?? ''), 0, 8), + 'tenant_uuid' => $this->resolveTenantUuidById($tokenTenantId), + 'last_used_at' => $token['last_used_at'] ?? null, + 'last_ip' => (string) ($token['last_ip'] ?? ''), + 'expires_at' => $token['expires_at'] ?? null, + 'revoked_at' => $token['revoked_at'] ?? null, + 'created' => $token['created'] ?? null, + 'is_current_token' => (int) ($token['id'] ?? 0) === (int) ($currentTokenId ?? 0), + ]; + } + + public function revokeSelfToken(string $tokenUuid, int $userId): bool + { + $tokenUuid = trim($tokenUuid); + if ($userId <= 0 || !$this->apiTokenRepository->isUuid($tokenUuid)) { + return false; + } + + return $this->apiTokenRepository->revokeByUuidForUser($tokenUuid, $userId); + } + + /** + * @return array{ok: bool, tenant_id?: int|null, tenant_uuid?: string|null, status?: int, error?: string, field?: string} + */ + public function resolveSelfCreateTenant(int $userId, ?int $scopedTenantId, string $tenantUuidInput): array + { + $tenantUuidInput = trim($tenantUuidInput); + $tenantId = null; + $tenantUuid = null; + + if ($tenantUuidInput !== '') { + $tenant = $this->tenantRepository->findByUuid($tenantUuidInput); + $tenantId = (int) ($tenant['id'] ?? 0); + $tenantUuid = trim((string) ($tenant['uuid'] ?? '')); + if ($tenantId <= 0 || $tenantUuid === '') { + return [ + 'ok' => false, + 'status' => 422, + 'error' => 'invalid_tenant_uuid', + 'field' => 'tenant_uuid', + ]; + } + } + + if ($scopedTenantId !== null && $scopedTenantId > 0) { + $scopedTenantUuid = $this->resolveTenantUuidById($scopedTenantId); + if ($scopedTenantUuid === null) { + return ['ok' => false, 'status' => 403, 'error' => 'tenant_scoped_token_forbidden']; + } + if ($tenantId !== null && $tenantId !== $scopedTenantId) { + return ['ok' => false, 'status' => 403, 'error' => 'tenant_scoped_token_forbidden']; + } + + $tenantId = $scopedTenantId; + $tenantUuid = $scopedTenantUuid; + } + + if ($tenantId !== null) { + $tenantIds = $this->authScopeGateway->getUserTenantIds($userId); + if (!in_array($tenantId, $tenantIds, true)) { + return ['ok' => false, 'status' => 403, 'error' => 'tenant_not_assigned']; + } + } + + return [ + 'ok' => true, + 'tenant_id' => $tenantId, + 'tenant_uuid' => $tenantUuid, + ]; + } + + /** + * @return array{ok: bool, expires_at?: string|null, error?: string} + */ + public function parseExpiresAt(string $expiresInput): array + { + $expiresInput = trim($expiresInput); + if ($expiresInput === '') { + return ['ok' => true, 'expires_at' => null]; + } + + try { + $expiresDate = new \DateTimeImmutable($expiresInput); + $expiresDateUtc = $expiresDate->setTimezone(new \DateTimeZone('UTC')); + $nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + if ($expiresDateUtc <= $nowUtc) { + return ['ok' => false, 'error' => 'must_be_in_future']; + } + return ['ok' => true, 'expires_at' => $expiresDateUtc->format('Y-m-d H:i:s')]; + } catch (\Throwable $exception) { + return ['ok' => false, 'error' => 'invalid_datetime']; + } + } + + public function capExpiryToParent(?string $requestedExpiresAt, ?string $parentExpiresAt): ?string + { + $parentExpiresAt = trim((string) ($parentExpiresAt ?? '')); + if ($parentExpiresAt === '') { + return $requestedExpiresAt; + } + + try { + $parentExpiry = new \DateTimeImmutable($parentExpiresAt, new \DateTimeZone('UTC')); + if ($requestedExpiresAt === null || trim($requestedExpiresAt) === '') { + return $parentExpiresAt; + } + + $requestedExpiry = new \DateTimeImmutable($requestedExpiresAt, new \DateTimeZone('UTC')); + if ($requestedExpiry > $parentExpiry) { + return $parentExpiresAt; + } + } catch (\Throwable $exception) { + return $requestedExpiresAt; + } + + return $requestedExpiresAt; + } + + /** + * @return array{ok: bool, tenant_id?: int|null, error?: string, status?: int} + */ + public function resolveLoginTenant(int $userId, string $tenantUuidInput): array + { + $tenantUuidInput = trim($tenantUuidInput); + if ($tenantUuidInput === '') { + return ['ok' => true, 'tenant_id' => null]; + } + + $tenant = $this->tenantRepository->findByUuid($tenantUuidInput); + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId <= 0) { + return ['ok' => false, 'error' => 'invalid_tenant_uuid', 'status' => 422]; + } + + $userTenantIds = $this->authScopeGateway->getUserTenantIds($userId); + if (!in_array($tenantId, $userTenantIds, true)) { + return ['ok' => false, 'error' => 'tenant_not_assigned', 'status' => 403]; + } + + return ['ok' => true, 'tenant_id' => $tenantId]; + } + + public function resolveTenantUuidById(?int $tenantId): ?string + { + $tenantId = (int) ($tenantId ?? 0); + if ($tenantId <= 0) { + return null; + } + + $tenant = $this->tenantRepository->find($tenantId); + $tenantUuid = trim((string) ($tenant['uuid'] ?? '')); + + return $tenantUuid !== '' ? $tenantUuid : null; + } + + /** + * @param array> $tokens + * @return array + */ + private function buildTenantUuidByIdMap(array $tokens): array + { + $tenantIds = []; + foreach ($tokens as $token) { + $tenantId = $this->normalizeTenantId($token['tenant_id'] ?? null); + if ($tenantId !== null) { + $tenantIds[] = $tenantId; + } + } + $tenantIds = array_values(array_unique($tenantIds)); + if (!$tenantIds) { + return []; + } + + $map = []; + foreach ($this->tenantRepository->listByIds($tenantIds) as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + $tenantUuid = trim((string) ($tenant['uuid'] ?? '')); + if ($tenantId > 0 && $tenantUuid !== '') { + $map[$tenantId] = $tenantUuid; + } + } + return $map; + } + + private function normalizeTenantId(mixed $tenantId): ?int + { + $tenantId = (int) ($tenantId ?? 0); + return $tenantId > 0 ? $tenantId : null; + } +} diff --git a/lib/Service/Auth/ApiTokenService.php b/lib/Service/Auth/ApiTokenService.php index 70dbca6..41b0665 100644 --- a/lib/Service/Auth/ApiTokenService.php +++ b/lib/Service/Auth/ApiTokenService.php @@ -2,8 +2,8 @@ namespace MintyPHP\Service\Auth; -use MintyPHP\DB; use MintyPHP\Repository\Auth\ApiTokenRepository; +use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\User\UserReadRepository; class ApiTokenService @@ -14,7 +14,8 @@ class ApiTokenService private readonly UserReadRepository $userReadRepository, private readonly ApiTokenRepository $apiTokenRepository, private readonly AuthSettingsGateway $settingsGateway, - private readonly AuthScopeGateway $scopeGateway + private readonly AuthScopeGateway $scopeGateway, + private readonly DatabaseSessionRepository $databaseSessionRepository ) { } @@ -139,20 +140,19 @@ class ApiTokenService $tenantId = null; } - $db = DB::handle(); $transactionStarted = false; try { - $db->begin_transaction(); + $this->databaseSessionRepository->beginTransaction(); $transactionStarted = true; if (!$this->apiTokenRepository->revoke($currentTokenId)) { - $db->rollback(); + $this->databaseSessionRepository->rollbackTransaction(); return ['ok' => false, 'error' => 'rotate_failed']; } $created = $this->create($userId, $name, $tenantId, $expiresAt, $userId); if (!($created['ok'] ?? false)) { - $db->rollback(); + $this->databaseSessionRepository->rollbackTransaction(); $error = (string) ($created['error'] ?? 'rotate_failed'); if ($error === 'invalid_expires_at') { return ['ok' => false, 'error' => 'invalid_expires_at']; @@ -163,13 +163,14 @@ class ApiTokenService return ['ok' => false, 'error' => 'rotate_failed']; } - $db->commit(); + $this->databaseSessionRepository->commitTransaction(); + $transactionStarted = false; $created['tenant_id'] = $tenantId; return $created; } catch (\Throwable $exception) { if ($transactionStarted) { try { - $db->rollback(); + $this->databaseSessionRepository->rollbackTransaction(); } catch (\Throwable $rollbackException) { // Ignore rollback error and return rotate_failed below. } diff --git a/lib/Service/Auth/AuthGatewayFactory.php b/lib/Service/Auth/AuthGatewayFactory.php new file mode 100644 index 0000000..412bb4a --- /dev/null +++ b/lib/Service/Auth/AuthGatewayFactory.php @@ -0,0 +1,98 @@ +authSettingsGateway ??= new AuthSettingsGateway($this->createSettingGateway()); + } + + public function createAuthScopeGateway(): AuthScopeGateway + { + return $this->authScopeGateway ??= new AuthScopeGateway($this->tenantScopeService); + } + + public function createAuthPermissionGateway(): AuthPermissionGateway + { + return $this->authPermissionGateway ??= new AuthPermissionGateway($this->createPermissionGateway()); + } + + public function createAuthTenantGateway(): AuthTenantGateway + { + return $this->authTenantGateway ??= new AuthTenantGateway( + $this->authRepositoryFactory->createTenantRepository() + ); + } + + public function createAuthCryptoGateway(): AuthCryptoGateway + { + return $this->authCryptoGateway ??= new AuthCryptoGateway(); + } + + public function createAuthTenantSsoGateway(TenantSsoService $tenantSsoService): AuthTenantSsoGateway + { + return $this->authTenantSsoGateway ??= new AuthTenantSsoGateway($tenantSsoService); + } + + public function createAuthSavedFilterGateway(): AuthSavedFilterGateway + { + return $this->authSavedFilterGateway ??= new AuthSavedFilterGateway( + $this->userServicesFactory->createUserSavedFilterService() + ); + } + + public function createAuthAvatarGateway(): AuthAvatarGateway + { + return $this->authAvatarGateway ??= new AuthAvatarGateway( + $this->userServicesFactory->createUserAvatarService() + ); + } + + public function createAuthExternalIdentityGateway(): AuthExternalIdentityGateway + { + return $this->authExternalIdentityGateway ??= new AuthExternalIdentityGateway( + $this->authRepositoryFactory->createUserExternalIdentityRepository() + ); + } + + private function createPermissionGateway(): PermissionGateway + { + return $this->permissionGateway ??= $this->accessServicesFactory->createPermissionGateway(); + } + + private function createSettingGateway(): SettingGateway + { + return $this->settingGateway ??= $this->settingServicesFactory->createSettingGateway(); + } + +} diff --git a/lib/Service/Auth/AuthRepositoryFactory.php b/lib/Service/Auth/AuthRepositoryFactory.php new file mode 100644 index 0000000..d54d3fa --- /dev/null +++ b/lib/Service/Auth/AuthRepositoryFactory.php @@ -0,0 +1,57 @@ +apiTokenRepository ??= new ApiTokenRepository(); + } + + public function createRememberTokenRepository(): RememberTokenRepository + { + return $this->rememberTokenRepository ??= new RememberTokenRepository(); + } + + public function createPasswordResetRepository(): PasswordResetRepository + { + return $this->passwordResetRepository ??= new PasswordResetRepository(); + } + + public function createEmailVerificationRepository(): EmailVerificationRepository + { + return $this->emailVerificationRepository ??= new EmailVerificationRepository(); + } + + public function createTenantRepository(): TenantRepository + { + return $this->tenantRepository ??= new TenantRepository(); + } + + public function createTenantMicrosoftAuthRepository(): TenantMicrosoftAuthRepository + { + return $this->tenantMicrosoftAuthRepository ??= new TenantMicrosoftAuthRepository(); + } + + public function createUserExternalIdentityRepository(): UserExternalIdentityRepository + { + return $this->userExternalIdentityRepository ??= new UserExternalIdentityRepository(); + } +} diff --git a/lib/Service/Auth/AuthScopeGateway.php b/lib/Service/Auth/AuthScopeGateway.php index 0a81a34..e562449 100644 --- a/lib/Service/Auth/AuthScopeGateway.php +++ b/lib/Service/Auth/AuthScopeGateway.php @@ -3,40 +3,31 @@ namespace MintyPHP\Service\Auth; use MintyPHP\Service\Tenant\TenantScopeService; -use MintyPHP\Service\Tenant\TenantServicesFactory; class AuthScopeGateway { - public function __construct(private readonly ?TenantScopeService $tenantScopeService = null) - { + public function __construct( + private readonly TenantScopeService $tenantScopeService + ) { } public function hasGlobalAccess(int $userId): bool { - return $this->tenantScopeService()->hasGlobalAccess($userId); + return $this->tenantScopeService->hasGlobalAccess($userId); } public function getUserTenantIds(int $userId): array { - return $this->tenantScopeService()->getUserTenantIds($userId); + return $this->tenantScopeService->getUserTenantIds($userId); } public function canAccess(string $resourceType, int $resourceId, int $userId): bool { - return $this->tenantScopeService()->canAccess($resourceType, $resourceId, $userId); + return $this->tenantScopeService->canAccess($resourceType, $resourceId, $userId); } public function resourceBelongsToTenant(string $resourceType, int $resourceId, int $tenantId): bool { - return $this->tenantScopeService()->resourceBelongsToTenant($resourceType, $resourceId, $tenantId); - } - - private function tenantScopeService(): TenantScopeService - { - if ($this->tenantScopeService instanceof TenantScopeService) { - return $this->tenantScopeService; - } - - return (new TenantServicesFactory())->createTenantScopeService(); + return $this->tenantScopeService->resourceBelongsToTenant($resourceType, $resourceId, $tenantId); } } diff --git a/lib/Service/Auth/AuthService.php b/lib/Service/Auth/AuthService.php index 673d71d..5a756fc 100644 --- a/lib/Service/Auth/AuthService.php +++ b/lib/Service/Auth/AuthService.php @@ -8,6 +8,7 @@ use MintyPHP\Repository\User\UserWriteRepository; use MintyPHP\Router; use MintyPHP\Service\User\UserAccountService; use MintyPHP\Service\User\UserTenantContextService; +use MintyPHP\Service\Audit\SystemAuditService; use MintyPHP\Session; use MintyPHP\Support\Flash; @@ -22,7 +23,8 @@ class AuthService private readonly EmailVerificationService $emailVerificationService, private readonly AuthPermissionGateway $permissionGateway, private readonly AuthTenantSsoGateway $tenantSsoGateway, - private readonly AuthSavedFilterGateway $savedFilterGateway + private readonly AuthSavedFilterGateway $savedFilterGateway, + private readonly SystemAuditService $systemAuditService ) { } @@ -33,6 +35,10 @@ class AuthService // Check if email is verified before attempting login $existingUser = $this->userReadRepository->findByEmail($email); if ($existingUser && empty($existingUser['email_verified_at'])) { + $this->recordAuthEvent('auth.login.failed', 'failed', [ + 'error_code' => 'email_not_verified', + 'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))], + ]); return [ 'ok' => false, 'message' => 'Please verify your email first', @@ -45,6 +51,10 @@ class AuthService $user = Auth::login($email, $password); if (!$user) { + $this->recordAuthEvent('auth.login.failed', 'failed', [ + 'error_code' => 'invalid_credentials', + 'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))], + ]); return [ 'ok' => false, 'message' => 'Email/password not valid', @@ -54,6 +64,10 @@ class AuthService if (!($_SESSION['user']['active'] ?? 1)) { Auth::logout(); + $this->recordAuthEvent('auth.login.failed', 'failed', [ + 'error_code' => 'account_inactive', + 'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))], + ]); return [ 'ok' => false, 'message' => 'Account is inactive', @@ -64,6 +78,10 @@ class AuthService $userId = (int) ($_SESSION['user']['id'] ?? 0); if ($userId > 0 && !$this->isLocalPasswordLoginAllowedForUser($userId)) { Auth::logout(); + $this->recordAuthEvent('auth.login.failed', 'failed', [ + 'error_code' => 'password_login_disabled', + 'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))], + ]); return [ 'ok' => false, 'message' => 'Password login is disabled for all assigned tenants', @@ -75,6 +93,10 @@ class AuthService $this->loadTenantDataIntoSession($userId); if (!empty($_SESSION['no_active_tenant'])) { Auth::logout(); + $this->recordAuthEvent('auth.login.failed', 'failed', [ + 'error_code' => 'no_active_tenant', + 'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))], + ]); return [ 'ok' => false, 'message' => 'No active tenant assigned', @@ -84,6 +106,11 @@ class AuthService $this->userWriteRepository->updateLastLogin($userId, 'local'); } + $this->recordAuthEvent('auth.login.success', 'success', [ + 'actor_user_id' => $userId > 0 ? $userId : null, + 'actor_tenant_id' => (int) ($_SESSION['current_tenant']['id'] ?? 0), + ]); + return ['ok' => true]; } @@ -154,10 +181,19 @@ class AuthService $this->loadTenantDataIntoSession($userId); if (!empty($_SESSION['no_active_tenant'])) { Auth::logout(); + $this->recordAuthEvent('auth.login.failed', 'failed', [ + 'error_code' => 'login_no_active_tenant', + 'actor_user_id' => $userId, + ]); return ['ok' => false, 'error' => 'login_no_active_tenant']; } $this->userWriteRepository->updateLastLogin($userId, $loginProvider); + $this->recordAuthEvent('auth.login.success', 'success', [ + 'actor_user_id' => $userId, + 'actor_tenant_id' => (int) ($_SESSION['current_tenant']['id'] ?? 0), + 'metadata' => ['provider' => $loginProvider], + ]); return ['ok' => true, 'user' => $user]; } @@ -214,17 +250,27 @@ class AuthService public function logout(): void { + $actorUserId = (int) ($_SESSION['user']['id'] ?? 0); + $actorTenantId = (int) ($_SESSION['current_tenant']['id'] ?? 0); $this->rememberMeService->forgetCurrentUser(); Auth::logout(); + $this->recordAuthEvent('auth.logout', 'success', [ + 'actor_user_id' => $actorUserId > 0 ? $actorUserId : null, + 'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null, + ]); } public function logoutAndRedirect(string $target = 'login'): void { - $this->rememberMeService->forgetCurrentUser(); - Auth::logout(); + $this->logout(); Router::redirect($target); } + private function recordAuthEvent(string $eventType, string $outcome, array $context = []): void + { + $this->systemAuditService->record($eventType, $outcome, $context); + } + public function refreshSessionAuthState(int $userId): array { if ($userId <= 0) { diff --git a/lib/Service/Auth/AuthServicesFactory.php b/lib/Service/Auth/AuthServicesFactory.php index 9048853..3dab552 100644 --- a/lib/Service/Auth/AuthServicesFactory.php +++ b/lib/Service/Auth/AuthServicesFactory.php @@ -2,46 +2,14 @@ namespace MintyPHP\Service\Auth; -use MintyPHP\Repository\Auth\ApiTokenRepository; -use MintyPHP\Repository\Auth\EmailVerificationRepository; -use MintyPHP\Repository\Auth\PasswordResetRepository; -use MintyPHP\Repository\Auth\RememberTokenRepository; -use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepository; -use MintyPHP\Repository\User\UserExternalIdentityRepository; -use MintyPHP\Service\Access\AccessServicesFactory; -use MintyPHP\Service\Access\PermissionGateway; +use MintyPHP\Repository\Support\DatabaseSessionRepository; +use MintyPHP\Service\Audit\AuditServicesFactory; use MintyPHP\Service\Mail\MailServicesFactory; -use MintyPHP\Service\Settings\SettingGateway; -use MintyPHP\Service\Settings\SettingServicesFactory; -use MintyPHP\Service\Tenant\TenantServicesFactory; use MintyPHP\Service\User\UserServicesFactory; class AuthServicesFactory { - private ?AccessServicesFactory $accessServicesFactory = null; - private ?TenantServicesFactory $tenantServicesFactory = null; - private ?UserServicesFactory $userServicesFactory = null; - private ?MailServicesFactory $mailServicesFactory = null; - private ?SettingServicesFactory $settingServicesFactory = null; - private ?AuthSettingsGateway $authSettingsGateway = null; - private ?SettingGateway $settingGateway = null; - private ?AuthScopeGateway $authScopeGateway = null; - private ?AuthPermissionGateway $authPermissionGateway = null; - private ?PermissionGateway $permissionGateway = null; - private ?AuthTenantGateway $authTenantGateway = null; - private ?AuthCryptoGateway $authCryptoGateway = null; - private ?AuthTenantSsoGateway $authTenantSsoGateway = null; - private ?AuthSavedFilterGateway $authSavedFilterGateway = null; - private ?AuthAvatarGateway $authAvatarGateway = null; - private ?AuthExternalIdentityGateway $authExternalIdentityGateway = null; - private ?ApiTokenRepository $apiTokenRepository = null; - private ?RememberTokenRepository $rememberTokenRepository = null; - private ?PasswordResetRepository $passwordResetRepository = null; - private ?EmailVerificationRepository $emailVerificationRepository = null; - private ?TenantMicrosoftAuthRepository $tenantMicrosoftAuthRepository = null; - private ?UserExternalIdentityRepository $userExternalIdentityRepository = null; private ?TenantSsoService $tenantSsoService = null; - private ?OidcHttpGateway $oidcHttpGateway = null; private ?MicrosoftOidcStateStoreService $microsoftOidcStateStoreService = null; private ?MicrosoftOidcService $microsoftOidcService = null; private ?ApiTokenService $apiTokenService = null; @@ -51,85 +19,96 @@ class AuthServicesFactory private ?AuthService $authService = null; private ?SsoUserLinkService $ssoUserLinkService = null; + public function __construct( + private readonly UserServicesFactory $userServicesFactory, + private readonly AuditServicesFactory $auditServicesFactory, + private readonly MailServicesFactory $mailServicesFactory, + private readonly AuthRepositoryFactory $authRepositoryFactory, + private readonly AuthGatewayFactory $authGatewayFactory, + private readonly DatabaseSessionRepository $databaseSessionRepository + ) {} + public function createApiTokenService(): ApiTokenService { return $this->apiTokenService ??= new ApiTokenService( - $this->userServicesFactory()->createUserReadRepository(), - $this->createApiTokenRepository(), - $this->createAuthSettingsGateway(), - $this->createAuthScopeGateway() + $this->userServicesFactory->createUserReadRepository(), + $this->authRepositoryFactory->createApiTokenRepository(), + $this->authGatewayFactory->createAuthSettingsGateway(), + $this->createAuthScopeGateway(), + $this->databaseSessionRepository ); } public function createPasswordResetService(): PasswordResetService { return $this->passwordResetService ??= new PasswordResetService( - $this->userServicesFactory()->createUserReadRepository(), - $this->createPasswordResetRepository(), - $this->userServicesFactory()->createUserPasswordService(), + $this->userServicesFactory->createUserReadRepository(), + $this->authRepositoryFactory->createPasswordResetRepository(), + $this->userServicesFactory->createUserPasswordService(), $this->createRememberMeService(), - $this->mailServicesFactory()->createMailService() + $this->mailServicesFactory->createMailService() ); } public function createEmailVerificationService(): EmailVerificationService { return $this->emailVerificationService ??= new EmailVerificationService( - $this->userServicesFactory()->createUserReadRepository(), - $this->userServicesFactory()->createUserWriteRepository(), - $this->createEmailVerificationRepository(), - $this->mailServicesFactory()->createMailService() + $this->userServicesFactory->createUserReadRepository(), + $this->userServicesFactory->createUserWriteRepository(), + $this->authRepositoryFactory->createEmailVerificationRepository(), + $this->mailServicesFactory->createMailService() ); } public function createRememberMeService(): RememberMeService { return $this->rememberMeService ??= new RememberMeService( - $this->userServicesFactory()->createUserReadRepository(), - $this->userServicesFactory()->createUserWriteRepository(), - $this->createRememberTokenRepository(), - $this->userServicesFactory()->createUserTenantContextService(), - $this->createAuthPermissionGateway(), - $this->createAuthSavedFilterGateway() + $this->userServicesFactory->createUserReadRepository(), + $this->userServicesFactory->createUserWriteRepository(), + $this->authRepositoryFactory->createRememberTokenRepository(), + $this->userServicesFactory->createUserTenantContextService(), + $this->authGatewayFactory->createAuthPermissionGateway(), + $this->authGatewayFactory->createAuthSavedFilterGateway() ); } public function createAuthService(): AuthService { return $this->authService ??= new AuthService( - $this->userServicesFactory()->createUserReadRepository(), - $this->userServicesFactory()->createUserWriteRepository(), - $this->userServicesFactory()->createUserAccountService(), - $this->userServicesFactory()->createUserTenantContextService(), + $this->userServicesFactory->createUserReadRepository(), + $this->userServicesFactory->createUserWriteRepository(), + $this->userServicesFactory->createUserAccountService(), + $this->userServicesFactory->createUserTenantContextService(), $this->createRememberMeService(), $this->createEmailVerificationService(), - $this->createAuthPermissionGateway(), + $this->authGatewayFactory->createAuthPermissionGateway(), $this->createAuthTenantSsoGateway(), - $this->createAuthSavedFilterGateway() + $this->authGatewayFactory->createAuthSavedFilterGateway(), + $this->auditServicesFactory->createSystemAuditService() ); } public function createSsoUserLinkService(): SsoUserLinkService { return $this->ssoUserLinkService ??= new SsoUserLinkService( - $this->userServicesFactory()->createUserReadRepository(), - $this->userServicesFactory()->createUserWriteRepository(), - $this->userServicesFactory()->createUserAssignmentService(), - $this->userServicesFactory()->createUserTenantRepository(), - $this->createAuthSettingsGateway(), + $this->userServicesFactory->createUserReadRepository(), + $this->userServicesFactory->createUserWriteRepository(), + $this->userServicesFactory->createUserAssignmentService(), + $this->userServicesFactory->createUserTenantRepository(), + $this->authGatewayFactory->createAuthSettingsGateway(), $this->createAuthTenantSsoGateway(), - $this->createAuthAvatarGateway(), - $this->createAuthExternalIdentityGateway() + $this->authGatewayFactory->createAuthAvatarGateway(), + $this->authGatewayFactory->createAuthExternalIdentityGateway() ); } public function createTenantSsoService(): TenantSsoService { return $this->tenantSsoService ??= new TenantSsoService( - $this->createAuthTenantGateway(), - $this->createTenantMicrosoftAuthRepository(), - $this->createAuthSettingsGateway(), - $this->createAuthCryptoGateway() + $this->authGatewayFactory->createAuthTenantGateway(), + $this->authRepositoryFactory->createTenantMicrosoftAuthRepository(), + $this->authGatewayFactory->createAuthSettingsGateway(), + $this->authGatewayFactory->createAuthCryptoGateway() ); } @@ -142,130 +121,24 @@ class AuthServicesFactory { return $this->microsoftOidcService ??= new MicrosoftOidcService( $this->createTenantSsoService(), - $this->createAuthTenantGateway(), + $this->authGatewayFactory->createAuthTenantGateway(), $this->createOidcHttpGateway(), $this->createMicrosoftOidcStateStore() ); } - private function userServicesFactory(): UserServicesFactory - { - return $this->userServicesFactory ??= new UserServicesFactory(); - } - - private function createAuthSettingsGateway(): AuthSettingsGateway - { - return $this->authSettingsGateway ??= new AuthSettingsGateway($this->createSettingGateway()); - } - public function createAuthScopeGateway(): AuthScopeGateway { - return $this->authScopeGateway ??= new AuthScopeGateway($this->tenantServicesFactory()->createTenantScopeService()); - } - - private function createAuthPermissionGateway(): AuthPermissionGateway - { - return $this->authPermissionGateway ??= new AuthPermissionGateway($this->createPermissionGateway()); - } - - private function createPermissionGateway(): PermissionGateway - { - return $this->permissionGateway ??= $this->accessServicesFactory()->createPermissionGateway(); - } - - private function accessServicesFactory(): AccessServicesFactory - { - return $this->accessServicesFactory ??= new AccessServicesFactory(); - } - - private function createSettingGateway(): SettingGateway - { - return $this->settingGateway ??= $this->settingServicesFactory()->createSettingGateway(); - } - - private function settingServicesFactory(): SettingServicesFactory - { - return $this->settingServicesFactory ??= new SettingServicesFactory(); - } - - private function mailServicesFactory(): MailServicesFactory - { - return $this->mailServicesFactory ??= new MailServicesFactory(); - } - - private function tenantServicesFactory(): TenantServicesFactory - { - return $this->tenantServicesFactory ??= new TenantServicesFactory(); - } - - private function createAuthTenantGateway(): AuthTenantGateway - { - return $this->authTenantGateway ??= new AuthTenantGateway(); - } - - private function createAuthCryptoGateway(): AuthCryptoGateway - { - return $this->authCryptoGateway ??= new AuthCryptoGateway(); + return $this->authGatewayFactory->createAuthScopeGateway(); } private function createAuthTenantSsoGateway(): AuthTenantSsoGateway { - return $this->authTenantSsoGateway ??= new AuthTenantSsoGateway($this->createTenantSsoService()); - } - - private function createAuthSavedFilterGateway(): AuthSavedFilterGateway - { - return $this->authSavedFilterGateway ??= new AuthSavedFilterGateway( - $this->userServicesFactory()->createUserSavedFilterService() - ); - } - - private function createAuthAvatarGateway(): AuthAvatarGateway - { - return $this->authAvatarGateway ??= new AuthAvatarGateway( - $this->userServicesFactory()->createUserAvatarService() - ); - } - - private function createAuthExternalIdentityGateway(): AuthExternalIdentityGateway - { - return $this->authExternalIdentityGateway ??= new AuthExternalIdentityGateway( - $this->createUserExternalIdentityRepository() - ); - } - - private function createTenantMicrosoftAuthRepository(): TenantMicrosoftAuthRepository - { - return $this->tenantMicrosoftAuthRepository ??= new TenantMicrosoftAuthRepository(); - } - - private function createApiTokenRepository(): ApiTokenRepository - { - return $this->apiTokenRepository ??= new ApiTokenRepository(); - } - - private function createRememberTokenRepository(): RememberTokenRepository - { - return $this->rememberTokenRepository ??= new RememberTokenRepository(); - } - - private function createPasswordResetRepository(): PasswordResetRepository - { - return $this->passwordResetRepository ??= new PasswordResetRepository(); - } - - private function createEmailVerificationRepository(): EmailVerificationRepository - { - return $this->emailVerificationRepository ??= new EmailVerificationRepository(); - } - - private function createUserExternalIdentityRepository(): UserExternalIdentityRepository - { - return $this->userExternalIdentityRepository ??= new UserExternalIdentityRepository(); + return $this->authGatewayFactory->createAuthTenantSsoGateway($this->createTenantSsoService()); } private function createOidcHttpGateway(): OidcHttpGateway { - return $this->oidcHttpGateway ??= new OidcHttpGateway(); + return new OidcHttpGateway(); } } diff --git a/lib/Service/Auth/AuthTenantGateway.php b/lib/Service/Auth/AuthTenantGateway.php index 1878d17..15501f7 100644 --- a/lib/Service/Auth/AuthTenantGateway.php +++ b/lib/Service/Auth/AuthTenantGateway.php @@ -6,18 +6,22 @@ use MintyPHP\Repository\Tenant\TenantRepository; class AuthTenantGateway { + public function __construct(private readonly TenantRepository $tenantRepository) + { + } + public function findById(int $tenantId): ?array { - return (new TenantRepository())->find($tenantId); + return $this->tenantRepository->find($tenantId); } public function findByUuid(string $uuid): ?array { - return (new TenantRepository())->findByUuid($uuid); + return $this->tenantRepository->findByUuid($uuid); } public function list(): array { - return (new TenantRepository())->list(); + return $this->tenantRepository->list(); } } diff --git a/lib/Service/Auth/SsoUserLinkService.php b/lib/Service/Auth/SsoUserLinkService.php index 6cf06a5..5407c62 100644 --- a/lib/Service/Auth/SsoUserLinkService.php +++ b/lib/Service/Auth/SsoUserLinkService.php @@ -6,6 +6,7 @@ use MintyPHP\Repository\Tenant\UserTenantRepository; use MintyPHP\Repository\User\UserReadRepository; use MintyPHP\Repository\User\UserWriteRepository; use MintyPHP\Service\User\UserAssignmentService; +use MintyPHP\Service\User\UserAvatarService; class SsoUserLinkService { diff --git a/lib/Service/Branding/BrandingServicesFactory.php b/lib/Service/Branding/BrandingServicesFactory.php index 8556a2d..bd0864a 100644 --- a/lib/Service/Branding/BrandingServicesFactory.php +++ b/lib/Service/Branding/BrandingServicesFactory.php @@ -3,15 +3,17 @@ namespace MintyPHP\Service\Branding; use MintyPHP\Service\Settings\SettingGateway; -use MintyPHP\Service\Settings\SettingServicesFactory; class BrandingServicesFactory { - private ?SettingServicesFactory $settingServicesFactory = null; - private ?SettingGateway $settingGateway = null; private ?BrandingLogoService $brandingLogoService = null; private ?BrandingFaviconService $brandingFaviconService = null; + public function __construct( + private readonly SettingGateway $settingGateway + ) { + } + public function createBrandingLogoService(): BrandingLogoService { return $this->brandingLogoService ??= new BrandingLogoService(); @@ -24,11 +26,6 @@ class BrandingServicesFactory private function createSettingGateway(): SettingGateway { - return $this->settingGateway ??= $this->settingServicesFactory()->createSettingGateway(); - } - - private function settingServicesFactory(): SettingServicesFactory - { - return $this->settingServicesFactory ??= new SettingServicesFactory(); + return $this->settingGateway; } } diff --git a/lib/Service/CustomField/CustomFieldServicesFactory.php b/lib/Service/CustomField/CustomFieldServicesFactory.php new file mode 100644 index 0000000..8a7d1d7 --- /dev/null +++ b/lib/Service/CustomField/CustomFieldServicesFactory.php @@ -0,0 +1,28 @@ +tenantCustomFieldService ??= new TenantCustomFieldService($this->tenantRepository); + } + + public function createUserCustomFieldValueService(): UserCustomFieldValueService + { + return $this->userCustomFieldValueService ??= new UserCustomFieldValueService($this->userScopeGateway); + } +} diff --git a/lib/Service/CustomField/TenantCustomFieldService.php b/lib/Service/CustomField/TenantCustomFieldService.php index 21bd12c..b0506ec 100644 --- a/lib/Service/CustomField/TenantCustomFieldService.php +++ b/lib/Service/CustomField/TenantCustomFieldService.php @@ -10,7 +10,12 @@ class TenantCustomFieldService { private const ALLOWED_TYPES = ['text', 'textarea', 'select', 'multiselect', 'boolean', 'date']; - public static function listForTenant(int $tenantId): array + public function __construct( + private readonly TenantRepository $tenantRepository + ) { + } + + public function listForTenant(int $tenantId): array { $tenantId = (int) $tenantId; if ($tenantId <= 0) { @@ -44,9 +49,9 @@ class TenantCustomFieldService return $definitions; } - public static function createForTenant(int $tenantId, array $input, int $currentUserId): array + public function createForTenant(int $tenantId, array $input, int $currentUserId): array { - if ($tenantId <= 0 || !(new TenantRepository())->find($tenantId)) { + if ($tenantId <= 0 || !$this->tenantRepository->find($tenantId)) { return ['ok' => false, 'errors' => [t('Tenant not found')]]; } @@ -85,7 +90,7 @@ class TenantCustomFieldService return ['ok' => true, 'id' => (int) $definitionId, 'form' => $form]; } - public static function updateByUuid(string $uuid, array $input, int $currentUserId): array + public function updateByUuid(string $uuid, array $input, int $currentUserId): array { $uuid = trim($uuid); if ($uuid === '') { @@ -133,7 +138,7 @@ class TenantCustomFieldService return ['ok' => true, 'form' => $form]; } - public static function deleteByUuid(string $uuid): array + public function deleteByUuid(string $uuid): array { $uuid = trim($uuid); if ($uuid === '') { @@ -150,7 +155,7 @@ class TenantCustomFieldService return ['ok' => true, 'definition' => $existing]; } - public static function definitionTenantIdByUuid(string $uuid): int + public function definitionTenantIdByUuid(string $uuid): int { $definition = TenantCustomFieldDefinitionRepository::findByUuid(trim($uuid)); return (int) ($definition['tenant_id'] ?? 0); diff --git a/lib/Service/CustomField/UserCustomFieldValueService.php b/lib/Service/CustomField/UserCustomFieldValueService.php index 15e790d..a74561d 100644 --- a/lib/Service/CustomField/UserCustomFieldValueService.php +++ b/lib/Service/CustomField/UserCustomFieldValueService.php @@ -11,7 +11,12 @@ use MintyPHP\Service\User\UserScopeGateway; class UserCustomFieldValueService { - public static function validateForTenants(array $tenantIds, array $post, bool $canEdit): array + public function __construct( + private readonly UserScopeGateway $userScopeGateway + ) { + } + + public function validateForTenants(array $tenantIds, array $post, bool $canEdit): array { if (!$canEdit) { return ['ok' => true, 'errors' => []]; @@ -20,14 +25,14 @@ class UserCustomFieldValueService if (!$tenantIds) { return ['ok' => true, 'errors' => []]; } - $prepared = self::prepareForSync($tenantIds, $post); + $prepared = $this->prepareForSync($tenantIds, $post); if ($prepared['errors']) { return ['ok' => false, 'errors' => $prepared['errors']]; } return ['ok' => true, 'errors' => []]; } - public static function buildDefinitionsByTenant(array $tenantIds, bool $includeInactive = false): array + public function buildDefinitionsByTenant(array $tenantIds, bool $includeInactive = false): array { $tenantIds = self::normalizeTenantIds($tenantIds); if (!$tenantIds) { @@ -69,7 +74,7 @@ class UserCustomFieldValueService return $grouped; } - public static function buildUserValueMap(int $userId, array $definitionIds): array + public function buildUserValueMap(int $userId, array $definitionIds): array { if ($userId <= 0) { return []; @@ -122,7 +127,7 @@ class UserCustomFieldValueService * from user assignment context, each with at least id/uuid/description/status. * @return array> */ - public static function buildPublicValuesByTenant(int $userId, array $tenantSummaries, ?int $tenantScopeId = null): array + public function buildPublicValuesByTenant(int $userId, array $tenantSummaries, ?int $tenantScopeId = null): array { if ($userId <= 0) { return []; @@ -154,7 +159,7 @@ class UserCustomFieldValueService } $tenantIds = array_keys($orderedTenants); - $definitionsByTenant = self::buildDefinitionsByTenant($tenantIds, false); + $definitionsByTenant = $this->buildDefinitionsByTenant($tenantIds, false); if (!$definitionsByTenant) { return []; } @@ -169,7 +174,7 @@ class UserCustomFieldValueService } } $definitionIds = array_values(array_unique($definitionIds)); - $valueMap = self::buildUserValueMap($userId, $definitionIds); + $valueMap = $this->buildUserValueMap($userId, $definitionIds); $result = []; foreach ($tenantIds as $tenantId) { @@ -239,7 +244,7 @@ class UserCustomFieldValueService return $result; } - public static function syncForUser(int $userId, array $tenantIds, array $post, bool $canEdit): array + public function syncForUser(int $userId, array $tenantIds, array $post, bool $canEdit): array { if ($userId <= 0) { return ['ok' => false, 'errors' => [t('User not found')]]; @@ -258,7 +263,7 @@ class UserCustomFieldValueService return ['ok' => true, 'errors' => []]; } - $prepared = self::prepareForSync($tenantIds, $post); + $prepared = $this->prepareForSync($tenantIds, $post); if ($prepared['errors']) { return ['ok' => false, 'errors' => $prepared['errors']]; } @@ -306,9 +311,9 @@ class UserCustomFieldValueService return ['ok' => true, 'errors' => []]; } - private static function prepareForSync(array $tenantIds, array $post): array + private function prepareForSync(array $tenantIds, array $post): array { - $definitionsByTenant = self::buildDefinitionsByTenant($tenantIds, false); + $definitionsByTenant = $this->buildDefinitionsByTenant($tenantIds, false); $definitions = []; foreach ($definitionsByTenant as $tenantDefinitionList) { foreach ($tenantDefinitionList as $definition) { @@ -404,9 +409,9 @@ class UserCustomFieldValueService return ['prepared' => $prepared, 'errors' => $errors]; } - public static function extractAddressBookFilterSpec(array $query, int $currentUserId): array + public function extractAddressBookFilterSpec(array $query, int $currentUserId): array { - $tenantIds = (new UserScopeGateway())->getUserTenantIds($currentUserId); + $tenantIds = $this->userScopeGateway()->getUserTenantIds($currentUserId); $definitions = TenantCustomFieldDefinitionRepository::listFilterableByTenantIds($tenantIds); if (!$definitions) { return ['definitions' => [], 'filters' => [], 'query' => []]; @@ -519,6 +524,11 @@ class UserCustomFieldValueService ]; } + private function userScopeGateway(): UserScopeGateway + { + return $this->userScopeGateway; + } + private static function normalizeTenantIds(array $tenantIds): array { $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); diff --git a/lib/Service/Directory/DirectoryGatewayFactory.php b/lib/Service/Directory/DirectoryGatewayFactory.php new file mode 100644 index 0000000..960dc1d --- /dev/null +++ b/lib/Service/Directory/DirectoryGatewayFactory.php @@ -0,0 +1,30 @@ +directorySettingsGateway ??= new DirectorySettingsGateway( + $this->settingServicesFactory->createSettingGateway() + ); + } + + public function createDirectoryScopeGateway(): DirectoryScopeGateway + { + return $this->directoryScopeGateway ??= new DirectoryScopeGateway($this->tenantScopeService); + } +} diff --git a/lib/Service/Directory/DirectoryRepositoryFactory.php b/lib/Service/Directory/DirectoryRepositoryFactory.php new file mode 100644 index 0000000..c6eeb21 --- /dev/null +++ b/lib/Service/Directory/DirectoryRepositoryFactory.php @@ -0,0 +1,29 @@ +tenantRepository ??= new TenantRepository(); + } + + public function createDepartmentRepository(): DepartmentRepository + { + return $this->departmentRepository ??= new DepartmentRepository(); + } + + public function createRoleRepository(): RoleRepository + { + return $this->roleRepository ??= new RoleRepository(); + } +} diff --git a/lib/Service/Directory/DirectoryScopeGateway.php b/lib/Service/Directory/DirectoryScopeGateway.php index 32c6158..b33f83e 100644 --- a/lib/Service/Directory/DirectoryScopeGateway.php +++ b/lib/Service/Directory/DirectoryScopeGateway.php @@ -3,42 +3,42 @@ namespace MintyPHP\Service\Directory; use MintyPHP\Service\Tenant\TenantScopeService; -use MintyPHP\Service\Tenant\TenantServicesFactory; class DirectoryScopeGateway { - public function __construct(private readonly ?TenantScopeService $tenantScopeService = null) - { + public function __construct( + private readonly TenantScopeService $tenantScopeService + ) { } public function hasGlobalAccess(int $userId): bool { - return $this->tenantScopeService()->hasGlobalAccess($userId); + return $this->tenantScopeService->hasGlobalAccess($userId); } public function getUserTenantIds(int $userId): array { - return $this->tenantScopeService()->getUserTenantIds($userId); + return $this->tenantScopeService->getUserTenantIds($userId); } public function isStrict(): bool { - return $this->tenantScopeService()->isStrict(); + return $this->tenantScopeService->isStrict(); } public function canAccess(string $resourceType, int $resourceId, int $userId): bool { - return $this->tenantScopeService()->canAccess($resourceType, $resourceId, $userId); + return $this->tenantScopeService->canAccess($resourceType, $resourceId, $userId); } public function resourceBelongsToTenant(string $resourceType, int $resourceId, int $tenantId): bool { - return $this->tenantScopeService()->resourceBelongsToTenant($resourceType, $resourceId, $tenantId); + return $this->tenantScopeService->resourceBelongsToTenant($resourceType, $resourceId, $tenantId); } public function filterTenantIdsForUser(array $tenantIds, int $userId): array { - return $this->tenantScopeService()->filterTenantIdsForUser($tenantIds, $userId); + return $this->tenantScopeService->filterTenantIdsForUser($tenantIds, $userId); } public function mergeTenantIdsPreservingOutOfScope( @@ -46,19 +46,10 @@ class DirectoryScopeGateway array $existingTenantIds, array $allowedTenantIds ): array { - return $this->tenantScopeService()->mergeTenantIdsPreservingOutOfScope( + return $this->tenantScopeService->mergeTenantIdsPreservingOutOfScope( $requestedTenantIds, $existingTenantIds, $allowedTenantIds ); } - - private function tenantScopeService(): TenantScopeService - { - if ($this->tenantScopeService instanceof TenantScopeService) { - return $this->tenantScopeService; - } - - return (new TenantServicesFactory())->createTenantScopeService(); - } } diff --git a/lib/Service/Directory/DirectoryServicesFactory.php b/lib/Service/Directory/DirectoryServicesFactory.php index 915a1e0..4a7f7f4 100644 --- a/lib/Service/Directory/DirectoryServicesFactory.php +++ b/lib/Service/Directory/DirectoryServicesFactory.php @@ -6,42 +6,42 @@ use MintyPHP\Repository\Access\RoleRepository; use MintyPHP\Repository\Org\DepartmentRepository; use MintyPHP\Repository\Tenant\TenantRepository; use MintyPHP\Service\Access\RoleService; +use MintyPHP\Service\Audit\AuditServicesFactory; use MintyPHP\Service\Org\DepartmentService; -use MintyPHP\Service\Settings\SettingServicesFactory; -use MintyPHP\Service\Tenant\TenantServicesFactory; use MintyPHP\Service\Tenant\TenantService; use MintyPHP\Service\User\UserServicesFactory; class DirectoryServicesFactory { - private ?TenantRepository $tenantRepository = null; - private ?DepartmentRepository $departmentRepository = null; - private ?RoleRepository $roleRepository = null; - private ?DirectorySettingsGateway $directorySettingsGateway = null; - private ?DirectoryScopeGateway $directoryScopeGateway = null; - private ?TenantServicesFactory $tenantServicesFactory = null; private ?TenantService $tenantService = null; private ?DepartmentService $departmentService = null; private ?RoleService $roleService = null; - private ?UserServicesFactory $userServicesFactory = null; - private ?SettingServicesFactory $settingServicesFactory = null; + + public function __construct( + private readonly UserServicesFactory $userServicesFactory, + private readonly AuditServicesFactory $auditServicesFactory, + private readonly DirectoryRepositoryFactory $directoryRepositoryFactory, + private readonly DirectoryGatewayFactory $directoryGatewayFactory + ) {} public function createTenantService(): TenantService { return $this->tenantService ??= new TenantService( $this->createTenantRepository(), $this->createDepartmentRepository(), - $this->createDirectorySettingsGateway() + $this->createDirectorySettingsGateway(), + $this->auditServicesFactory->createSystemAuditService() ); } public function createDepartmentService(): DepartmentService { return $this->departmentService ??= new DepartmentService( + $this->userServicesFactory, $this->createDepartmentRepository(), - $this->userServicesFactory(), $this->createDirectorySettingsGateway(), - $this->createDirectoryScopeGateway() + $this->createDirectoryScopeGateway(), + $this->auditServicesFactory->createSystemAuditService() ); } @@ -49,51 +49,33 @@ class DirectoryServicesFactory { return $this->roleService ??= new RoleService( $this->createRoleRepository(), - $this->createDirectorySettingsGateway() + $this->createDirectorySettingsGateway(), + $this->auditServicesFactory->createSystemAuditService() ); } public function createTenantRepository(): TenantRepository { - return $this->tenantRepository ??= new TenantRepository(); + return $this->directoryRepositoryFactory->createTenantRepository(); } public function createDepartmentRepository(): DepartmentRepository { - return $this->departmentRepository ??= new DepartmentRepository(); + return $this->directoryRepositoryFactory->createDepartmentRepository(); } public function createRoleRepository(): RoleRepository { - return $this->roleRepository ??= new RoleRepository(); + return $this->directoryRepositoryFactory->createRoleRepository(); } public function createDirectorySettingsGateway(): DirectorySettingsGateway { - return $this->directorySettingsGateway ??= new DirectorySettingsGateway( - $this->settingServicesFactory()->createSettingGateway() - ); + return $this->directoryGatewayFactory->createDirectorySettingsGateway(); } public function createDirectoryScopeGateway(): DirectoryScopeGateway { - return $this->directoryScopeGateway ??= new DirectoryScopeGateway( - $this->tenantServicesFactory()->createTenantScopeService() - ); - } - - private function userServicesFactory(): UserServicesFactory - { - return $this->userServicesFactory ??= new UserServicesFactory(); - } - - private function settingServicesFactory(): SettingServicesFactory - { - return $this->settingServicesFactory ??= new SettingServicesFactory(); - } - - private function tenantServicesFactory(): TenantServicesFactory - { - return $this->tenantServicesFactory ??= new TenantServicesFactory(); + return $this->directoryGatewayFactory->createDirectoryScopeGateway(); } } diff --git a/lib/Service/Docs/DocsCatalogService.php b/lib/Service/Docs/DocsCatalogService.php index c094ac5..0ce40b7 100644 --- a/lib/Service/Docs/DocsCatalogService.php +++ b/lib/Service/Docs/DocsCatalogService.php @@ -30,6 +30,12 @@ class DocsCatalogService private static function humanizeGroupLabel(string $groupHeading): string { $knownGroups = [ + 'Lernpfad (chronologisch)' => t('Learning path'), + 'Konzepte (Explanation)' => t('Concepts'), + 'Aufgabenanleitungen (How-to)' => t('How-to guides'), + 'Referenz (Reference)' => t('Reference'), + 'Betrieb (Operations)' => t('Operations'), + 'Mitwirken (Contributor Guide)' => t('Contributor guide'), 'Start hier (neue Entwickler)' => t('Getting started'), 'Architektur und Regeln' => t('Architecture & rules'), 'Frontend' => t('Frontend'), @@ -189,7 +195,7 @@ class DocsCatalogService public static function defaultSlug(): string { - return array_key_first(self::allDocs()) ?: 'erste-schritte'; + return array_key_first(self::allDocs()) ?: '00-systemueberblick'; } public static function docPathBySlug(string $slug): ?string diff --git a/lib/Service/Import/ImportRepositoryFactory.php b/lib/Service/Import/ImportRepositoryFactory.php new file mode 100644 index 0000000..239a5cb --- /dev/null +++ b/lib/Service/Import/ImportRepositoryFactory.php @@ -0,0 +1,15 @@ +importAuditRunRepository ??= new ImportAuditRunRepository(); + } +} diff --git a/lib/Service/Import/ImportServicesFactory.php b/lib/Service/Import/ImportServicesFactory.php index 48a2a1e..a25b395 100644 --- a/lib/Service/Import/ImportServicesFactory.php +++ b/lib/Service/Import/ImportServicesFactory.php @@ -6,27 +6,34 @@ use MintyPHP\Repository\Audit\ImportAuditRunRepository; use MintyPHP\Service\Access\AccessServicesFactory; use MintyPHP\Service\Access\PermissionGateway; use MintyPHP\Service\Audit\ImportAuditService; +use MintyPHP\Service\Directory\DirectoryServicesFactory; use MintyPHP\Service\Import\Profile\DepartmentImportGateway; use MintyPHP\Service\Import\Profile\DepartmentImportProfile; use MintyPHP\Service\Import\Profile\UserImportGateway; use MintyPHP\Service\Import\Profile\UserImportProfile; use MintyPHP\Service\Settings\SettingGateway; use MintyPHP\Service\Settings\SettingServicesFactory; +use MintyPHP\Service\User\UserRepositoryFactory; use MintyPHP\Service\User\UserServicesFactory; class ImportServicesFactory { - private ?AccessServicesFactory $accessServicesFactory = null; private ?CsvReaderService $csvReaderService = null; private ?ImportTempFileService $importTempFileService = null; private ?ImportStateStoreService $importStateStoreService = null; - private ?ImportAuditRunRepository $importAuditRunRepository = null; private ?ImportAuditService $importAuditService = null; private ?PermissionGateway $permissionGateway = null; - private ?SettingServicesFactory $settingServicesFactory = null; private ?SettingGateway $settingGateway = null; private ?ImportService $importService = null; - private ?UserServicesFactory $userServicesFactory = null; + + public function __construct( + private readonly UserServicesFactory $userServicesFactory, + private readonly AccessServicesFactory $accessServicesFactory, + private readonly SettingServicesFactory $settingServicesFactory, + private readonly UserRepositoryFactory $userRepositoryFactory, + private readonly DirectoryServicesFactory $directoryServicesFactory, + private readonly ImportRepositoryFactory $importRepositoryFactory + ) {} public function createImportService(): ImportService { @@ -36,10 +43,15 @@ class ImportServicesFactory $profiles = [ 'users' => new UserImportProfile(new UserImportGateway( - $this->getUserServicesFactory()->createUserReadRepository(), - $this->getUserServicesFactory()->createUserAccountService() + $this->userServicesFactory->createUserReadRepository(), + $this->userServicesFactory->createUserAccountService(), + $this->userRepositoryFactory + )), + 'departments' => new DepartmentImportProfile(new DepartmentImportGateway( + $this->directoryServicesFactory->createDepartmentRepository(), + $this->directoryServicesFactory->createTenantRepository(), + $this->directoryServicesFactory->createDepartmentService() )), - 'departments' => new DepartmentImportProfile(new DepartmentImportGateway()), ]; return $this->importService = new ImportService( @@ -50,7 +62,7 @@ class ImportServicesFactory $profiles, $this->createPermissionGateway(), $this->createSettingGateway(), - $this->getUserServicesFactory()->createUserScopeGateway() + $this->userServicesFactory->createUserScopeGateway() ); } @@ -76,31 +88,16 @@ class ImportServicesFactory private function getImportAuditRunRepository(): ImportAuditRunRepository { - return $this->importAuditRunRepository ??= new ImportAuditRunRepository(); - } - - private function getUserServicesFactory(): UserServicesFactory - { - return $this->userServicesFactory ??= new UserServicesFactory(); + return $this->importRepositoryFactory->createImportAuditRunRepository(); } private function createPermissionGateway(): PermissionGateway { - return $this->permissionGateway ??= $this->accessServicesFactory()->createPermissionGateway(); - } - - private function accessServicesFactory(): AccessServicesFactory - { - return $this->accessServicesFactory ??= new AccessServicesFactory(); + return $this->permissionGateway ??= $this->accessServicesFactory->createPermissionGateway(); } private function createSettingGateway(): SettingGateway { - return $this->settingGateway ??= $this->settingServicesFactory()->createSettingGateway(); - } - - private function settingServicesFactory(): SettingServicesFactory - { - return $this->settingServicesFactory ??= new SettingServicesFactory(); + return $this->settingGateway ??= $this->settingServicesFactory->createSettingGateway(); } } diff --git a/lib/Service/Import/Profile/DepartmentImportGateway.php b/lib/Service/Import/Profile/DepartmentImportGateway.php index 4c48303..2a0daa7 100644 --- a/lib/Service/Import/Profile/DepartmentImportGateway.php +++ b/lib/Service/Import/Profile/DepartmentImportGateway.php @@ -9,9 +9,9 @@ use MintyPHP\Service\Org\DepartmentService; class DepartmentImportGateway { public function __construct( - private readonly ?DepartmentRepository $departmentRepository = null, - private readonly ?TenantRepository $tenantRepository = null, - private readonly ?DepartmentService $departmentService = null + private readonly DepartmentRepository $departmentRepository, + private readonly TenantRepository $tenantRepository, + private readonly DepartmentService $departmentService ) { } @@ -46,16 +46,16 @@ class DepartmentImportGateway private function departmentRepository(): DepartmentRepository { - return $this->departmentRepository ?? new DepartmentRepository(); + return $this->departmentRepository; } private function tenantRepository(): TenantRepository { - return $this->tenantRepository ?? new TenantRepository(); + return $this->tenantRepository; } private function departmentService(): DepartmentService { - return $this->departmentService ?? new DepartmentService(); + return $this->departmentService; } } diff --git a/lib/Service/Import/Profile/UserImportGateway.php b/lib/Service/Import/Profile/UserImportGateway.php index 2d90aee..d82ec44 100644 --- a/lib/Service/Import/Profile/UserImportGateway.php +++ b/lib/Service/Import/Profile/UserImportGateway.php @@ -7,12 +7,14 @@ use MintyPHP\Repository\Org\DepartmentRepository; use MintyPHP\Repository\Tenant\TenantRepository; use MintyPHP\Repository\User\UserReadRepository; use MintyPHP\Service\User\UserAccountService; +use MintyPHP\Service\User\UserRepositoryFactory; class UserImportGateway { public function __construct( private readonly UserReadRepository $userReadRepository, - private readonly UserAccountService $userAccountService + private readonly UserAccountService $userAccountService, + private readonly UserRepositoryFactory $userRepositoryFactory ) { } @@ -23,32 +25,32 @@ class UserImportGateway public function findTenantByUuid(string $uuid): ?array { - return (new TenantRepository())->findByUuid($uuid); + return $this->tenantRepository()->findByUuid($uuid); } public function findTenantById(int $id): ?array { - return (new TenantRepository())->find($id); + return $this->tenantRepository()->find($id); } public function findRoleByUuid(string $uuid): ?array { - return (new RoleRepository())->findByUuid($uuid); + return $this->roleRepository()->findByUuid($uuid); } public function findRoleById(int $id): ?array { - return (new RoleRepository())->find($id); + return $this->roleRepository()->find($id); } public function findDepartmentByUuid(string $uuid): ?array { - return (new DepartmentRepository())->findByUuid($uuid); + return $this->departmentRepository()->findByUuid($uuid); } public function findDepartmentById(int $id): ?array { - return (new DepartmentRepository())->find($id); + return $this->departmentRepository()->find($id); } /** @@ -59,4 +61,19 @@ class UserImportGateway { return $this->userAccountService->createFromAdmin($input, $currentUserId); } + + private function tenantRepository(): TenantRepository + { + return $this->userRepositoryFactory->createTenantRepository(); + } + + private function roleRepository(): RoleRepository + { + return $this->userRepositoryFactory->createRoleRepository(); + } + + private function departmentRepository(): DepartmentRepository + { + return $this->userRepositoryFactory->createDepartmentRepository(); + } } diff --git a/lib/Service/Mail/MailRepositoryFactory.php b/lib/Service/Mail/MailRepositoryFactory.php new file mode 100644 index 0000000..0b012ee --- /dev/null +++ b/lib/Service/Mail/MailRepositoryFactory.php @@ -0,0 +1,15 @@ +mailLogRepository ??= new MailLogRepository(); + } +} diff --git a/lib/Service/Mail/MailService.php b/lib/Service/Mail/MailService.php index 9e5b691..61e05bb 100644 --- a/lib/Service/Mail/MailService.php +++ b/lib/Service/Mail/MailService.php @@ -2,6 +2,7 @@ namespace MintyPHP\Service\Mail; +use MintyPHP\Domain\Taxonomy\MailLogStatus; use MintyPHP\I18n; use MintyPHP\Repository\Mail\MailLogRepository; use MintyPHP\Service\Settings\SettingGateway; @@ -42,7 +43,7 @@ class MailService 'to_email' => $to, 'subject' => $subject, 'template' => $meta['template'] ?? null, - 'status' => 'queued', + 'status' => MailLogStatus::Queued->value, ]); if (!class_exists(PHPMailer::class)) { diff --git a/lib/Service/Mail/MailServicesFactory.php b/lib/Service/Mail/MailServicesFactory.php index 46fb09c..eef0a4b 100644 --- a/lib/Service/Mail/MailServicesFactory.php +++ b/lib/Service/Mail/MailServicesFactory.php @@ -4,19 +4,20 @@ namespace MintyPHP\Service\Mail; use MintyPHP\Repository\Mail\MailLogRepository; use MintyPHP\Service\Settings\SettingGateway; -use MintyPHP\Service\Settings\SettingServicesFactory; class MailServicesFactory { - private ?MailLogRepository $mailLogRepository = null; - private ?SettingServicesFactory $settingServicesFactory = null; - private ?SettingGateway $settingGateway = null; private ?MailService $mailService = null; private ?MailLogService $mailLogService = null; + public function __construct( + private readonly MailRepositoryFactory $mailRepositoryFactory, + private readonly SettingGateway $settingGateway + ) {} + public function createMailLogRepository(): MailLogRepository { - return $this->mailLogRepository ??= new MailLogRepository(); + return $this->mailRepositoryFactory->createMailLogRepository(); } public function createMailService(): MailService @@ -34,11 +35,6 @@ class MailServicesFactory private function createSettingGateway(): SettingGateway { - return $this->settingGateway ??= $this->settingServicesFactory()->createSettingGateway(); - } - - private function settingServicesFactory(): SettingServicesFactory - { - return $this->settingServicesFactory ??= new SettingServicesFactory(); + return $this->settingGateway; } } diff --git a/lib/Service/Org/DepartmentService.php b/lib/Service/Org/DepartmentService.php index b45c981..fbb897f 100644 --- a/lib/Service/Org/DepartmentService.php +++ b/lib/Service/Org/DepartmentService.php @@ -3,40 +3,41 @@ namespace MintyPHP\Service\Org; use MintyPHP\Repository\Org\DepartmentRepository; +use MintyPHP\Service\Audit\SystemAuditService; use MintyPHP\Service\Directory\DirectoryScopeGateway; use MintyPHP\Service\Directory\DirectorySettingsGateway; -use MintyPHP\Service\Settings\SettingServicesFactory; use MintyPHP\Service\User\UserServicesFactory; class DepartmentService { public function __construct( - private readonly ?DepartmentRepository $departmentRepository = null, - private readonly ?UserServicesFactory $userServicesFactory = null, - private readonly ?DirectorySettingsGateway $settingsGateway = null, - private readonly ?DirectoryScopeGateway $scopeGateway = null + private readonly UserServicesFactory $userServicesFactory, + private readonly DepartmentRepository $departmentRepository, + private readonly DirectorySettingsGateway $settingsGateway, + private readonly DirectoryScopeGateway $scopeGateway, + private readonly SystemAuditService $systemAuditService ) { } public function list(): array { - return $this->departmentRepository()->list(); + return $this->departmentRepository->list(); } public function listPaged(array $options): array { if (!empty($options['tenantUserId'])) { $tenantUserId = (int) $options['tenantUserId']; - if ($tenantUserId > 0 && $this->scopeGateway()->hasGlobalAccess($tenantUserId)) { + if ($tenantUserId > 0 && $this->scopeGateway->hasGlobalAccess($tenantUserId)) { unset($options['tenantUserId'], $options['tenantIds']); } } - return $this->departmentRepository()->listPaged($options); + return $this->departmentRepository->listPaged($options); } public function listByTenantIds(array $tenantIds): array { - return $this->departmentRepository()->listByTenantIds($tenantIds); + return $this->departmentRepository->listByTenantIds($tenantIds); } public function groupActiveByTenantIds(array $tenantIds): array @@ -74,7 +75,7 @@ class DepartmentService public function listByIds(array $departmentIds): array { - return $this->departmentRepository()->listByIds($departmentIds); + return $this->departmentRepository->listByIds($departmentIds); } public function listForUserAssignments(array $tenantIds, array $selectedDepartmentIds): array @@ -100,12 +101,12 @@ class DepartmentService public function findByUuid(string $uuid): ?array { - return $this->departmentRepository()->findByUuid($uuid); + return $this->departmentRepository->findByUuid($uuid); } public function findById(int $id): ?array { - return $this->departmentRepository()->find($id); + return $this->departmentRepository->find($id); } public function createFromAdmin(array $input, int $currentUserId = 0): array @@ -118,7 +119,7 @@ class DepartmentService return ['ok' => false, 'errors' => $errors, 'warnings' => $warnings, 'form' => $form]; } - $createdId = $this->departmentRepository()->create([ + $createdId = $this->departmentRepository->create([ 'description' => $form['description'], 'tenant_id' => $form['tenant_id'], 'code' => $form['code'] ?: null, @@ -131,11 +132,20 @@ class DepartmentService return ['ok' => false, 'errors' => [t('Department can not be created')], 'form' => $form]; } - $createdDepartment = $this->departmentRepository()->find((int) $createdId); + $createdDepartment = $this->departmentRepository->find((int) $createdId); $uuid = $createdDepartment['uuid'] ?? null; if (!empty($input['is_default'])) { - $this->settingsGateway()->setDefaultDepartmentId((int) $createdId); + $this->settingsGateway->setDefaultDepartmentId((int) $createdId); } + + $this->systemAuditService->record('admin.departments.create', 'success', [ + 'actor_user_id' => $currentUserId > 0 ? $currentUserId : null, + 'target_type' => 'department', + 'target_id' => (int) $createdId, + 'target_uuid' => is_string($uuid) ? $uuid : '', + 'metadata' => ['tenant_id' => $form['tenant_id']], + ]); + return ['ok' => true, 'form' => $form, 'warnings' => $warnings, 'uuid' => $uuid, 'id' => (int) $createdId]; } @@ -149,7 +159,9 @@ class DepartmentService return ['ok' => false, 'errors' => $errors, 'warnings' => $warnings, 'form' => $form]; } - $updated = $this->departmentRepository()->update($departmentId, [ + $existingBefore = $this->departmentRepository->find($departmentId) ?? []; + + $updated = $this->departmentRepository->update($departmentId, [ 'description' => $form['description'], 'tenant_id' => $form['tenant_id'], 'code' => $form['code'] ?: null, @@ -162,12 +174,27 @@ class DepartmentService return ['ok' => false, 'errors' => [t('Department can not be updated')], 'form' => $form]; } + $this->systemAuditService->record('admin.departments.update', 'success', [ + 'actor_user_id' => $currentUserId > 0 ? $currentUserId : null, + 'target_type' => 'department', + 'target_id' => $departmentId, + 'target_uuid' => (string) ($existingBefore['uuid'] ?? ''), + 'before' => [ + 'active' => $existingBefore['active'] ?? null, + 'tenant_id' => $existingBefore['tenant_id'] ?? null, + ], + 'after' => [ + 'active' => $form['active'], + 'tenant_id' => $form['tenant_id'], + ], + ]); + return ['ok' => true, 'form' => $form, 'warnings' => $warnings]; } public function syncTenant(int $departmentId, int $tenantId): int { - $updated = $this->departmentRepository()->setTenant($departmentId, $tenantId); + $updated = $this->departmentRepository->setTenant($departmentId, $tenantId); if (!$updated) { return -1; } @@ -185,7 +212,7 @@ class DepartmentService public function cleanupUserAssignments(int $departmentId): int { - return $this->userServicesFactory() + return $this->userServicesFactory ->createUserDepartmentRepository() ->removeInvalidForDepartment($departmentId); } @@ -197,16 +224,22 @@ class DepartmentService return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } - $department = $this->departmentRepository()->findByUuid($uuid); + $department = $this->departmentRepository->findByUuid($uuid); if (!$department || !isset($department['id'])) { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } - $deleted = $this->departmentRepository()->delete((int) $department['id']); + $deleted = $this->departmentRepository->delete((int) $department['id']); if (!$deleted) { return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; } + $this->systemAuditService->record('admin.departments.delete', 'success', [ + 'target_type' => 'department', + 'target_id' => (int) $department['id'], + 'target_uuid' => (string) ($department['uuid'] ?? ''), + ]); + return ['ok' => true, 'department' => $department]; } @@ -237,7 +270,7 @@ class DepartmentService { $warnings = []; $code = $form['code'] ?? ''; - if ($code !== '' && $this->departmentRepository()->existsByCode($code, $excludeId)) { + if ($code !== '' && $this->departmentRepository->existsByCode($code, $excludeId)) { $warnings[] = t('Department code already exists'); } return $warnings; @@ -252,29 +285,4 @@ class DepartmentService return 1; } - private function departmentRepository(): DepartmentRepository - { - return $this->departmentRepository ?? new DepartmentRepository(); - } - - private function userServicesFactory(): UserServicesFactory - { - return $this->userServicesFactory ?? new UserServicesFactory(); - } - - private function settingsGateway(): DirectorySettingsGateway - { - if ($this->settingsGateway instanceof DirectorySettingsGateway) { - return $this->settingsGateway; - } - - return new DirectorySettingsGateway( - (new SettingServicesFactory())->createSettingGateway() - ); - } - - private function scopeGateway(): DirectoryScopeGateway - { - return $this->scopeGateway ?? new DirectoryScopeGateway(); - } } diff --git a/lib/Service/Scheduler/Handler/SystemAuditPurgeJobHandler.php b/lib/Service/Scheduler/Handler/SystemAuditPurgeJobHandler.php new file mode 100644 index 0000000..0bfcb4d --- /dev/null +++ b/lib/Service/Scheduler/Handler/SystemAuditPurgeJobHandler.php @@ -0,0 +1,42 @@ + 'System audit purge', + 'description' => 'Purges system audit entries by retention policy', + 'default_enabled' => 1, + 'default_timezone' => defined('APP_TIMEZONE') ? (string) APP_TIMEZONE : 'UTC', + 'default_schedule_type' => 'daily', + 'default_schedule_interval' => 1, + 'default_schedule_time' => '03:00', + 'default_schedule_weekdays_csv' => null, + 'default_catchup_once' => 1, + 'allowed_schedule_types' => ['hourly', 'daily', 'weekly'], + ]; + } + + public function execute(?int $actorUserId): array + { + $deleted = $this->systemAuditService->purgeExpired(); + + return [ + 'status' => 'success', + 'error_code' => null, + 'error_message' => null, + 'result' => [ + 'deleted_count' => $deleted, + ], + ]; + } +} diff --git a/lib/Service/Scheduler/ScheduledJobRegistry.php b/lib/Service/Scheduler/ScheduledJobRegistry.php index d13a300..bca69a3 100644 --- a/lib/Service/Scheduler/ScheduledJobRegistry.php +++ b/lib/Service/Scheduler/ScheduledJobRegistry.php @@ -3,12 +3,15 @@ namespace MintyPHP\Service\Scheduler; use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface; +use MintyPHP\Service\Scheduler\Handler\SystemAuditPurgeJobHandler; use MintyPHP\Service\Scheduler\Handler\UserLifecycleJobHandler; +use MintyPHP\Service\Audit\SystemAuditService; use MintyPHP\Service\User\UserLifecycleService; class ScheduledJobRegistry { public const USER_LIFECYCLE_RUN = 'user_lifecycle_run'; + public const SYSTEM_AUDIT_PURGE = 'system_audit_purge'; /** * Maps job_key => handler instance (must implement ScheduledJobHandlerInterface). @@ -24,10 +27,14 @@ class ScheduledJobRegistry */ private array $handlers; - public function __construct(UserLifecycleService $userLifecycleService) + public function __construct( + UserLifecycleService $userLifecycleService, + SystemAuditService $systemAuditService + ) { $this->handlers = [ self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService), + self::SYSTEM_AUDIT_PURGE => new SystemAuditPurgeJobHandler($systemAuditService), ]; } diff --git a/lib/Service/Scheduler/SchedulerRepositoryFactory.php b/lib/Service/Scheduler/SchedulerRepositoryFactory.php new file mode 100644 index 0000000..c09eb81 --- /dev/null +++ b/lib/Service/Scheduler/SchedulerRepositoryFactory.php @@ -0,0 +1,29 @@ +scheduledJobRepository ??= new ScheduledJobRepository(); + } + + public function createScheduledJobRunRepository(): ScheduledJobRunRepository + { + return $this->scheduledJobRunRepository ??= new ScheduledJobRunRepository(); + } + + public function createSchedulerRuntimeRepository(): SchedulerRuntimeRepository + { + return $this->schedulerRuntimeRepository ??= new SchedulerRuntimeRepository(); + } +} diff --git a/lib/Service/Scheduler/SchedulerRunService.php b/lib/Service/Scheduler/SchedulerRunService.php index 641b03b..be5ad27 100644 --- a/lib/Service/Scheduler/SchedulerRunService.php +++ b/lib/Service/Scheduler/SchedulerRunService.php @@ -2,11 +2,16 @@ namespace MintyPHP\Service\Scheduler; -use MintyPHP\DB; +use MintyPHP\Domain\Taxonomy\ScheduledJobRunStatus; +use MintyPHP\Domain\Taxonomy\ScheduledJobTriggerType; +use MintyPHP\Domain\Taxonomy\SchedulerRuntimeResult; +use MintyPHP\Domain\Taxonomy\SystemAuditOutcome; use MintyPHP\Repository\Scheduler\ScheduledJobRepository; use MintyPHP\Repository\Scheduler\ScheduledJobRunRepository; use MintyPHP\Repository\Scheduler\SchedulerRuntimeRepository; +use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\Support\RepoQuery; +use MintyPHP\Service\Audit\SystemAuditService; /** * Orchestrates the execution of scheduled jobs. @@ -26,7 +31,9 @@ class SchedulerRunService private readonly ScheduledJobRunRepository $scheduledJobRunRepository, private readonly SchedulerRuntimeRepository $schedulerRuntimeRepository, private readonly ScheduledJobRegistry $scheduledJobRegistry, - private readonly ScheduleCalculator $scheduleCalculator + private readonly ScheduleCalculator $scheduleCalculator, + private readonly DatabaseSessionRepository $databaseSessionRepository, + private readonly SystemAuditService $systemAuditService ) { } @@ -46,9 +53,13 @@ class SchedulerRunService if (!$this->acquireRunnerLock()) { $result['ok'] = false; - $result['error'] = 'lock_not_acquired'; + $result['error'] = SchedulerRuntimeResult::LockNotAcquired->value; $result['duration_ms'] = $this->durationMs($startedAt); - $this->updateRuntimeHeartbeat('lock_not_acquired', 'lock_not_acquired'); + $this->updateRuntimeHeartbeat( + SchedulerRuntimeResult::LockNotAcquired->value, + SchedulerRuntimeResult::LockNotAcquired->value + ); + $this->recordSchedulerRunEvent(ScheduledJobTriggerType::Scheduler->value, $result); return $result; } @@ -56,12 +67,15 @@ class SchedulerRunService $nowUtc = gmdate('Y-m-d H:i:s'); $dueJobs = $this->scheduledJobRepository->listDueJobs($nowUtc, $limit); foreach ($dueJobs as $job) { - $run = $this->runOne($job, 'scheduler', null, false); + $run = $this->runOne($job, ScheduledJobTriggerType::Scheduler->value, null, false); $result['processed']++; - $status = (string) ($run['status'] ?? 'failed'); - if ($status === 'success') { + $status = ScheduledJobRunStatus::normalizeOr( + (string) ($run['status'] ?? ''), + ScheduledJobRunStatus::Failed + )->value; + if ($status === ScheduledJobRunStatus::Success->value) { $result['success']++; - } elseif ($status === 'skipped') { + } elseif ($status === ScheduledJobRunStatus::Skipped->value) { $result['skipped']++; } else { $result['failed']++; @@ -69,18 +83,20 @@ class SchedulerRunService } } catch (\Throwable $exception) { $result['ok'] = false; - $result['error'] = 'unexpected_error'; + $result['error'] = SchedulerRuntimeResult::UnexpectedError->value; } finally { $this->releaseRunnerLock(); $result['duration_ms'] = $this->durationMs($startedAt); if ($result['ok']) { - $this->updateRuntimeHeartbeat('ok', null); + $this->updateRuntimeHeartbeat(SchedulerRuntimeResult::Ok->value, null); } else { - $errorCode = $this->nullableString($result['error']) ?? 'unexpected_error'; - $this->updateRuntimeHeartbeat('unexpected_error', $errorCode); + $errorCode = $this->nullableString($result['error']) ?? SchedulerRuntimeResult::UnexpectedError->value; + $this->updateRuntimeHeartbeat(SchedulerRuntimeResult::UnexpectedError->value, $errorCode); } } + $this->recordSchedulerRunEvent(ScheduledJobTriggerType::Scheduler->value, $result); + return $result; } @@ -88,23 +104,40 @@ class SchedulerRunService { $this->scheduledJobService->ensureSystemJobs(); if ($jobId <= 0 || $actorUserId <= 0) { - return ['ok' => false, 'error' => 'invalid_request']; + $result = ['ok' => false, 'error' => 'invalid_request']; + $this->recordSchedulerRunEvent(ScheduledJobTriggerType::Manual->value, $result, $actorUserId); + return $result; } if (!$this->acquireRunnerLock()) { - return ['ok' => false, 'error' => 'lock_not_acquired']; + $result = ['ok' => false, 'error' => SchedulerRuntimeResult::LockNotAcquired->value]; + $this->recordSchedulerRunEvent(ScheduledJobTriggerType::Manual->value, $result, $actorUserId); + return $result; } try { $job = $this->scheduledJobRepository->find($jobId); if (!$job) { - return ['ok' => false, 'error' => 'job_not_found']; + $result = ['ok' => false, 'error' => 'job_not_found']; + $this->recordSchedulerRunEvent(ScheduledJobTriggerType::Manual->value, $result, $actorUserId); + return $result; } - $run = $this->runOne($job, 'manual', $actorUserId, true); - return [ - 'ok' => in_array((string) ($run['status'] ?? ''), ['success', 'skipped'], true), - 'status' => (string) ($run['status'] ?? 'failed'), + $run = $this->runOne($job, ScheduledJobTriggerType::Manual->value, $actorUserId, true); + $runStatus = ScheduledJobRunStatus::normalizeOr( + (string) ($run['status'] ?? ''), + ScheduledJobRunStatus::Failed + )->value; + $result = [ + 'ok' => in_array($runStatus, [ScheduledJobRunStatus::Success->value, ScheduledJobRunStatus::Skipped->value], true), + 'status' => $runStatus, 'error' => (string) ($run['error_code'] ?? ''), ]; + $this->recordSchedulerRunEvent(ScheduledJobTriggerType::Manual->value, $result, $actorUserId, [ + 'processed' => 1, + 'success' => (int) ($result['status'] === ScheduledJobRunStatus::Success->value), + 'failed' => (int) ($result['status'] === ScheduledJobRunStatus::Failed->value), + 'skipped' => (int) ($result['status'] === ScheduledJobRunStatus::Skipped->value), + ]); + return $result; } finally { $this->releaseRunnerLock(); } @@ -116,13 +149,15 @@ class SchedulerRunService $nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $startedAtUtc = $nowUtc->format('Y-m-d H:i:s'); - $triggerType = in_array($triggerType, ['scheduler', 'manual'], true) ? $triggerType : 'scheduler'; + $triggerType = ScheduledJobTriggerType::normalizeOr($triggerType, ScheduledJobTriggerType::Scheduler)->value; if ($jobId <= 0) { - return ['status' => 'failed', 'error_code' => 'job_not_found', 'error_message' => null]; + return ['status' => ScheduledJobRunStatus::Failed->value, 'error_code' => 'job_not_found', 'error_message' => null]; } if (!$force && ((int) ($job['enabled'] ?? 0) !== 1 || empty($job['next_run_at']))) { - return ['status' => 'skipped', 'error_code' => 'job_disabled', 'error_message' => null]; + $run = ['status' => ScheduledJobRunStatus::Skipped->value, 'error_code' => 'job_disabled', 'error_message' => null]; + $this->recordSchedulerJobEvent($jobId, (string) ($job['job_key'] ?? ''), $triggerType, $actorUserId, $run, 0); + return $run; } $markedRunning = $this->scheduledJobRepository->markRunning($jobId, $startedAtUtc); @@ -131,7 +166,7 @@ class SchedulerRunService 'job_id' => $jobId, 'job_key' => (string) ($job['job_key'] ?? ''), 'trigger_type' => $triggerType, - 'status' => 'skipped', + 'status' => ScheduledJobRunStatus::Skipped->value, 'actor_user_id' => $actorUserId, 'started_at' => $startedAtUtc, 'finished_at' => $startedAtUtc, @@ -140,11 +175,13 @@ class SchedulerRunService 'error_message' => null, 'result_json' => null, ]); - return ['status' => 'skipped', 'error_code' => 'job_already_running', 'error_message' => null]; + $run = ['status' => ScheduledJobRunStatus::Skipped->value, 'error_code' => 'job_already_running', 'error_message' => null]; + $this->recordSchedulerJobEvent($jobId, (string) ($job['job_key'] ?? ''), $triggerType, $actorUserId, $run, 0); + return $run; } $startedMs = microtime(true); - $status = 'failed'; + $status = ScheduledJobRunStatus::Failed->value; $errorCode = null; $errorMessage = null; $resultPayload = []; @@ -152,20 +189,19 @@ class SchedulerRunService try { $definition = $this->scheduledJobRegistry->get((string) ($job['job_key'] ?? '')); if (!$definition) { - $status = 'failed'; + $status = ScheduledJobRunStatus::Failed->value; $errorCode = 'job_not_registered'; } else { $execution = $this->scheduledJobRegistry->execute((string) $job['job_key'], $actorUserId); - $status = in_array((string) $execution['status'], ['success', 'failed', 'skipped'], true) - ? (string) $execution['status'] - : 'failed'; + $status = ScheduledJobRunStatus::tryNormalize((string) $execution['status'])?->value + ?? ScheduledJobRunStatus::Failed->value; $errorCode = $this->nullableString($execution['error_code']); $errorMessage = $this->nullableString($execution['error_message']); $resultPayload = $execution['result']; } } catch (\Throwable $exception) { - $status = 'failed'; - $errorCode = 'unexpected_error'; + $status = ScheduledJobRunStatus::Failed->value; + $errorCode = SchedulerRuntimeResult::UnexpectedError->value; $errorMessage = substr($exception->getMessage(), 0, 255) ?: null; } @@ -175,7 +211,11 @@ class SchedulerRunService $nextRunUtc = null; if ((int) ($job['enabled'] ?? 0) === 1) { - if ($triggerType === 'scheduler' && (int) ($job['catchup_once'] ?? 1) === 0 && !empty($job['next_run_at'])) { + if ( + $triggerType === ScheduledJobTriggerType::Scheduler->value + && (int) ($job['catchup_once'] ?? 1) === 0 + && !empty($job['next_run_at']) + ) { // catchup_once = 0: advance next_run_at strictly from the originally scheduled // time, skipping any windows that already passed. This prevents a backlog storm // after downtime - missed slots are dropped, only the next future slot is kept. @@ -222,7 +262,10 @@ class SchedulerRunService 'result_json' => $this->encodeResult($resultPayload), ]); - return ['status' => $status, 'error_code' => $errorCode, 'error_message' => $errorMessage]; + $run = ['status' => $status, 'error_code' => $errorCode, 'error_message' => $errorMessage]; + $this->recordSchedulerJobEvent($jobId, (string) ($job['job_key'] ?? ''), $triggerType, $actorUserId, $run, $durationMs); + + return $run; } private function insertRunLog(array $data): void @@ -232,8 +275,8 @@ class SchedulerRunService 'run_uuid' => RepoQuery::uuidV4(), 'job_id' => (int) ($data['job_id'] ?? 0), 'job_key' => (string) ($data['job_key'] ?? ''), - 'trigger_type' => (string) ($data['trigger_type'] ?? 'scheduler'), - 'status' => (string) ($data['status'] ?? 'failed'), + 'trigger_type' => (string) ($data['trigger_type'] ?? ScheduledJobTriggerType::Scheduler->value), + 'status' => (string) ($data['status'] ?? ScheduledJobRunStatus::Failed->value), 'actor_user_id' => ($data['actor_user_id'] ?? null) !== null ? (int) $data['actor_user_id'] : null, 'started_at' => (string) ($data['started_at'] ?? ''), 'finished_at' => $data['finished_at'] ?? null, @@ -277,8 +320,7 @@ class SchedulerRunService private function acquireRunnerLock(): bool { - $got = DB::selectValue('select GET_LOCK(?, 0) as got_lock', self::RUNNER_LOCK_NAME); - return (int) $got === 1; + return $this->databaseSessionRepository->acquireAdvisoryLock(self::RUNNER_LOCK_NAME, 0); } private function updateRuntimeHeartbeat(string $status, ?string $errorCode): void @@ -293,7 +335,7 @@ class SchedulerRunService private function releaseRunnerLock(): void { try { - DB::selectValue('select RELEASE_LOCK(?) as released_lock', self::RUNNER_LOCK_NAME); + $this->databaseSessionRepository->releaseAdvisoryLock(self::RUNNER_LOCK_NAME); } catch (\Throwable $exception) { // no-op } @@ -303,4 +345,89 @@ class SchedulerRunService { return (int) max(0, round((microtime(true) - $startedAt) * 1000)); } + + private function recordSchedulerRunEvent(string $triggerType, array $result, ?int $actorUserId = null, array $counts = []): void + { + $status = ScheduledJobRunStatus::tryNormalize((string) ($result['status'] ?? ''))?->value; + if ($status === null) { + if (!($result['ok'] ?? false)) { + $status = ScheduledJobRunStatus::Failed->value; + } elseif ((int) ($counts['failed'] ?? $result['failed'] ?? 0) > 0) { + $status = ScheduledJobRunStatus::Failed->value; + } elseif ((int) ($counts['success'] ?? $result['success'] ?? 0) > 0) { + $status = ScheduledJobRunStatus::Success->value; + } else { + $status = ScheduledJobRunStatus::Skipped->value; + } + } + + $this->recordAudit( + 'scheduler.run', + $this->auditOutcomeForRunStatus($status), + [ + 'actor_user_id' => ($actorUserId ?? 0) > 0 ? (int) $actorUserId : null, + 'error_code' => $this->nullableString($result['error'] ?? null), + 'metadata' => [ + 'trigger_type' => ScheduledJobTriggerType::normalizeOr($triggerType, ScheduledJobTriggerType::Scheduler)->value, + 'run_status' => $status, + 'processed' => (int) ($counts['processed'] ?? $result['processed'] ?? 0), + 'success_count' => (int) ($counts['success'] ?? $result['success'] ?? 0), + 'failed_count' => (int) ($counts['failed'] ?? $result['failed'] ?? 0), + 'skipped_count' => (int) ($counts['skipped'] ?? $result['skipped'] ?? 0), + 'duration_ms' => (int) ($result['duration_ms'] ?? 0), + ], + ] + ); + } + + private function recordSchedulerJobEvent( + int $jobId, + string $jobKey, + string $triggerType, + ?int $actorUserId, + array $runResult, + int $durationMs + ): void { + $status = ScheduledJobRunStatus::normalizeOr((string) ($runResult['status'] ?? ''), ScheduledJobRunStatus::Failed)->value; + + $this->recordAudit( + 'scheduler.job.run', + $this->auditOutcomeForRunStatus($status), + [ + 'actor_user_id' => ($actorUserId ?? 0) > 0 ? (int) $actorUserId : null, + 'target_type' => 'scheduled_job', + 'target_id' => $jobId > 0 ? $jobId : null, + 'error_code' => $this->nullableString($runResult['error_code'] ?? null), + 'metadata' => [ + 'job_id' => $jobId, + 'job_key' => $jobKey, + 'trigger_type' => ScheduledJobTriggerType::normalizeOr($triggerType, ScheduledJobTriggerType::Scheduler)->value, + 'run_status' => $status, + 'duration_ms' => max(0, $durationMs), + ], + ] + ); + } + + private function recordAudit(string $eventType, string $outcome, array $context): void + { + try { + $this->systemAuditService->record($eventType, $outcome, $context); + } catch (\Throwable) { + // fail-open + } + } + + private function auditOutcomeForRunStatus(string $status): string + { + if ($status === ScheduledJobRunStatus::Success->value) { + return SystemAuditOutcome::Success->value; + } + if ($status === ScheduledJobRunStatus::Failed->value) { + return SystemAuditOutcome::Failed->value; + } + // "skipped" is an operational non-error state (e.g. already running), + // not an authorization denial. + return SystemAuditOutcome::Success->value; + } } diff --git a/lib/Service/Scheduler/SchedulerServicesFactory.php b/lib/Service/Scheduler/SchedulerServicesFactory.php index 757099c..288272b 100644 --- a/lib/Service/Scheduler/SchedulerServicesFactory.php +++ b/lib/Service/Scheduler/SchedulerServicesFactory.php @@ -5,20 +5,26 @@ namespace MintyPHP\Service\Scheduler; use MintyPHP\Repository\Scheduler\ScheduledJobRepository; use MintyPHP\Repository\Scheduler\ScheduledJobRunRepository; use MintyPHP\Repository\Scheduler\SchedulerRuntimeRepository; +use MintyPHP\Repository\Support\DatabaseSessionRepository; +use MintyPHP\Service\Audit\AuditServicesFactory; +use MintyPHP\Service\Audit\SystemAuditService; use MintyPHP\Service\User\UserLifecycleService; use MintyPHP\Service\User\UserServicesFactory; class SchedulerServicesFactory { - private ?ScheduledJobRepository $scheduledJobRepository = null; - private ?ScheduledJobRunRepository $scheduledJobRunRepository = null; - private ?SchedulerRuntimeRepository $schedulerRuntimeRepository = null; private ?ScheduleCalculator $scheduleCalculator = null; - private ?UserServicesFactory $userServicesFactory = null; private ?ScheduledJobRegistry $scheduledJobRegistry = null; private ?ScheduledJobService $scheduledJobService = null; private ?SchedulerRunService $schedulerRunService = null; + public function __construct( + private readonly UserServicesFactory $userServicesFactory, + private readonly AuditServicesFactory $auditServicesFactory, + private readonly DatabaseSessionRepository $databaseSessionRepository, + private readonly SchedulerRepositoryFactory $schedulerRepositoryFactory + ) {} + public function createScheduledJobService(): ScheduledJobService { return $this->scheduledJobService ??= new ScheduledJobService( @@ -37,7 +43,9 @@ class SchedulerServicesFactory $this->getScheduledJobRunRepository(), $this->getSchedulerRuntimeRepository(), $this->getScheduledJobRegistry(), - $this->getScheduleCalculator() + $this->getScheduleCalculator(), + $this->getDatabaseSessionRepository(), + $this->getSystemAuditService() ); } @@ -48,17 +56,17 @@ class SchedulerServicesFactory private function getScheduledJobRepository(): ScheduledJobRepository { - return $this->scheduledJobRepository ??= new ScheduledJobRepository(); + return $this->schedulerRepositoryFactory->createScheduledJobRepository(); } private function getScheduledJobRunRepository(): ScheduledJobRunRepository { - return $this->scheduledJobRunRepository ??= new ScheduledJobRunRepository(); + return $this->schedulerRepositoryFactory->createScheduledJobRunRepository(); } private function getSchedulerRuntimeRepository(): SchedulerRuntimeRepository { - return $this->schedulerRuntimeRepository ??= new SchedulerRuntimeRepository(); + return $this->schedulerRepositoryFactory->createSchedulerRuntimeRepository(); } private function getScheduleCalculator(): ScheduleCalculator @@ -68,16 +76,25 @@ class SchedulerServicesFactory private function getUserLifecycleService(): UserLifecycleService { - return $this->getUserServicesFactory()->createUserLifecycleService(); + return $this->userServicesFactory->createUserLifecycleService(); } private function getScheduledJobRegistry(): ScheduledJobRegistry { - return $this->scheduledJobRegistry ??= new ScheduledJobRegistry($this->getUserLifecycleService()); + return $this->scheduledJobRegistry ??= new ScheduledJobRegistry( + $this->getUserLifecycleService(), + $this->getSystemAuditService() + ); } - private function getUserServicesFactory(): UserServicesFactory + private function getDatabaseSessionRepository(): DatabaseSessionRepository { - return $this->userServicesFactory ??= new UserServicesFactory(); + return $this->databaseSessionRepository; } + + private function getSystemAuditService(): SystemAuditService + { + return $this->auditServicesFactory->createSystemAuditService(); + } + } diff --git a/lib/Service/Search/SearchDataService.php b/lib/Service/Search/SearchDataService.php new file mode 100644 index 0000000..69a4aa2 --- /dev/null +++ b/lib/Service/Search/SearchDataService.php @@ -0,0 +1,238 @@ +>, total: int} + */ + public function buildResultData(int $userId, string $query, string $locale, int $limit, int $offset): array + { + $tenantScope = $this->resolveTenantScope($userId); + $tenantIds = $tenantScope['tenantIds']; + $tenantScoped = $tenantScope['tenantScoped']; + + $tenantScopeFilters = SearchConfig::tenantScopeFilters(); + $resources = SearchConfig::resources($query, $locale); + $items = []; + + foreach ($resources as $resource) { + $key = (string) ($resource['key'] ?? ''); + if ($key === '') { + continue; + } + + $perm = (string) ($resource['permission'] ?? ''); + if ($perm !== '' && !$this->permissionGateway->userHas($userId, $perm)) { + continue; + } + + $tenantFilter = (string) ($tenantScopeFilters[$key] ?? ''); + $isTenantScoped = $tenantFilter !== ''; + if ($isTenantScoped && !$tenantScoped) { + continue; + } + + $sql = (string) ($resource['resultSql'] ?? ''); + if ($sql === '') { + continue; + } + $sql = str_replace('{{tenantFilter}}', $isTenantScoped ? $tenantFilter : '', $sql); + $params = is_array($resource['resultParams'] ?? null) ? $resource['resultParams'] : []; + if ($isTenantScoped && strpos($sql, '???') !== false) { + $params[] = $tenantIds; + } + + $rows = $this->searchQueryRepository->selectRows($sql, $params); + foreach ($rows as $row) { + $data = self::normalizeResourceRow($row); + $mapped = SearchConfig::mapResultItem($key, (string) ($resource['label'] ?? ''), $data); + if ($mapped !== null) { + $items[] = $mapped; + } + } + } + + $items = array_merge($items, SearchConfig::hotkeyResultItems($query)); + if ($this->permissionGateway->userHas($userId, PermissionService::DOCS_VIEW)) { + $items = array_merge($items, SearchConfig::docsResultItems($query)); + } + + $scoreQuery = SearchConfig::normalizeScoreQuery($query); + $queryLower = mb_strtolower($scoreQuery); + $scoreItem = static function (array $item) use ($queryLower): int { + $manualScore = (int) ($item['search_score'] ?? 0); + if ($manualScore > 0) { + return $manualScore; + } + + $title = mb_strtolower((string) ($item['title'] ?? '')); + $description = mb_strtolower((string) ($item['description'] ?? '')); + if ($title === $queryLower) { + return 400; + } + if ($title !== '' && strpos($title, $queryLower) === 0) { + return 300; + } + if ($title !== '' && strpos($title, $queryLower) !== false) { + return 200; + } + if ($description !== '' && strpos($description, $queryLower) !== false) { + return 100; + } + return 0; + }; + + usort($items, static function (array $a, array $b) use ($scoreItem): int { + $scoreA = $scoreItem($a); + $scoreB = $scoreItem($b); + if ($scoreA !== $scoreB) { + return $scoreB <=> $scoreA; + } + $title = strcmp((string) ($a['title'] ?? ''), (string) ($b['title'] ?? '')); + if ($title !== 0) { + return $title; + } + return strcmp((string) ($a['type'] ?? ''), (string) ($b['type'] ?? '')); + }); + + $total = count($items); + $items = array_slice($items, $offset, $limit); + + return [ + 'data' => $items, + 'total' => $total, + ]; + } + + /** + * @return array> + */ + public function buildPreviewData(int $userId, string $query, string $locale, int $previewLimit = 5): array + { + $tenantScope = $this->resolveTenantScope($userId); + $tenantIds = $tenantScope['tenantIds']; + $tenantScoped = $tenantScope['tenantScoped']; + + $tenantScopeFilters = SearchConfig::tenantScopeFilters(); + $resources = SearchConfig::resources($query, $locale); + $results = []; + + foreach ($resources as $resource) { + $key = (string) ($resource['key'] ?? ''); + if ($key === '') { + continue; + } + + $tenantFilter = (string) ($tenantScopeFilters[$key] ?? ''); + $isTenantScoped = $tenantFilter !== ''; + if ($isTenantScoped && !$tenantScoped) { + continue; + } + + $perm = (string) ($resource['permission'] ?? ''); + if ($perm !== '' && !$this->permissionGateway->userHas($userId, $perm)) { + continue; + } + + $countSql = str_replace('{{tenantFilter}}', $isTenantScoped ? $tenantFilter : '', (string) ($resource['countSql'] ?? '')); + $countParams = is_array($resource['countParams'] ?? null) ? $resource['countParams'] : []; + if ($isTenantScoped && strpos($countSql, '???') !== false) { + $countParams[] = $tenantIds; + } + $count = $this->searchQueryRepository->selectCount($countSql, $countParams); + + $items = []; + $previewSql = $resource['previewSql'] ?? null; + if (is_string($previewSql) && $previewSql !== '') { + $previewSql = str_replace('{{tenantFilter}}', $isTenantScoped ? $tenantFilter : '', $previewSql); + $previewParams = is_array($resource['previewParams'] ?? null) ? $resource['previewParams'] : []; + if ($isTenantScoped && strpos($previewSql, '???') !== false) { + $previewParams[] = $tenantIds; + } + $previewParams[] = $previewLimit; + $rows = $this->searchQueryRepository->selectRows($previewSql, $previewParams); + foreach ($rows as $row) { + $data = self::normalizeResourceRow($row); + $mapped = SearchConfig::mapPreviewItem($key, $data, $query); + if ($mapped !== null) { + $items[] = $mapped; + } + } + } + + $result = [ + 'key' => $key, + 'label' => $resource['label'] ?? '', + 'count' => $count, + 'url' => SearchConfig::listUrl($key, $query), + 'items' => $items, + ]; + if ($key === 'pages' && !empty($items[0]['url'])) { + $result['url'] = $items[0]['url']; + } + $results[] = $result; + } + + $hotkeyResource = SearchConfig::hotkeyPreviewResource($query); + if ($hotkeyResource !== null) { + $results[] = $hotkeyResource; + } + + if ($this->permissionGateway->userHas($userId, PermissionService::DOCS_VIEW)) { + $docsResource = SearchConfig::docsPreviewResource($query); + if ($docsResource !== null) { + $results[] = $docsResource; + } + } + + return $results; + } + + /** + * @return array{tenantIds: array, tenantScoped: bool} + */ + private function resolveTenantScope(int $userId): array + { + if ($userId > 0) { + // Prime cached permissions for the current request. + $this->permissionGateway->getUserPermissions($userId); + } + + $tenantIds = $userId > 0 + ? $this->userTenantRepository->listTenantIdsByUserId($userId) + : []; + + return [ + 'tenantIds' => $tenantIds, + 'tenantScoped' => !empty($tenantIds), + ]; + } + + /** + * @return array + */ + private static function normalizeResourceRow(mixed $row): array + { + $data = $row; + if (is_array($row) && count($row) === 1) { + $data = reset($row); + } + + return is_array($data) ? $data : []; + } +} diff --git a/lib/Service/Security/SecurityRepositoryFactory.php b/lib/Service/Security/SecurityRepositoryFactory.php new file mode 100644 index 0000000..d1cd8d0 --- /dev/null +++ b/lib/Service/Security/SecurityRepositoryFactory.php @@ -0,0 +1,15 @@ +rateLimitRepository ??= new RateLimitRepository(); + } +} diff --git a/lib/Service/Security/SecurityServicesFactory.php b/lib/Service/Security/SecurityServicesFactory.php index d612038..3b8985d 100644 --- a/lib/Service/Security/SecurityServicesFactory.php +++ b/lib/Service/Security/SecurityServicesFactory.php @@ -6,12 +6,15 @@ use MintyPHP\Repository\Security\RateLimitRepository; class SecurityServicesFactory { - private ?RateLimitRepository $rateLimitRepository = null; private ?RateLimiterService $rateLimiterService = null; + public function __construct( + private readonly SecurityRepositoryFactory $securityRepositoryFactory + ) {} + public function createRateLimitRepository(): RateLimitRepository { - return $this->rateLimitRepository ??= new RateLimitRepository(); + return $this->securityRepositoryFactory->createRateLimitRepository(); } public function createRateLimiterService(): RateLimiterService diff --git a/lib/Service/Settings/AdminSettingsService.php b/lib/Service/Settings/AdminSettingsService.php new file mode 100644 index 0000000..96b44ac --- /dev/null +++ b/lib/Service/Settings/AdminSettingsService.php @@ -0,0 +1,314 @@ +tenantService->list(); + $roles = $this->roleService->listActive(); + $departments = $this->departmentService->list(); + + $sortByDescription = static fn (array $left, array $right): int => + strcmp((string) ($left['description'] ?? ''), (string) ($right['description'] ?? '')); + usort($tenants, $sortByDescription); + usort($roles, $sortByDescription); + usort($departments, $sortByDescription); + + return [ + 'tenants' => $tenants, + 'roles' => $roles, + 'departments' => $departments, + 'values' => [ + 'default_tenant_id' => $this->settingGateway->getDefaultTenantId(), + 'default_role_id' => $this->settingGateway->getDefaultRoleId(), + 'default_department_id' => $this->settingGateway->getDefaultDepartmentId(), + 'app_title' => $this->settingGateway->getValue(SettingService::APP_TITLE_KEY), + 'app_locale' => $this->settingGateway->getValue(SettingService::APP_LOCALE_KEY), + 'app_theme' => $this->settingGateway->getValue(SettingService::APP_THEME_KEY), + 'app_theme_user' => $this->settingGateway->isUserThemeAllowed(), + 'app_registration' => $this->settingGateway->isRegistrationEnabled(), + 'app_primary_color' => $this->settingGateway->getValue(SettingService::APP_PRIMARY_COLOR_KEY), + 'api_token_default_ttl_days' => $this->settingGateway->getApiTokenDefaultTtlDays(), + 'api_token_max_ttl_days' => $this->settingGateway->getApiTokenMaxTtlDays(), + 'user_inactivity_deactivate_days' => $this->settingGateway->getUserInactivityDeactivateDays(), + 'user_inactivity_delete_days' => $this->settingGateway->getUserInactivityDeleteDays(), + 'api_cors_allowed_origins' => $this->settingGateway->getApiCorsAllowedOriginsText(), + 'system_audit_enabled' => $this->settingGateway->isSystemAuditEnabled(), + 'system_audit_retention_days' => $this->settingGateway->getSystemAuditRetentionDays(), + 'microsoft_shared_client_id' => $this->settingGateway->getValue(SettingService::MICROSOFT_SHARED_CLIENT_ID_KEY), + 'microsoft_authority' => $this->settingGateway->getMicrosoftAuthority(), + 'smtp_host' => $this->settingGateway->getValue(SettingService::SMTP_HOST_KEY), + 'smtp_port' => $this->settingGateway->getValue(SettingService::SMTP_PORT_KEY), + 'smtp_user' => $this->settingGateway->getValue(SettingService::SMTP_USER_KEY), + 'smtp_secure' => $this->settingGateway->getSmtpSecure(), + 'smtp_from' => $this->settingGateway->getValue(SettingService::SMTP_FROM_KEY), + 'smtp_from_name' => $this->settingGateway->getValue(SettingService::SMTP_FROM_NAME_KEY), + ], + 'settings' => [ + 'default_tenant' => $this->settingGateway->get(SettingService::DEFAULT_TENANT_KEY), + 'default_role' => $this->settingGateway->get(SettingService::DEFAULT_ROLE_KEY), + 'default_department' => $this->settingGateway->get(SettingService::DEFAULT_DEPARTMENT_KEY), + 'app_title' => $this->settingGateway->get(SettingService::APP_TITLE_KEY), + 'app_locale' => $this->settingGateway->get(SettingService::APP_LOCALE_KEY), + 'app_theme' => $this->settingGateway->get(SettingService::APP_THEME_KEY), + 'app_theme_user' => $this->settingGateway->get(SettingService::APP_THEME_USER_KEY), + 'app_registration' => $this->settingGateway->get(SettingService::APP_REGISTRATION_KEY), + 'app_primary_color' => $this->settingGateway->get(SettingService::APP_PRIMARY_COLOR_KEY), + 'api_token_default_ttl_days' => $this->settingGateway->get(SettingService::API_TOKEN_DEFAULT_TTL_DAYS_KEY), + 'api_token_max_ttl_days' => $this->settingGateway->get(SettingService::API_TOKEN_MAX_TTL_DAYS_KEY), + 'user_inactivity_deactivate_days' => $this->settingGateway->get(SettingService::USER_INACTIVITY_DEACTIVATE_DAYS_KEY), + 'user_inactivity_delete_days' => $this->settingGateway->get(SettingService::USER_INACTIVITY_DELETE_DAYS_KEY), + 'api_cors_allowed_origins' => $this->settingGateway->get(SettingService::API_CORS_ALLOWED_ORIGINS_KEY), + 'system_audit_enabled' => $this->settingGateway->get(SettingService::SYSTEM_AUDIT_ENABLED_KEY), + 'system_audit_retention_days' => $this->settingGateway->get(SettingService::SYSTEM_AUDIT_RETENTION_DAYS_KEY), + 'microsoft_shared_client_id' => $this->settingGateway->get(SettingService::MICROSOFT_SHARED_CLIENT_ID_KEY), + 'microsoft_shared_client_secret_enc' => $this->settingGateway->get(SettingService::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY), + 'microsoft_authority' => $this->settingGateway->get(SettingService::MICROSOFT_AUTHORITY_KEY), + 'smtp_host' => $this->settingGateway->get(SettingService::SMTP_HOST_KEY), + 'smtp_port' => $this->settingGateway->get(SettingService::SMTP_PORT_KEY), + 'smtp_user' => $this->settingGateway->get(SettingService::SMTP_USER_KEY), + 'smtp_password' => $this->settingGateway->get(SettingService::SMTP_PASSWORD_KEY), + 'smtp_secure' => $this->settingGateway->get(SettingService::SMTP_SECURE_KEY), + 'smtp_from' => $this->settingGateway->get(SettingService::SMTP_FROM_KEY), + 'smtp_from_name' => $this->settingGateway->get(SettingService::SMTP_FROM_NAME_KEY), + ], + 'active_login_tokens' => $this->rememberTokenRepository->countActive(), + 'active_api_tokens' => $this->apiTokenRepository->countActive(), + ]; + } + + public function updateFromAdmin(array $post): array + { + $defaultTenantId = (int) ($post['default_tenant_id'] ?? 0); + $defaultRoleId = (int) ($post['default_role_id'] ?? 0); + $defaultDepartmentId = (int) ($post['default_department_id'] ?? 0); + $appTitle = trim((string) ($post['app_title'] ?? '')); + $appLocale = trim((string) ($post['app_locale'] ?? '')); + $appTheme = trim((string) ($post['app_theme'] ?? '')); + $appThemeUser = isset($post['app_theme_user']); + $appRegistration = isset($post['app_registration']); + $apiTokenDefaultTtlDays = (int) ($post['api_token_default_ttl_days'] ?? 0); + $apiTokenMaxTtlDays = (int) ($post['api_token_max_ttl_days'] ?? 0); + $userInactivityDeactivateDays = (int) ($post['user_inactivity_deactivate_days'] ?? 0); + $userInactivityDeleteDays = (int) ($post['user_inactivity_delete_days'] ?? 0); + $apiCorsAllowedOrigins = $this->normalizeScalarText($post['api_cors_allowed_origins'] ?? ''); + $systemAuditEnabled = isset($post['system_audit_enabled']); + $systemAuditRetentionDays = (int) ($post['system_audit_retention_days'] ?? 0); + $appPrimaryColor = $this->normalizePrimaryColor($post['app_primary_color'] ?? ''); + $smtpHost = trim((string) ($post['smtp_host'] ?? '')); + $smtpPort = (int) ($post['smtp_port'] ?? 0); + $smtpUser = trim((string) ($post['smtp_user'] ?? '')); + $smtpPassword = (string) ($post['smtp_password'] ?? ''); + $smtpSecure = trim((string) ($post['smtp_secure'] ?? '')); + $smtpFrom = trim((string) ($post['smtp_from'] ?? '')); + $smtpFromName = trim((string) ($post['smtp_from_name'] ?? '')); + $microsoftSharedClientId = trim((string) ($post['microsoft_shared_client_id'] ?? '')); + $microsoftSharedClientSecret = (string) ($post['microsoft_shared_client_secret'] ?? ''); + $microsoftAuthority = trim((string) ($post['microsoft_authority'] ?? '')); + + $errors = []; + $beforeAudit = [ + 'default_tenant_id' => $this->settingGateway->getDefaultTenantId(), + 'default_role_id' => $this->settingGateway->getDefaultRoleId(), + 'default_department_id' => $this->settingGateway->getDefaultDepartmentId(), + 'app_locale' => $this->settingGateway->getAppLocale(), + 'app_theme' => $this->settingGateway->getAppTheme(), + 'app_theme_user' => $this->settingGateway->isUserThemeAllowed(), + 'app_registration' => $this->settingGateway->isRegistrationEnabled(), + 'app_primary_color' => $this->settingGateway->getAppPrimaryColor(), + 'api_token_default_ttl_days' => $this->settingGateway->getApiTokenDefaultTtlDays(), + 'api_token_max_ttl_days' => $this->settingGateway->getApiTokenMaxTtlDays(), + 'user_inactivity_deactivate_days' => $this->settingGateway->getUserInactivityDeactivateDays(), + 'user_inactivity_delete_days' => $this->settingGateway->getUserInactivityDeleteDays(), + 'system_audit_enabled' => $this->settingGateway->isSystemAuditEnabled(), + 'system_audit_retention_days' => $this->settingGateway->getSystemAuditRetentionDays(), + ]; + + $this->settingGateway->setDefaultTenantId($defaultTenantId > 0 ? $defaultTenantId : null); + $this->settingGateway->setDefaultRoleId($defaultRoleId > 0 ? $defaultRoleId : null); + $this->settingGateway->setDefaultDepartmentId($defaultDepartmentId > 0 ? $defaultDepartmentId : null); + $this->settingGateway->setAppTitle($appTitle); + $this->settingGateway->setAppLocale($appLocale); + $this->settingGateway->setAppTheme($appTheme); + $this->settingGateway->setUserThemeAllowed($appThemeUser); + $this->settingGateway->setRegistrationEnabled($appRegistration); + + $apiTokenMaxTtlOk = $this->settingGateway->setApiTokenMaxTtlDays($apiTokenMaxTtlDays > 0 ? $apiTokenMaxTtlDays : null); + if (!$apiTokenMaxTtlOk) { + $errors[] = ['message' => 'API token max lifetime is invalid', 'key' => 'api_token_max_ttl_invalid']; + } + $apiTokenTtlOk = $this->settingGateway->setApiTokenDefaultTtlDays($apiTokenDefaultTtlDays > 0 ? $apiTokenDefaultTtlDays : null); + if (!$apiTokenTtlOk) { + $errors[] = ['message' => 'API token default lifetime is invalid', 'key' => 'api_token_default_ttl_invalid']; + } + + $userDeactivateOk = $this->settingGateway->setUserInactivityDeactivateDays($userInactivityDeactivateDays); + if (!$userDeactivateOk) { + $errors[] = [ + 'message' => 'User inactivity deactivation period is invalid', + 'key' => 'user_inactivity_deactivate_days_invalid', + ]; + } + + if ($userInactivityDeactivateDays > 0) { + $userDeleteOk = $this->settingGateway->setUserInactivityDeleteDays($userInactivityDeleteDays); + if (!$userDeleteOk) { + $errors[] = [ + 'message' => 'User inactivity deletion period is invalid', + 'key' => 'user_inactivity_delete_days_invalid', + ]; + } + } else { + if ($userInactivityDeleteDays > 0) { + $errors[] = [ + 'message' => 'Auto-delete is ignored while auto-deactivate is disabled', + 'key' => 'user_inactivity_delete_requires_deactivate', + ]; + } + $this->settingGateway->setUserInactivityDeleteDays(0); + } + + $apiCorsOriginsOk = $this->settingGateway->setApiCorsAllowedOrigins($apiCorsAllowedOrigins); + if (!$apiCorsOriginsOk) { + $errors[] = ['message' => 'CORS allowed origins are invalid', 'key' => 'api_cors_allowed_origins_invalid']; + } + + $systemAuditEnabledOk = $this->settingGateway->setSystemAuditEnabled($systemAuditEnabled); + if (!$systemAuditEnabledOk) { + $errors[] = ['message' => 'System audit setting is invalid', 'key' => 'system_audit_enabled_invalid']; + } + + $systemAuditRetentionOk = $this->settingGateway->setSystemAuditRetentionDays( + $systemAuditRetentionDays > 0 ? $systemAuditRetentionDays : null + ); + if (!$systemAuditRetentionOk) { + $errors[] = ['message' => 'System audit retention is invalid', 'key' => 'system_audit_retention_invalid']; + } + + $primaryOk = $this->settingGateway->setAppPrimaryColor($appPrimaryColor); + if (!$primaryOk) { + $appPrimaryColor = ''; + $errors[] = ['message' => 'Primary color is invalid', 'key' => 'app_primary_invalid']; + } + + $this->settingGateway->setSmtpHost($smtpHost); + $this->settingGateway->setSmtpPort($smtpPort > 0 ? $smtpPort : null); + $this->settingGateway->setSmtpUser($smtpUser); + if (trim($smtpPassword) !== '') { + $this->settingGateway->setSmtpPassword($smtpPassword); + } + $this->settingGateway->setSmtpSecure($smtpSecure); + $this->settingGateway->setSmtpFrom($smtpFrom); + $this->settingGateway->setSmtpFromName($smtpFromName); + + $msClientIdOk = $this->settingGateway->setMicrosoftSharedClientId($microsoftSharedClientId); + if (!$msClientIdOk) { + $errors[] = ['message' => 'Microsoft client ID is invalid', 'key' => 'microsoft_client_id_invalid']; + } + + $msAuthorityOk = $this->settingGateway->setMicrosoftAuthority($microsoftAuthority); + if (!$msAuthorityOk) { + $errors[] = ['message' => 'Microsoft authority is invalid', 'key' => 'microsoft_authority_invalid']; + } + + if (trim($microsoftSharedClientSecret) !== '') { + try { + $this->settingGateway->setMicrosoftSharedClientSecret($microsoftSharedClientSecret); + } catch (\Throwable) { + $errors[] = [ + 'message' => 'Microsoft client secret could not be encrypted', + 'key' => 'microsoft_client_secret_invalid', + ]; + } + } + + $this->settingCacheService->update([ + 'app_title' => $appTitle !== '' ? $appTitle : null, + 'app_locale' => $appLocale !== '' ? $appLocale : null, + 'app_theme' => $appTheme !== '' ? $appTheme : null, + 'app_theme_user' => $appThemeUser ? '1' : '0', + 'app_registration' => $appRegistration ? '1' : '0', + 'app_primary_color' => $appPrimaryColor !== '' ? $appPrimaryColor : null, + 'api_token_default_ttl_days' => (string) $this->settingGateway->getApiTokenDefaultTtlDays(), + 'api_token_max_ttl_days' => (string) $this->settingGateway->getApiTokenMaxTtlDays(), + 'api_cors_allowed_origins' => $this->settingGateway->getValue(SettingService::API_CORS_ALLOWED_ORIGINS_KEY), + ]); + + $afterAudit = [ + 'default_tenant_id' => $this->settingGateway->getDefaultTenantId(), + 'default_role_id' => $this->settingGateway->getDefaultRoleId(), + 'default_department_id' => $this->settingGateway->getDefaultDepartmentId(), + 'app_locale' => $this->settingGateway->getAppLocale(), + 'app_theme' => $this->settingGateway->getAppTheme(), + 'app_theme_user' => $this->settingGateway->isUserThemeAllowed(), + 'app_registration' => $this->settingGateway->isRegistrationEnabled(), + 'app_primary_color' => $this->settingGateway->getAppPrimaryColor(), + 'api_token_default_ttl_days' => $this->settingGateway->getApiTokenDefaultTtlDays(), + 'api_token_max_ttl_days' => $this->settingGateway->getApiTokenMaxTtlDays(), + 'user_inactivity_deactivate_days' => $this->settingGateway->getUserInactivityDeactivateDays(), + 'user_inactivity_delete_days' => $this->settingGateway->getUserInactivityDeleteDays(), + 'system_audit_enabled' => $this->settingGateway->isSystemAuditEnabled(), + 'system_audit_retention_days' => $this->settingGateway->getSystemAuditRetentionDays(), + ]; + + $outcome = $errors === [] ? 'success' : 'failed'; + $errorKeys = []; + if ($errors !== []) { + foreach ($errors as $error) { + $key = trim((string) $error['key']); + if ($key !== '') { + $errorKeys[] = $key; + } + } + } + + $this->systemAuditService->record('admin.settings.update', $outcome, [ + 'before' => $beforeAudit, + 'after' => $afterAudit, + 'error_code' => $errors === [] ? null : 'validation_error', + 'metadata' => $errors === [] ? [] : ['error_keys' => $errorKeys], + ]); + + return ['errors' => $errors]; + } + + private function normalizeScalarText(mixed $value): string + { + if (is_array($value)) { + return ''; + } + + return trim((string) $value); + } + + private function normalizePrimaryColor(mixed $value): string + { + $color = $this->normalizeScalarText($value); + if (in_array(strtolower($color), ['null', 'undefined'], true)) { + return ''; + } + + return $color; + } +} diff --git a/lib/Service/Settings/SettingGateway.php b/lib/Service/Settings/SettingGateway.php index 25d93c6..a18ca2a 100644 --- a/lib/Service/Settings/SettingGateway.php +++ b/lib/Service/Settings/SettingGateway.php @@ -138,6 +138,26 @@ class SettingGateway return $this->settingService->setUserInactivityDeleteDays($days, $description); } + public function isSystemAuditEnabled(): bool + { + return $this->settingService->isSystemAuditEnabled(); + } + + public function setSystemAuditEnabled(bool $enabled, ?string $description = null): bool + { + return $this->settingService->setSystemAuditEnabled($enabled, $description); + } + + public function getSystemAuditRetentionDays(): int + { + return $this->settingService->getSystemAuditRetentionDays(); + } + + public function setSystemAuditRetentionDays(?int $days, ?string $description = null): bool + { + return $this->settingService->setSystemAuditRetentionDays($days, $description); + } + public function getApiCorsAllowedOrigins(): array { return $this->settingService->getApiCorsAllowedOrigins(); diff --git a/lib/Service/Settings/SettingRepositoryFactory.php b/lib/Service/Settings/SettingRepositoryFactory.php new file mode 100644 index 0000000..f4803e4 --- /dev/null +++ b/lib/Service/Settings/SettingRepositoryFactory.php @@ -0,0 +1,36 @@ +settingRepository ??= new SettingRepository(); + } + + public function createTenantRepository(): TenantRepository + { + return $this->tenantRepository ??= new TenantRepository(); + } + + public function createRoleRepository(): RoleRepository + { + return $this->roleRepository ??= new RoleRepository(); + } + + public function createDepartmentRepository(): DepartmentRepository + { + return $this->departmentRepository ??= new DepartmentRepository(); + } +} diff --git a/lib/Service/Settings/SettingService.php b/lib/Service/Settings/SettingService.php index 2ae5581..9e60515 100644 --- a/lib/Service/Settings/SettingService.php +++ b/lib/Service/Settings/SettingService.php @@ -42,6 +42,8 @@ class SettingService public const SMTP_FROM_NAME_KEY = 'smtp_from_name'; public const USER_INACTIVITY_DEACTIVATE_DAYS_KEY = 'user_inactivity_deactivate_days'; public const USER_INACTIVITY_DELETE_DAYS_KEY = 'user_inactivity_delete_days'; + public const SYSTEM_AUDIT_ENABLED_KEY = 'system_audit_enabled'; + public const SYSTEM_AUDIT_RETENTION_DAYS_KEY = 'system_audit_retention_days'; private const API_TOKEN_DEFAULT_TTL_DAYS_FALLBACK = 90; private const API_TOKEN_MAX_TTL_DAYS_FALLBACK = 365; private const API_TOKEN_TTL_DAYS_MIN = 1; @@ -50,6 +52,10 @@ class SettingService private const USER_INACTIVITY_DELETE_DAYS_FALLBACK = 365; private const USER_INACTIVITY_DAYS_MIN = 0; private const USER_INACTIVITY_DAYS_MAX = 3650; + private const SYSTEM_AUDIT_ENABLED_FALLBACK = true; + private const SYSTEM_AUDIT_RETENTION_DAYS_FALLBACK = 365; + private const SYSTEM_AUDIT_RETENTION_DAYS_MIN = 30; + private const SYSTEM_AUDIT_RETENTION_DAYS_MAX = 1095; public function get(string $key): ?array { @@ -338,6 +344,45 @@ class SettingService return $this->settingRepository->set(self::USER_INACTIVITY_DELETE_DAYS_KEY, (string) $value, $desc); } + public function isSystemAuditEnabled(): bool + { + $value = strtolower(trim((string) ($this->settingRepository->getValue(self::SYSTEM_AUDIT_ENABLED_KEY) ?? ''))); + if ($value === '') { + return self::SYSTEM_AUDIT_ENABLED_FALLBACK; + } + + return in_array($value, ['1', 'true', 'yes', 'on'], true); + } + + public function setSystemAuditEnabled(bool $enabled, ?string $description = null): bool + { + $desc = $description ?? 'setting.system_audit_enabled'; + return $this->settingRepository->set(self::SYSTEM_AUDIT_ENABLED_KEY, $enabled ? '1' : '0', $desc); + } + + public function getSystemAuditRetentionDays(): int + { + $value = $this->getInt(self::SYSTEM_AUDIT_RETENTION_DAYS_KEY); + if ($value === null) { + return self::SYSTEM_AUDIT_RETENTION_DAYS_FALLBACK; + } + if ($value < self::SYSTEM_AUDIT_RETENTION_DAYS_MIN || $value > self::SYSTEM_AUDIT_RETENTION_DAYS_MAX) { + return self::SYSTEM_AUDIT_RETENTION_DAYS_FALLBACK; + } + return $value; + } + + public function setSystemAuditRetentionDays(?int $days, ?string $description = null): bool + { + $value = $days ?? self::SYSTEM_AUDIT_RETENTION_DAYS_FALLBACK; + if ($value < self::SYSTEM_AUDIT_RETENTION_DAYS_MIN || $value > self::SYSTEM_AUDIT_RETENTION_DAYS_MAX) { + return false; + } + + $desc = $description ?? 'setting.system_audit_retention_days'; + return $this->settingRepository->set(self::SYSTEM_AUDIT_RETENTION_DAYS_KEY, (string) $value, $desc); + } + public function getApiCorsAllowedOrigins(): array { $stored = $this->settingRepository->getValue(self::API_CORS_ALLOWED_ORIGINS_KEY); diff --git a/lib/Service/Settings/SettingServicesFactory.php b/lib/Service/Settings/SettingServicesFactory.php index 046c44d..df5dfe1 100644 --- a/lib/Service/Settings/SettingServicesFactory.php +++ b/lib/Service/Settings/SettingServicesFactory.php @@ -9,18 +9,18 @@ use MintyPHP\Repository\Tenant\TenantRepository; class SettingServicesFactory { - private ?SettingRepository $settingRepository = null; - private ?TenantRepository $tenantRepository = null; - private ?RoleRepository $roleRepository = null; - private ?DepartmentRepository $departmentRepository = null; private ?ThemeConfigService $themeConfigService = null; private ?SettingService $settingService = null; private ?SettingCacheService $settingCacheService = null; private ?SettingGateway $settingGateway = null; + public function __construct( + private readonly SettingRepositoryFactory $settingRepositoryFactory + ) {} + public function createSettingRepository(): SettingRepository { - return $this->settingRepository ??= new SettingRepository(); + return $this->settingRepositoryFactory->createSettingRepository(); } public function createSettingService(): SettingService @@ -51,16 +51,16 @@ class SettingServicesFactory private function createTenantRepository(): TenantRepository { - return $this->tenantRepository ??= new TenantRepository(); + return $this->settingRepositoryFactory->createTenantRepository(); } private function createRoleRepository(): RoleRepository { - return $this->roleRepository ??= new RoleRepository(); + return $this->settingRepositoryFactory->createRoleRepository(); } private function createDepartmentRepository(): DepartmentRepository { - return $this->departmentRepository ??= new DepartmentRepository(); + return $this->settingRepositoryFactory->createDepartmentRepository(); } } diff --git a/lib/Service/Stats/AdminStatsViewDataService.php b/lib/Service/Stats/AdminStatsViewDataService.php new file mode 100644 index 0000000..5acccf2 --- /dev/null +++ b/lib/Service/Stats/AdminStatsViewDataService.php @@ -0,0 +1,21 @@ + + */ + public function build(): array + { + return $this->adminStatsRepository->buildViewData(); + } +} diff --git a/lib/Service/Tenant/TenantRepositoryFactory.php b/lib/Service/Tenant/TenantRepositoryFactory.php new file mode 100644 index 0000000..bce8042 --- /dev/null +++ b/lib/Service/Tenant/TenantRepositoryFactory.php @@ -0,0 +1,29 @@ +tenantRepository ??= new TenantRepository(); + } + + public function createDepartmentRepository(): DepartmentRepository + { + return $this->departmentRepository ??= new DepartmentRepository(); + } + + public function createUserTenantRepository(): UserTenantRepository + { + return $this->userTenantRepository ??= new UserTenantRepository(); + } +} diff --git a/lib/Service/Tenant/TenantService.php b/lib/Service/Tenant/TenantService.php index cb29b25..caa8508 100644 --- a/lib/Service/Tenant/TenantService.php +++ b/lib/Service/Tenant/TenantService.php @@ -2,38 +2,41 @@ namespace MintyPHP\Service\Tenant; +use MintyPHP\Domain\Taxonomy\SystemAuditOutcome; +use MintyPHP\Domain\Taxonomy\TenantStatus; use MintyPHP\Repository\Org\DepartmentRepository; use MintyPHP\Repository\Tenant\TenantRepository; +use MintyPHP\Service\Audit\SystemAuditService; use MintyPHP\Service\Directory\DirectorySettingsGateway; -use MintyPHP\Service\Settings\SettingServicesFactory; class TenantService { public function __construct( - private readonly ?TenantRepository $tenantRepository = null, - private readonly ?DepartmentRepository $departmentRepository = null, - private readonly ?DirectorySettingsGateway $settingsGateway = null + private readonly TenantRepository $tenantRepository, + private readonly DepartmentRepository $departmentRepository, + private readonly DirectorySettingsGateway $settingsGateway, + private readonly SystemAuditService $systemAuditService ) { } public function list(): array { - return $this->tenantRepository()->list(); + return $this->tenantRepository->list(); } public function listPaged(array $options): array { - return $this->tenantRepository()->listPaged($options); + return $this->tenantRepository->listPaged($options); } public function findByUuid(string $uuid): ?array { - return $this->tenantRepository()->findByUuid($uuid); + return $this->tenantRepository->findByUuid($uuid); } public function findById(int $id): ?array { - return $this->tenantRepository()->find($id); + return $this->tenantRepository->find($id); } public function createFromAdmin(array $input, int $currentUserId = 0): array @@ -47,7 +50,7 @@ class TenantService } $statusChangedAt = gmdate('Y-m-d H:i:s'); - $createdId = $this->tenantRepository()->create([ + $createdId = $this->tenantRepository->create([ 'description' => $form['description'], 'address' => $form['address'], 'postal_code' => $form['postal_code'], @@ -80,11 +83,20 @@ class TenantService return ['ok' => false, 'errors' => [t('Tenant can not be created')], 'form' => $form]; } - $createdTenant = $this->tenantRepository()->find((int) $createdId); + $createdTenant = $this->tenantRepository->find((int) $createdId); $uuid = $createdTenant['uuid'] ?? null; if (!empty($input['is_default'])) { - $this->settingsGateway()->setDefaultTenantId((int) $createdId); + $this->settingsGateway->setDefaultTenantId((int) $createdId); } + + $this->systemAuditService->record('admin.tenants.create', SystemAuditOutcome::Success->value, [ + 'actor_user_id' => $currentUserId > 0 ? $currentUserId : null, + 'target_type' => 'tenant', + 'target_id' => (int) $createdId, + 'target_uuid' => is_string($uuid) ? $uuid : '', + 'metadata' => ['status' => $form['status']], + ]); + return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId]; } @@ -98,7 +110,7 @@ class TenantService return ['ok' => false, 'errors' => $errors, 'form' => $form]; } - $existing = $this->tenantRepository()->find($tenantId); + $existing = $this->tenantRepository->find($tenantId); $statusChanged = false; if ($existing && isset($existing['status'])) { $statusChanged = (string) $existing['status'] !== $form['status']; @@ -135,12 +147,25 @@ class TenantService $updateData['status_changed_by'] = $currentUserId > 0 ? $currentUserId : null; } - $updated = $this->tenantRepository()->update($tenantId, $updateData); + $updated = $this->tenantRepository->update($tenantId, $updateData); if (!$updated) { return ['ok' => false, 'errors' => [t('Tenant can not be updated')], 'form' => $form]; } + $this->systemAuditService->record('admin.tenants.update', SystemAuditOutcome::Success->value, [ + 'actor_user_id' => $currentUserId > 0 ? $currentUserId : null, + 'target_type' => 'tenant', + 'target_id' => $tenantId, + 'target_uuid' => (string) ($existing['uuid'] ?? ''), + 'before' => [ + 'status' => $existing['status'] ?? null, + ], + 'after' => [ + 'status' => $form['status'], + ], + ]); + return ['ok' => true, 'form' => $form]; } @@ -151,21 +176,27 @@ class TenantService return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } - $tenant = $this->tenantRepository()->findByUuid($uuid); + $tenant = $this->tenantRepository->findByUuid($uuid); if (!$tenant || !isset($tenant['id'])) { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } $tenantId = (int) $tenant['id']; - if ($this->departmentRepository()->countByTenantId($tenantId) > 0) { + if ($this->departmentRepository->countByTenantId($tenantId) > 0) { return ['ok' => false, 'status' => 409, 'error' => 'tenant_has_departments']; } - $deleted = $this->tenantRepository()->delete($tenantId); + $deleted = $this->tenantRepository->delete($tenantId); if (!$deleted) { return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; } + $this->systemAuditService->record('admin.tenants.delete', SystemAuditOutcome::Success->value, [ + 'target_type' => 'tenant', + 'target_id' => $tenantId, + 'target_uuid' => (string) ($tenant['uuid'] ?? ''), + ]); + return ['ok' => true, 'tenant' => $tenant]; } @@ -203,7 +234,7 @@ class TenantService if ($form['description'] === '') { $errors[] = t('Description cannot be empty'); } - if (!in_array($form['status'], ['active', 'inactive'], true)) { + if (TenantStatus::tryNormalize((string) $form['status']) === null) { $errors[] = t('Status is invalid'); } if ( @@ -225,31 +256,7 @@ class TenantService private function normalizeStatus(string $status): string { - $status = strtolower(trim($status)); - if (!in_array($status, ['active', 'inactive'], true)) { - return 'active'; - } - return $status; + return TenantStatus::normalizeOr($status, TenantStatus::Active)->value; } - private function tenantRepository(): TenantRepository - { - return $this->tenantRepository ?? new TenantRepository(); - } - - private function departmentRepository(): DepartmentRepository - { - return $this->departmentRepository ?? new DepartmentRepository(); - } - - private function settingsGateway(): DirectorySettingsGateway - { - if ($this->settingsGateway instanceof DirectorySettingsGateway) { - return $this->settingsGateway; - } - - return new DirectorySettingsGateway( - (new SettingServicesFactory())->createSettingGateway() - ); - } } diff --git a/lib/Service/Tenant/TenantServicesFactory.php b/lib/Service/Tenant/TenantServicesFactory.php index 7675732..c1548cf 100644 --- a/lib/Service/Tenant/TenantServicesFactory.php +++ b/lib/Service/Tenant/TenantServicesFactory.php @@ -5,20 +5,19 @@ namespace MintyPHP\Service\Tenant; use MintyPHP\Repository\Org\DepartmentRepository; use MintyPHP\Repository\Tenant\TenantRepository; use MintyPHP\Repository\Tenant\UserTenantRepository; -use MintyPHP\Service\Access\AccessServicesFactory; use MintyPHP\Service\Access\PermissionGateway; class TenantServicesFactory { - private ?TenantRepository $tenantRepository = null; - private ?DepartmentRepository $departmentRepository = null; - private ?UserTenantRepository $userTenantRepository = null; - private ?AccessServicesFactory $accessServicesFactory = null; - private ?PermissionGateway $permissionGateway = null; private ?TenantScopeService $tenantScopeService = null; private ?TenantAvatarService $tenantAvatarService = null; private ?TenantFaviconService $tenantFaviconService = null; + public function __construct( + private readonly PermissionGateway $permissionGateway, + private readonly TenantRepositoryFactory $tenantRepositoryFactory + ) {} + public function createTenantScopeService(): TenantScopeService { return $this->tenantScopeService ??= new TenantScopeService( @@ -41,26 +40,21 @@ class TenantServicesFactory public function createTenantRepository(): TenantRepository { - return $this->tenantRepository ??= new TenantRepository(); + return $this->tenantRepositoryFactory->createTenantRepository(); } public function createDepartmentRepository(): DepartmentRepository { - return $this->departmentRepository ??= new DepartmentRepository(); + return $this->tenantRepositoryFactory->createDepartmentRepository(); } public function createUserTenantRepository(): UserTenantRepository { - return $this->userTenantRepository ??= new UserTenantRepository(); + return $this->tenantRepositoryFactory->createUserTenantRepository(); } public function createPermissionGateway(): PermissionGateway { - return $this->permissionGateway ??= $this->accessServicesFactory()->createPermissionGateway(); - } - - private function accessServicesFactory(): AccessServicesFactory - { - return $this->accessServicesFactory ??= new AccessServicesFactory(); + return $this->permissionGateway; } } diff --git a/lib/Service/Ui/HotkeyService.php b/lib/Service/Ui/HotkeyService.php index 429057e..492d816 100644 --- a/lib/Service/Ui/HotkeyService.php +++ b/lib/Service/Ui/HotkeyService.php @@ -17,6 +17,11 @@ class HotkeyService 'mac' => 'Cmd + K', 'win' => 'Ctrl + K', ], + [ + 'action_key' => 'Save in edit pages', + 'mac' => 'Cmd + S', + 'win' => 'Ctrl + S', + ], [ 'action_key' => 'Toggle sidebar', 'mac' => 'Cmd + B', diff --git a/lib/Service/User/UserAccessPdfService.php b/lib/Service/User/UserAccessPdfService.php index 6f16ba4..f16db00 100644 --- a/lib/Service/User/UserAccessPdfService.php +++ b/lib/Service/User/UserAccessPdfService.php @@ -6,24 +6,30 @@ use Dompdf\Dompdf; use Dompdf\Options; use Endroid\QrCode\QrCode; use Endroid\QrCode\Writer\PngWriter; -use MintyPHP\Service\Branding\BrandingServicesFactory; +use MintyPHP\Service\Branding\BrandingLogoService; use Throwable; use ZipArchive; class UserAccessPdfService { + public function __construct( + private readonly UserAccessTemplateService $userAccessTemplateService, + private readonly BrandingLogoService $brandingLogoService + ) { + } + // Returns PDF bytes for one user; empty string means rendering failed. - public static function renderUserAccessPdf(array $user): string + public function renderUserAccessPdf(array $user): string { if (!class_exists(Dompdf::class) || !class_exists(Options::class)) { return ''; } - $context = UserAccessTemplateService::buildContext($user); + $context = $this->userAccessTemplateService->buildContext($user); $locale = (string) ($context['locale'] ?? ''); $vars = is_array($context['vars'] ?? null) ? $context['vars'] : []; $vars['pdf_title'] = self::buildPdfTitle($locale); - $vars['app_logo_url'] = self::resolveLogoDataUri(); + $vars['app_logo_url'] = $this->resolveLogoDataUri(); $vars['login_qr_data_uri'] = self::buildLoginQrDataUri((string) ($vars['login_url'] ?? '')); $vars['generated_at'] = gmdate('Y-m-d H:i:s') . ' UTC'; @@ -49,7 +55,7 @@ class UserAccessPdfService // Creates one PDF per user and packs them into a temp ZIP file. // Caller is responsible for deleting the returned ZIP path after download. - public static function renderUsersAccessZip(array $users): array + public function renderUsersAccessZip(array $users): array { if (!class_exists(ZipArchive::class) || !$users) { return ['path' => '', 'filename' => '', 'count' => 0]; @@ -72,7 +78,7 @@ class UserAccessPdfService if (!is_array($user)) { continue; } - $pdf = self::renderUserAccessPdf($user); + $pdf = $this->renderUserAccessPdf($user); if ($pdf === '') { continue; } @@ -163,18 +169,17 @@ class UserAccessPdfService return $html; } - private static function resolveLogoDataUri(): string + private function resolveLogoDataUri(): string { $path = ''; $mime = ''; - $logoService = (new BrandingServicesFactory())->createBrandingLogoService(); // Prefer tenant/app branding logo first. - if ($logoService->hasLogo()) { - $logoPath = $logoService->findLogoPath(128); + if ($this->brandingLogoService->hasLogo()) { + $logoPath = $this->brandingLogoService->findLogoPath(128); if ($logoPath && is_file($logoPath)) { $path = $logoPath; - $mime = $logoService->detectMime($logoPath); + $mime = $this->brandingLogoService->detectMime($logoPath); } } diff --git a/lib/Service/User/UserAccessTemplateService.php b/lib/Service/User/UserAccessTemplateService.php index ab11b88..c479588 100644 --- a/lib/Service/User/UserAccessTemplateService.php +++ b/lib/Service/User/UserAccessTemplateService.php @@ -4,15 +4,22 @@ namespace MintyPHP\Service\User; use MintyPHP\Http\Request; use MintyPHP\I18n; -use MintyPHP\Repository\Tenant\TenantRepository; -use MintyPHP\Service\Auth\AuthServicesFactory; +use MintyPHP\Repository\Tenant\UserTenantRepository; +use MintyPHP\Service\Auth\TenantSsoService; class UserAccessTemplateService { + public function __construct( + private readonly TenantSsoService $tenantSsoService, + private readonly UserDirectoryGateway $userDirectoryGateway, + private readonly UserTenantRepository $userTenantRepository + ) { + } + // Builds locale-aware template variables shared by mail and PDF onboarding output. - public static function buildContext(array $user): array + public function buildContext(array $user): array { - $locale = self::resolveLocale($user); + $locale = $this->resolveLocale($user); $name = trim((string) (($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''))); $isGerman = str_starts_with($locale, 'de'); $greeting = $isGerman ? 'Hallo' : 'Hello'; @@ -21,7 +28,7 @@ class UserAccessTemplateService } $greeting .= ','; - $loginUrl = self::resolveTenantLoginUrl($user, $locale); + $loginUrl = $this->resolveTenantLoginUrl($user, $locale); $resetUrl = appUrl(Request::withLocale('password/forgot', $locale)); // Temporarily switch locale so subject translation matches recipient language. @@ -46,7 +53,7 @@ class UserAccessTemplateService ]; } - public static function resolveLocale(array $user): string + public function resolveLocale(array $user): string { // Keep user locale within configured APP_LOCALES; otherwise fallback to app default. $locale = strtolower(trim((string) ($user['locale'] ?? ''))); @@ -60,9 +67,8 @@ class UserAccessTemplateService return $locale; } - private static function resolveTenantLoginUrl(array $user, string $locale): string + private function resolveTenantLoginUrl(array $user, string $locale): string { - $tenantSsoService = (new AuthServicesFactory())->createTenantSsoService(); $tenantIds = []; $primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0); $currentTenantId = (int) ($user['current_tenant_id'] ?? 0); @@ -77,7 +83,7 @@ class UserAccessTemplateService if (!$tenantIds && $userId > 0) { $tenantIds = array_values(array_map( 'intval', - (new UserServicesFactory())->createUserTenantRepository()->listTenantIdsByUserId($userId) + $this->userTenantRepository->listTenantIdsByUserId($userId) )); } @@ -85,11 +91,11 @@ class UserAccessTemplateService if ($tenantId <= 0) { continue; } - $tenant = (new TenantRepository())->find($tenantId); + $tenant = $this->userDirectoryGateway->findTenant($tenantId); if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') { continue; } - $tenantSlug = $tenantSsoService->tenantLoginSlug($tenant); + $tenantSlug = $this->tenantSsoService->tenantLoginSlug($tenant); if ($tenantSlug !== '') { return appUrl(Request::withLocale('login?tenant=' . rawurlencode($tenantSlug), $locale)); } diff --git a/lib/Service/User/UserAccountService.php b/lib/Service/User/UserAccountService.php index f632b49..72be408 100644 --- a/lib/Service/User/UserAccountService.php +++ b/lib/Service/User/UserAccountService.php @@ -6,6 +6,7 @@ use MintyPHP\I18n; use MintyPHP\Repository\User\UserListQueryRepository; use MintyPHP\Repository\User\UserReadRepository; use MintyPHP\Repository\User\UserWriteRepository; +use MintyPHP\Service\Audit\SystemAuditService; class UserAccountService { @@ -16,7 +17,9 @@ class UserAccountService private readonly UserAssignmentService $userAssignmentService, private readonly UserPasswordService $userPasswordService, private readonly UserSettingsGateway $settingsGateway, - private readonly UserScopeGateway $scopeGateway + private readonly UserScopeGateway $scopeGateway, + private readonly UserDirectoryGateway $directoryGateway, + private readonly SystemAuditService $systemAuditService ) { } @@ -84,6 +87,13 @@ class UserAccountService return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; } + $this->systemAuditService->record('admin.users.delete', 'success', [ + 'actor_user_id' => $currentUserId > 0 ? $currentUserId : null, + 'target_type' => 'user', + 'target_id' => $userId, + 'target_uuid' => (string) ($user['uuid'] ?? ''), + ]); + return ['ok' => true, 'user' => $user]; } @@ -117,6 +127,16 @@ class UserAccountService return ['ok' => false, 'error' => 'delete_failed']; } + $this->systemAuditService->record('admin.users.bulk_update', 'success', [ + 'actor_user_id' => $currentUserId > 0 ? $currentUserId : null, + 'target_type' => 'user', + 'metadata' => [ + 'action' => 'delete', + 'count' => count($uuids), + 'target_uuids' => $uuids, + ], + ]); + return ['ok' => true, 'count' => count($uuids)]; } @@ -218,6 +238,18 @@ class UserAccountService $this->userAssignmentService->syncDepartments($userId, $departmentIds); } + $this->systemAuditService->record('admin.users.create', 'success', [ + 'actor_user_id' => $currentUserId > 0 ? $currentUserId : null, + 'target_type' => 'user', + 'target_id' => $userId > 0 ? $userId : null, + 'target_uuid' => is_string($uuid) ? $uuid : '', + 'metadata' => [ + 'assigned_tenant_ids' => $tenantIds, + 'assigned_role_ids' => $roleIds, + 'assigned_department_ids' => $departmentIds, + ], + ]); + return ['ok' => true, 'form' => $form, 'uuid' => $uuid]; } @@ -320,6 +352,25 @@ class UserAccountService $this->userAssignmentService->bumpAuthzVersion($userId); } + $this->systemAuditService->record('admin.users.update', 'success', [ + 'actor_user_id' => $currentUserId > 0 ? $currentUserId : null, + 'target_type' => 'user', + 'target_id' => $userId, + 'target_uuid' => (string) ($existing['uuid'] ?? ''), + 'before' => [ + 'active' => $existing['active'] ?? null, + 'locale' => $existing['locale'] ?? null, + 'theme' => $existing['theme'] ?? null, + 'primary_tenant_id' => $existing['primary_tenant_id'] ?? null, + ], + 'after' => [ + 'active' => $form['active'], + 'locale' => $form['locale'], + 'theme' => $form['theme'], + 'primary_tenant_id' => $form['primary_tenant_id'] ?? ($existing['primary_tenant_id'] ?? null), + ], + ]); + return ['ok' => true, 'form' => $form]; } @@ -352,6 +403,15 @@ class UserAccountService $this->userAssignmentService->bumpAuthzVersion($userId); + $this->systemAuditService->record($active ? 'admin.users.activate' : 'admin.users.deactivate', 'success', [ + 'actor_user_id' => $currentUserId > 0 ? $currentUserId : null, + 'target_type' => 'user', + 'target_id' => $userId, + 'target_uuid' => (string) ($user['uuid'] ?? ''), + 'before' => ['active' => $user['active'] ?? null], + 'after' => ['active' => $active ? 1 : 0], + ]); + return ['ok' => true, 'user' => $user]; } @@ -386,6 +446,16 @@ class UserAccountService $this->userWriteRepository->bumpAuthzVersionByUserIds($userIds); } + $this->systemAuditService->record('admin.users.bulk_update', 'success', [ + 'actor_user_id' => $currentUserId > 0 ? $currentUserId : null, + 'target_type' => 'user', + 'metadata' => [ + 'action' => $active ? 'activate' : 'deactivate', + 'count' => count($uuids), + 'target_uuids' => $uuids, + ], + ]); + return ['ok' => true, 'count' => count($uuids)]; } @@ -490,8 +560,7 @@ class UserAccountService if (array_key_exists('current_tenant_uuid', $input)) { $tenantUuid = trim((string) ($input['current_tenant_uuid'] ?? '')); if ($tenantUuid !== '') { - $tenantService = directoryServicesFactory()->createTenantService(); - $tenant = $tenantService->findByUuid($tenantUuid); + $tenant = $this->directoryGateway->findTenantByUuid($tenantUuid); if (!$tenant || !isset($tenant['id'])) { $errors[] = t('Tenant not found'); } else { diff --git a/lib/Service/User/UserApiWriteInputMapper.php b/lib/Service/User/UserApiWriteInputMapper.php new file mode 100644 index 0000000..2e63a38 --- /dev/null +++ b/lib/Service/User/UserApiWriteInputMapper.php @@ -0,0 +1,128 @@ + $input + * @return array{ok: bool, input?: array, errors?: array>} + */ + public function normalize(array $input): array + { + $errors = []; + foreach ([ + 'tenant_ids' => 'use_tenant_uuids', + 'primary_tenant_id' => 'use_primary_tenant_uuid', + 'role_ids' => 'use_role_uuids', + 'department_ids' => 'use_department_uuids', + ] as $legacyKey => $errorCode) { + if (array_key_exists($legacyKey, $input)) { + $errors[$legacyKey] = [$errorCode]; + } + } + if ($errors) { + return ['ok' => false, 'errors' => $errors]; + } + + $normalizeUuidList = static function (mixed $raw): array { + $values = is_array($raw) ? $raw : [$raw]; + $uuids = []; + foreach ($values as $value) { + $uuid = trim((string) $value); + if ($uuid !== '') { + $uuids[] = $uuid; + } + } + return array_values(array_unique($uuids)); + }; + + if (array_key_exists('tenant_uuids', $input)) { + $tenantUuids = $normalizeUuidList($input['tenant_uuids']); + $tenantIds = []; + foreach ($tenantUuids as $tenantUuid) { + $tenant = $this->tenantService->findByUuid($tenantUuid); + if (!$tenant) { + $errors['tenant_uuids'][] = 'not_found'; + continue; + } + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId > 0) { + $tenantIds[] = $tenantId; + } + } + $input['tenant_ids'] = array_values(array_unique($tenantIds)); + unset($input['tenant_uuids']); + } + + if (array_key_exists('primary_tenant_uuid', $input)) { + $primaryTenantUuid = trim((string) ($input['primary_tenant_uuid'] ?? '')); + if ($primaryTenantUuid === '') { + $input['primary_tenant_id'] = 0; + } else { + $primaryTenant = $this->tenantService->findByUuid($primaryTenantUuid); + if (!$primaryTenant) { + $errors['primary_tenant_uuid'][] = 'not_found'; + } else { + $input['primary_tenant_id'] = (int) ($primaryTenant['id'] ?? 0); + } + } + unset($input['primary_tenant_uuid']); + } + + if (array_key_exists('role_uuids', $input)) { + $roleUuids = $normalizeUuidList($input['role_uuids']); + $roleIds = []; + foreach ($roleUuids as $roleUuid) { + $role = $this->roleService->findByUuid($roleUuid); + if (!$role) { + $errors['role_uuids'][] = 'not_found'; + continue; + } + $roleId = (int) ($role['id'] ?? 0); + if ($roleId > 0) { + $roleIds[] = $roleId; + } + } + $input['role_ids'] = array_values(array_unique($roleIds)); + unset($input['role_uuids']); + } + + if (array_key_exists('department_uuids', $input)) { + $departmentUuids = $normalizeUuidList($input['department_uuids']); + $departmentIds = []; + foreach ($departmentUuids as $departmentUuid) { + $department = $this->departmentService->findByUuid($departmentUuid); + if (!$department) { + $errors['department_uuids'][] = 'not_found'; + continue; + } + $departmentId = (int) ($department['id'] ?? 0); + if ($departmentId > 0) { + $departmentIds[] = $departmentId; + } + } + $input['department_ids'] = array_values(array_unique($departmentIds)); + unset($input['department_uuids']); + } + + if ($errors) { + return ['ok' => false, 'errors' => $errors]; + } + + return ['ok' => true, 'input' => $input]; + } +} diff --git a/lib/Service/User/UserAssignmentService.php b/lib/Service/User/UserAssignmentService.php index 652c061..86160f7 100644 --- a/lib/Service/User/UserAssignmentService.php +++ b/lib/Service/User/UserAssignmentService.php @@ -4,6 +4,7 @@ namespace MintyPHP\Service\User; use MintyPHP\Repository\Access\UserRoleRepository; use MintyPHP\Repository\Org\UserDepartmentRepository; +use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\Tenant\UserTenantRepository; use MintyPHP\Repository\User\UserWriteRepository; @@ -15,7 +16,8 @@ class UserAssignmentService private readonly UserRoleRepository $userRoleRepository, private readonly UserDepartmentRepository $userDepartmentRepository, private readonly UserDirectoryGateway $directoryGateway, - private readonly UserPermissionGateway $permissionGateway + private readonly UserPermissionGateway $permissionGateway, + private readonly DatabaseSessionRepository $databaseSessionRepository ) { } @@ -224,6 +226,38 @@ class UserAssignmentService $this->userWriteRepository->bumpAuthzVersion($userId); } + public function syncAllAssignments(int $userId, array $tenantIds, array $roleIds, array $departmentIds): bool + { + if ($userId <= 0) { + return false; + } + + $transactionStarted = false; + try { + $this->databaseSessionRepository->beginTransaction(); + $transactionStarted = true; + + $ok = $this->syncTenants($userId, $tenantIds, false) + && $this->syncRoles($userId, $roleIds, false) + && $this->syncDepartments($userId, $departmentIds, false); + if (!$ok) { + $this->rollbackQuietly(); + return false; + } + + $this->bumpAuthzVersion($userId); + $this->databaseSessionRepository->commitTransaction(); + $transactionStarted = false; + + return true; + } catch (\Throwable $exception) { + if ($transactionStarted) { + $this->rollbackQuietly(); + } + return false; + } + } + public function normalizeIdInput($value): array { $raw = is_array($value) ? $value : [$value]; @@ -271,4 +305,13 @@ class UserAssignmentService } return $ids; } + + private function rollbackQuietly(): void + { + try { + $this->databaseSessionRepository->rollbackTransaction(); + } catch (\Throwable $exception) { + // no-op + } + } } diff --git a/lib/Service/User/UserDirectoryGateway.php b/lib/Service/User/UserDirectoryGateway.php index d94a628..8ee95d3 100644 --- a/lib/Service/User/UserDirectoryGateway.php +++ b/lib/Service/User/UserDirectoryGateway.php @@ -8,48 +8,73 @@ use MintyPHP\Repository\Tenant\TenantRepository; class UserDirectoryGateway { + public function __construct( + private readonly UserRepositoryFactory $userRepositoryFactory + ) { + } + public function listTenantIds(): array { - return (new TenantRepository())->listIds(); + return $this->tenantRepository()->listIds(); } public function listTenantsByIds(array $tenantIds): array { - return (new TenantRepository())->listByIds($tenantIds); + return $this->tenantRepository()->listByIds($tenantIds); } public function findTenant(int $tenantId): ?array { - return (new TenantRepository())->find($tenantId); + return $this->tenantRepository()->find($tenantId); + } + + public function findTenantByUuid(string $tenantUuid): ?array + { + return $this->tenantRepository()->findByUuid($tenantUuid); } public function listActiveTenantIdsByIds(array $tenantIds): array { - return (new TenantRepository())->listActiveIdsByIds($tenantIds); + return $this->tenantRepository()->listActiveIdsByIds($tenantIds); } public function listActiveRoleIds(): array { - return (new RoleRepository())->listActiveIds(); + return $this->roleRepository()->listActiveIds(); } public function listRolesByIds(array $roleIds): array { - return (new RoleRepository())->listByIds($roleIds); + return $this->roleRepository()->listByIds($roleIds); } public function listDepartmentsByIds(array $departmentIds, bool $includeInactive = true): array { - return (new DepartmentRepository())->listByIds($departmentIds, $includeInactive); + return $this->departmentRepository()->listByIds($departmentIds, $includeInactive); } public function listActiveDepartmentIdsByTenantIds(array $tenantIds): array { - return (new DepartmentRepository())->listActiveIdsByTenantIds($tenantIds); + return $this->departmentRepository()->listActiveIdsByTenantIds($tenantIds); } public function listDepartmentsByTenantIds(array $tenantIds): array { - return (new DepartmentRepository())->listByTenantIds($tenantIds); + return $this->departmentRepository()->listByTenantIds($tenantIds); + } + + private function tenantRepository(): TenantRepository + { + return $this->userRepositoryFactory->createTenantRepository(); + } + + private function roleRepository(): RoleRepository + { + return $this->userRepositoryFactory->createRoleRepository(); + } + + private function departmentRepository(): DepartmentRepository + { + return $this->userRepositoryFactory->createDepartmentRepository(); } } diff --git a/lib/Service/User/UserGatewayFactory.php b/lib/Service/User/UserGatewayFactory.php new file mode 100644 index 0000000..6290158 --- /dev/null +++ b/lib/Service/User/UserGatewayFactory.php @@ -0,0 +1,58 @@ +userSettingsGateway ??= new UserSettingsGateway($this->createSettingGateway()); + } + + public function createUserScopeGateway(): UserScopeGateway + { + return $this->userScopeGateway ??= new UserScopeGateway($this->tenantScopeService); + } + + public function createUserDirectoryGateway(): UserDirectoryGateway + { + return $this->userDirectoryGateway ??= new UserDirectoryGateway($this->userRepositoryFactory); + } + + public function createUserPermissionGateway(): UserPermissionGateway + { + return $this->userPermissionGateway ??= new UserPermissionGateway($this->createPermissionGateway()); + } + + private function createPermissionGateway(): PermissionGateway + { + return $this->permissionGateway ??= $this->accessServicesFactory->createPermissionGateway(); + } + + private function createSettingGateway(): SettingGateway + { + return $this->settingGateway ??= $this->settingServicesFactory->createSettingGateway(); + } + +} diff --git a/lib/Service/User/UserLifecycleRestoreService.php b/lib/Service/User/UserLifecycleRestoreService.php index 7f53413..5a04c5d 100644 --- a/lib/Service/User/UserLifecycleRestoreService.php +++ b/lib/Service/User/UserLifecycleRestoreService.php @@ -2,7 +2,7 @@ namespace MintyPHP\Service\User; -use MintyPHP\DB; +use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\Support\RepoQuery; use MintyPHP\Repository\User\UserReadRepository; use MintyPHP\Repository\User\UserWriteRepository; @@ -13,7 +13,8 @@ class UserLifecycleRestoreService public function __construct( private readonly UserReadRepository $userReadRepository, private readonly UserWriteRepository $userWriteRepository, - private readonly UserLifecycleAuditService $userLifecycleAuditService + private readonly UserLifecycleAuditService $userLifecycleAuditService, + private readonly DatabaseSessionRepository $databaseSessionRepository ) { } @@ -23,38 +24,40 @@ class UserLifecycleRestoreService return ['ok' => false, 'error' => 'invalid_request']; } - $db = DB::handle(); - $db->begin_transaction(); + $transactionStarted = false; try { + $this->databaseSessionRepository->beginTransaction(); + $transactionStarted = true; + $event = $this->userLifecycleAuditService->findDeleteEventForRestore($auditId, true); if (!$event) { - $db->rollback(); + $this->rollbackQuietly(); return ['ok' => false, 'error' => 'audit_event_not_found']; } if (!empty($event['restored_at'])) { - $db->rollback(); + $this->rollbackQuietly(); return ['ok' => false, 'error' => 'audit_event_already_restored']; } $snapshot = $this->userLifecycleAuditService->decryptSnapshot($event); if (!$snapshot) { - $db->rollback(); + $this->rollbackQuietly(); return ['ok' => false, 'error' => 'snapshot_unavailable']; } $uuid = trim((string) ($snapshot['uuid'] ?? '')); $email = trim((string) ($snapshot['email'] ?? '')); if ($uuid === '' || $email === '') { - $db->rollback(); + $this->rollbackQuietly(); return ['ok' => false, 'error' => 'snapshot_invalid']; } if ($this->userReadRepository->findByUuid($uuid)) { - $db->rollback(); + $this->rollbackQuietly(); return ['ok' => false, 'error' => 'restore_uuid_exists']; } if ($this->userReadRepository->findByEmail($email)) { - $db->rollback(); + $this->rollbackQuietly(); return ['ok' => false, 'error' => 'restore_email_exists']; } @@ -86,7 +89,7 @@ class UserLifecycleRestoreService 'active_changed_by' => $actorUserId, ]); if (!$createdId) { - $db->rollback(); + $this->rollbackQuietly(); return ['ok' => false, 'error' => 'restore_create_failed']; } $restoredUserId = (int) $createdId; @@ -97,7 +100,7 @@ class UserLifecycleRestoreService $restoredUserId ); if (!$marked) { - $db->rollback(); + $this->rollbackQuietly(); return ['ok' => false, 'error' => 'restore_mark_failed']; } @@ -118,7 +121,8 @@ class UserLifecycleRestoreService null ); - $db->commit(); + $this->databaseSessionRepository->commitTransaction(); + $transactionStarted = false; return [ 'ok' => true, 'restored_user_id' => $restoredUserId, @@ -126,15 +130,22 @@ class UserLifecycleRestoreService 'audit_id' => $auditId, ]; } catch (\Throwable $exception) { - if ($db->errno === 0) { - $db->rollback(); - } else { - $db->rollback(); + if ($transactionStarted) { + $this->rollbackQuietly(); } return ['ok' => false, 'error' => 'restore_unexpected_error']; } } + private function rollbackQuietly(): void + { + try { + $this->databaseSessionRepository->rollbackTransaction(); + } catch (\Throwable $exception) { + // no-op + } + } + private function nullableText(mixed $value): ?string { $value = trim((string) $value); diff --git a/lib/Service/User/UserLifecycleService.php b/lib/Service/User/UserLifecycleService.php index 2a02475..930ede9 100644 --- a/lib/Service/User/UserLifecycleService.php +++ b/lib/Service/User/UserLifecycleService.php @@ -2,7 +2,7 @@ namespace MintyPHP\Service\User; -use MintyPHP\DB; +use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\Support\RepoQuery; use MintyPHP\Repository\User\UserReadRepository; use MintyPHP\Repository\User\UserWriteRepository; @@ -18,7 +18,8 @@ class UserLifecycleService private readonly UserReadRepository $userReadRepository, private readonly UserWriteRepository $userWriteRepository, private readonly UserSettingsGateway $settingsGateway, - private readonly UserLifecycleAuditService $userLifecycleAuditService + private readonly UserLifecycleAuditService $userLifecycleAuditService, + private readonly DatabaseSessionRepository $databaseSessionRepository ) { } @@ -167,14 +168,13 @@ class UserLifecycleService private function acquireLock(): bool { - $gotLock = DB::selectValue('select GET_LOCK(?, 0) as got_lock', self::LOCK_NAME); - return (int) $gotLock === 1; + return $this->databaseSessionRepository->acquireAdvisoryLock(self::LOCK_NAME, 0); } private function releaseLock(): void { try { - DB::selectValue('select RELEASE_LOCK(?) as released_lock', self::LOCK_NAME); + $this->databaseSessionRepository->releaseAdvisoryLock(self::LOCK_NAME); } catch (\Throwable $exception) { // no-op } diff --git a/lib/Service/User/UserRepositoryFactory.php b/lib/Service/User/UserRepositoryFactory.php new file mode 100644 index 0000000..cbb7257 --- /dev/null +++ b/lib/Service/User/UserRepositoryFactory.php @@ -0,0 +1,78 @@ +userReadRepository ??= new UserReadRepository(); + } + + public function createUserWriteRepository(): UserWriteRepository + { + return $this->userWriteRepository ??= new UserWriteRepository(); + } + + public function createUserListQueryRepository(): UserListQueryRepository + { + return $this->userListQueryRepository ??= new UserListQueryRepository(); + } + + public function createUserSavedFilterRepository(): UserSavedFilterRepository + { + return $this->userSavedFilterRepository ??= new UserSavedFilterRepository(); + } + + public function createTenantRepository(): TenantRepository + { + return $this->tenantRepository ??= new TenantRepository(); + } + + public function createRoleRepository(): RoleRepository + { + return $this->roleRepository ??= new RoleRepository(); + } + + public function createDepartmentRepository(): DepartmentRepository + { + return $this->departmentRepository ??= new DepartmentRepository(); + } + + public function createUserTenantRepository(): UserTenantRepository + { + return $this->userTenantRepository ??= new UserTenantRepository(); + } + + public function createUserRoleRepository(): UserRoleRepository + { + return $this->userRoleRepository ??= new UserRoleRepository(); + } + + public function createUserDepartmentRepository(): UserDepartmentRepository + { + return $this->userDepartmentRepository ??= new UserDepartmentRepository(); + } +} diff --git a/lib/Service/User/UserScopeGateway.php b/lib/Service/User/UserScopeGateway.php index 8f829c1..9f095bb 100644 --- a/lib/Service/User/UserScopeGateway.php +++ b/lib/Service/User/UserScopeGateway.php @@ -3,35 +3,26 @@ namespace MintyPHP\Service\User; use MintyPHP\Service\Tenant\TenantScopeService; -use MintyPHP\Service\Tenant\TenantServicesFactory; class UserScopeGateway { - public function __construct(private readonly ?TenantScopeService $tenantScopeService = null) - { + public function __construct( + private readonly TenantScopeService $tenantScopeService + ) { } public function hasGlobalAccess(int $userId): bool { - return $this->tenantScopeService()->hasGlobalAccess($userId); + return $this->tenantScopeService->hasGlobalAccess($userId); } public function canAccess(string $resourceType, int $resourceId, int $userId): bool { - return $this->tenantScopeService()->canAccess($resourceType, $resourceId, $userId); + return $this->tenantScopeService->canAccess($resourceType, $resourceId, $userId); } public function getUserTenantIds(int $userId): array { - return $this->tenantScopeService()->getUserTenantIds($userId); - } - - private function tenantScopeService(): TenantScopeService - { - if ($this->tenantScopeService instanceof TenantScopeService) { - return $this->tenantScopeService; - } - - return (new TenantServicesFactory())->createTenantScopeService(); + return $this->tenantScopeService->getUserTenantIds($userId); } } diff --git a/lib/Service/User/UserServicesFactory.php b/lib/Service/User/UserServicesFactory.php index 511eb10..b252306 100644 --- a/lib/Service/User/UserServicesFactory.php +++ b/lib/Service/User/UserServicesFactory.php @@ -2,10 +2,9 @@ namespace MintyPHP\Service\User; -use MintyPHP\Service\Access\AccessServicesFactory; -use MintyPHP\Service\Access\PermissionGateway; use MintyPHP\Repository\Access\UserRoleRepository; use MintyPHP\Repository\Org\UserDepartmentRepository; +use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\Tenant\UserTenantRepository; use MintyPHP\Repository\User\UserListQueryRepository; use MintyPHP\Repository\User\UserReadRepository; @@ -13,33 +12,13 @@ use MintyPHP\Repository\User\UserSavedFilterRepository; use MintyPHP\Repository\User\UserWriteRepository; use MintyPHP\Service\Audit\AuditServicesFactory; use MintyPHP\Service\Audit\UserLifecycleAuditService; -use MintyPHP\Service\Settings\SettingGateway; -use MintyPHP\Service\Settings\SettingServicesFactory; -use MintyPHP\Service\Tenant\TenantServicesFactory; class UserServicesFactory { - private ?AccessServicesFactory $accessServicesFactory = null; - private ?TenantServicesFactory $tenantServicesFactory = null; - private ?UserReadRepository $userReadRepository = null; - private ?AuditServicesFactory $auditServicesFactory = null; - private ?SettingServicesFactory $settingServicesFactory = null; - private ?UserWriteRepository $userWriteRepository = null; - private ?UserListQueryRepository $userListQueryRepository = null; - private ?UserSavedFilterRepository $userSavedFilterRepository = null; - private ?UserTenantRepository $userTenantRepository = null; - private ?UserRoleRepository $userRoleRepository = null; - private ?UserDepartmentRepository $userDepartmentRepository = null; private ?UserPasswordPolicyService $userPasswordPolicyService = null; private ?UserPasswordService $userPasswordService = null; private ?UserAvatarService $userAvatarService = null; private ?UserSavedFilterService $userSavedFilterService = null; - private ?UserSettingsGateway $userSettingsGateway = null; - private ?SettingGateway $settingGateway = null; - private ?UserScopeGateway $userScopeGateway = null; - private ?UserDirectoryGateway $userDirectoryGateway = null; - private ?UserPermissionGateway $userPermissionGateway = null; - private ?PermissionGateway $permissionGateway = null; private ?UserAssignmentService $userAssignmentService = null; private ?UserTenantContextService $userTenantContextService = null; private ?UserAccountService $userAccountService = null; @@ -47,6 +26,13 @@ class UserServicesFactory private ?UserLifecycleRestoreService $userLifecycleRestoreService = null; private ?UserLifecycleAuditService $userLifecycleAuditService = null; + public function __construct( + private readonly AuditServicesFactory $auditServicesFactory, + private readonly UserRepositoryFactory $userRepositoryFactory, + private readonly UserGatewayFactory $userGatewayFactory, + private readonly DatabaseSessionRepository $databaseSessionRepository + ) {} + public function createUserAccountService(): UserAccountService { return $this->userAccountService ??= new UserAccountService( @@ -56,7 +42,9 @@ class UserServicesFactory $this->createUserAssignmentService(), $this->createUserPasswordService(), $this->createUserSettingsGateway(), - $this->createUserScopeGateway() + $this->createUserScopeGateway(), + $this->createUserDirectoryGateway(), + $this->auditServicesFactory->createSystemAuditService() ); } @@ -68,7 +56,8 @@ class UserServicesFactory $this->createUserRoleRepository(), $this->createUserDepartmentRepository(), $this->createUserDirectoryGateway(), - $this->createUserPermissionGateway() + $this->createUserPermissionGateway(), + $this->databaseSessionRepository ); } @@ -105,37 +94,37 @@ class UserServicesFactory public function createUserReadRepository(): UserReadRepository { - return $this->userReadRepository ??= new UserReadRepository(); + return $this->userRepositoryFactory->createUserReadRepository(); } public function createUserWriteRepository(): UserWriteRepository { - return $this->userWriteRepository ??= new UserWriteRepository(); + return $this->userRepositoryFactory->createUserWriteRepository(); } public function createUserListQueryRepository(): UserListQueryRepository { - return $this->userListQueryRepository ??= new UserListQueryRepository(); + return $this->userRepositoryFactory->createUserListQueryRepository(); } public function createUserSavedFilterRepository(): UserSavedFilterRepository { - return $this->userSavedFilterRepository ??= new UserSavedFilterRepository(); + return $this->userRepositoryFactory->createUserSavedFilterRepository(); } public function createUserTenantRepository(): UserTenantRepository { - return $this->userTenantRepository ??= new UserTenantRepository(); + return $this->userRepositoryFactory->createUserTenantRepository(); } public function createUserRoleRepository(): UserRoleRepository { - return $this->userRoleRepository ??= new UserRoleRepository(); + return $this->userRepositoryFactory->createUserRoleRepository(); } public function createUserDepartmentRepository(): UserDepartmentRepository { - return $this->userDepartmentRepository ??= new UserDepartmentRepository(); + return $this->userRepositoryFactory->createUserDepartmentRepository(); } public function createUserPasswordPolicyService(): UserPasswordPolicyService @@ -145,47 +134,22 @@ class UserServicesFactory public function createUserSettingsGateway(): UserSettingsGateway { - return $this->userSettingsGateway ??= new UserSettingsGateway($this->createSettingGateway()); + return $this->userGatewayFactory->createUserSettingsGateway(); } public function createUserScopeGateway(): UserScopeGateway { - return $this->userScopeGateway ??= new UserScopeGateway($this->tenantServicesFactory()->createTenantScopeService()); + return $this->userGatewayFactory->createUserScopeGateway(); } public function createUserDirectoryGateway(): UserDirectoryGateway { - return $this->userDirectoryGateway ??= new UserDirectoryGateway(); + return $this->userGatewayFactory->createUserDirectoryGateway(); } public function createUserPermissionGateway(): UserPermissionGateway { - return $this->userPermissionGateway ??= new UserPermissionGateway($this->createPermissionGateway()); - } - - private function createPermissionGateway(): PermissionGateway - { - return $this->permissionGateway ??= $this->accessServicesFactory()->createPermissionGateway(); - } - - private function accessServicesFactory(): AccessServicesFactory - { - return $this->accessServicesFactory ??= new AccessServicesFactory(); - } - - private function createSettingGateway(): SettingGateway - { - return $this->settingGateway ??= $this->settingServicesFactory()->createSettingGateway(); - } - - private function settingServicesFactory(): SettingServicesFactory - { - return $this->settingServicesFactory ??= new SettingServicesFactory(); - } - - private function tenantServicesFactory(): TenantServicesFactory - { - return $this->tenantServicesFactory ??= new TenantServicesFactory(); + return $this->userGatewayFactory->createUserPermissionGateway(); } public function createUserLifecycleService(): UserLifecycleService @@ -194,7 +158,8 @@ class UserServicesFactory $this->createUserReadRepository(), $this->createUserWriteRepository(), $this->createUserSettingsGateway(), - $this->createUserLifecycleAuditService() + $this->createUserLifecycleAuditService(), + $this->databaseSessionRepository ); } @@ -203,17 +168,13 @@ class UserServicesFactory return $this->userLifecycleRestoreService ??= new UserLifecycleRestoreService( $this->createUserReadRepository(), $this->createUserWriteRepository(), - $this->createUserLifecycleAuditService() + $this->createUserLifecycleAuditService(), + $this->databaseSessionRepository ); } private function createUserLifecycleAuditService(): UserLifecycleAuditService { - return $this->userLifecycleAuditService ??= $this->auditServicesFactory()->createUserLifecycleAuditService(); - } - - private function auditServicesFactory(): AuditServicesFactory - { - return $this->auditServicesFactory ??= new AuditServicesFactory(); + return $this->userLifecycleAuditService ??= $this->auditServicesFactory->createUserLifecycleAuditService(); } } diff --git a/lib/Support/Guard.php b/lib/Support/Guard.php index 8122ad1..3b7fae9 100644 --- a/lib/Support/Guard.php +++ b/lib/Support/Guard.php @@ -4,16 +4,30 @@ namespace MintyPHP\Support; use MintyPHP\Http\Request; use MintyPHP\Router; -use MintyPHP\Service\Access\AccessServicesFactory; -use MintyPHP\Service\Access\PermissionGateway; -use MintyPHP\Service\Auth\AuthServicesFactory; -use MintyPHP\Service\Directory\DirectoryServicesFactory; +use MintyPHP\Service\Access\AuthorizationDecision; +use MintyPHP\Service\Access\AuthorizationService; +use MintyPHP\Service\Auth\AuthService; +use MintyPHP\Service\Tenant\TenantService; class Guard { private const TENANT_REFRESH_INTERVAL = 60; - private static ?AccessServicesFactory $accessServicesFactory = null; - private static ?PermissionGateway $permissionGateway = null; + /** @var (callable(): AuthService)|null */ + private static $authServiceResolver = null; + /** @var (callable(): TenantService)|null */ + private static $tenantServiceResolver = null; + /** @var (callable(): AuthorizationService)|null */ + private static $authorizationServiceResolver = null; + + public static function configure( + callable $authServiceResolver, + callable $tenantServiceResolver, + callable $authorizationServiceResolver + ): void { + self::$authServiceResolver = $authServiceResolver; + self::$tenantServiceResolver = $tenantServiceResolver; + self::$authorizationServiceResolver = $authorizationServiceResolver; + } public static function requireLogin(string $redirect = 'login'): void { @@ -23,7 +37,7 @@ class Guard } self::refreshTenantContext(); if (!empty($_SESSION['no_active_tenant'])) { - (new AuthServicesFactory())->createAuthService()->logout(); + self::authService()->logout(); Flash::error('No active tenant assigned', 'login', 'no_active_tenant'); Router::redirect($redirect); } @@ -37,7 +51,7 @@ class Guard } $tenantId = (int) $currentTenant['id']; - $tenant = (new DirectoryServicesFactory())->createTenantService()->findById($tenantId); + $tenant = self::tenantService()->findById($tenantId); if ($tenant) { return; } @@ -45,7 +59,7 @@ class Guard // Current tenant was deleted, refresh session data $userId = (int) ($_SESSION['user']['id'] ?? 0); if ($userId > 0) { - (new AuthServicesFactory())->createAuthService()->loadTenantDataIntoSession($userId); + self::authService()->loadTenantDataIntoSession($userId); } else { unset($_SESSION['current_tenant']); } @@ -62,54 +76,114 @@ class Guard if ($last > 0 && ($now - $last) < self::TENANT_REFRESH_INTERVAL) { return; } - (new AuthServicesFactory())->createAuthService()->loadTenantDataIntoSession($userId); + self::authService()->loadTenantDataIntoSession($userId); $_SESSION['tenant_context_refreshed_at'] = $now; } - public static function requirePermission(string $permissionKey, string $redirect = 'admin'): void + public static function requireAbility(string $ability, string $redirect = 'admin', array $context = []): void { self::requireLogin(); self::validateCurrentTenant(); $userId = (int) ($_SESSION['user']['id'] ?? 0); - if (!$userId || !self::permissionGateway()->userHas($userId, $permissionKey)) { + if (!self::isAllowedByAbility($userId, $ability, $context)) { Flash::error('Permission denied', $redirect, 'permission_denied'); Router::redirect($redirect); } } - public static function requirePermissionOrForbidden(string $permissionKey): void + public static function requireAbilityOrForbidden(string $ability, array $context = []): void + { + self::requireAbilityDecisionOrForbidden($ability, $context); + } + + public static function requireAbilityDecisionOrForbidden(string $ability, array $context = []): AuthorizationDecision { self::requireLogin(); self::validateCurrentTenant(); $userId = (int) ($_SESSION['user']['id'] ?? 0); - if (!$userId || !self::permissionGateway()->userHas($userId, $permissionKey)) { + $decision = self::authorizationService()->authorize($ability, [ + 'actor_user_id' => $userId, + ...$context, + ]); + if (!$decision->isAllowed()) { if (Request::wantsJson()) { - http_response_code(403); + http_response_code($decision->status()); header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['error' => 'forbidden']); + $error = trim($decision->error()); + echo json_encode(['error' => $error !== '' ? $error : 'forbidden']); exit; } Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery())); } + + return $decision; } - private static function permissionGateway(): PermissionGateway + public static function deny(string $redirect = 'error/forbidden'): void { - if (self::$permissionGateway instanceof PermissionGateway) { - return self::$permissionGateway; + if (Request::wantsJson()) { + http_response_code(403); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['error' => 'forbidden']); + exit; } - self::$permissionGateway = self::accessServicesFactory()->createPermissionGateway(); - return self::$permissionGateway; + if ($redirect === 'error/forbidden') { + Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery())); + return; + } + + Router::redirect($redirect); } - private static function accessServicesFactory(): AccessServicesFactory + private static function authorizationService(): AuthorizationService { - if (self::$accessServicesFactory instanceof AccessServicesFactory) { - return self::$accessServicesFactory; + if (!is_callable(self::$authorizationServiceResolver)) { + throw new \RuntimeException('Guard is not configured for dependency: ' . AuthorizationService::class); + } + $service = (self::$authorizationServiceResolver)(); + if (!$service instanceof AuthorizationService) { + throw new \RuntimeException('Guard resolver returned invalid dependency: ' . AuthorizationService::class); + } + return $service; + } + + private static function authService(): AuthService + { + if (!is_callable(self::$authServiceResolver)) { + throw new \RuntimeException('Guard is not configured for dependency: ' . AuthService::class); + } + $service = (self::$authServiceResolver)(); + if (!$service instanceof AuthService) { + throw new \RuntimeException('Guard resolver returned invalid dependency: ' . AuthService::class); + } + return $service; + } + + private static function tenantService(): TenantService + { + if (!is_callable(self::$tenantServiceResolver)) { + throw new \RuntimeException('Guard is not configured for dependency: ' . TenantService::class); + } + $service = (self::$tenantServiceResolver)(); + if (!$service instanceof TenantService) { + throw new \RuntimeException('Guard resolver returned invalid dependency: ' . TenantService::class); + } + return $service; + } + + private static function isAllowedByAbility(int $userId, string $ability, array $context = []): bool + { + $ability = trim($ability); + if ($userId <= 0 || $ability === '') { + return false; } - self::$accessServicesFactory = new AccessServicesFactory(); - return self::$accessServicesFactory; + $decision = self::authorizationService()->authorize($ability, [ + 'actor_user_id' => $userId, + ...$context, + ]); + + return $decision->isAllowed(); } } diff --git a/lib/Support/Search/SearchItemMapperProvider.php b/lib/Support/Search/SearchItemMapperProvider.php index 366223a..dd3ca6e 100644 --- a/lib/Support/Search/SearchItemMapperProvider.php +++ b/lib/Support/Search/SearchItemMapperProvider.php @@ -3,11 +3,16 @@ namespace MintyPHP\Support\Search; use MintyPHP\Service\User\UserAvatarService; -use MintyPHP\Service\User\UserServicesFactory; class SearchItemMapperProvider { - private static ?UserAvatarService $userAvatarService = null; + /** @var (callable(): UserAvatarService)|null */ + private static $userAvatarServiceResolver = null; + + public static function configure(callable $userAvatarServiceResolver): void + { + self::$userAvatarServiceResolver = $userAvatarServiceResolver; + } public static function mapPreviewItem(string $key, array $data, string $query): ?array { @@ -59,6 +64,15 @@ class SearchItemMapperProvider $label = $requestId; } $url = lurl('admin/api-audit/view/' . ($data['id'] ?? '')) . '?search=' . urlencode($query); + } elseif ($key === 'system-audit') { + $eventType = trim((string) ($data['event_type'] ?? '')); + $outcome = strtolower(trim((string) ($data['outcome'] ?? ''))); + $requestId = trim((string) ($data['request_id'] ?? '')); + $label = $eventType !== '' ? $eventType : $requestId; + if ($outcome !== '') { + $label = trim($label . ' — ' . $outcome); + } + $url = lurl('admin/system-audit/view/' . ($data['id'] ?? '')) . '?search=' . urlencode($query); } elseif ($key === 'pages') { $label = (string) ($data['slug'] ?? ''); $url = lurl('page/' . $label) . '?search=' . urlencode($query); @@ -90,7 +104,8 @@ class SearchItemMapperProvider $description = (string) ($data['email'] ?? ''); $uuid = (string) ($data['uuid'] ?? ''); $url = lurl('address-book/view/' . $uuid); - if ($uuid !== '' && self::userAvatarService()->hasAvatar($uuid)) { + $userAvatarService = self::userAvatarService(); + if ($uuid !== '' && $userAvatarService->hasAvatar($uuid)) { $image = lurl('admin/users/avatar-file') . '?uuid=' . urlencode($uuid) . '&size=64'; } } elseif ($key === 'permissions') { @@ -128,6 +143,13 @@ class SearchItemMapperProvider } $description = trim(($statusCode > 0 ? (string) $statusCode : '') . ($requestId !== '' ? ' — ' . $requestId : '')); $url = lurl('admin/api-audit/view/' . ($data['id'] ?? '')); + } elseif ($key === 'system-audit') { + $eventType = trim((string) ($data['event_type'] ?? '')); + $outcome = strtolower(trim((string) ($data['outcome'] ?? ''))); + $requestId = trim((string) ($data['request_id'] ?? '')); + $title = $eventType !== '' ? $eventType : $requestId; + $description = trim(($outcome !== '' ? strtoupper($outcome) : '') . ($requestId !== '' ? ' — ' . $requestId : '')); + $url = lurl('admin/system-audit/view/' . ($data['id'] ?? '')); } else { $title = (string) ($data['description'] ?? ''); $description = $label; @@ -150,11 +172,16 @@ class SearchItemMapperProvider private static function userAvatarService(): UserAvatarService { - if (self::$userAvatarService instanceof UserAvatarService) { - return self::$userAvatarService; + if (!is_callable(self::$userAvatarServiceResolver)) { + throw new \RuntimeException('SearchItemMapperProvider is not configured for dependency: ' . UserAvatarService::class); } - self::$userAvatarService = (new UserServicesFactory())->createUserAvatarService(); - return self::$userAvatarService; + $service = (self::$userAvatarServiceResolver)(); + if (!$service instanceof UserAvatarService) { + throw new \RuntimeException('SearchItemMapperProvider resolver returned invalid dependency: ' . UserAvatarService::class); + } + + return $service; } + } diff --git a/lib/Support/Search/SearchSqlResourceProvider.php b/lib/Support/Search/SearchSqlResourceProvider.php index 5676afc..b573fc8 100644 --- a/lib/Support/Search/SearchSqlResourceProvider.php +++ b/lib/Support/Search/SearchSqlResourceProvider.php @@ -131,6 +131,17 @@ class SearchSqlResourceProvider 'resultSql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc", 'resultParams' => [$like, $like, $like, $like], ], + [ + 'key' => 'system-audit', + 'label' => t('System audit'), + 'permission' => PermissionService::SYSTEM_AUDIT_VIEW, + 'countSql' => "select count(*) from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\')", + 'countParams' => [$like, $like, $like, $like], + 'previewSql' => "select id, request_id, event_type, outcome, created_at from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\') order by created_at desc limit ?", + 'previewParams' => [$like, $like, $like, $like], + 'resultSql' => "select id, request_id, event_type, outcome, created_at from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\') order by created_at desc", + 'resultParams' => [$like, $like, $like, $like], + ], ]; } diff --git a/lib/Support/Search/SearchUiMetaProvider.php b/lib/Support/Search/SearchUiMetaProvider.php index 9fd0d87..fe5e9a0 100644 --- a/lib/Support/Search/SearchUiMetaProvider.php +++ b/lib/Support/Search/SearchUiMetaProvider.php @@ -15,6 +15,7 @@ class SearchUiMetaProvider 'pages' => 'bi-file-text', 'mail-log' => 'bi-envelope-paper', 'api-audit' => 'bi-shield-check', + 'system-audit' => 'bi-journal-lock', 'docs' => 'bi-journal-text', ]; @@ -29,6 +30,7 @@ class SearchUiMetaProvider 'pages', 'mail-log', 'api-audit', + 'system-audit', ]; public static function iconForKey(string $key): string @@ -51,6 +53,7 @@ class SearchUiMetaProvider 'pages' => lurl(''), 'mail-log' => lurl('admin/mail-log') . '?search=' . $encoded, 'api-audit' => lurl('admin/api-audit') . '?search=' . $encoded, + 'system-audit' => lurl('admin/system-audit') . '?search=' . $encoded, default => lurl(''), }; } diff --git a/lib/Support/helpers.php b/lib/Support/helpers.php index a5d190a..7d83516 100644 --- a/lib/Support/helpers.php +++ b/lib/Support/helpers.php @@ -3,5 +3,7 @@ // Load all global helper groups used by templates and page actions. require __DIR__ . '/helpers/app.php'; require __DIR__ . '/helpers/auth.php'; +require __DIR__ . '/helpers/grid.php'; require __DIR__ . '/helpers/i18n.php'; +require __DIR__ . '/helpers/request.php'; require __DIR__ . '/helpers/ui.php'; diff --git a/lib/Support/helpers/app.php b/lib/Support/helpers/app.php index 64ccfee..bc1699e 100644 --- a/lib/Support/helpers/app.php +++ b/lib/Support/helpers/app.php @@ -24,6 +24,59 @@ function templatePath(string $path): string return dirname(__DIR__, 3) . '/templates/' . ltrim($path, '/'); } +/** + * Register the app service container for the current request. + */ +function setAppContainer(\MintyPHP\App\AppContainer $container): void +{ + $GLOBALS['minty_app_container'] = $container; + + \MintyPHP\Http\ApiAuth::configure( + static fn (): \MintyPHP\Service\Auth\AuthScopeGateway => $container->get(\MintyPHP\Service\Auth\AuthScopeGateway::class), + static fn (): \MintyPHP\Service\Auth\ApiTokenService => $container->get(\MintyPHP\Service\Auth\ApiTokenService::class), + static fn (): \MintyPHP\Repository\Access\UserRoleRepository => $container->get(\MintyPHP\Repository\Access\UserRoleRepository::class), + static fn (): \MintyPHP\Repository\Access\RolePermissionRepository => $container->get(\MintyPHP\Repository\Access\RolePermissionRepository::class), + static fn (): \MintyPHP\Service\User\UserTenantContextService => $container->get(\MintyPHP\Service\User\UserTenantContextService::class), + static fn (): \MintyPHP\Service\Access\AuthorizationService => $container->get(\MintyPHP\Service\Access\AuthorizationService::class) + ); + + \MintyPHP\Http\ApiBootstrap::configure( + static fn (): \MintyPHP\Service\Audit\ApiAuditService => $container->get(\MintyPHP\Service\Audit\ApiAuditService::class), + static fn (): \MintyPHP\Service\Settings\SettingGateway => $container->get(\MintyPHP\Service\Settings\SettingGateway::class), + static fn (): \MintyPHP\Service\Security\RateLimiterService => $container->get(\MintyPHP\Service\Security\RateLimiterService::class), + static fn (): \MintyPHP\Http\ApiSystemAuditReporter => $container->get(\MintyPHP\Http\ApiSystemAuditReporter::class) + ); + + \MintyPHP\Http\ApiResponse::configure( + static fn (): \MintyPHP\Service\Audit\ApiAuditService => $container->get(\MintyPHP\Service\Audit\ApiAuditService::class), + static fn (): \MintyPHP\Service\Access\AuthorizationService => $container->get(\MintyPHP\Service\Access\AuthorizationService::class), + static fn (): \MintyPHP\Http\ApiSystemAuditReporter => $container->get(\MintyPHP\Http\ApiSystemAuditReporter::class) + ); + + \MintyPHP\Support\Guard::configure( + static fn (): \MintyPHP\Service\Auth\AuthService => $container->get(\MintyPHP\Service\Auth\AuthService::class), + static fn (): \MintyPHP\Service\Tenant\TenantService => $container->get(\MintyPHP\Service\Tenant\TenantService::class), + static fn (): \MintyPHP\Service\Access\AuthorizationService => $container->get(\MintyPHP\Service\Access\AuthorizationService::class) + ); + + \MintyPHP\Support\Search\SearchItemMapperProvider::configure( + static fn (): \MintyPHP\Service\User\UserAvatarService => $container->get(\MintyPHP\Service\User\UserAvatarService::class) + ); +} + +/** + * Resolve a service from the app container. + */ +function app(string $id): mixed +{ + $container = $GLOBALS['minty_app_container'] ?? null; + if (!$container instanceof \MintyPHP\App\AppContainer) { + throw new \RuntimeException('App container is not initialized'); + } + + return $container->get($id); +} + /** * Build an asset URL with filemtime cache-busting when available. */ @@ -127,72 +180,11 @@ function appLogoUrlAbsolute(int $size = 128): string */ function appSetting(string $key): ?string { - if (!class_exists('MintyPHP\\Service\\Settings\\SettingServicesFactory')) { + if (!class_exists('MintyPHP\\Service\\Settings\\SettingCacheService')) { return null; } - static $settingsFactory = null; - if (!$settingsFactory instanceof \MintyPHP\Service\Settings\SettingServicesFactory) { - $settingsFactory = new \MintyPHP\Service\Settings\SettingServicesFactory(); - } - - return $settingsFactory->createSettingCacheService()->get($key); -} - -/** - * Shared per-request factory for tenant/department/role domain services. - */ -function directoryServicesFactory(): \MintyPHP\Service\Directory\DirectoryServicesFactory -{ - static $factory = null; - if (!$factory instanceof \MintyPHP\Service\Directory\DirectoryServicesFactory) { - $factory = new \MintyPHP\Service\Directory\DirectoryServicesFactory(); - } - return $factory; -} - -/** - * Shared per-request factory for access/permission services. - */ -function accessServicesFactory(): \MintyPHP\Service\Access\AccessServicesFactory -{ - static $factory = null; - if (!$factory instanceof \MintyPHP\Service\Access\AccessServicesFactory) { - $factory = new \MintyPHP\Service\Access\AccessServicesFactory(); - } - return $factory; -} - -/** - * Shared per-request permission gateway wrapper. - */ -function permissionGateway(): \MintyPHP\Service\Access\PermissionGateway -{ - return accessServicesFactory()->createPermissionGateway(); -} - -/** - * Shared per-request factory for audit services. - */ -function auditServicesFactory(): \MintyPHP\Service\Audit\AuditServicesFactory -{ - static $factory = null; - if (!$factory instanceof \MintyPHP\Service\Audit\AuditServicesFactory) { - $factory = new \MintyPHP\Service\Audit\AuditServicesFactory(); - } - return $factory; -} - -/** - * Shared per-request factory for branding services. - */ -function brandingServicesFactory(): \MintyPHP\Service\Branding\BrandingServicesFactory -{ - static $factory = null; - if (!$factory instanceof \MintyPHP\Service\Branding\BrandingServicesFactory) { - $factory = new \MintyPHP\Service\Branding\BrandingServicesFactory(); - } - return $factory; + return app(\MintyPHP\Service\Settings\SettingCacheService::class)->get($key); } /** @@ -200,19 +192,14 @@ function brandingServicesFactory(): \MintyPHP\Service\Branding\BrandingServicesF */ function appThemes(): array { - if (!class_exists('MintyPHP\\Service\\Settings\\SettingServicesFactory')) { + if (!class_exists('MintyPHP\\Service\\Settings\\ThemeConfigService')) { return [ 'light' => 'Light', 'dark' => 'Dark', ]; } - static $settingsFactory = null; - if (!$settingsFactory instanceof \MintyPHP\Service\Settings\SettingServicesFactory) { - $settingsFactory = new \MintyPHP\Service\Settings\SettingServicesFactory(); - } - - return $settingsFactory->createThemeConfigService()->all(); + return app(\MintyPHP\Service\Settings\ThemeConfigService::class)->all(); } /** @@ -393,8 +380,8 @@ function appPrimaryCssVars(): string */ function appLogoUrl(?int $size = null): string { - if (class_exists('MintyPHP\\Service\\Branding\\BrandingServicesFactory')) { - $logoService = brandingServicesFactory()->createBrandingLogoService(); + if (class_exists('MintyPHP\\Service\\Branding\\BrandingLogoService')) { + $logoService = app(\MintyPHP\Service\Branding\BrandingLogoService::class); if ($logoService->hasLogo()) { $query = $size ? '?size=' . (int) $size : ''; return lurl('branding/logo' . $query); @@ -409,13 +396,8 @@ function appLogoUrl(?int $size = null): string function appFaviconUrl(string $file): string { $tenantUuid = $_SESSION['current_tenant']['uuid'] ?? ''; - if ($tenantUuid !== '' && class_exists('MintyPHP\\Service\\Tenant\\TenantServicesFactory')) { - static $tenantServicesFactory = null; - if (!$tenantServicesFactory instanceof \MintyPHP\Service\Tenant\TenantServicesFactory) { - $tenantServicesFactory = new \MintyPHP\Service\Tenant\TenantServicesFactory(); - } - - if ($tenantServicesFactory->createTenantFaviconService()->hasFavicon($tenantUuid)) { + if ($tenantUuid !== '' && class_exists('MintyPHP\\Service\\Tenant\\TenantFaviconService')) { + if (app(\MintyPHP\Service\Tenant\TenantFaviconService::class)->hasFavicon($tenantUuid)) { return asset('favicon/tenants/' . $tenantUuid . '/favicon/' . ltrim($file, '/')); } } diff --git a/lib/Support/helpers/auth.php b/lib/Support/helpers/auth.php index 939691f..86fb472 100644 --- a/lib/Support/helpers/auth.php +++ b/lib/Support/helpers/auth.php @@ -9,29 +9,3 @@ function accountUrl(): string $uuid = (string) ($user['uuid'] ?? ''); return $uuid !== '' ? lurl('profile') : lurl('login'); } - -/** - * Check if the current user has a permission key. - */ -function can(string $permissionKey): bool -{ - $userId = (int) ($_SESSION['user']['id'] ?? 0); - if ($userId <= 0) { - return false; - } - if (!function_exists('permissionGateway')) { - return false; - } - - $permissionGateway = permissionGateway(); - - // First try the in-memory/session cache, then fall back to a direct fetch. - $keys = $permissionGateway->getCachedPermissions($userId); - if (!$keys) { - $keys = $permissionGateway->getUserPermissions($userId); - if (!$keys) { - return false; - } - } - return in_array($permissionKey, $keys, true); -} diff --git a/lib/Support/helpers/grid.php b/lib/Support/helpers/grid.php new file mode 100644 index 0000000..9fe4230 --- /dev/null +++ b/lib/Support/helpers/grid.php @@ -0,0 +1,934 @@ +isMethod('GET')) { + return; + } + + http_response_code(405); + \MintyPHP\Router::json(['error' => 'method_not_allowed']); +} + +/** + * Load a local filter schema file and parse request query values. + * + * @return array + */ +function gridParseFiltersFromSchemaFile(string $schemaFile, ?array $query = null): array +{ + $schemaFile = trim($schemaFile); + if ($schemaFile === '' || !is_file($schemaFile)) { + return []; + } + + $schema = require $schemaFile; + if (!is_array($schema)) { + return []; + } + + return gridParseFilters($query ?? requestInput()->queryAll(), gridSchemaQuery($schema)); +} + +/** + * Emit a standardized grid JSON payload. + */ +function gridJsonDataResult(array $rows, int $total): void +{ + \MintyPHP\Router::json([ + 'data' => array_values($rows), + 'total' => max(0, $total), + ]); +} + +/** + * Resolve a badge variant from a status/outcome map. + * + * @param array $variantMap + */ +function gridResolveBadgeVariant( + string $value, + array $variantMap, + string $default = 'neutral', + bool $normalizeLowercase = true +): string { + $key = trim($value); + if ($normalizeLowercase) { + $key = strtolower($key); + } + + $variant = (string) ($variantMap[$key] ?? $default); + return trim($variant) !== '' ? $variant : $default; +} + +/** + * Build a csv_strings sanitizer for string-backed enums. + * + * @param class-string<\BackedEnum> $enumClass + */ +function gridEnumSanitizer(string $enumClass, bool $lowercase = true): callable +{ + return static function (string $value) use ($enumClass, $lowercase): string { + if (!enum_exists($enumClass) || !is_subclass_of($enumClass, \BackedEnum::class)) { + return ''; + } + + $value = trim($value); + if ($value === '') { + return ''; + } + if ($lowercase) { + $value = strtolower($value); + } + + foreach ($enumClass::cases() as $case) { + $candidate = (string) $case->value; + $normalizedCandidate = $lowercase ? strtolower($candidate) : $candidate; + if ($normalizedCandidate === $value) { + return $candidate; + } + } + + return ''; + }; +} + +/** + * Identity helper for filter schema declarations. + * + * @param array|array $schema + * @return array|array + */ +function gridFilterSchema(array $schema = []): array +{ + return $schema; +} + +/** + * Render a value as safe inline JavaScript JSON. + */ +function gridJsonForJs(mixed $value): void +{ + $json = json_encode( + $value, + JSON_UNESCAPED_UNICODE + | JSON_UNESCAPED_SLASHES + | JSON_HEX_TAG + | JSON_HEX_AMP + | JSON_HEX_APOS + | JSON_HEX_QUOT + ); + + if (!is_string($json)) { + $json = 'null'; + } + + echo $json; +} + +/** + * Extract query parsing rules from a list filter schema. + * + * Supports two shapes: + * - ['query' => [...], 'toolbar' => [...]] + * - [['key' => '...', 'query' => [...]], ...] + * + * @return array + */ +function gridSchemaQuery(array $schema): array +{ + if (isset($schema['query']) && is_array($schema['query'])) { + return $schema['query']; + } + + $rules = []; + foreach ($schema as $entry) { + if (!is_array($entry)) { + continue; + } + $key = trim((string) ($entry['key'] ?? '')); + if ($key === '') { + continue; + } + if (is_array($entry['query'] ?? null)) { + $rules[$key] = $entry['query']; + } + } + + return $rules; +} + +/** + * Extract toolbar field schema from a list filter schema. + * + * @return array> + */ +function gridSchemaToolbar(array $schema): array +{ + if (isset($schema['toolbar']) && is_array($schema['toolbar'])) { + return array_values(array_filter($schema['toolbar'], 'is_array')); + } + + $toolbar = []; + foreach ($schema as $entry) { + if (!is_array($entry) || (($entry['render'] ?? true) === false)) { + continue; + } + if (!isset($entry['type']) || !isset($entry['key'])) { + continue; + } + $toolbar[] = $entry; + } + + return $toolbar; +} + +/** + * Split toolbar schema into search and drawer sections. + * + * @param array> $toolbarSchema + * @param string[] $searchKeys + * @return array{search: array>, drawer: array>} + */ +function gridSplitToolbarSchema(array $toolbarSchema, array $searchKeys = []): array +{ + $searchKeyMap = []; + foreach ($searchKeys as $searchKey) { + $key = trim((string) $searchKey); + if ($key !== '') { + $searchKeyMap[$key] = true; + } + } + + $search = []; + $drawer = []; + foreach ($toolbarSchema as $field) { + if (!is_array($field)) { + continue; + } + $key = trim((string) ($field['key'] ?? '')); + $isSearch = (bool) ($field['search'] ?? false) || ($key !== '' && isset($searchKeyMap[$key])); + if ($isSearch) { + $search[] = $field; + continue; + } + $drawer[] = $field; + } + + return ['search' => $search, 'drawer' => $drawer]; +} + +/** + * Index toolbar schema entries by filter key. + * + * @param array> $toolbarSchema + * @return array> + */ +function gridSchemaByKey(array $toolbarSchema): array +{ + $schemaByKey = []; + foreach ($toolbarSchema as $field) { + if (!is_array($field)) { + continue; + } + $key = trim((string) ($field['key'] ?? '')); + if ($key === '') { + continue; + } + $schemaByKey[$key] = $field; + } + + return $schemaByKey; +} + +/** + * Build toolbar state values from parsed filter state and toolbar schema defaults. + * + * @param array> $toolbarSchema + * @param array $filterState + * @return array + */ +function gridBuildToolbarFilterState(array $toolbarSchema, array $filterState): array +{ + $state = []; + foreach ($toolbarSchema as $field) { + if (!is_array($field)) { + continue; + } + + $key = trim((string) ($field['key'] ?? '')); + if ($key === '') { + continue; + } + + $type = strtolower(trim((string) ($field['type'] ?? ''))); + $default = array_key_exists('default', $field) ? $field['default'] : ''; + $value = array_key_exists($key, $filterState) ? $filterState[$key] : $default; + + if ($type === 'multi_csv') { + if (is_array($value)) { + $items = array_values(array_filter( + array_map(static fn (mixed $item): string => trim((string) $item), $value), + static fn (string $item): bool => $item !== '' + )); + $state[$key] = array_values(array_unique($items)); + continue; + } + + $text = trim((string) $value); + if ($text === '') { + $state[$key] = []; + continue; + } + + $items = array_values(array_filter( + array_map(static fn (string $item): string => trim($item), explode(',', $text)), + static fn (string $item): bool => $item !== '' + )); + $state[$key] = array_values(array_unique($items)); + continue; + } + + if (is_array($value)) { + $value = (string) (reset($value) ?: ''); + } + + $state[$key] = trim((string) $value); + } + + return $state; +} + +/** + * Build normalized list-filter context used by index actions/templates. + * + * @param array $filterSchema + * @param array $options + * @return array{ + * filterState: array, + * toolbarFilterSchema: array>, + * toolbarFilterState: array, + * searchToolbarFilterSchema: array>, + * drawerToolbarFilterSchema: array>, + * schemaByKey: array>, + * toolbarOptionSets: array, + * clientFilterSchema: array>, + * searchConfig: ?array + * } + */ +function gridBuildListFilterContext(array $filterSchema, array $options = []): array +{ + $query = is_array($options['query'] ?? null) ? $options['query'] : requestInput()->queryAll(); + $querySchema = is_array($options['query_schema'] ?? null) + ? $options['query_schema'] + : gridSchemaQuery($filterSchema); + + $filterState = is_array($options['filter_state'] ?? null) + ? $options['filter_state'] + : gridParseFilters($query, $querySchema); + + $toolbarFilterSchema = gridSchemaToolbar($filterSchema); + $toolbarFilterState = gridBuildToolbarFilterState($toolbarFilterSchema, $filterState); + $toolbarStateOverrides = is_array($options['toolbar_state_overrides'] ?? null) + ? $options['toolbar_state_overrides'] + : []; + if ($toolbarStateOverrides) { + $toolbarFilterState = array_replace($toolbarFilterState, $toolbarStateOverrides); + } + + $searchKeys = is_array($options['search_keys'] ?? null) ? $options['search_keys'] : ['search']; + $toolbarSchemaSections = gridSplitToolbarSchema($toolbarFilterSchema, $searchKeys); + $toolbarOptionSets = is_array($options['toolbar_option_sets'] ?? null) ? $options['toolbar_option_sets'] : []; + + return [ + 'filterState' => $filterState, + 'toolbarFilterSchema' => $toolbarFilterSchema, + 'toolbarFilterState' => $toolbarFilterState, + 'searchToolbarFilterSchema' => $toolbarSchemaSections['search'], + 'drawerToolbarFilterSchema' => $toolbarSchemaSections['drawer'], + 'schemaByKey' => gridSchemaByKey($toolbarFilterSchema), + 'toolbarOptionSets' => $toolbarOptionSets, + 'clientFilterSchema' => gridSchemaClientFilters($filterSchema), + 'searchConfig' => gridSchemaClientSearch($filterSchema), + ]; +} + +/** + * Build id => label maps from option set items. + * + * @param array> $items + * @return array + */ +function gridOptionMapFromItems(array $items): array +{ + $map = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $id = trim((string) ($item['id'] ?? '')); + if ($id === '') { + continue; + } + $map[$id] = (string) ($item['description'] ?? $id); + } + + return $map; +} + +/** + * Build id => label maps from schema allowed declarations. + * + * @param array $field + * @return array + */ +function gridOptionMapFromAllowed(array $field): array +{ + $map = []; + foreach ((array) ($field['allowed'] ?? []) as $item) { + if (!is_array($item)) { + continue; + } + $id = trim((string) ($item['id'] ?? '')); + if ($id === '') { + continue; + } + $label = (string) ($item['description'] ?? $id); + $translate = !array_key_exists('translate', $item) || (bool) $item['translate']; + $map[$id] = $translate ? t($label) : $label; + } + + return $map; +} + +/** + * Build client filter schema consumed by JS helper gridFiltersFromSchema(). + * + * @return array> + */ +function gridSchemaClientFilters(array $schema): array +{ + if (isset($schema['client_filters']) && is_array($schema['client_filters'])) { + return array_values(array_filter($schema['client_filters'], 'is_array')); + } + + $filters = []; + foreach (gridSchemaToolbar($schema) as $field) { + $type = strtolower(trim((string) ($field['type'] ?? ''))); + if (!in_array($type, ['text', 'date', 'number', 'select', 'multi_csv', 'hidden'], true)) { + continue; + } + if ((bool) ($field['search'] ?? false)) { + continue; + } + if (($field['bind'] ?? true) === false) { + continue; + } + + $key = trim((string) ($field['key'] ?? '')); + if ($key === '') { + continue; + } + $inputId = trim((string) ($field['input_id'] ?? '')); + if ($inputId === '') { + $inputId = $key . '-filter'; + } + + $filter = [ + 'type' => $type, + 'input' => '#' . $inputId, + 'param' => (string) ($field['param'] ?? $key), + ]; + + if (array_key_exists('default', $field)) { + $filter['default'] = $field['default']; + } + if (isset($field['event'])) { + $filter['event'] = (string) $field['event']; + } + if (isset($field['normalize'])) { + $filter['normalize'] = (string) $field['normalize']; + } + + $filters[] = $filter; + } + + return $filters; +} + +/** + * Build client search config consumed by createServerGrid(). + * + * @return array|null + */ +function gridSchemaClientSearch(array $schema): ?array +{ + foreach (gridSchemaToolbar($schema) as $field) { + if (!(bool) ($field['search'] ?? false)) { + continue; + } + $key = trim((string) ($field['key'] ?? '')); + if ($key === '') { + continue; + } + $inputId = trim((string) ($field['input_id'] ?? '')); + if ($inputId === '') { + $inputId = $key . '-filter'; + } + + return [ + 'input' => '#' . $inputId, + 'param' => (string) ($field['param'] ?? $key), + 'debounce' => (int) ($field['debounce'] ?? 250), + ]; + } + + return null; +} + +/** + * Parse filter/query values by a compact schema. + * + * Supported types: + * - int + * - number + * - bool + * - string + * - enum + * - uuid + * - date (Y-m-d) + * - csv_ids + * - csv_uuid + * - csv_strings + * - order + * - dir + * + * @return array + */ +function gridParseFilters(array $query, array $schema): array +{ + $parsed = []; + + foreach ($schema as $key => $rule) { + if (!is_array($rule)) { + $rule = ['type' => (string) $rule]; + } + + $type = strtolower(trim((string) ($rule['type'] ?? 'string'))); + $source = (string) ($rule['source'] ?? $key); + + if ($source === '') { + $source = (string) $key; + } + + if ($type === 'int') { + $default = (int) ($rule['default'] ?? 0); + $min = isset($rule['min']) ? (int) $rule['min'] : null; + $max = isset($rule['max']) ? (int) $rule['max'] : null; + $value = (int) ($query[$source] ?? $default); + if ($min !== null && $value < $min) { + $value = $default; + } + if ($max !== null && $value > $max) { + $value = $max; + } + $parsed[$key] = $value; + continue; + } + + if ($type === 'number') { + $default = (float) ($rule['default'] ?? 0); + $min = isset($rule['min']) ? (float) $rule['min'] : null; + $max = isset($rule['max']) ? (float) $rule['max'] : null; + $parsed[$key] = gridQueryNumber($query, $source, $default, $min, $max); + continue; + } + + if ($type === 'bool') { + $default = (bool) ($rule['default'] ?? false); + $parsed[$key] = gridQueryBool($query, $source, $default); + continue; + } + + if ($type === 'string') { + $parsed[$key] = gridQueryString($query, $source, (string) ($rule['default'] ?? '')); + continue; + } + + if ($type === 'enum') { + $parsed[$key] = gridQueryEnum( + $query, + $source, + (array) ($rule['allowed'] ?? []), + (string) ($rule['default'] ?? ''), + (bool) ($rule['lowercase'] ?? false) + ); + continue; + } + + if ($type === 'date') { + $parsed[$key] = gridQueryDate($query, $source, (string) ($rule['default'] ?? '')); + continue; + } + + if ($type === 'uuid') { + $parsed[$key] = gridQueryUuid($query, $source, (string) ($rule['default'] ?? '')); + continue; + } + + if ($type === 'csv_ids') { + $ids = gridQueryCsvIds($query, $source, (int) ($rule['max'] ?? 200)); + $parsed[$key] = (($rule['return'] ?? 'csv') === 'array') + ? $ids + : implode(',', array_map('strval', $ids)); + continue; + } + + if ($type === 'csv_uuid') { + $items = gridQueryCsvUuids($query, $source, (int) ($rule['max'] ?? 200)); + $parsed[$key] = (($rule['return'] ?? 'csv') === 'array') + ? $items + : implode(',', $items); + continue; + } + + if ($type === 'csv_strings') { + $items = gridQueryCsvStrings( + $query, + $source, + (int) ($rule['max'] ?? 200), + $rule['sanitizer'] ?? null + ); + $parsed[$key] = (($rule['return'] ?? 'csv') === 'array') + ? $items + : implode(',', $items); + continue; + } + + if ($type === 'order') { + $allowed = array_values(array_filter(array_map( + static fn (mixed $item): string => trim((string) $item), + (array) ($rule['allowed'] ?? []) + ), static fn (string $item): bool => $item !== '')); + $default = (string) ($rule['default'] ?? ($allowed[0] ?? '')); + $parsed[$key] = in_array(trim((string) ($query[$source] ?? $default)), $allowed, true) + ? trim((string) ($query[$source] ?? $default)) + : $default; + continue; + } + + if ($type === 'dir') { + $parsed[$key] = gridQueryEnum($query, $source, ['asc', 'desc'], (string) ($rule['default'] ?? 'asc'), true); + continue; + } + + $parsed[$key] = gridQueryString($query, $source, (string) ($rule['default'] ?? '')); + } + + return $parsed; +} + +/** + * Normalize common limit/offset query params. + * + * @return array{0:int,1:int} + */ +function gridQueryLimitOffset(array $query, int $defaultLimit = 10, int $maxLimit = 100): array +{ + $limit = (int) ($query['limit'] ?? $defaultLimit); + if ($limit < 1) { + $limit = $defaultLimit; + } elseif ($limit > $maxLimit) { + $limit = $maxLimit; + } + + $offset = (int) ($query['offset'] ?? 0); + if ($offset < 0) { + $offset = 0; + } + + return [$limit, $offset]; +} + +/** + * Normalize order/dir query params against an allow-list. + * + * @return array{0:string,1:string} + */ +function gridQueryOrderDir(array $query, array $allowedOrder, string $defaultOrder, string $defaultDir = 'asc'): array +{ + if (!in_array($defaultDir, ['asc', 'desc'], true)) { + $defaultDir = 'asc'; + } + + $order = trim((string) ($query['order'] ?? $defaultOrder)); + if (!in_array($order, $allowedOrder, true)) { + $order = $defaultOrder; + } + + $dir = strtolower(trim((string) ($query['dir'] ?? $defaultDir))); + if (!in_array($dir, ['asc', 'desc'], true)) { + $dir = $defaultDir; + } + + return [$order, $dir]; +} + +/** + * Read a trimmed string from query params. + */ +function gridQueryString(array $query, string $key, string $default = ''): string +{ + return trim((string) ($query[$key] ?? $default)); +} + +/** + * Read and normalize a numeric query value. + */ +function gridQueryNumber(array $query, string $key, float $default = 0, ?float $min = null, ?float $max = null): float +{ + $value = trim((string) ($query[$key] ?? '')); + if ($value === '' || !is_numeric($value)) { + $number = $default; + } else { + $number = (float) $value; + } + + if ($min !== null && $number < $min) { + $number = $min; + } + if ($max !== null && $number > $max) { + $number = $max; + } + + return $number; +} + +/** + * Read and normalize a bool query value. + */ +function gridQueryBool(array $query, string $key, bool $default = false): bool +{ + if (!array_key_exists($key, $query)) { + return $default; + } + $value = strtolower(trim((string) $query[$key])); + if (in_array($value, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (in_array($value, ['0', 'false', 'no', 'off'], true)) { + return false; + } + return $default; +} + +/** + * Read and validate a date (Y-m-d) from query params. + */ +function gridQueryDate(array $query, string $key, string $default = ''): string +{ + $value = trim((string) ($query[$key] ?? $default)); + if ($value === '') { + return ''; + } + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : ''; +} + +/** + * Read and validate a UUID from query params. + */ +function gridQueryUuid(array $query, string $key, string $default = ''): string +{ + $value = strtolower(trim((string) ($query[$key] ?? $default))); + if ($value === '') { + return ''; + } + + return preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/', $value) === 1 + ? $value + : ''; +} + +/** + * Read a single enum value from query params. + * + * @param string[] $allowed + */ +function gridQueryEnum(array $query, string $key, array $allowed, string $default = '', bool $lowercase = false): string +{ + $value = trim((string) ($query[$key] ?? $default)); + if ($value === '') { + return $default; + } + + if ($lowercase) { + $value = strtolower($value); + $allowed = array_map(static fn (string $item): string => strtolower($item), $allowed); + } + + return in_array($value, $allowed, true) ? $value : $default; +} + +/** + * Normalize comma-separated positive IDs from query params. + * + * @return int[] + */ +function gridQueryCsvIds(array $query, string $key, int $max = 200): array +{ + if ($max <= 0) { + return []; + } + + $value = $query[$key] ?? ''; + $items = []; + if (is_string($value)) { + $items = $value === '' ? [] : explode(',', $value); + } elseif (is_array($value)) { + array_walk_recursive($value, static function (mixed $item) use (&$items): void { + $items[] = $item; + }); + } + + $ids = []; + $seen = []; + foreach ($items as $item) { + $id = (int) trim((string) $item); + if ($id <= 0 || isset($seen[$id])) { + continue; + } + $seen[$id] = true; + $ids[] = $id; + if (count($ids) >= $max) { + break; + } + } + + return $ids; +} + +/** + * Normalize comma-separated UUIDs from query params. + * + * @return string[] + */ +function gridQueryCsvUuids(array $query, string $key, int $max = 200): array +{ + if ($max <= 0) { + return []; + } + + $value = $query[$key] ?? ''; + $items = []; + if (is_string($value)) { + $items = $value === '' ? [] : explode(',', $value); + } elseif (is_array($value)) { + array_walk_recursive($value, static function (mixed $item) use (&$items): void { + $items[] = $item; + }); + } + + $uuids = []; + $seen = []; + foreach ($items as $item) { + $uuid = gridQueryUuid(['v' => $item], 'v'); + if ($uuid === '' || isset($seen[$uuid])) { + continue; + } + $seen[$uuid] = true; + $uuids[] = $uuid; + if (count($uuids) >= $max) { + break; + } + } + + return $uuids; +} + +/** + * Normalize comma-separated strings from query params. + * + * @return string[] + */ +function gridQueryCsvStrings(array $query, string $key, int $max = 200, ?callable $sanitizer = null): array +{ + if ($max <= 0) { + return []; + } + + $value = $query[$key] ?? ''; + $items = []; + if (is_string($value)) { + $items = $value === '' ? [] : explode(',', $value); + } elseif (is_array($value)) { + array_walk_recursive($value, static function (mixed $item) use (&$items): void { + $items[] = $item; + }); + } + + $result = []; + $seen = []; + foreach ($items as $item) { + $text = trim((string) $item); + if ($text === '') { + continue; + } + + if ($sanitizer !== null) { + $text = trim((string) $sanitizer($text)); + if ($text === '') { + continue; + } + } + + if (isset($seen[$text])) { + continue; + } + $seen[$text] = true; + $result[] = $text; + if (count($result) >= $max) { + break; + } + } + + return $result; +} + +/** + * Normalize a mixed value (string "a||b" or list) to a clean label list. + * + * @return string[] + */ +function gridNormalizeLabelList(mixed $value, string $separator = '||'): array +{ + if (is_string($value)) { + $items = $value === '' ? [] : explode($separator, $value); + } elseif (is_array($value)) { + $items = $value; + } else { + return []; + } + + $items = array_map(static fn (mixed $item): string => trim((string) $item), $items); + $items = array_values(array_filter($items, static fn (string $item): bool => $item !== '')); + return $items; +} + +/** + * Normalize mixed values to a unique list of positive integer IDs. + * + * @return int[] + */ +function gridNormalizePositiveIntList(mixed $value): array +{ + if (!is_array($value)) { + return []; + } + + $ids = array_map('intval', $value); + $ids = array_values(array_filter($ids, static fn (int $id): bool => $id > 0)); + return array_values(array_unique($ids)); +} diff --git a/lib/Support/helpers/request.php b/lib/Support/helpers/request.php new file mode 100644 index 0000000..c36051f --- /dev/null +++ b/lib/Support/helpers/request.php @@ -0,0 +1,38 @@ +create(); + return $requestInput; +} + +function formErrors(): FormErrors +{ + return new FormErrors(); +} + +function formErrorsFrom(array|FormErrors $errors): FormErrors +{ + return formErrors()->merge($errors); +} + +function flashFormErrors(FormErrors $errors, ?string $scope = null, string $keyPrefix = 'form_error'): void +{ + $index = 0; + foreach ($errors->toFlatList() as $message) { + $key = $keyPrefix . '_' . $index; + Flash::error((string) $message, $scope, $key); + $index++; + } +} diff --git a/lib/Support/helpers/ui.php b/lib/Support/helpers/ui.php index 118aec5..583ce5e 100644 --- a/lib/Support/helpers/ui.php +++ b/lib/Support/helpers/ui.php @@ -76,6 +76,212 @@ function multiSelectFilter( }> $items + * @param array $labelAttributes + * @param array $selectAttributes + */ +function selectFilter( + string $id, + string $label, + array $items, + string $selected = '', + array $labelAttributes = [], + array $selectAttributes = [] +): void { + $labelClass = trim((string) ($labelAttributes['class'] ?? '')); + if ($labelClass === '') { + $labelClass = 'app-field'; + } elseif (!str_contains($labelClass, 'app-field')) { + $labelClass = 'app-field ' . $labelClass; + } + $labelAttributes['class'] = $labelClass; + + $selectAttributes['id'] = $id; + + ?> + > + + > + + + + + + + + + + > + + + + + + + + + + > $schema + * @param array $active + * @param array>> $optionSets + */ +function renderGridFilterToolbar(array $schema, array $active = [], array $optionSets = []): void +{ + foreach ($schema as $field) { + if (!is_array($field)) { + continue; + } + if (($field['render'] ?? true) === false) { + continue; + } + + $key = trim((string) ($field['key'] ?? '')); + if ($key === '') { + continue; + } + $type = strtolower(trim((string) ($field['type'] ?? 'text'))); + if (!in_array($type, ['text', 'date', 'number', 'hidden', 'select', 'multi_csv'], true)) { + continue; + } + + $inputId = trim((string) ($field['input_id'] ?? '')); + if ($inputId === '') { + $inputId = $key . '-filter'; + } + + $label = (string) ($field['label'] ?? $key); + $placeholder = (string) ($field['placeholder'] ?? ''); + $defaultValue = $field['default'] ?? ''; + $activeValue = $active[$key] ?? $defaultValue; + $labelAttributes = is_array($field['label_attributes'] ?? null) ? $field['label_attributes'] : []; + $inputAttributes = is_array($field['input_attributes'] ?? null) ? $field['input_attributes'] : []; + + if ($type === 'hidden') { + $inputAttributes['type'] = 'hidden'; + $inputAttributes['id'] = $inputId; + $inputAttributes['value'] = is_scalar($activeValue) ? (string) $activeValue : ''; + echo ''; + continue; + } + + if ($type === 'select') { + $items = uiResolveFilterItems($field, $optionSets); + $selected = is_scalar($activeValue) ? (string) $activeValue : (string) $defaultValue; + selectFilter( + $inputId, + $label, + $items, + $selected, + $labelAttributes, + $inputAttributes + ); + continue; + } + + if ($type === 'multi_csv') { + $items = uiResolveFilterItems($field, $optionSets); + $selected = uiNormalizeFilterSelection($activeValue); + multiSelectFilter( + $inputId, + $label, + $placeholder !== '' ? $placeholder : 'Select options', + $items, + $selected + ); + continue; + } + + $labelClass = trim((string) ($labelAttributes['class'] ?? '')); + if ($labelClass === '') { + $labelClass = 'app-field'; + } elseif (!str_contains($labelClass, 'app-field')) { + $labelClass = 'app-field ' . $labelClass; + } + $labelAttributes['class'] = $labelClass; + + $inputAttributes['id'] = $inputId; + $inputAttributes['type'] = $type === 'number' ? 'number' : $type; + if ($placeholder !== '' && !isset($inputAttributes['placeholder']) && $type === 'text') { + $inputAttributes['placeholder'] = t($placeholder); + } + if (in_array($type, ['text', 'date', 'number'], true)) { + $inputAttributes['value'] = is_scalar($activeValue) ? (string) $activeValue : ''; + } + + echo ''; + echo ''; + e(t($label)); + echo ''; + echo ''; + echo ''; + } +} + +/** + * @param array $field + * @param array>> $optionSets + * @return array> + */ +function uiResolveFilterItems(array $field, array $optionSets): array +{ + $allowed = $field['allowed'] ?? null; + if (is_array($allowed)) { + return array_values(array_filter(array_map(static function (mixed $item): array { + if (is_array($item)) { + return $item; + } + if (is_scalar($item)) { + $value = (string) $item; + return ['id' => $value, 'description' => $value, 'translate' => false]; + } + return []; + }, $allowed), static fn (array $item): bool => $item !== [])); + } + + $optionsKey = trim((string) ($field['options_key'] ?? '')); + if ($optionsKey !== '' && isset($optionSets[$optionsKey])) { + return array_values(array_filter($optionSets[$optionsKey], static fn (array $item): bool => $item !== [])); + } + + return []; +} + +/** + * @return string[] + */ +function uiNormalizeFilterSelection(mixed $value): array +{ + if (is_array($value)) { + return array_values(array_filter(array_map( + static fn (mixed $item): string => trim((string) $item), + $value + ), static fn (string $item): bool => $item !== '')); + } + if (!is_scalar($value)) { + return []; + } + $text = trim((string) $value); + if ($text === '') { + return []; + } + return array_values(array_filter(array_map('trim', explode(',', $text)), static fn (string $item): bool => $item !== '')); +} + /** * Render a multi-select form field for data entry. * @@ -144,6 +350,31 @@ function multiSelectForm( $attributes + */ +function uiRenderAttributes(array $attributes): void +{ + foreach ($attributes as $name => $value) { + $attrName = trim((string) $name); + if ($attrName === '') { + continue; + } + if ($value === false || $value === null) { + continue; + } + if ($value === true) { + echo ' ' . $attrName; + continue; + } + echo ' ' . $attrName . '="'; + e((string) $value); + echo '"'; + } +} + /** * Resolve nav active state for one or many paths. * diff --git a/pages/account/profile().php b/pages/account/profile().php index 5353d08..763f5a9 100644 --- a/pages/account/profile().php +++ b/pages/account/profile().php @@ -2,16 +2,25 @@ use MintyPHP\Support\Guard; use MintyPHP\Router; +use MintyPHP\Service\Access\AuthorizationService; +use MintyPHP\Service\Access\UserAuthorizationPolicy; Guard::requireLogin(); $user = $_SESSION['user'] ?? []; +$userId = (int) ($user['id'] ?? 0); $uuid = trim((string) ($user['uuid'] ?? '')); if ($uuid === '') { Router::redirect('login'); } -if (!can('users.view') && !can('users.self_update') && !can('users.update')) { +$decision = app(AuthorizationService::class)->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_CONTEXT, [ + 'actor_user_id' => $userId, + 'target_user_id' => $userId, +]); +$capabilities = $decision->attribute('capabilities', []); +$canViewPage = is_array($capabilities) && (bool) ($capabilities['can_view_page'] ?? false); +if (!$decision->isAllowed() || !$canViewPage) { Router::redirect('error/forbidden'); } diff --git a/pages/address-book/data().php b/pages/address-book/data().php index 49eaf8d..759cb44 100644 --- a/pages/address-book/data().php +++ b/pages/address-book/data().php @@ -1,13 +1,28 @@ queryAll(); +$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php', $query); + +foreach ($query as $rawKey => $rawValue) { + $key = strtolower(trim((string) $rawKey)); + if (preg_match('/^(cf_|cfm_|cfd_)/', $key) !== 1) { + continue; + } + $filters[$key] = $rawValue; +} $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); -$addressBookService = (new AddressBookServicesFactory())->createAddressBookService(); -Router::json($addressBookService->buildGridResult($currentUserId, $_GET)); +$addressBookService = (app(AddressBookServicesFactory::class))->createAddressBookService(); +$result = $addressBookService->buildGridResult($currentUserId, $filters); +gridJsonDataResult( + (array) ($result['data'] ?? []), + (int) ($result['total'] ?? 0) +); diff --git a/pages/address-book/filter-schema.php b/pages/address-book/filter-schema.php new file mode 100644 index 0000000..a38d5df --- /dev/null +++ b/pages/address-book/filter-schema.php @@ -0,0 +1,56 @@ + [ + 'limit' => ['type' => 'int', 'default' => 10, 'min' => 1, 'max' => 100], + 'offset' => ['type' => 'int', 'default' => 0, 'min' => 0], + 'search' => ['type' => 'string'], + 'order' => ['type' => 'order', 'allowed' => ['display_name', 'email', 'first_name', 'last_name'], 'default' => 'last_name'], + 'dir' => ['type' => 'dir', 'default' => 'asc'], + 'tenant' => ['type' => 'string'], + 'tenants' => ['type' => 'csv_strings', 'max' => 200], + 'departments' => ['type' => 'csv_strings', 'max' => 200], + 'roles' => ['type' => 'csv_strings', 'max' => 200], + ], + 'toolbar' => [ + [ + 'key' => 'search', + 'type' => 'text', + 'label' => 'Search', + 'placeholder' => 'Search...', + 'input_id' => 'address-book-search', + 'search' => true, + 'query' => ['type' => 'string'], + ], + [ + 'key' => 'tenants', + 'type' => 'multi_csv', + 'label' => 'Tenants', + 'placeholder' => 'Select tenants', + 'input_id' => 'address-book-tenant-filter', + 'options_key' => 'tenant_items', + 'query' => ['type' => 'csv_strings', 'max' => 200, 'return' => 'array'], + 'default' => [], + ], + [ + 'key' => 'departments', + 'type' => 'multi_csv', + 'label' => 'Departments', + 'placeholder' => 'Select departments', + 'input_id' => 'address-book-department-filter', + 'options_key' => 'department_items', + 'query' => ['type' => 'csv_strings', 'max' => 200, 'return' => 'array'], + 'default' => [], + ], + [ + 'key' => 'roles', + 'type' => 'multi_csv', + 'label' => 'Roles', + 'placeholder' => 'Select roles', + 'input_id' => 'address-book-role-filter', + 'options_key' => 'role_items', + 'query' => ['type' => 'csv_strings', 'max' => 200, 'return' => 'array'], + 'default' => [], + ], + ], +]); diff --git a/pages/address-book/index().php b/pages/address-book/index().php index 9f2e495..dc773c9 100644 --- a/pages/address-book/index().php +++ b/pages/address-book/index().php @@ -7,11 +7,13 @@ use MintyPHP\Session; use MintyPHP\Support\Guard; Guard::requireLogin(); -Guard::requirePermissionOrForbidden(PermissionService::ADDRESS_BOOK_VIEW); +Guard::requireAbilityOrForbidden(\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW); +$filterSchema = require __DIR__ . '/filter-schema.php'; +$query = requestInput()->queryAll(); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); -$addressBookService = (new AddressBookServicesFactory())->createAddressBookService(); -$context = $addressBookService->buildIndexContext($currentUserId, $_GET); +$addressBookService = (app(AddressBookServicesFactory::class))->createAddressBookService(); +$context = $addressBookService->buildIndexContext($currentUserId, $query); $activeTenants = $context['activeTenants'] ?? []; $activeRoles = $context['activeRoles'] ?? []; $activeDepartments = $context['activeDepartments'] ?? []; @@ -21,6 +23,95 @@ $roles = $context['roles'] ?? []; $customFieldFilterDefinitions = $context['customFieldFilterDefinitions'] ?? []; $customFieldFilterQuery = $context['customFieldFilterQuery'] ?? []; $tenantDepartmentMap = $context['tenantDepartmentMap'] ?? []; +$toolbarOptionSets = [ + 'tenant_items' => (array) $tenantItems, + 'department_items' => (array) $departments, + 'role_items' => (array) $roles, +]; +$toolbarFilterStateOverrides = [ + 'search' => (string) gridQueryString($query, 'search', ''), + 'tenants' => array_map('strval', (array) $activeTenants), + 'departments' => array_map('strval', (array) $activeDepartments), + 'roles' => array_map('strval', (array) $activeRoles), +]; +$listFilterContext = gridBuildListFilterContext($filterSchema, [ + 'query' => $query, + 'search_keys' => ['search'], + 'toolbar_state_overrides' => $toolbarFilterStateOverrides, + 'toolbar_option_sets' => $toolbarOptionSets, +]); +$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema']; +$toolbarFilterState = $listFilterContext['toolbarFilterState']; +$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema']; +$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema']; +$toolbarOptionSets = $listFilterContext['toolbarOptionSets']; +$clientFilterSchema = $listFilterContext['clientFilterSchema']; +$searchConfig = $listFilterContext['searchConfig']; +$customFieldChipMeta = []; +foreach ($customFieldFilterDefinitions as $definition) { + $definitionUuid = strtolower(trim((string) ($definition['uuid'] ?? ''))); + $definitionType = strtolower(trim((string) ($definition['type'] ?? ''))); + $definitionLabel = trim((string) ($definition['label'] ?? '')); + $definitionOptions = is_array($definition['options'] ?? null) ? $definition['options'] : []; + if ($definitionUuid === '' || $definitionType === '' || $definitionLabel === '') { + continue; + } + + if (in_array($definitionType, ['select', 'boolean'], true)) { + $options = gridOptionMapFromItems($definitionOptions); + if ($definitionType === 'boolean') { + $options = ['1' => t('Yes'), '0' => t('No')]; + } + $customFieldChipMeta[] = [ + 'type' => 'scalar', + 'param' => 'cf_' . $definitionUuid, + 'label' => $definitionLabel, + 'options' => $options, + ]; + continue; + } + + if ($definitionType === 'multiselect') { + $customFieldChipMeta[] = [ + 'type' => 'multi_csv', + 'param' => 'cfm_' . $definitionUuid, + 'label' => $definitionLabel, + 'options' => gridOptionMapFromItems($definitionOptions), + ]; + continue; + } + + if ($definitionType === 'date') { + $customFieldChipMeta[] = [ + 'type' => 'date_range', + 'label' => $definitionLabel, + 'from_param' => 'cfd_' . $definitionUuid . '_from', + 'to_param' => 'cfd_' . $definitionUuid . '_to', + ]; + } +} +$filterChipMeta = [ + 'search' => [ + 'label' => t('Search'), + 'type' => 'text', + ], + 'tenants' => [ + 'label' => t('Tenants'), + 'type' => 'multi_csv', + 'options' => gridOptionMapFromItems((array) $tenantItems), + ], + 'departments' => [ + 'label' => t('Departments'), + 'type' => 'multi_csv', + 'options' => gridOptionMapFromItems((array) $departments), + ], + 'roles' => [ + 'label' => t('Roles'), + 'type' => 'multi_csv', + 'options' => gridOptionMapFromItems((array) $roles), + ], + 'custom_fields' => $customFieldChipMeta, +]; $csrfKey = Session::$csrfSessionKey; $csrfToken = $_SESSION[$csrfKey] ?? ''; diff --git a/pages/address-book/index(default).phtml b/pages/address-book/index(default).phtml index 41c2279..93c7e99 100644 --- a/pages/address-book/index(default).phtml +++ b/pages/address-book/index(default).phtml @@ -6,6 +6,14 @@ $csrfKey = $csrfKey ?? Session::$csrfSessionKey; $csrfToken = $csrfToken ?? ($_SESSION[$csrfKey] ?? ''); $customFieldFilterDefinitions = is_array($customFieldFilterDefinitions ?? null) ? $customFieldFilterDefinitions : []; $activeCustomFieldQuery = is_array($customFieldFilterQuery ?? null) ? $customFieldFilterQuery : []; +$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : []; +$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : []; +$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : []; +$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : []; +$toolbarOptionSets = is_array($toolbarOptionSets ?? null) ? $toolbarOptionSets : []; +$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : []; +$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : []; +$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null; ?> -
    -

    -
    - -
    -
    + + +
    - - - - + +
    - -
    - - - - - - - - - - $item !== '')); - multiSelectFilter( - 'address-book-custom-filter-' . $idSuffix, - $label, - 'Select options', - $definitionOptions, - $currentValues - ); - ?> - - - - - +
    +

    + +
    + +
    + +
  • - +
  • > @@ -533,7 +570,7 @@ $csrfToken = $_SESSION[$csrfKey] ?? '';
    • - +
    • -
    • - > +
    • + > 0 diff --git a/tests/App/Bootstrap/EnvValidatorTest.php b/tests/App/Bootstrap/EnvValidatorTest.php new file mode 100644 index 0000000..428ec91 --- /dev/null +++ b/tests/App/Bootstrap/EnvValidatorTest.php @@ -0,0 +1,150 @@ +baseEnv([ + 'APP_ENV' => 'dev', + 'APP_DEBUG' => 'true', + 'APP_CRYPTO_KEY' => '', + 'APP_URL' => 'http://localhost:8080', + ])); + + $this->assertSame([], $errors); + } + + public function testValidateArrayRequiresAppUrlInDevelopment(): void + { + $errors = EnvValidator::validateArray($this->baseEnv([ + 'APP_ENV' => 'dev', + 'APP_URL' => '', + ])); + + $this->assertContains('APP_URL must be set in development', $errors); + } + + public function testValidateArrayRequiresProductionCryptoKeyAndUrl(): void + { + $errors = EnvValidator::validateArray($this->baseEnv([ + 'APP_ENV' => 'prod', + 'APP_DEBUG' => 'false', + 'APP_CRYPTO_KEY' => '', + 'APP_URL' => '', + ])); + + $this->assertContains('APP_URL must be set in production', $errors); + $this->assertContains('APP_CRYPTO_KEY must be set in production', $errors); + } + + public function testValidateArrayAcceptsMissingAppUrlInTestEnv(): void + { + $errors = EnvValidator::validateArray($this->baseEnv([ + 'APP_ENV' => 'test', + 'APP_URL' => '', + ])); + + $this->assertSame([], $errors); + } + + public function testValidateArrayRejectsInvalidCryptoKeyFormat(): void + { + $errors = EnvValidator::validateArray($this->baseEnv([ + 'APP_CRYPTO_KEY' => 'invalid-key-format', + ])); + + $this->assertContains('APP_CRYPTO_KEY must be 64-char hex or base64 encoded 32-byte key', $errors); + } + + public function testValidateArrayAcceptsValidHexCryptoKeyInProduction(): void + { + $errors = EnvValidator::validateArray($this->baseEnv([ + 'APP_ENV' => 'prod', + 'APP_DEBUG' => 'false', + 'APP_URL' => 'https://example.test', + 'APP_CRYPTO_KEY' => str_repeat('a', 64), + 'TENANT_SCOPE_STRICT' => 'true', + ])); + + $this->assertSame([], $errors); + } + + public function testValidateArrayRequiresHttpsAppUrlInProduction(): void + { + $errors = EnvValidator::validateArray($this->baseEnv([ + 'APP_ENV' => 'prod', + 'APP_DEBUG' => 'false', + 'APP_URL' => 'http://example.test', + 'APP_CRYPTO_KEY' => str_repeat('a', 64), + 'TENANT_SCOPE_STRICT' => 'true', + ])); + + $this->assertContains('APP_URL must use https in production', $errors); + } + + public function testValidateArrayRejectsLocalhostAppUrlInProduction(): void + { + $errors = EnvValidator::validateArray($this->baseEnv([ + 'APP_ENV' => 'prod', + 'APP_DEBUG' => 'false', + 'APP_URL' => 'https://localhost', + 'APP_CRYPTO_KEY' => str_repeat('a', 64), + 'TENANT_SCOPE_STRICT' => 'true', + ])); + + $this->assertContains('APP_URL must not use localhost or an IP host in production', $errors); + } + + public function testValidateArrayRejectsIpHostAppUrlInProduction(): void + { + $errors = EnvValidator::validateArray($this->baseEnv([ + 'APP_ENV' => 'prod', + 'APP_DEBUG' => 'false', + 'APP_URL' => 'https://127.0.0.1', + 'APP_CRYPTO_KEY' => str_repeat('a', 64), + 'TENANT_SCOPE_STRICT' => 'true', + ])); + + $this->assertContains('APP_URL must not use localhost or an IP host in production', $errors); + } + + /** + * @param array $override + * @return array + */ + private function baseEnv(array $override = []): array + { + $storagePath = sys_get_temp_dir(); + + return array_merge([ + 'APP_NAME' => 'IMVS', + 'APP_ENV' => 'dev', + 'APP_DEBUG' => 'true', + 'APP_TIMEZONE' => 'Europe/Berlin', + 'APP_STORAGE_PATH' => $storagePath, + 'APP_LOCALE' => 'de', + 'APP_LOCALES' => 'de,en', + 'APP_I18N_DOMAIN' => 'default', + 'APP_URL' => 'http://localhost:8080', + 'APP_CRYPTO_KEY' => '', + 'SESSION_NAME' => 'IMVS', + 'TENANT_SCOPE_STRICT' => 'true', + 'DB_HOST' => 'db', + 'DB_PORT' => '3306', + 'DB_NAME' => 'imvs', + 'DB_USER' => 'imvs', + 'DB_PASS' => 'imvs', + 'CACHE_SERVERS' => 'memcached:11211', + 'FIREWALL_CONCURRENCY' => '10', + 'FIREWALL_SPINLOCK_SECONDS' => '0.15', + 'FIREWALL_INTERVAL_SECONDS' => '300', + 'FIREWALL_CACHE_PREFIX' => 'fw_concurrency_', + 'FIREWALL_REVERSE_PROXY' => 'false', + ], $override); + } +} diff --git a/tests/Architecture/AsideNavigationContractTest.php b/tests/Architecture/AsideNavigationContractTest.php new file mode 100644 index 0000000..c0a9f4a --- /dev/null +++ b/tests/Architecture/AsideNavigationContractTest.php @@ -0,0 +1,44 @@ +readProjectFile('templates/partials/app-main-aside.phtml'); + + $this->assertStringContainsString('data-aside-details-storage="aside-admin-sections-v1"', $content); + $this->assertStringContainsString('data-aside-details-open-active="1"', $content); + $this->assertStringContainsString('
      assertStringContainsString("'key' => 'admin-organization'", $content); + $this->assertStringContainsString("'key' => 'admin-roles-permissions'", $content); + $this->assertStringContainsString("'key' => 'admin-automation'", $content); + $this->assertStringContainsString("'key' => 'admin-monitoring'", $content); + $this->assertStringContainsString("'key' => 'admin-logs'", $content); + $this->assertStringContainsString("'key' => 'admin-system'", $content); + } + + public function testAsidePanelsUseSharedDetailsOpenStateHelper(): void + { + $content = $this->readProjectFile('web/js/components/app-toggle-aside-panels.js'); + + $this->assertStringContainsString("import { initPersistedDetailsGroup } from '../core/app-details-open-state.js';", $content); + $this->assertStringContainsString('initPersistedDetailsGroup({', $content); + $this->assertStringContainsString("panel.dataset.asideDetailsOpenActive === '1'", $content); + } + + public function testDetailStateUsesSharedDetailsOpenStateHelper(): void + { + $content = $this->readProjectFile('web/js/components/app-details-state.js'); + + $this->assertStringContainsString("import { initPersistedDetailsGroup } from '../core/app-details-open-state.js';", $content); + $this->assertStringContainsString('initPersistedDetailsGroup({', $content); + $this->assertStringNotContainsString('window.localStorage.setItem(storageKey, JSON.stringify(openKeys));', $content); + } +} diff --git a/tests/Architecture/AuthzAdminContractTest.php b/tests/Architecture/AuthzAdminContractTest.php new file mode 100644 index 0000000..3944aab --- /dev/null +++ b/tests/Architecture/AuthzAdminContractTest.php @@ -0,0 +1,281 @@ +readProjectFile('pages/admin/users/data().php'); + $this->assertStringContainsString('Guard::requireLogin();', $content); + $this->assertStringContainsString('Guard::requireAbilityOrForbidden(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW);', $content); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW', $content); + } + + + public function testUsersExportActionRequiresUsersViewPermission(): void + { + $content = $this->readProjectFile('pages/admin/users/export().php'); + $this->assertStringContainsString('Guard::requireLogin();', $content); + $this->assertStringContainsString('AuthorizationService::class', $content); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW', $content); + } + + + public function testUsersActivateActionRequiresUsersUpdatePermission(): void + { + $content = $this->readProjectFile('pages/admin/users/activate($id).php'); + $this->assertStringContainsString('AuthorizationService::class', $content); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACTIVATE', $content); + } + + + public function testUsersDeactivateActionRequiresUsersUpdatePermission(): void + { + $content = $this->readProjectFile('pages/admin/users/deactivate($id).php'); + $this->assertStringContainsString('AuthorizationService::class', $content); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_DEACTIVATE', $content); + } + + + public function testUsersBulkActionPermissionMappingIsConsistent(): void + { + $content = $this->readProjectFile('pages/admin/users/bulk($action).php'); + $this->assertStringContainsString('AuthorizationService::class', $content); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_BULK', $content); + $this->assertStringContainsString("'bulk_action' => \$action", $content); + } + + + public function testAdditionalAdminUserActionsUseCentralPolicy(): void + { + $indexContent = $this->readProjectFile('pages/admin/users/index().php'); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW', $indexContent); + + $createContent = $this->readProjectFile('pages/admin/users/create().php'); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_CREATE', $createContent); + + $deleteContent = $this->readProjectFile('pages/admin/users/delete($id).php'); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_DELETE', $deleteContent); + + $accessPdfContent = $this->readProjectFile('pages/admin/users/access-pdf($id).php'); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACCESS_PDF', $accessPdfContent); + + $accessPdfBulkContent = $this->readProjectFile('pages/admin/users/access-pdf-bulk().php'); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACCESS_PDF_BULK', $accessPdfBulkContent); + + $tokenCreateContent = $this->readProjectFile('pages/admin/users/api-token-create($id).php'); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE', $tokenCreateContent); + + $tokenRevokeContent = $this->readProjectFile('pages/admin/users/api-token-revoke($id).php'); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE', $tokenRevokeContent); + + $sendAccessContent = $this->readProjectFile('pages/admin/users/send-access($id).php'); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_SEND_ACCESS', $sendAccessContent); + + $forgetTokensContent = $this->readProjectFile('pages/admin/users/forget-tokens($id).php'); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_FORGET_TOKENS', $forgetTokensContent); + } + + + public function testAdminUserEditUsesContextAndSubmitPolicies(): void + { + $content = $this->readProjectFile('pages/admin/users/edit($id).php'); + $this->assertStringContainsString('AuthorizationService::class', $content); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_CONTEXT', $content); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_SUBMIT', $content); + } + + + public function testAdminUserAvatarEndpointsUseCentralPolicies(): void + { + $uploadContent = $this->readProjectFile('pages/admin/users/avatar($id).php'); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_UPLOAD', $uploadContent); + + $deleteContent = $this->readProjectFile('pages/admin/users/avatar-delete($id).php'); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_DELETE', $deleteContent); + + $viewContent = $this->readProjectFile('pages/admin/users/avatar-file().php'); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_VIEW', $viewContent); + } + + + public function testAdminTenantEditAndCustomFieldActionsUseCentralPolicies(): void + { + $indexContent = $this->readProjectFile('pages/admin/tenants/index().php'); + $this->assertStringContainsString('AuthorizationService::class', $indexContent); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW', $indexContent); + + $dataContent = $this->readProjectFile('pages/admin/tenants/data().php'); + $this->assertStringContainsString('Guard::requireAbilityDecisionOrForbidden(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW);', $dataContent); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW', $dataContent); + + $createContent = $this->readProjectFile('pages/admin/tenants/create().php'); + $this->assertStringContainsString('AuthorizationService::class', $createContent); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CREATE', $createContent); + + $editContent = $this->readProjectFile('pages/admin/tenants/edit($id).php'); + $this->assertStringContainsString('AuthorizationService::class', $editContent); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_CONTEXT', $editContent); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_SUBMIT', $editContent); + + $customFieldCreate = $this->readProjectFile('pages/admin/tenants/custom-field-create($id).php'); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE', $customFieldCreate); + + $customFieldUpdate = $this->readProjectFile('pages/admin/tenants/custom-field-update($id).php'); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE', $customFieldUpdate); + + $customFieldDelete = $this->readProjectFile('pages/admin/tenants/custom-field-delete($id).php'); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE', $customFieldDelete); + } + + + public function testAdminTenantDeleteUsesCentralPolicy(): void + { + $deleteContent = $this->readProjectFile('pages/admin/tenants/delete($id).php'); + $this->assertStringContainsString('AuthorizationService::class', $deleteContent); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_DELETE', $deleteContent); + } + + + public function testAdminTenantMediaEndpointsUseCentralPolicies(): void + { + $avatarView = $this->readProjectFile('pages/admin/tenants/avatar-file().php'); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_AVATAR_VIEW', $avatarView); + + $avatarUpload = $this->readProjectFile('pages/admin/tenants/avatar($id).php'); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $avatarUpload); + + $avatarDelete = $this->readProjectFile('pages/admin/tenants/avatar-delete($id).php'); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $avatarDelete); + + $faviconUpload = $this->readProjectFile('pages/admin/tenants/favicon($id).php'); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $faviconUpload); + + $faviconDelete = $this->readProjectFile('pages/admin/tenants/favicon-delete($id).php'); + $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $faviconDelete); + } + + + public function testAdminDepartmentsEditAndDeleteUseCentralPolicies(): void + { + $indexContent = $this->readProjectFile('pages/admin/departments/index().php'); + $this->assertStringContainsString('AuthorizationService::class', $indexContent); + $this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_VIEW', $indexContent); + + $dataContent = $this->readProjectFile('pages/admin/departments/data().php'); + $this->assertStringContainsString('Guard::requireAbilityDecisionOrForbidden(DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_VIEW);', $dataContent); + $this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_VIEW', $dataContent); + + $createContent = $this->readProjectFile('pages/admin/departments/create().php'); + $this->assertStringContainsString('AuthorizationService::class', $createContent); + $this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_CREATE', $createContent); + + $editContent = $this->readProjectFile('pages/admin/departments/edit($id).php'); + $this->assertStringContainsString('AuthorizationService::class', $editContent); + $this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_CONTEXT', $editContent); + $this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_SUBMIT', $editContent); + + $deleteContent = $this->readProjectFile('pages/admin/departments/delete($id).php'); + $this->assertStringContainsString('AuthorizationService::class', $deleteContent); + $this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_DELETE', $deleteContent); + } + + + public function testAdminRolesActionsUseCentralPolicies(): void + { + $indexContent = $this->readProjectFile('pages/admin/roles/index().php'); + $this->assertStringContainsString('AuthorizationService::class', $indexContent); + $this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW', $indexContent); + + $dataContent = $this->readProjectFile('pages/admin/roles/data().php'); + $this->assertStringContainsString('Guard::requireAbilityOrForbidden(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW);', $dataContent); + $this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW', $dataContent); + + $createContent = $this->readProjectFile('pages/admin/roles/create().php'); + $this->assertStringContainsString('AuthorizationService::class', $createContent); + $this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_CREATE', $createContent); + + $editContent = $this->readProjectFile('pages/admin/roles/edit($id).php'); + $this->assertStringContainsString('AuthorizationService::class', $editContent); + $this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_CONTEXT', $editContent); + $this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_SUBMIT', $editContent); + + $deleteContent = $this->readProjectFile('pages/admin/roles/delete($id).php'); + $this->assertStringContainsString('AuthorizationService::class', $deleteContent); + $this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_DELETE', $deleteContent); + } + + + public function testAdminPermissionsActionsUseCentralPolicies(): void + { + $indexContent = $this->readProjectFile('pages/admin/permissions/index().php'); + $this->assertStringContainsString('AuthorizationService::class', $indexContent); + $this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW', $indexContent); + + $dataContent = $this->readProjectFile('pages/admin/permissions/data().php'); + $this->assertStringContainsString('Guard::requireAbilityOrForbidden(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW);', $dataContent); + $this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW', $dataContent); + + $createContent = $this->readProjectFile('pages/admin/permissions/create().php'); + $this->assertStringContainsString('AuthorizationService::class', $createContent); + $this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_CREATE', $createContent); + + $editContent = $this->readProjectFile('pages/admin/permissions/edit($id).php'); + $this->assertStringContainsString('AuthorizationService::class', $editContent); + $this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_CONTEXT', $editContent); + $this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_SUBMIT', $editContent); + + $deleteContent = $this->readProjectFile('pages/admin/permissions/delete($id).php'); + $this->assertStringContainsString('AuthorizationService::class', $deleteContent); + $this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_DELETE', $deleteContent); + } + + + public function testAdminSettingsActionsUseCentralPolicies(): void + { + $indexContent = $this->readProjectFile('pages/admin/settings/index().php'); + $this->assertStringContainsString('AuthorizationService::class', $indexContent); + $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_VIEW', $indexContent); + $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE', $indexContent); + + $logoUpload = $this->readProjectFile('pages/admin/settings/logo().php'); + $this->assertStringContainsString('AuthorizationService::class', $logoUpload); + $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $logoUpload); + + $logoDelete = $this->readProjectFile('pages/admin/settings/logo-delete().php'); + $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $logoDelete); + + $faviconUpload = $this->readProjectFile('pages/admin/settings/favicon().php'); + $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $faviconUpload); + + $faviconDelete = $this->readProjectFile('pages/admin/settings/favicon-delete().php'); + $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $faviconDelete); + + $revokeApiTokens = $this->readProjectFile('pages/admin/settings/revoke-api-tokens().php'); + $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE', $revokeApiTokens); + + $expireRememberTokens = $this->readProjectFile('pages/admin/settings/expire-remember-tokens().php'); + $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE', $expireRememberTokens); + + $runUserLifecycle = $this->readProjectFile('pages/admin/settings/run-user-lifecycle().php'); + $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_USER_LIFECYCLE_RUN', $runUserLifecycle); + } + + + public function testScheduledJobEditUsesAbilityChecksAndNoPermissionHelper(): void + { + $content = $this->readProjectFile('pages/admin/scheduled-jobs/edit($id).php'); + $this->assertStringContainsString('OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_VIEW', $content); + $this->assertStringContainsString('OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_MANAGE', $content); + $this->assertStringContainsString('OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_RUN_NOW', $content); + $this->assertStringNotContainsString("can('jobs.manage')", $content); + $this->assertStringNotContainsString("can('jobs.run_now')", $content); + } + + +} diff --git a/tests/Architecture/AuthzAndApiLoginContractTest.php b/tests/Architecture/AuthzAndApiLoginContractTest.php deleted file mode 100644 index 739e793..0000000 --- a/tests/Architecture/AuthzAndApiLoginContractTest.php +++ /dev/null @@ -1,471 +0,0 @@ -readProjectFile('pages/admin/users/data().php'); - $this->assertStringContainsString('Guard::requireLogin();', $content); - $this->assertStringContainsString('createAuthorizationService()', $content); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW', $content); - } - - public function testUsersExportActionRequiresUsersViewPermission(): void - { - $content = $this->readProjectFile('pages/admin/users/export().php'); - $this->assertStringContainsString('Guard::requireLogin();', $content); - $this->assertStringContainsString('createAuthorizationService()', $content); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW', $content); - } - - public function testUsersActivateActionRequiresUsersUpdatePermission(): void - { - $content = $this->readProjectFile('pages/admin/users/activate($id).php'); - $this->assertStringContainsString('createAuthorizationService()', $content); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACTIVATE', $content); - } - - public function testUsersDeactivateActionRequiresUsersUpdatePermission(): void - { - $content = $this->readProjectFile('pages/admin/users/deactivate($id).php'); - $this->assertStringContainsString('createAuthorizationService()', $content); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_DEACTIVATE', $content); - } - - public function testUsersBulkActionPermissionMappingIsConsistent(): void - { - $content = $this->readProjectFile('pages/admin/users/bulk($action).php'); - $this->assertStringContainsString('createAuthorizationService()', $content); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_BULK', $content); - $this->assertStringContainsString("'bulk_action' => \$action", $content); - } - - public function testAdditionalAdminUserActionsUseCentralPolicy(): void - { - $indexContent = $this->readProjectFile('pages/admin/users/index().php'); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW', $indexContent); - - $createContent = $this->readProjectFile('pages/admin/users/create().php'); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_CREATE', $createContent); - - $deleteContent = $this->readProjectFile('pages/admin/users/delete($id).php'); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_DELETE', $deleteContent); - - $accessPdfContent = $this->readProjectFile('pages/admin/users/access-pdf($id).php'); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACCESS_PDF', $accessPdfContent); - - $accessPdfBulkContent = $this->readProjectFile('pages/admin/users/access-pdf-bulk().php'); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACCESS_PDF_BULK', $accessPdfBulkContent); - - $tokenCreateContent = $this->readProjectFile('pages/admin/users/api-token-create($id).php'); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE', $tokenCreateContent); - - $tokenRevokeContent = $this->readProjectFile('pages/admin/users/api-token-revoke($id).php'); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE', $tokenRevokeContent); - - $sendAccessContent = $this->readProjectFile('pages/admin/users/send-access($id).php'); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_SEND_ACCESS', $sendAccessContent); - - $forgetTokensContent = $this->readProjectFile('pages/admin/users/forget-tokens($id).php'); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_FORGET_TOKENS', $forgetTokensContent); - } - - public function testAdminUserEditUsesContextAndSubmitPolicies(): void - { - $content = $this->readProjectFile('pages/admin/users/edit($id).php'); - $this->assertStringContainsString('createAuthorizationService()', $content); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_CONTEXT', $content); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_SUBMIT', $content); - } - - public function testAdminUserAvatarEndpointsUseCentralPolicies(): void - { - $uploadContent = $this->readProjectFile('pages/admin/users/avatar($id).php'); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_UPLOAD', $uploadContent); - - $deleteContent = $this->readProjectFile('pages/admin/users/avatar-delete($id).php'); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_DELETE', $deleteContent); - - $viewContent = $this->readProjectFile('pages/admin/users/avatar-file().php'); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_VIEW', $viewContent); - } - - public function testAdminTenantEditAndCustomFieldActionsUseCentralPolicies(): void - { - $indexContent = $this->readProjectFile('pages/admin/tenants/index().php'); - $this->assertStringContainsString('createAuthorizationService()', $indexContent); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW', $indexContent); - - $dataContent = $this->readProjectFile('pages/admin/tenants/data().php'); - $this->assertStringContainsString('createAuthorizationService()', $dataContent); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW', $dataContent); - - $createContent = $this->readProjectFile('pages/admin/tenants/create().php'); - $this->assertStringContainsString('createAuthorizationService()', $createContent); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CREATE', $createContent); - - $editContent = $this->readProjectFile('pages/admin/tenants/edit($id).php'); - $this->assertStringContainsString('createAuthorizationService()', $editContent); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_CONTEXT', $editContent); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_SUBMIT', $editContent); - - $customFieldCreate = $this->readProjectFile('pages/admin/tenants/custom-field-create($id).php'); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE', $customFieldCreate); - - $customFieldUpdate = $this->readProjectFile('pages/admin/tenants/custom-field-update($id).php'); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE', $customFieldUpdate); - - $customFieldDelete = $this->readProjectFile('pages/admin/tenants/custom-field-delete($id).php'); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE', $customFieldDelete); - } - - public function testAdminTenantTemplatesUseServerCapabilities(): void - { - $indexTemplate = $this->readProjectFile('pages/admin/tenants/index(default).phtml'); - $this->assertStringNotContainsString("can('tenants.create')", $indexTemplate); - $this->assertStringContainsString('$canCreateTenants', $indexTemplate); - - $createTemplate = $this->readProjectFile('pages/admin/tenants/create(default).phtml'); - $this->assertStringNotContainsString("can('custom_fields.manage')", $createTemplate); - $this->assertStringNotContainsString("can('tenants.sso_manage')", $createTemplate); - $this->assertStringContainsString('$canManageCustomFields', $createTemplate); - $this->assertStringContainsString('$canManageSso', $createTemplate); - } - - public function testAdminTenantDeleteUsesCentralPolicy(): void - { - $deleteContent = $this->readProjectFile('pages/admin/tenants/delete($id).php'); - $this->assertStringContainsString('createAuthorizationService()', $deleteContent); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_DELETE', $deleteContent); - } - - public function testAdminTenantMediaEndpointsUseCentralPolicies(): void - { - $avatarView = $this->readProjectFile('pages/admin/tenants/avatar-file().php'); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_AVATAR_VIEW', $avatarView); - - $avatarUpload = $this->readProjectFile('pages/admin/tenants/avatar($id).php'); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $avatarUpload); - - $avatarDelete = $this->readProjectFile('pages/admin/tenants/avatar-delete($id).php'); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $avatarDelete); - - $faviconUpload = $this->readProjectFile('pages/admin/tenants/favicon($id).php'); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $faviconUpload); - - $faviconDelete = $this->readProjectFile('pages/admin/tenants/favicon-delete($id).php'); - $this->assertStringContainsString('TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE', $faviconDelete); - } - - public function testAdminDepartmentsEditAndDeleteUseCentralPolicies(): void - { - $indexContent = $this->readProjectFile('pages/admin/departments/index().php'); - $this->assertStringContainsString('createAuthorizationService()', $indexContent); - $this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_VIEW', $indexContent); - - $dataContent = $this->readProjectFile('pages/admin/departments/data().php'); - $this->assertStringContainsString('createAuthorizationService()', $dataContent); - $this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_VIEW', $dataContent); - - $createContent = $this->readProjectFile('pages/admin/departments/create().php'); - $this->assertStringContainsString('createAuthorizationService()', $createContent); - $this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_CREATE', $createContent); - - $editContent = $this->readProjectFile('pages/admin/departments/edit($id).php'); - $this->assertStringContainsString('createAuthorizationService()', $editContent); - $this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_CONTEXT', $editContent); - $this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_SUBMIT', $editContent); - - $deleteContent = $this->readProjectFile('pages/admin/departments/delete($id).php'); - $this->assertStringContainsString('createAuthorizationService()', $deleteContent); - $this->assertStringContainsString('DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_DELETE', $deleteContent); - } - - public function testAdminDepartmentsListTemplateUsesServerCapabilities(): void - { - $content = $this->readProjectFile('pages/admin/departments/index(default).phtml'); - $this->assertStringNotContainsString("can('departments.create')", $content); - $this->assertStringContainsString('$canCreateDepartments', $content); - } - - public function testAdminRolesActionsUseCentralPolicies(): void - { - $indexContent = $this->readProjectFile('pages/admin/roles/index().php'); - $this->assertStringContainsString('createAuthorizationService()', $indexContent); - $this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW', $indexContent); - - $dataContent = $this->readProjectFile('pages/admin/roles/data().php'); - $this->assertStringContainsString('createAuthorizationService()', $dataContent); - $this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW', $dataContent); - - $createContent = $this->readProjectFile('pages/admin/roles/create().php'); - $this->assertStringContainsString('createAuthorizationService()', $createContent); - $this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_CREATE', $createContent); - - $editContent = $this->readProjectFile('pages/admin/roles/edit($id).php'); - $this->assertStringContainsString('createAuthorizationService()', $editContent); - $this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_CONTEXT', $editContent); - $this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_SUBMIT', $editContent); - - $deleteContent = $this->readProjectFile('pages/admin/roles/delete($id).php'); - $this->assertStringContainsString('createAuthorizationService()', $deleteContent); - $this->assertStringContainsString('RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_DELETE', $deleteContent); - } - - public function testAdminPermissionsActionsUseCentralPolicies(): void - { - $indexContent = $this->readProjectFile('pages/admin/permissions/index().php'); - $this->assertStringContainsString('createAuthorizationService()', $indexContent); - $this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW', $indexContent); - - $dataContent = $this->readProjectFile('pages/admin/permissions/data().php'); - $this->assertStringContainsString('createAuthorizationService()', $dataContent); - $this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW', $dataContent); - - $createContent = $this->readProjectFile('pages/admin/permissions/create().php'); - $this->assertStringContainsString('createAuthorizationService()', $createContent); - $this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_CREATE', $createContent); - - $editContent = $this->readProjectFile('pages/admin/permissions/edit($id).php'); - $this->assertStringContainsString('createAuthorizationService()', $editContent); - $this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_CONTEXT', $editContent); - $this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_SUBMIT', $editContent); - - $deleteContent = $this->readProjectFile('pages/admin/permissions/delete($id).php'); - $this->assertStringContainsString('createAuthorizationService()', $deleteContent); - $this->assertStringContainsString('PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_DELETE', $deleteContent); - } - - public function testAdminRoleTemplatesUseServerCapabilities(): void - { - $indexTemplate = $this->readProjectFile('pages/admin/roles/index(default).phtml'); - $this->assertStringNotContainsString("can('roles.create')", $indexTemplate); - $this->assertStringContainsString('$canCreateRoles', $indexTemplate); - - $editTemplate = $this->readProjectFile('pages/admin/roles/edit(default).phtml'); - $this->assertStringNotContainsString("can('roles.update')", $editTemplate); - $this->assertStringNotContainsString("can('roles.delete')", $editTemplate); - $this->assertStringContainsString('$canUpdateRole', $editTemplate); - $this->assertStringContainsString('$canDeleteRole', $editTemplate); - } - - public function testAdminPermissionTemplatesUseServerCapabilities(): void - { - $indexTemplate = $this->readProjectFile('pages/admin/permissions/index(default).phtml'); - $this->assertStringNotContainsString("can('permissions.create')", $indexTemplate); - $this->assertStringContainsString('$canCreatePermissions', $indexTemplate); - - $editTemplate = $this->readProjectFile('pages/admin/permissions/edit(default).phtml'); - $this->assertStringNotContainsString("can('permissions.update')", $editTemplate); - $this->assertStringNotContainsString("can('permissions.delete')", $editTemplate); - $this->assertStringContainsString('$canUpdatePermission', $editTemplate); - $this->assertStringContainsString('$canDeletePermission', $editTemplate); - } - - public function testAdminSettingsActionsUseCentralPolicies(): void - { - $indexContent = $this->readProjectFile('pages/admin/settings/index().php'); - $this->assertStringContainsString('createAuthorizationService()', $indexContent); - $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_VIEW', $indexContent); - $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE', $indexContent); - - $logoUpload = $this->readProjectFile('pages/admin/settings/logo().php'); - $this->assertStringContainsString('createAuthorizationService()', $logoUpload); - $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $logoUpload); - - $logoDelete = $this->readProjectFile('pages/admin/settings/logo-delete().php'); - $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $logoDelete); - - $faviconUpload = $this->readProjectFile('pages/admin/settings/favicon().php'); - $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $faviconUpload); - - $faviconDelete = $this->readProjectFile('pages/admin/settings/favicon-delete().php'); - $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE', $faviconDelete); - - $revokeApiTokens = $this->readProjectFile('pages/admin/settings/revoke-api-tokens().php'); - $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE', $revokeApiTokens); - - $expireRememberTokens = $this->readProjectFile('pages/admin/settings/expire-remember-tokens().php'); - $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE', $expireRememberTokens); - - $runUserLifecycle = $this->readProjectFile('pages/admin/settings/run-user-lifecycle().php'); - $this->assertStringContainsString('SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_USER_LIFECYCLE_RUN', $runUserLifecycle); - } - - public function testAdminSettingsTemplateUsesServerCapabilities(): void - { - $templateContent = $this->readProjectFile('pages/admin/settings/index(default).phtml'); - $this->assertStringNotContainsString("can('settings.update')", $templateContent); - $this->assertStringContainsString('$canUpdateSettings', $templateContent); - } - - public function testAdminUserEditTemplateUsesServerCapabilities(): void - { - $content = $this->readProjectFile('pages/admin/users/edit(default).phtml'); - $this->assertStringNotContainsString("can('users.", $content); - $this->assertStringNotContainsString("can('permissions.view')", $content); - $this->assertStringContainsString('$canViewSecurityArtifacts', $content); - } - - public function testApiLoginIsBootstrappedAsPublic(): void - { - $content = $this->readProjectFile('pages/api/v1/auth/login().php'); - $this->assertStringContainsString('ApiBootstrap::init(false);', $content); - } - - public function testApiBootstrapSupportsOptionalAuthRequirement(): void - { - $content = $this->readProjectFile('lib/Http/ApiBootstrap.php'); - $this->assertStringContainsString('public static function init(bool $requireAuth = true): void', $content); - $this->assertStringContainsString('if ($requireAuth) {', $content); - $this->assertStringContainsString('$authenticated = ApiAuth::authenticate();', $content); - $this->assertStringContainsString('ApiResponse::unauthorized();', $content); - } - - public function testApiUsersEndpointsUseCentralUserPolicy(): void - { - $indexContent = $this->readProjectFile('pages/api/v1/users/index().php'); - $this->assertStringContainsString('createAuthorizationService()', $indexContent); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_INDEX_GET', $indexContent); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_INDEX_POST', $indexContent); - - $showContent = $this->readProjectFile('pages/api/v1/users/show($id).php'); - $this->assertStringContainsString('createAuthorizationService()', $showContent); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_GET', $showContent); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_UPDATE', $showContent); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_DELETE', $showContent); - } - - public function testApiPermissionAndSettingsEndpointsHaveExpectedGuards(): void - { - $permissionContent = $this->readProjectFile('pages/api/v1/permissions/index().php'); - $this->assertStringContainsString("ApiResponse::requirePermission(PermissionService::PERMISSIONS_VIEW);", $permissionContent); - $this->assertStringContainsString("foreach ((\$result['data'] ?? []) as \$row)", $permissionContent); - - $settingsContent = $this->readProjectFile('pages/api/v1/settings/index().php'); - $this->assertStringContainsString("ApiResponse::requirePermission(PermissionService::SETTINGS_VIEW);", $settingsContent); - $this->assertStringContainsString("ApiResponse::requirePermission(PermissionService::SETTINGS_UPDATE);", $settingsContent); - } - - public function testApiMeEndpointsUseSelfServicePermissions(): void - { - $meContent = $this->readProjectFile('pages/api/v1/me/index().php'); - $this->assertStringContainsString('$method === \'PUT\' || $method === \'PATCH\'', $meContent); - $this->assertStringContainsString('PermissionService::USERS_SELF_UPDATE', $meContent); - $this->assertStringContainsString("ApiResponse::validationError(['profile' => array_values(\$errors)]);", $meContent); - - $passwordContent = $this->readProjectFile('pages/api/v1/me/password().php'); - $this->assertStringContainsString("ApiResponse::requireMethod('POST');", $passwordContent); - $this->assertStringContainsString('PermissionService::USERS_SELF_UPDATE', $passwordContent); - - $avatarContent = $this->readProjectFile('pages/api/v1/me/avatar().php'); - $this->assertStringContainsString("define('MINTY_ALLOW_OUTPUT', true);", $avatarContent); - $this->assertStringContainsString('PermissionService::USERS_SELF_UPDATE', $avatarContent); - } - - public function testApiAvatarEndpointsUseExpectedAuthorizationModel(): void - { - $userAvatarContent = $this->readProjectFile('pages/api/v1/users/avatar($id).php'); - $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_GET', $userAvatarContent); - - $tenantAvatarContent = $this->readProjectFile('pages/api/v1/tenants/avatar($id).php'); - $this->assertStringContainsString('PermissionService::TENANTS_VIEW', $tenantAvatarContent); - $this->assertStringContainsString("ApiAuth::requireResourceAccess('tenants', \$tenantId);", $tenantAvatarContent); - } - - public function testApiTenantShowExposesFullTenantMasterData(): void - { - $tenantShowContent = $this->readProjectFile('pages/api/v1/tenants/show($id).php'); - $this->assertStringContainsString("'region' => \$tenant['region'] ?? ''", $tenantShowContent); - $this->assertStringContainsString("'vat_id' => \$tenant['vat_id'] ?? ''", $tenantShowContent); - $this->assertStringContainsString("'support_email' => \$tenant['support_email'] ?? ''", $tenantShowContent); - $this->assertStringContainsString("'billing_email' => \$tenant['billing_email'] ?? ''", $tenantShowContent); - $this->assertStringContainsString("'primary_color_use_default' => \$primaryColor === ''", $tenantShowContent); - $this->assertStringContainsString("'allow_user_theme_mode' => \$allowUserThemeMode", $tenantShowContent); - - $openApiContent = $this->readProjectFile('docs/openapi.yaml'); - $this->assertStringContainsString('TenantShowResponse:', $openApiContent); - $this->assertStringContainsString('primary_color_use_default:', $openApiContent); - $this->assertStringContainsString('allow_user_theme_mode:', $openApiContent); - $this->assertStringContainsString('support_email:', $openApiContent); - } - - public function testApiUserWriteEndpointsUseUuidAssignmentInput(): void - { - $usersIndex = $this->readProjectFile('pages/api/v1/users/index().php'); - $this->assertStringContainsString('normalizeUserWriteInputToInternalIds($input)', $usersIndex); - $this->assertStringContainsString("'tenant_ids' => 'use_tenant_uuids'", $usersIndex); - $this->assertStringContainsString("'role_ids' => 'use_role_uuids'", $usersIndex); - $this->assertStringContainsString("'department_ids' => 'use_department_uuids'", $usersIndex); - - $usersShow = $this->readProjectFile('pages/api/v1/users/show($id).php'); - $this->assertStringContainsString('normalizeUserWriteInputToInternalIds($input)', $usersShow); - $this->assertStringContainsString("'primary_tenant_id' => 'use_primary_tenant_uuid'", $usersShow); - } - - public function testApiDepartmentEndpointsUseTenantUuidAndNoTenantIdExposure(): void - { - $departmentIndex = $this->readProjectFile('pages/api/v1/departments/index().php'); - $this->assertStringContainsString("ApiResponse::validationError(['tenant_id' => ['use_tenant_uuid']]);", $departmentIndex); - $this->assertStringContainsString("if (array_key_exists('tenant_uuid', \$input)) {", $departmentIndex); - - $departmentShow = $this->readProjectFile('pages/api/v1/departments/show($id).php'); - $this->assertStringContainsString("'tenant_uuid' => \$tenantUuid", $departmentShow); - $this->assertStringNotContainsString("'tenant_id' => \$department['tenant_id'] ?? null", $departmentShow); - $this->assertStringContainsString("'cost_center' => \$department['cost_center'] ?? ''", $departmentShow); - } - - public function testApiRoleShowIncludesPermissionKeys(): void - { - $roleShow = $this->readProjectFile('pages/api/v1/roles/show($id).php'); - $this->assertStringContainsString('createRolePermissionRepository()', $roleShow); - $this->assertStringContainsString('listPermissionKeysByRoleIds([$roleId])', $roleShow); - $this->assertStringContainsString("'permission_keys' => \$permissionKeys", $roleShow); - } - - public function testOpenApiMarksLoginAsPublic(): void - { - $content = $this->readProjectFile('docs/openapi.yaml'); - $this->assertMatchesRegularExpression('/\/auth\/login:\s+post:.*?security:\s*\[\]/s', $content); - $this->assertStringContainsString('no Authorization header required', $content); - } - - public function testOpenApiIncludesNewCriticalFrontendEndpoints(): void - { - $content = $this->readProjectFile('docs/openapi.yaml'); - $this->assertStringContainsString('/permissions:', $content); - $this->assertStringContainsString('/me/password:', $content); - $this->assertStringContainsString('/me/avatar:', $content); - $this->assertStringContainsString('/users/avatar/{uuid}:', $content); - $this->assertStringContainsString('/tenants/avatar/{uuid}:', $content); - $this->assertStringContainsString('/settings:', $content); - $this->assertStringContainsString('/settings/public:', $content); - } - - public function testOpenApiUsesUuidBasedMasterDataContracts(): void - { - $content = $this->readProjectFile('docs/openapi.yaml'); - $this->assertStringContainsString('required: [description, tenant_uuid]', $content); - $this->assertStringContainsString('tenant_uuids:', $content); - $this->assertStringContainsString('primary_tenant_uuid:', $content); - $this->assertStringContainsString('role_uuids:', $content); - $this->assertStringContainsString('department_uuids:', $content); - $this->assertStringContainsString('permission_keys:', $content); - $this->assertStringContainsString('tenant_uuid:', $content); - } - - private function readProjectFile(string $path): string - { - $root = realpath(__DIR__ . '/../..'); - $this->assertNotFalse($root, 'Project root not found.'); - $fullPath = $root . '/' . $path; - $this->assertFileExists($fullPath, 'File not found: ' . $path); - $content = file_get_contents($fullPath); - $this->assertNotFalse($content, 'Could not read file: ' . $path); - return $content; - } -} diff --git a/tests/Architecture/AuthzApiContractTest.php b/tests/Architecture/AuthzApiContractTest.php new file mode 100644 index 0000000..11c1c74 --- /dev/null +++ b/tests/Architecture/AuthzApiContractTest.php @@ -0,0 +1,192 @@ +readProjectFile('pages/api/v1/auth/login().php'); + $this->assertStringContainsString('ApiBootstrap::init(false);', $content); + } + + + public function testApiBootstrapSupportsOptionalAuthRequirement(): void + { + $content = $this->readProjectFile('lib/Http/ApiBootstrap.php'); + $this->assertStringContainsString('public static function init(bool $requireAuth = true): void', $content); + $this->assertStringContainsString('if ($requireAuth) {', $content); + $this->assertStringContainsString('$authenticated = ApiAuth::authenticate();', $content); + $this->assertStringContainsString('ApiResponse::unauthorized();', $content); + } + + + public function testApiUsersEndpointsUseCentralUserPolicy(): void + { + $indexContent = $this->readProjectFile('pages/api/v1/users/index().php'); + $this->assertStringContainsString('AuthorizationService::class', $indexContent); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_INDEX_GET', $indexContent); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_INDEX_POST', $indexContent); + + $showContent = $this->readProjectFile('pages/api/v1/users/show($id).php'); + $this->assertStringContainsString('AuthorizationService::class', $showContent); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_GET', $showContent); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_UPDATE', $showContent); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_DELETE', $showContent); + } + + + public function testApiPermissionAndSettingsEndpointsHaveExpectedGuards(): void + { + $permissionContent = $this->readProjectFile('pages/api/v1/permissions/index().php'); + $this->assertStringContainsString('ApiResponse::requireAbility(\\MintyPHP\\Service\\Access\\OperationsAuthorizationPolicy::ABILITY_API_PERMISSIONS_VIEW);', $permissionContent); + $this->assertStringContainsString("foreach ((\$result['rows'] ?? []) as \$row)", $permissionContent); + + $settingsContent = $this->readProjectFile('pages/api/v1/settings/index().php'); + $this->assertStringContainsString('ApiResponse::requireAbility(\\MintyPHP\\Service\\Access\\OperationsAuthorizationPolicy::ABILITY_API_SETTINGS_VIEW);', $settingsContent); + $this->assertStringContainsString('ApiResponse::requireAbility(\\MintyPHP\\Service\\Access\\OperationsAuthorizationPolicy::ABILITY_API_SETTINGS_UPDATE);', $settingsContent); + } + + + public function testApiMeEndpointsUseSelfServicePermissions(): void + { + $meContent = $this->readProjectFile('pages/api/v1/me/index().php'); + $this->assertStringContainsString('$method === \'PUT\' || $method === \'PATCH\'', $meContent); + $this->assertStringContainsString('OperationsAuthorizationPolicy::ABILITY_API_ME_SELF_UPDATE', $meContent); + $this->assertStringContainsString('ApiResponse::validationFromFormErrors(', $meContent); + + $passwordContent = $this->readProjectFile('pages/api/v1/me/password().php'); + $this->assertStringContainsString("ApiResponse::requireMethod('POST');", $passwordContent); + $this->assertStringContainsString('ApiResponse::requireAbility(\\MintyPHP\\Service\\Access\\OperationsAuthorizationPolicy::ABILITY_API_ME_SELF_UPDATE);', $passwordContent); + + $avatarContent = $this->readProjectFile('pages/api/v1/me/avatar().php'); + $this->assertStringContainsString("define('MINTY_ALLOW_OUTPUT', true);", $avatarContent); + $this->assertStringContainsString('OperationsAuthorizationPolicy::ABILITY_API_ME_SELF_UPDATE', $avatarContent); + } + + + public function testApiAvatarEndpointsUseExpectedAuthorizationModel(): void + { + $userAvatarContent = $this->readProjectFile('pages/api/v1/users/avatar($id).php'); + $this->assertStringContainsString('UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_GET', $userAvatarContent); + + $tenantAvatarContent = $this->readProjectFile('pages/api/v1/tenants/avatar($id).php'); + $this->assertStringContainsString('OperationsAuthorizationPolicy::ABILITY_API_TENANTS_VIEW', $tenantAvatarContent); + $this->assertStringContainsString("ApiAuth::requireResourceAccess('tenants', \$tenantId);", $tenantAvatarContent); + } + + + public function testApiTenantShowExposesFullTenantMasterData(): void + { + $tenantShowContent = $this->readProjectFile('pages/api/v1/tenants/show($id).php'); + $this->assertStringContainsString("'region' => \$tenant['region'] ?? ''", $tenantShowContent); + $this->assertStringContainsString("'vat_id' => \$tenant['vat_id'] ?? ''", $tenantShowContent); + $this->assertStringContainsString("'support_email' => \$tenant['support_email'] ?? ''", $tenantShowContent); + $this->assertStringContainsString("'billing_email' => \$tenant['billing_email'] ?? ''", $tenantShowContent); + $this->assertStringContainsString("'primary_color_use_default' => \$primaryColor === ''", $tenantShowContent); + $this->assertStringContainsString("'allow_user_theme_mode' => \$allowUserThemeMode", $tenantShowContent); + + $openApiContent = $this->readProjectFile('docs/openapi.yaml'); + $this->assertStringContainsString('TenantShowResponse:', $openApiContent); + $this->assertStringContainsString('primary_color_use_default:', $openApiContent); + $this->assertStringContainsString('allow_user_theme_mode:', $openApiContent); + $this->assertStringContainsString('support_email:', $openApiContent); + } + + + public function testApiUserWriteEndpointsUseUuidAssignmentInput(): void + { + $usersIndex = $this->readProjectFile('pages/api/v1/users/index().php'); + $this->assertStringContainsString('UserApiWriteInputMapper::class', $usersIndex); + $this->assertStringContainsString('->normalize($input)', $usersIndex); + + $usersShow = $this->readProjectFile('pages/api/v1/users/show($id).php'); + $this->assertStringContainsString('UserApiWriteInputMapper::class', $usersShow); + $this->assertStringContainsString('->normalize($input)', $usersShow); + + $mapper = $this->readProjectFile('lib/Service/User/UserApiWriteInputMapper.php'); + $this->assertStringContainsString("'tenant_ids' => 'use_tenant_uuids'", $mapper); + $this->assertStringContainsString("'role_ids' => 'use_role_uuids'", $mapper); + $this->assertStringContainsString("'department_ids' => 'use_department_uuids'", $mapper); + $this->assertStringContainsString("'primary_tenant_id' => 'use_primary_tenant_uuid'", $mapper); + } + + + public function testApiDepartmentEndpointsUseTenantUuidAndNoTenantIdExposure(): void + { + $departmentIndex = $this->readProjectFile('pages/api/v1/departments/index().php'); + $this->assertStringContainsString("ApiResponse::validationFromFormErrors(formErrorsFrom(['tenant_id' => ['use_tenant_uuid']]));", $departmentIndex); + $this->assertStringContainsString("if (array_key_exists('tenant_uuid', \$input)) {", $departmentIndex); + + $departmentShow = $this->readProjectFile('pages/api/v1/departments/show($id).php'); + $this->assertStringContainsString("'tenant_uuid' => \$tenantUuid", $departmentShow); + $this->assertStringNotContainsString("'tenant_id' => \$department['tenant_id'] ?? null", $departmentShow); + $this->assertStringContainsString("'cost_center' => \$department['cost_center'] ?? ''", $departmentShow); + } + + + public function testApiRoleShowIncludesPermissionKeys(): void + { + $roleShow = $this->readProjectFile('pages/api/v1/roles/show($id).php'); + $this->assertStringContainsString('RolePermissionRepository::class', $roleShow); + $this->assertStringContainsString('listPermissionKeysByRoleIds([$roleId])', $roleShow); + $this->assertStringContainsString("'permission_keys' => \$permissionKeys", $roleShow); + } + + + public function testOpenApiMarksLoginAsPublic(): void + { + $content = $this->readProjectFile('docs/openapi.yaml'); + $this->assertMatchesRegularExpression('/\/auth\/login:\s+post:.*?security:\s*\[\]/s', $content); + $this->assertStringContainsString('no Authorization header required', $content); + } + + + public function testOpenApiIncludesNewCriticalFrontendEndpoints(): void + { + $content = $this->readProjectFile('docs/openapi.yaml'); + $this->assertStringContainsString('/permissions:', $content); + $this->assertStringContainsString('/me/password:', $content); + $this->assertStringContainsString('/me/avatar:', $content); + $this->assertStringContainsString('/users/avatar/{uuid}:', $content); + $this->assertStringContainsString('/tenants/avatar/{uuid}:', $content); + $this->assertStringContainsString('/settings:', $content); + $this->assertStringContainsString('/settings/public:', $content); + } + + + public function testOpenApiUsesUuidBasedMasterDataContracts(): void + { + $content = $this->readProjectFile('docs/openapi.yaml'); + $this->assertStringContainsString('required: [description, tenant_uuid]', $content); + $this->assertStringContainsString('tenant_uuids:', $content); + $this->assertStringContainsString('primary_tenant_uuid:', $content); + $this->assertStringContainsString('role_uuids:', $content); + $this->assertStringContainsString('department_uuids:', $content); + $this->assertStringContainsString('permission_keys:', $content); + $this->assertStringContainsString('tenant_uuid:', $content); + } + + + public function testApiErrorEnvelopeIsCentralizedAndRequestIdAware(): void + { + $apiResponse = $this->readProjectFile('lib/Http/ApiResponse.php'); + $this->assertStringContainsString("'ok' => false", $apiResponse); + $this->assertStringContainsString("'error_code' => \$normalizedErrorCode", $apiResponse); + $this->assertStringContainsString("'details' => \$normalizedDetails", $apiResponse); + $this->assertStringContainsString("header('X-Request-Id: ' . \$requestId);", $apiResponse); + $this->assertStringNotContainsString("'error' => \$normalizedErrorCode", $apiResponse); + $this->assertStringNotContainsString("\$body['errors']", $apiResponse); + + $openApi = $this->readProjectFile('docs/openapi.yaml'); + $this->assertStringContainsString('required: [ok, request_id, error_code, details]', $openApi); + $this->assertStringNotContainsString('Legacy alias of `error_code`', $openApi); + $this->assertStringNotContainsString('Legacy alias of `details.errors`', $openApi); + } + + +} diff --git a/tests/Architecture/AuthzUiContractTest.php b/tests/Architecture/AuthzUiContractTest.php new file mode 100644 index 0000000..fcbcd6e --- /dev/null +++ b/tests/Architecture/AuthzUiContractTest.php @@ -0,0 +1,183 @@ +readProjectFile('pages/admin/tenants/index(default).phtml'); + $this->assertStringNotContainsString("can('tenants.create')", $indexTemplate); + $this->assertStringContainsString('$canCreateTenants', $indexTemplate); + + $createTemplate = $this->readProjectFile('pages/admin/tenants/create(default).phtml'); + $this->assertStringNotContainsString("can('custom_fields.manage')", $createTemplate); + $this->assertStringNotContainsString("can('tenants.sso_manage')", $createTemplate); + $this->assertStringContainsString('$canManageCustomFields', $createTemplate); + $this->assertStringContainsString('$canManageSso', $createTemplate); + } + + + public function testAdminDepartmentsListTemplateUsesServerCapabilities(): void + { + $content = $this->readProjectFile('pages/admin/departments/index(default).phtml'); + $this->assertStringNotContainsString("can('departments.create')", $content); + $this->assertStringContainsString('$canCreateDepartments', $content); + } + + + public function testAdminRoleTemplatesUseServerCapabilities(): void + { + $indexTemplate = $this->readProjectFile('pages/admin/roles/index(default).phtml'); + $this->assertStringNotContainsString("can('roles.create')", $indexTemplate); + $this->assertStringContainsString('$canCreateRoles', $indexTemplate); + + $editTemplate = $this->readProjectFile('pages/admin/roles/edit(default).phtml'); + $this->assertStringNotContainsString("can('roles.update')", $editTemplate); + $this->assertStringNotContainsString("can('roles.delete')", $editTemplate); + $this->assertStringContainsString('$canUpdateRole', $editTemplate); + $this->assertStringContainsString('$canDeleteRole', $editTemplate); + } + + + public function testAdminPermissionTemplatesUseServerCapabilities(): void + { + $indexTemplate = $this->readProjectFile('pages/admin/permissions/index(default).phtml'); + $this->assertStringNotContainsString("can('permissions.create')", $indexTemplate); + $this->assertStringContainsString('$canCreatePermissions', $indexTemplate); + + $editTemplate = $this->readProjectFile('pages/admin/permissions/edit(default).phtml'); + $this->assertStringNotContainsString("can('permissions.update')", $editTemplate); + $this->assertStringNotContainsString("can('permissions.delete')", $editTemplate); + $this->assertStringContainsString('$canUpdatePermission', $editTemplate); + $this->assertStringContainsString('$canDeletePermission', $editTemplate); + } + + + public function testAdminSettingsTemplateUsesServerCapabilities(): void + { + $templateContent = $this->readProjectFile('pages/admin/settings/index(default).phtml'); + $this->assertStringNotContainsString("can('settings.update')", $templateContent); + $this->assertStringContainsString('$canUpdateSettings', $templateContent); + } + + + public function testAdminUserEditTemplateUsesServerCapabilities(): void + { + $content = $this->readProjectFile('pages/admin/users/edit(default).phtml'); + $this->assertStringNotContainsString("can('users.", $content); + $this->assertStringNotContainsString("can('permissions.view')", $content); + $this->assertStringContainsString('$canViewSecurityArtifacts', $content); + } + + + public function testHardCutTemplatesDoNotUseLegacyCanHelper(): void + { + $templates = [ + 'templates/partials/app-main-aside.phtml', + 'templates/partials/app-main-aside-icon-bar.phtml', + 'pages/admin/api-audit/index(default).phtml', + 'pages/admin/system-audit/index(default).phtml', + 'pages/admin/import-audit/index(default).phtml', + 'pages/admin/scheduled-jobs/index(default).phtml', + 'pages/admin/stats/index(default).phtml', + 'pages/admin/user-lifecycle-audit/index(default).phtml', + 'pages/admin/user-lifecycle-audit/view(default).phtml', + 'pages/admin/users/index(default).phtml', + 'pages/admin/users/create(default).phtml', + 'pages/admin/tenants/edit(default).phtml', + 'pages/admin/departments/edit(default).phtml', + ]; + + foreach ($templates as $template) { + $content = $this->readProjectFile($template); + $this->assertStringNotContainsString("can('", $content, $template); + } + } + + + public function testLayoutPartialsUseViewAuthLayoutCapabilities(): void + { + $defaultTemplate = $this->readProjectFile('templates/default.phtml'); + $this->assertStringContainsString("\$viewAuth['layout']", $defaultTemplate); + $this->assertStringNotContainsString('UiAccessService::class', $defaultTemplate); + + $aside = $this->readProjectFile('templates/partials/app-main-aside.phtml'); + $this->assertStringContainsString("\$viewAuth['layout']", $aside); + + $iconBar = $this->readProjectFile('templates/partials/app-main-aside-icon-bar.phtml'); + $this->assertStringContainsString("\$viewAuth['layout']", $iconBar); + } + + + public function testHardCutActionsProvideViewAuthPageCapabilities(): void + { + $actions = [ + 'pages/admin/users/index().php', + 'pages/admin/users/create().php', + 'pages/admin/tenants/edit($id).php', + 'pages/admin/departments/edit($id).php', + 'pages/admin/stats/index().php', + 'pages/admin/api-audit/index().php', + 'pages/admin/system-audit/index().php', + 'pages/admin/import-audit/index().php', + 'pages/admin/scheduled-jobs/index().php', + 'pages/admin/user-lifecycle-audit/index().php', + 'pages/admin/user-lifecycle-audit/view($id).php', + ]; + + foreach ($actions as $action) { + $content = $this->readProjectFile($action); + $this->assertStringContainsString("\$viewAuth['page']", $content, $action); + } + } + + + public function testMapDrivenHardCutActionsUseUiCapabilityMaps(): void + { + $actions = [ + 'pages/admin/users/index().php' => 'UiCapabilityMap::PAGE_USERS_INDEX', + 'pages/admin/users/create().php' => 'UiCapabilityMap::PAGE_USERS_CREATE', + 'pages/admin/stats/index().php' => 'UiCapabilityMap::PAGE_STATS_INDEX', + 'pages/admin/api-audit/index().php' => 'UiCapabilityMap::PAGE_AUDIT_PURGE', + 'pages/admin/system-audit/index().php' => 'UiCapabilityMap::PAGE_SYSTEM_AUDIT', + 'pages/admin/import-audit/index().php' => 'UiCapabilityMap::PAGE_AUDIT_PURGE', + 'pages/admin/scheduled-jobs/index().php' => 'UiCapabilityMap::PAGE_AUDIT_PURGE', + 'pages/admin/user-lifecycle-audit/index().php' => 'UiCapabilityMap::PAGE_AUDIT_PURGE', + 'pages/admin/user-lifecycle-audit/view($id).php' => 'UiCapabilityMap::PAGE_USER_LIFECYCLE_VIEW', + ]; + + foreach ($actions as $action => $mapConstant) { + $content = $this->readProjectFile($action); + $this->assertStringContainsString('->pageCapabilities(', $content, $action); + $this->assertStringContainsString($mapConstant, $content, $action); + } + } + + + public function testUiAccessServiceUsesCentralLayoutMapDefinition(): void + { + $content = $this->readProjectFile('lib/Service/Access/UiAccessService.php'); + $this->assertStringContainsString('UiCapabilityMap::LAYOUT', $content); + } + + public function testStatsPageCapabilityMapIncludesAuditCapabilities(): void + { + $content = $this->readProjectFile('lib/Service/Access/UiCapabilityMap.php'); + $this->assertStringContainsString("'can_view_system_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_SYSTEM_AUDIT_VIEW", $content); + $this->assertStringContainsString("'can_view_user_lifecycle_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW", $content); + } + + public function testStatsTemplateDefinesAuditTabAndPanel(): void + { + $content = $this->readProjectFile('pages/admin/stats/index(default).phtml'); + $this->assertStringContainsString('data-tab="audit"', $content); + $this->assertStringContainsString('data-tab-panel="audit"', $content); + } + + +} diff --git a/tests/Architecture/CodexSkillsContractTest.php b/tests/Architecture/CodexSkillsContractTest.php new file mode 100644 index 0000000..dad37d7 --- /dev/null +++ b/tests/Architecture/CodexSkillsContractTest.php @@ -0,0 +1,58 @@ +readProjectFile('tools/codex-skills/core-guardrails/SKILL.md'); + $this->assertStringContainsString('name: core-guardrails', $skill); + $this->assertStringContainsString('MUST', $skill); + $this->assertStringContainsString('MUST NOT', $skill); + $this->assertStringContainsString('Out of Scope', $skill); + + $this->readProjectFile('tools/codex-skills/core-guardrails/references/boundaries-core.md'); + $this->readProjectFile('tools/codex-skills/core-guardrails/references/boundaries-ui.md'); + $this->readProjectFile('tools/codex-skills/core-guardrails/references/quality-gates.md'); + $this->readProjectFile('tools/codex-skills/core-guardrails/references/source-map.md'); + } + + public function testStarterkitPlannerSkillContainsDecisionCompleteTemplateContract(): void + { + $skill = $this->readProjectFile('tools/codex-skills/starterkit-planner/SKILL.md'); + $this->assertStringContainsString('name: starterkit-planner', $skill); + $this->assertStringContainsString('Decision-complete Implementierungsplan', $skill); + + $this->readProjectFile('tools/codex-skills/starterkit-planner/references/plan-template.md'); + $this->readProjectFile('tools/codex-skills/starterkit-planner/references/tradeoff-matrix.md'); + } + + public function testCodexPromptsDocumentationReferencesBothSkillsAndExtensionScope(): void + { + $prompts = $this->readProjectFile('docs/codex-prompts.md'); + $this->assertStringContainsString('core-guardrails', $prompts); + $this->assertStringContainsString('starterkit-planner', $prompts); + $this->assertStringContainsString('Out of Scope: Extensions', $prompts); + } + + public function testDocsIndexReferencesCodexPrompts(): void + { + $index = $this->readProjectFile('docs/index.md'); + $this->assertStringContainsString('/docs/codex-prompts.md', $index); + } + + public function testCodexSkillsSyncScriptSupportsCheckAndApply(): void + { + $script = $this->readProjectFile('bin/codex-skills-sync.sh'); + $this->assertStringContainsString('--check', $script); + $this->assertStringContainsString('--apply', $script); + $this->assertStringContainsString('mode="check"', $script); + $this->assertStringContainsString('core-guardrails', $script); + $this->assertStringContainsString('starterkit-planner', $script); + } +} diff --git a/tests/Architecture/ContainerFactoryContractTest.php b/tests/Architecture/ContainerFactoryContractTest.php new file mode 100644 index 0000000..5505323 --- /dev/null +++ b/tests/Architecture/ContainerFactoryContractTest.php @@ -0,0 +1,60 @@ + + */ + public static function factoryClassProvider(): array + { + return [ + [\MintyPHP\Service\Access\AccessServicesFactory::class], + [\MintyPHP\Service\AddressBook\AddressBookServicesFactory::class], + [\MintyPHP\Service\Audit\AuditServicesFactory::class], + [\MintyPHP\Service\Auth\AuthServicesFactory::class], + [\MintyPHP\Service\Branding\BrandingServicesFactory::class], + [\MintyPHP\Service\Directory\DirectoryServicesFactory::class], + [\MintyPHP\Service\Import\ImportServicesFactory::class], + [\MintyPHP\Service\Mail\MailServicesFactory::class], + [\MintyPHP\Service\Scheduler\SchedulerServicesFactory::class], + [\MintyPHP\Service\Security\SecurityServicesFactory::class], + [\MintyPHP\Service\Settings\SettingServicesFactory::class], + [\MintyPHP\Service\Tenant\TenantServicesFactory::class], + [\MintyPHP\Service\User\UserServicesFactory::class], + ]; + } + + #[DataProvider('factoryClassProvider')] + public function testContainerFactoriesCachePublicNoArgCreateMethods(string $factoryClass): void + { + $factory = app($factoryClass); + $this->assertInstanceOf($factoryClass, $factory); + + $reflection = new ReflectionClass($factoryClass); + $methods = array_filter( + $reflection->getMethods(ReflectionMethod::IS_PUBLIC), + static fn (ReflectionMethod $method): bool => + !$method->isStatic() + && str_starts_with($method->getName(), 'create') + && $method->getNumberOfRequiredParameters() === 0 + ); + usort($methods, static fn (ReflectionMethod $a, ReflectionMethod $b): int => strcmp($a->getName(), $b->getName())); + + foreach ($methods as $method) { + $methodName = $method->getName(); + $first = $factory->{$methodName}(); + $second = $factory->{$methodName}(); + + $this->assertIsObject($first, sprintf('%s::%s() must return an object', $factoryClass, $methodName)); + $this->assertInstanceOf($first::class, $second, sprintf('%s::%s() must be type-stable', $factoryClass, $methodName)); + $this->assertSame($first, $second, sprintf('%s::%s() must return the cached instance', $factoryClass, $methodName)); + } + } +} diff --git a/tests/Architecture/CoreStarterkitContractTest.php b/tests/Architecture/CoreStarterkitContractTest.php new file mode 100644 index 0000000..2156b88 --- /dev/null +++ b/tests/Architecture/CoreStarterkitContractTest.php @@ -0,0 +1,90 @@ +findPatternMatchesInPhpFiles('pages', '/\bnew\s+[\\\\A-Za-z0-9_]+(?:Service|Gateway|Repository)\s*\(/'); + $this->assertSame([], $violations, "Direct service/repository/gateway instantiation found in pages/:\n" . implode("\n", $violations)); + } + + public function testPagesDoNotUseDatabaseFacadeDirectly(): void + { + $violations = $this->findPatternMatchesInPhpFiles('pages', '/\bDB::/'); + $this->assertSame([], $violations, "Direct DB facade usage found in pages/:\n" . implode("\n", $violations)); + } + + public function testAdminPagesDoNotUseFactoryCreateChains(): void + { + $violations = $this->findPatternMatchesInPhpFiles('pages/admin', '/app\([^\n]*Factory::class\)->create/'); + $this->assertSame([], $violations, "Factory create-chain usage found in pages/admin/:\n" . implode("\n", $violations)); + } + + public function testAdminPagesDoNotReferenceFactoryClasses(): void + { + $violations = $this->findPatternMatchesInPhpFiles('pages/admin', '/Factory::class/'); + $this->assertSame([], $violations, "Factory class usage found in pages/admin/:\n" . implode("\n", $violations)); + } + + public function testPagesUseRequestInputInsteadOfSuperglobals(): void + { + $patterns = [ + '/\$_POST/' => '$_POST', + '/\$_GET/' => '$_GET', + '/\$_FILES/' => '$_FILES', + '/REQUEST_METHOD/' => 'REQUEST_METHOD', + ]; + + foreach ($patterns as $pattern => $label) { + $violations = $this->findPatternMatchesInPhpFiles('pages', $pattern); + $this->assertSame([], $violations, "Superglobal {$label} usage found in pages/:\n" . implode("\n", $violations)); + } + } + + public function testApiPagesDoNotReadJsonBodyDirectly(): void + { + $violations = $this->findPatternMatchesInPhpFiles('pages/api/v1', '/ApiResponse::readJsonBody\s*\(/'); + $this->assertSame([], $violations, "ApiResponse::readJsonBody usage found in pages/api/v1/:\n" . implode("\n", $violations)); + } + + /** + * @return list + */ + private function findPatternMatchesInPhpFiles(string $relativeDirectory, string $pattern): array + { + $root = $this->projectRootPath(); + $basePath = $root . '/' . $relativeDirectory; + $this->assertDirectoryExists($basePath, 'Directory not found: ' . $relativeDirectory); + + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($basePath)); + $violations = []; + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $content = file_get_contents($file->getPathname()); + $this->assertNotFalse($content, 'Could not read file: ' . $file->getPathname()); + + if (!preg_match($pattern, $content)) { + continue; + } + + $relativePath = str_replace($root . '/', '', $file->getPathname()); + $violations[] = $relativePath; + } + + sort($violations); + return $violations; + } +} diff --git a/tests/Architecture/DetailActionPolicyContractTest.php b/tests/Architecture/DetailActionPolicyContractTest.php new file mode 100644 index 0000000..5b37580 --- /dev/null +++ b/tests/Architecture/DetailActionPolicyContractTest.php @@ -0,0 +1,68 @@ + + */ + private function migratedConfirmFiles(): array + { + return [ + 'pages/admin/users/edit(default).phtml', + 'pages/admin/users/_form.phtml', + 'pages/admin/tenants/edit(default).phtml', + 'pages/admin/tenants/_form.phtml', + 'pages/admin/departments/edit(default).phtml', + 'pages/admin/roles/edit(default).phtml', + 'pages/admin/permissions/edit(default).phtml', + 'pages/admin/settings/index(default).phtml', + 'templates/partials/app-details-titlebar.phtml', + 'templates/partials/app-details-aside-actions.phtml', + ]; + } + + public function testDetailPageFactoryUsesActionPolicyModule(): void + { + $content = $this->readProjectFile('web/js/core/app-detail-page-factory.js'); + $this->assertStringContainsString("import { initDetailActionPolicy } from './app-details-action-policy.js';", $content); + $this->assertStringContainsString('const actionPolicy = initDetailActionPolicy(', $content); + $this->assertStringContainsString('actionPolicy.isSubmitting()', $content); + } + + public function testDetailsPartialsExposeActionPolicyContract(): void + { + $titlebar = $this->readProjectFile('templates/partials/app-details-titlebar.phtml'); + $this->assertStringContainsString('data-detail-action-policy="1"', $titlebar); + $this->assertStringContainsString('data-detail-action-kind=', $titlebar); + $this->assertStringContainsString('data-detail-confirm-message=', $titlebar); + + $asideActions = $this->readProjectFile('templates/partials/app-details-aside-actions.phtml'); + $this->assertStringContainsString('data-detail-confirm-message=', $asideActions); + + $danger = $this->readProjectFile('templates/partials/app-danger-zone-delete-field.phtml'); + $this->assertStringContainsString('data-detail-action-kind="delete"', $danger); + } + + public function testMigratedFilesNoLongerUseInlineConfirmHandlers(): void + { + foreach ($this->migratedConfirmFiles() as $file) { + $content = $this->readProjectFile($file); + $this->assertStringNotContainsString('onsubmit="return confirm(', $content, $file); + $this->assertStringNotContainsString('onclick="return confirm(', $content, $file); + } + } + + public function testMigratedFilesExposeDataDetailConfirmContract(): void + { + foreach ($this->migratedConfirmFiles() as $file) { + $content = $this->readProjectFile($file); + $this->assertStringContainsString('data-detail-confirm-message', $content, $file); + } + } +} diff --git a/tests/Architecture/DetailPageContractTest.php b/tests/Architecture/DetailPageContractTest.php new file mode 100644 index 0000000..8b96ab3 --- /dev/null +++ b/tests/Architecture/DetailPageContractTest.php @@ -0,0 +1,58 @@ + + */ + private function standardDetailFormFiles(): array + { + return [ + 'pages/admin/users/_form.phtml', + 'pages/admin/tenants/_form.phtml', + 'pages/admin/departments/_form.phtml', + 'pages/admin/roles/_form.phtml', + 'pages/admin/permissions/_form.phtml', + 'pages/admin/scheduled-jobs/edit(default).phtml', + 'pages/admin/settings/index(default).phtml', + ]; + } + + public function testScopedDetailFormsUseStandardDetailOptInAttribute(): void + { + foreach ($this->standardDetailFormFiles() as $file) { + $content = $this->readProjectFile($file); + $this->assertStringContainsString('data-standard-detail-form="1"', $content, $file); + } + } + + public function testAppInitIncludesStandardDetailPageAutoInitComponent(): void + { + $content = $this->readProjectFile('web/js/app-init.js'); + $this->assertStringContainsString("import './components/app-standard-detail-page.js';", $content); + } + + public function testDetailsTitlebarExposesUnsavedMessageAndPrimarySaveMarkers(): void + { + $content = $this->readProjectFile('templates/partials/app-details-titlebar.phtml'); + $this->assertStringContainsString('data-detail-unsaved-message=', $content); + $this->assertStringContainsString('data-detail-action-policy="1"', $content); + $this->assertStringContainsString('data-detail-action-kind=', $content); + $this->assertStringContainsString('data-detail-save-primary="1"', $content); + } + + public function testImportsWizardFormsUseStandardDetailOptIn(): void + { + $content = $this->readProjectFile('pages/admin/imports/index(default).phtml'); + $this->assertStringContainsString('id="imports-upload-form"', $content); + $this->assertStringContainsString('id="imports-mapping-form"', $content); + $this->assertStringContainsString('id="imports-commit-form"', $content); + $this->assertSame(3, substr_count($content, 'data-standard-detail-form="1"')); + } +} diff --git a/tests/Architecture/DetailValidationSummaryContractTest.php b/tests/Architecture/DetailValidationSummaryContractTest.php new file mode 100644 index 0000000..1e49d24 --- /dev/null +++ b/tests/Architecture/DetailValidationSummaryContractTest.php @@ -0,0 +1,89 @@ + + */ + private function detailTemplateFiles(): array + { + return [ + 'pages/admin/users/create(default).phtml', + 'pages/admin/users/edit(default).phtml', + 'pages/admin/tenants/create(default).phtml', + 'pages/admin/tenants/edit(default).phtml', + 'pages/admin/departments/create(default).phtml', + 'pages/admin/departments/edit(default).phtml', + 'pages/admin/roles/create(default).phtml', + 'pages/admin/roles/edit(default).phtml', + 'pages/admin/permissions/create(default).phtml', + 'pages/admin/permissions/edit(default).phtml', + 'pages/admin/scheduled-jobs/edit(default).phtml', + ]; + } + + /** + * @return list + */ + private function detailActionFiles(): array + { + return [ + 'pages/admin/users/create().php', + 'pages/admin/users/edit($id).php', + 'pages/admin/tenants/create().php', + 'pages/admin/tenants/edit($id).php', + 'pages/admin/departments/create().php', + 'pages/admin/departments/edit($id).php', + 'pages/admin/roles/create().php', + 'pages/admin/roles/edit($id).php', + 'pages/admin/permissions/create().php', + 'pages/admin/permissions/edit($id).php', + 'pages/admin/scheduled-jobs/edit($id).php', + ]; + } + + public function testScopedDetailTemplatesUseSharedValidationSummaryPartial(): void + { + foreach ($this->detailTemplateFiles() as $file) { + $content = $this->readProjectFile($file); + $this->assertStringContainsString("require templatePath('partials/app-details-validation-summary.phtml');", $content, $file); + } + } + + public function testScopedDetailTemplatesDoNotInlineLegacyErrorLoop(): void + { + foreach ($this->detailTemplateFiles() as $file) { + $content = $this->readProjectFile($file); + $this->assertSame(0, preg_match('/foreach\s*\(\$errors\s+as\s+\$error\)/', $content), $file); + } + } + + public function testSharedPartialExposesValidationSummaryContract(): void + { + $content = $this->readProjectFile('templates/partials/app-details-validation-summary.phtml'); + $this->assertStringContainsString('data-validation-summary', $content); + $this->assertStringContainsString('data-validation-summary-autofocus=', $content); + $this->assertStringContainsString('data-validation-error-link', $content); + $this->assertStringNotContainsString('class="notice"', $content); + } + + public function testScopedDetailActionsProvideStructuredValidationSummaryErrors(): void + { + foreach ($this->detailActionFiles() as $file) { + $content = $this->readProjectFile($file); + $this->assertStringContainsString('$validationSummaryErrors = $errorBag->toArray();', $content, $file); + } + } + + public function testDefaultStyleGroupIncludesValidationSummaryComponentCss(): void + { + $content = $this->readProjectFile('config/assets.php'); + $this->assertStringContainsString("'css/components/app-details-validation-summary.css'", $content); + } +} diff --git a/tests/Architecture/FilterDrawerContractTest.php b/tests/Architecture/FilterDrawerContractTest.php new file mode 100644 index 0000000..1ec0002 --- /dev/null +++ b/tests/Architecture/FilterDrawerContractTest.php @@ -0,0 +1,113 @@ + + */ + private function drawerTemplateFiles(): array + { + return [ + 'pages/address-book/index(default).phtml', + 'pages/admin/users/index(default).phtml', + 'pages/admin/tenants/index(default).phtml', + 'pages/admin/departments/index(default).phtml', + 'pages/admin/roles/index(default).phtml', + 'pages/admin/permissions/index(default).phtml', + 'pages/admin/mail-log/index(default).phtml', + 'pages/admin/scheduled-jobs/index(default).phtml', + 'pages/admin/api-audit/index(default).phtml', + 'pages/admin/import-audit/index(default).phtml', + 'pages/admin/user-lifecycle-audit/index(default).phtml', + 'pages/admin/system-audit/index(default).phtml', + ]; + } + + public function testDrawerTemplatesUseStandardListWrapperForFilterExperience(): void + { + foreach ($this->drawerTemplateFiles() as $file) { + $content = $this->readProjectFile($file); + $usesPartial = $this->usesSharedListFiltersPartial($content); + $this->assertStringContainsString('initStandardListPage(', $content, $file); + $this->assertStringContainsString("mode: 'drawer'", $content, $file); + $this->assertStringNotContainsString('initListFilterExperience(', $content, $file); + if (!$usesPartial) { + $this->assertStringContainsString('role="dialog"', $content, $file); + $this->assertStringContainsString('aria-modal="true"', $content, $file); + $this->assertStringContainsString('aria-labelledby="', $content, $file); + $this->assertStringContainsString('data-filter-live-region', $content, $file); + } + $this->assertStringNotContainsString('data-filter-drawer-draft-hint', $content, $file); + } + } + + public function testAddressBookSaveFilterReadsAppliedGridState(): void + { + $content = $this->readProjectFile('pages/address-book/index(default).phtml'); + + $this->assertStringContainsString('new URL(gridConfig.baseUrl()).searchParams', $content); + $this->assertStringNotContainsString('const state = collectFilterState();', $content); + $this->assertStringContainsString('data-filter-chips-clear-all-label', $content); + $this->assertStringContainsString('data-filter-chips-remove-label', $content); + $this->assertStringContainsString('data-filter-live-applied-message', $content); + $this->assertStringContainsString('data-filter-live-removed-message', $content); + $this->assertStringContainsString('data-filter-live-cleared-message', $content); + } + + public function testUsersResetKeepsTenantParam(): void + { + $content = $this->readProjectFile('pages/admin/users/index(default).phtml'); + + $this->assertStringContainsString("preserveFilterParams: ['tenant']", $content); + } + + public function testFilterDrawerAndExperienceImplementFocusTrapAndDirtyApplyUi(): void + { + $drawerJs = $this->readProjectFile('web/js/components/app-filter-drawer.js'); + $this->assertStringContainsString("drawer.setAttribute('role', 'dialog')", $drawerJs); + $this->assertStringContainsString("drawer.setAttribute('aria-modal', 'true')", $drawerJs); + $this->assertStringContainsString("if (event.key !== 'Tab') {return;}", $drawerJs); + $this->assertStringContainsString('if (lastTrigger && typeof lastTrigger.focus === \'function\'', $drawerJs); + + $experienceJs = $this->readProjectFile('web/js/pages/app-list-filter-experience.js'); + $this->assertStringContainsString('countDraftChanges(', $experienceJs); + $this->assertStringContainsString('countActiveDraftFilters(', $experienceJs); + $this->assertStringContainsString('drawerController.setApplyEnabled(changedCount > 0);', $experienceJs); + $this->assertStringContainsString('drawerController.setApplyCount(activeCount);', $experienceJs); + $this->assertStringContainsString('announceLive(', $experienceJs); + + $stateJs = $this->readProjectFile('web/js/pages/app-list-filter-state.js'); + $this->assertStringContainsString('buildChipsFromMeta', $stateJs); + $this->assertStringContainsString('removeChipFromMetaState', $stateJs); + $this->assertStringContainsString('clearMetaState', $stateJs); + } + + public function testSharedListFiltersPartialKeepsDrawerAccessibilityContract(): void + { + $content = $this->readProjectFile('templates/partials/app-list-filters.phtml'); + $this->assertStringContainsString('renderGridFilterToolbar(', $content); + $this->assertStringContainsString('data-filter-drawer-open', $content); + $this->assertStringContainsString('data-filter-drawer-apply', $content); + $this->assertStringContainsString('data-active-filter-chips', $content); + $this->assertStringContainsString('data-filter-chips-clear-all-label', $content); + $this->assertStringContainsString('data-filter-chips-remove-label', $content); + $this->assertStringContainsString('role="dialog"', $content); + $this->assertStringContainsString('aria-modal="true"', $content); + $this->assertStringContainsString('aria-labelledby=', $content); + $this->assertStringContainsString('data-filter-live-region', $content); + $this->assertStringContainsString('data-filter-live-applied-message', $content); + $this->assertStringContainsString('data-filter-live-removed-message', $content); + $this->assertStringContainsString('data-filter-live-cleared-message', $content); + } +} diff --git a/tests/Architecture/ListFilterContractTest.php b/tests/Architecture/ListFilterContractTest.php new file mode 100644 index 0000000..bf2aefe --- /dev/null +++ b/tests/Architecture/ListFilterContractTest.php @@ -0,0 +1,287 @@ + + */ + private function hardCutGridEndpointFiles(): array + { + return [ + 'pages/address-book/data().php', + 'pages/search/data().php', + 'pages/admin/users/data().php', + 'pages/admin/tenants/data().php', + 'pages/admin/departments/data().php', + 'pages/admin/roles/data().php', + 'pages/admin/permissions/data().php', + 'pages/admin/mail-log/data().php', + 'pages/admin/scheduled-jobs/data().php', + 'pages/admin/scheduled-jobs/runs-data($id).php', + 'pages/admin/api-audit/data().php', + 'pages/admin/import-audit/data().php', + 'pages/admin/user-lifecycle-audit/data().php', + 'pages/admin/system-audit/data().php', + ]; + } + + /** + * @return array + */ + private function hardCutGridEndpointSchemaFiles(): array + { + return [ + 'pages/address-book/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + 'pages/search/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + 'pages/admin/users/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + 'pages/admin/tenants/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + 'pages/admin/departments/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + 'pages/admin/roles/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + 'pages/admin/permissions/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + 'pages/admin/mail-log/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + 'pages/admin/scheduled-jobs/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + 'pages/admin/scheduled-jobs/runs-data($id).php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/runs-filter-schema.php'", + 'pages/admin/api-audit/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + 'pages/admin/import-audit/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + 'pages/admin/user-lifecycle-audit/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + 'pages/admin/system-audit/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + ]; + } + + /** + * @return list + */ + private function listIndexTemplates(): array + { + return [ + 'pages/address-book/index(default).phtml', + 'pages/search/index(default).phtml', + 'pages/admin/users/index(default).phtml', + 'pages/admin/tenants/index(default).phtml', + 'pages/admin/departments/index(default).phtml', + 'pages/admin/roles/index(default).phtml', + 'pages/admin/permissions/index(default).phtml', + 'pages/admin/mail-log/index(default).phtml', + 'pages/admin/scheduled-jobs/index(default).phtml', + 'pages/admin/api-audit/index(default).phtml', + 'pages/admin/import-audit/index(default).phtml', + 'pages/admin/user-lifecycle-audit/index(default).phtml', + 'pages/admin/system-audit/index(default).phtml', + ]; + } + + /** + * @return list + */ + private function listIndexActions(): array + { + return [ + 'pages/address-book/index().php', + 'pages/search/index().php', + 'pages/admin/users/index().php', + 'pages/admin/tenants/index().php', + 'pages/admin/departments/index().php', + 'pages/admin/roles/index().php', + 'pages/admin/permissions/index().php', + 'pages/admin/mail-log/index().php', + 'pages/admin/scheduled-jobs/index().php', + 'pages/admin/api-audit/index().php', + 'pages/admin/import-audit/index().php', + 'pages/admin/user-lifecycle-audit/index().php', + 'pages/admin/system-audit/index().php', + ]; + } + + /** + * @return list + */ + private function drawerListTemplates(): array + { + return [ + 'pages/address-book/index(default).phtml', + 'pages/admin/users/index(default).phtml', + 'pages/admin/tenants/index(default).phtml', + 'pages/admin/departments/index(default).phtml', + 'pages/admin/roles/index(default).phtml', + 'pages/admin/permissions/index(default).phtml', + 'pages/admin/mail-log/index(default).phtml', + 'pages/admin/scheduled-jobs/index(default).phtml', + 'pages/admin/api-audit/index(default).phtml', + 'pages/admin/import-audit/index(default).phtml', + 'pages/admin/user-lifecycle-audit/index(default).phtml', + 'pages/admin/system-audit/index(default).phtml', + ]; + } + + /** + * @return list + */ + private function searchOnlyTemplates(): array + { + return [ + 'pages/search/index(default).phtml', + ]; + } + + public function testDataEndpointsRequireGetGuard(): void + { + $files = $this->hardCutGridEndpointFiles(); + $this->assertCount(14, $files, 'Unexpected number of hard-cut grid data endpoints.'); + $this->assertNotContains('pages/admin/search/data().php', $files); + + foreach ($files as $file) { + $content = $this->readProjectFile($file); + $this->assertStringContainsString('gridRequireGetRequest();', $content, $file); + $this->assertStringNotContainsString("strtoupper((string) (requestInput()->method())) !== 'GET'", $content, $file); + } + } + + public function testDataEndpointsDoNotInlineQueryAllIndexParsing(): void + { + foreach ($this->hardCutGridEndpointFiles() as $file) { + $content = $this->readProjectFile($file); + $this->assertStringNotContainsString("requestInput()->queryAll()['", $content, $file); + $this->assertStringContainsString('gridParseFiltersFromSchemaFile', $content, $file); + } + } + + public function testDataEndpointsDoNotUseLegacySingleIdAliases(): void + { + foreach ($this->hardCutGridEndpointFiles() as $file) { + $content = $this->readProjectFile($file); + $this->assertSame( + 0, + preg_match('/\?\?\s*\([^\n]*\[[\"\"][a-z_]+_id[\"\"]\]/', $content), + $file + ); + } + } + + public function testAuditRepositoriesDoNotUseLegacySingleIdAliasFallbacks(): void + { + $files = [ + 'lib/Repository/Audit/ApiAuditLogRepository.php', + 'lib/Repository/Audit/ImportAuditRunRepository.php', + 'lib/Repository/Audit/UserLifecycleAuditRepository.php', + 'lib/Repository/Audit/SystemAuditLogRepository.php', + ]; + + foreach ($files as $file) { + $content = $this->readProjectFile($file); + $this->assertSame( + 0, + preg_match('/\[[\"\"][a-z_]+_ids[\"\"]\]\s*\?\?\s*\(\$filters\[[\"\"][a-z_]+_id[\"\"]\]/', $content), + $file + ); + } + } + + public function testDataEndpointsUseDirectoryFilterSchema(): void + { + foreach ($this->hardCutGridEndpointSchemaFiles() as $file => $expectedHelperCall) { + $content = $this->readProjectFile($file); + $this->assertStringContainsString($expectedHelperCall, $content, $file); + + $schemaFile = str_contains($expectedHelperCall, '/runs-filter-schema.php') + ? dirname($file) . '/runs-filter-schema.php' + : dirname($file) . '/filter-schema.php'; + $schemaContent = $this->readProjectFile($schemaFile); + $this->assertStringContainsString('gridFilterSchema', $schemaContent, $schemaFile); + } + } + + public function testDataEndpointsUseUnifiedGuardAbilityPatternExceptSearchResults(): void + { + foreach ($this->hardCutGridEndpointFiles() as $file) { + $content = $this->readProjectFile($file); + if ($file === 'pages/search/data().php') { + $this->assertStringContainsString('Guard::requireLogin();', $content, $file); + $this->assertStringNotContainsString('Guard::requireAbility', $content, $file); + continue; + } + + $this->assertMatchesRegularExpression( + '/Guard::requireAbility(?:OrForbidden|DecisionOrForbidden)\(/', + $content, + $file + ); + $this->assertStringNotContainsString('->authorize(', $content, $file); + } + } + + public function testDataEndpointsUseUnifiedDataAndTotalResponseHelper(): void + { + foreach ($this->hardCutGridEndpointFiles() as $file) { + $content = $this->readProjectFile($file); + $this->assertStringContainsString('gridJsonDataResult(', $content, $file); + $this->assertStringNotContainsString('Router::json($result);', $content, $file); + } + } + + public function testListTemplatesUseCentralToolbarRendererAndStandardListWrapper(): void + { + foreach ($this->listIndexTemplates() as $file) { + $content = $this->readProjectFile($file); + $usesPartial = $this->usesSharedListFiltersPartial($content); + $this->assertTrue( + $usesPartial || str_contains($content, 'renderGridFilterToolbar('), + $file . ' does not render toolbar directly and does not include shared list-filters partial.' + ); + $this->assertStringContainsString('initStandardListPage(', $content, $file); + $this->assertStringNotContainsString('gridFiltersFromSchema(', $content, $file); + $this->assertStringNotContainsString('data-toolbar-toggle', $content, $file); + $this->assertStringNotContainsString('data-filter-overflow', $content, $file); + $this->assertStringNotContainsString('data-filter-toggle', $content, $file); + $this->assertSame(0, preg_match('/