1
0

major update

This commit is contained in:
2026-03-04 15:56:58 +01:00
parent 16759a2732
commit 8f4dd5840d
478 changed files with 24313 additions and 8201 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

115
bin/codex-skills-sync.sh Executable file
View File

@@ -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}

323
bin/doctor.php Executable file
View File

@@ -0,0 +1,323 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use MintyPHP\App\AppContainer;
use MintyPHP\App\Bootstrap\EnvValidator;
use MintyPHP\DB;
use MintyPHP\Http\RequestContext;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\UiAccessService;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Auth\AuthService;
chdir(__DIR__ . '/..');
error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
ini_set('display_errors', '0');
require 'vendor/autoload.php';
require 'config/config.php';
require 'lib/Support/helpers.php';
/** @var AppContainer $container */
$container = require 'lib/App/registerContainer.php';
setAppContainer($container);
RequestContext::start([
'channel' => 'cli',
'method' => 'CLI',
'path' => 'bin/doctor.php',
]);
$startedAt = microtime(true);
/** @var list<array{status: string, name: string, message: string}> $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);

View File

@@ -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 {

View File

@@ -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',

View File

@@ -1,6 +1,7 @@
<?php
use MintyPHP\Auth;
use MintyPHP\App\Bootstrap\EnvValidator;
use MintyPHP\Cache;
use MintyPHP\DB;
use MintyPHP\Debugger;
@@ -9,6 +10,8 @@ use MintyPHP\I18n;
use MintyPHP\Router;
use MintyPHP\Session;
EnvValidator::validate();
$envString = static function (string $key, string $default): string {
$value = getenv($key);
if ($value === false || $value === '') {

View File

@@ -1,6 +1,6 @@
<?php
return [
'app_title' => 'Malphite',
'app_title' => 'CoreCore',
'app_locale' => 'de',
'app_theme' => 'light',
'app_theme_user' => '1',

View File

@@ -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`);

View File

@@ -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`);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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`

View File

@@ -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`

32
docs/02-domain-glossar.md Normal file
View File

@@ -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`

View File

@@ -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`

View File

@@ -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`

View File

@@ -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`

View File

@@ -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`

32
docs/07-api-erweitern.md Normal file
View File

@@ -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 <TOKEN>" \
http://localhost:8080/api/v1/me
```
## Vertiefung
- API-Referenz: `/docs/api.md`
- Sicherheitsdetails: `/docs/sicherheitsmodell.md`

View File

@@ -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.

View File

@@ -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: `<selector>|<ip>`
- 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: `<ip>`
- 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: <sekunden>`
## 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: `<ip>`
- Limit: `25` Hits pro `600` Sekunden
- Block: `300` Sekunden
## Smoke-Test
2. Schritt `login_password` (email + ip)
- Scope: `login.password.email_ip`
- Key: `<lowercase-email>|<ip>`
- 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: `<ip>`
- Limit: `10` Fehlversuche pro `600` Sekunden
- Block: `300` Sekunden
2. Schritt `login_email_ip` (email + ip)
- Scope: `api.auth.login.email_ip`
- Key: `<lowercase-email>|<ip>`
- 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.

View File

@@ -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: <uuid>`
- `error_code: <stable_machine_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`

View File

@@ -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`

View File

@@ -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
```

65
docs/codex-prompts.md Normal file
View File

@@ -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:
<aufgabe>
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:
<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:
<vorhaben>
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.

106
docs/di-container.md Normal file
View File

@@ -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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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/<slug>.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.

View File

@@ -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 `<div class="app-list-titlebar">`)
- [ ] Listen-Tabs über `templates/partials/app-list-tabs.phtml` (kein inline `<div class="app-list-tabs">`)
- [ ] 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=<slug>` -> 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`

View File

@@ -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/<modul>/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

View File

@@ -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 <TOKEN>" \
```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`

View File

@@ -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

View File

@@ -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
<?php
$listTitle = t('Users');
ob_start();
?>
<a role="button" class="primary" href="admin/users/create"><?php e(t('Create user')); ?></a>
<?php
$listTitleActionsHtml = ob_get_clean();
require templatePath('partials/app-list-titlebar.phtml');
?>
```
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 `<div class="notice" data-variant="error">`-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
<?php
$validationSummaryErrors = $validationSummaryErrors ?? ($errors ?? []);
require templatePath('partials/app-details-validation-summary.phtml');
?>
```
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.

View File

@@ -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
<?php
namespace MintyPHP\Service\Scheduler\Handler;
use MintyPHP\Service\MeinBereich\MeinService;
class MeinJobHandler implements ScheduledJobHandlerInterface
{
public function __construct(private readonly MeinService $meinService)
{
}
public function definition(): array
{
return [
'label' => '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.

View File

@@ -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`:
- `<li data-search-key="..." data-search-base="...">` 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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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)

View File

@@ -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': <int>}`.
- 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.

View File

@@ -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`

View File

@@ -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:

View File

@@ -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 "(?<!::)\\bcan\\(" templates pages -g '*.phtml' -P
rg -n "Guard::requirePermission\\(|Guard::requirePermissionOrForbidden\\(|ApiResponse::requirePermission\\(" pages lib tests web templates -g '*.php'
docker compose exec php vendor/bin/phpunit
docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
```
Erwartung:
1. Die beiden `rg`-Checks liefern `0` Treffer.
2. `phpunit` und `phpstan` sind grün.

View File

@@ -0,0 +1,58 @@
# Request Input + Validation
Letzte Aktualisierung: 2026-02-25
Kurzstandard fuer neue Actions/Endpoints.
## Regeln
- Input immer ueber `requestInput()`.
- Validierungsfehler intern immer als `FormErrors`.
- API-Validation immer mit `ApiResponse::validationFromFormErrors(...)`.
- Web-Templates bekommen kompakte Fehlerlisten (`$errors`) aus `toFlatList()`.
- `Flash` ist fuer Redirect-/Outcome-Meldungen (z. B. Success) gedacht, nicht als primaerer Validation-Container.
- Bei Redirect-POST-Formen: `FormErrors` intern sammeln und erst am Redirect-Rand transportieren (z. B. `flashFormErrors(...)`).
## Web-Beispiel (Action)
```php
$request = requestInput();
$errorBag = formErrors();
if ($request->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.

View File

@@ -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=<slug>` 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`

View File

@@ -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'],
},
},
];

View File

@@ -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)"
}

View File

@@ -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)"
}

38
lib/App/AppContainer.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
namespace MintyPHP\App;
use RuntimeException;
final class AppContainer
{
/** @var array<string, callable(self): mixed> */
private array $bindings = [];
/** @var array<string, mixed> */
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];
}
}

View File

@@ -0,0 +1,330 @@
<?php
namespace MintyPHP\App\Bootstrap;
final class EnvValidator
{
/** @var list<string> */
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<string, mixed> $env
* @return list<string>
*/
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<string, mixed> $env
*/
private static function hasEnvKey(array $env, string $key): bool
{
return array_key_exists($key, $env) && $env[$key] !== false && $env[$key] !== null;
}
/**
* @param array<string, mixed> $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<string>
*/
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;
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace MintyPHP\App\Container;
use MintyPHP\App\AppContainer;
interface ContainerRegistrar
{
public function register(AppContainer $container): void;
}

View File

@@ -0,0 +1,33 @@
<?php
namespace MintyPHP\App\Container\Registrars;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Repository\Access\PermissionRepository;
use MintyPHP\Repository\Access\RolePermissionRepository;
use MintyPHP\Repository\Access\RoleRepository;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Service\Access\PermissionGateway;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\RoleService;
use MintyPHP\Service\Access\UiAccessService;
use MintyPHP\Service\Directory\DirectoryServicesFactory;
final class AccessRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->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());
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace MintyPHP\App\Container\Registrars;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Http\ApiSystemAuditReporter;
use MintyPHP\Http\Input\RequestInputFactory;
use MintyPHP\Repository\Search\SearchQueryRepository;
use MintyPHP\Repository\Stats\AdminStatsRepository;
use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Service\Access\PermissionGateway;
use MintyPHP\Service\AddressBook\AddressBookService;
use MintyPHP\Service\AddressBook\AddressBookServicesFactory;
use MintyPHP\Service\Audit\ApiAuditService;
use MintyPHP\Service\Audit\AuditServicesFactory;
use MintyPHP\Service\Audit\ImportAuditService;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Audit\UserLifecycleAuditService;
use MintyPHP\Service\CustomField\CustomFieldServicesFactory;
use MintyPHP\Service\CustomField\TenantCustomFieldService;
use MintyPHP\Service\CustomField\UserCustomFieldValueService;
use MintyPHP\Service\Import\ImportService;
use MintyPHP\Service\Import\ImportServicesFactory;
use MintyPHP\Service\Mail\MailLogService;
use MintyPHP\Service\Mail\MailService;
use MintyPHP\Service\Mail\MailServicesFactory;
use MintyPHP\Service\Scheduler\ScheduledJobService;
use MintyPHP\Service\Scheduler\SchedulerRunService;
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
use MintyPHP\Service\Search\SearchDataService;
use MintyPHP\Service\Security\RateLimiterService;
use MintyPHP\Service\Security\SecurityServicesFactory;
use MintyPHP\Service\Stats\AdminStatsViewDataService;
final class AppServicesRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->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());
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace MintyPHP\App\Container\Registrars;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Repository\Auth\ApiTokenRepository;
use MintyPHP\Repository\Auth\PasswordResetRepository;
use MintyPHP\Repository\Auth\RememberTokenRepository;
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Service\Auth\ApiTokenEndpointService;
use MintyPHP\Service\Auth\ApiTokenService;
use MintyPHP\Service\Auth\AuthRepositoryFactory;
use MintyPHP\Service\Auth\AuthScopeGateway;
use MintyPHP\Service\Auth\AuthService;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Service\Auth\EmailVerificationService;
use MintyPHP\Service\Auth\MicrosoftOidcService;
use MintyPHP\Service\Auth\PasswordResetService;
use MintyPHP\Service\Auth\RememberMeService;
use MintyPHP\Service\Auth\SsoUserLinkService;
use MintyPHP\Service\Auth\TenantSsoService;
final class AuthRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->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());
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace MintyPHP\App\Container\Registrars;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Repository\Org\DepartmentRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Service\Directory\DirectoryScopeGateway;
use MintyPHP\Service\Directory\DirectoryServicesFactory;
use MintyPHP\Service\Directory\DirectorySettingsGateway;
use MintyPHP\Service\Org\DepartmentService;
use MintyPHP\Service\Tenant\TenantAvatarService;
use MintyPHP\Service\Tenant\TenantFaviconService;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\Tenant\TenantService;
use MintyPHP\Service\Tenant\TenantServicesFactory;
final class DirectoryRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->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());
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace MintyPHP\App\Container\Registrars;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Repository\Search\SearchQueryRepository;
use MintyPHP\Repository\Stats\AdminStatsRepository;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Service\Access\AccessRepositoryFactory;
use MintyPHP\Service\Audit\AuditRepositoryFactory;
use MintyPHP\Service\Auth\AuthRepositoryFactory;
use MintyPHP\Service\Directory\DirectoryRepositoryFactory;
use MintyPHP\Service\Import\ImportRepositoryFactory;
use MintyPHP\Service\Mail\MailRepositoryFactory;
use MintyPHP\Service\Scheduler\SchedulerRepositoryFactory;
use MintyPHP\Service\Security\SecurityRepositoryFactory;
use MintyPHP\Service\Settings\SettingRepositoryFactory;
use MintyPHP\Service\Tenant\TenantRepositoryFactory;
use MintyPHP\Service\User\UserRepositoryFactory;
final class RepositoryFactoryRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->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());
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace MintyPHP\App\Container\Registrars;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Service\Access\AccessGatewayFactory;
use MintyPHP\Service\Access\AccessPolicyFactory;
use MintyPHP\Service\Access\AccessRepositoryFactory;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\PermissionGateway;
use MintyPHP\Service\Directory\DirectoryScopeGateway;
use MintyPHP\Service\AddressBook\AddressBookServicesFactory;
use MintyPHP\Service\Audit\AuditRepositoryFactory;
use MintyPHP\Service\Audit\AuditServicesFactory;
use MintyPHP\Service\Auth\AuthGatewayFactory;
use MintyPHP\Service\Auth\AuthRepositoryFactory;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Service\Branding\BrandingServicesFactory;
use MintyPHP\Service\CustomField\CustomFieldServicesFactory;
use MintyPHP\Service\Directory\DirectoryGatewayFactory;
use MintyPHP\Service\Directory\DirectoryRepositoryFactory;
use MintyPHP\Service\Directory\DirectoryServicesFactory;
use MintyPHP\Service\Import\ImportRepositoryFactory;
use MintyPHP\Service\Import\ImportServicesFactory;
use MintyPHP\Service\Mail\MailRepositoryFactory;
use MintyPHP\Service\Mail\MailServicesFactory;
use MintyPHP\Service\Scheduler\SchedulerRepositoryFactory;
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
use MintyPHP\Service\Security\SecurityRepositoryFactory;
use MintyPHP\Service\Security\SecurityServicesFactory;
use MintyPHP\Service\Settings\SettingGateway;
use MintyPHP\Service\Settings\SettingRepositoryFactory;
use MintyPHP\Service\Settings\SettingServicesFactory;
use MintyPHP\Service\Tenant\TenantRepositoryFactory;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\Tenant\TenantServicesFactory;
use MintyPHP\Service\User\UserGatewayFactory;
use MintyPHP\Service\User\UserRepositoryFactory;
use MintyPHP\Service\User\UserScopeGateway;
use MintyPHP\Service\User\UserServicesFactory;
final class ServiceFactoryRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->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)
));
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace MintyPHP\App\Container\Registrars;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Repository\Auth\ApiTokenRepository;
use MintyPHP\Repository\Auth\RememberTokenRepository;
use MintyPHP\Service\Access\RoleService;
use MintyPHP\Service\Branding\BrandingFaviconService;
use MintyPHP\Service\Branding\BrandingLogoService;
use MintyPHP\Service\Branding\BrandingServicesFactory;
use MintyPHP\Service\Org\DepartmentService;
use MintyPHP\Service\Settings\AdminSettingsService;
use MintyPHP\Service\Settings\SettingCacheService;
use MintyPHP\Service\Settings\SettingGateway;
use MintyPHP\Service\Settings\SettingService;
use MintyPHP\Service\Settings\SettingServicesFactory;
use MintyPHP\Service\Settings\ThemeConfigService;
use MintyPHP\Service\Tenant\TenantService;
use MintyPHP\Service\Audit\SystemAuditService;
final class SettingsRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->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());
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace MintyPHP\App\Container\Registrars;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Repository\Access\UserRoleRepository;
use MintyPHP\Repository\Org\UserDepartmentRepository;
use MintyPHP\Repository\User\UserReadRepository;
use MintyPHP\Repository\User\UserWriteRepository;
use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Service\Auth\TenantSsoService;
use MintyPHP\Service\Branding\BrandingLogoService;
use MintyPHP\Service\User\UserAccessPdfService;
use MintyPHP\Service\User\UserAccessTemplateService;
use MintyPHP\Service\User\UserAccountService;
use MintyPHP\Service\User\UserAssignmentService;
use MintyPHP\Service\User\UserAvatarService;
use MintyPHP\Service\User\UserDirectoryGateway;
use MintyPHP\Service\User\UserLifecycleRestoreService;
use MintyPHP\Service\User\UserLifecycleService;
use MintyPHP\Service\User\UserPasswordPolicyService;
use MintyPHP\Service\User\UserPasswordService;
use MintyPHP\Service\User\UserApiWriteInputMapper;
use MintyPHP\Service\User\UserRepositoryFactory;
use MintyPHP\Service\User\UserSavedFilterService;
use MintyPHP\Service\User\UserScopeGateway;
use MintyPHP\Service\User\UserServicesFactory;
use MintyPHP\Service\User\UserTenantContextService;
final class UserRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->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());
}
}

View File

@@ -0,0 +1,30 @@
<?php
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\Registrars\AccessRegistrar;
use MintyPHP\App\Container\Registrars\AppServicesRegistrar;
use MintyPHP\App\Container\Registrars\AuthRegistrar;
use MintyPHP\App\Container\Registrars\DirectoryRegistrar;
use MintyPHP\App\Container\Registrars\RepositoryFactoryRegistrar;
use MintyPHP\App\Container\Registrars\ServiceFactoryRegistrar;
use MintyPHP\App\Container\Registrars\SettingsRegistrar;
use MintyPHP\App\Container\Registrars\UserRegistrar;
$container = new AppContainer();
$registrars = [
new RepositoryFactoryRegistrar(),
new ServiceFactoryRegistrar(),
new AccessRegistrar(),
new AuthRegistrar(),
new DirectoryRegistrar(),
new UserRegistrar(),
new SettingsRegistrar(),
new AppServicesRegistrar(),
];
foreach ($registrars as $registrar) {
$registrar->register($container);
}
return $container;

View File

@@ -0,0 +1,33 @@
<?php
namespace MintyPHP\Domain\Taxonomy;
enum ImportAuditStatus: string
{
use SupportsStringTaxonomy;
case Running = 'running';
case Success = 'success';
case Partial = 'partial';
case Failed = 'failed';
public function labelToken(): string
{
return match ($this) {
self::Running => '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',
};
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace MintyPHP\Domain\Taxonomy;
enum MailLogStatus: string
{
use SupportsStringTaxonomy;
case Queued = 'queued';
case Sent = 'sent';
case Failed = 'failed';
public function labelToken(): string
{
return match ($this) {
self::Queued => 'Queued',
self::Sent => 'Sent',
self::Failed => 'Failed',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Queued => 'info',
self::Sent => 'success',
self::Failed => 'danger',
};
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace MintyPHP\Domain\Taxonomy;
enum ScheduledJobRunStatus: string
{
use SupportsStringTaxonomy;
case Success = 'success';
case Failed = 'failed';
case Skipped = 'skipped';
public function labelToken(): string
{
return match ($this) {
self::Success => 'Success',
self::Failed => 'Failed',
self::Skipped => 'Skipped',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Success => 'success',
self::Failed => 'danger',
self::Skipped => 'warning',
};
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace MintyPHP\Domain\Taxonomy;
enum ScheduledJobStatus: string
{
use SupportsStringTaxonomy;
case Running = 'running';
case Success = 'success';
case Failed = 'failed';
case Skipped = 'skipped';
public function labelToken(): string
{
return match ($this) {
self::Running => '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',
};
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace MintyPHP\Domain\Taxonomy;
enum ScheduledJobTriggerType: string
{
use SupportsStringTaxonomy;
case Scheduler = 'scheduler';
case Manual = 'manual';
public function labelToken(): string
{
return match ($this) {
self::Scheduler => 'Scheduler',
self::Manual => 'Manual',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Scheduler => 'neutral',
self::Manual => 'info',
};
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace MintyPHP\Domain\Taxonomy;
enum SchedulerRuntimeResult: string
{
use SupportsStringTaxonomy;
case Ok = 'ok';
case LockNotAcquired = 'lock_not_acquired';
case UnexpectedError = 'unexpected_error';
public function labelToken(): string
{
return match ($this) {
self::Ok => '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',
};
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace MintyPHP\Domain\Taxonomy;
trait SupportsStringTaxonomy
{
/**
* @return list<string>
*/
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;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace MintyPHP\Domain\Taxonomy;
enum SystemAuditChannel: string
{
use SupportsStringTaxonomy;
case Web = 'web';
case Api = 'api';
case Scheduler = 'scheduler';
case Cli = 'cli';
public function labelToken(): string
{
return match ($this) {
self::Web => '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',
};
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace MintyPHP\Domain\Taxonomy;
enum SystemAuditOutcome: string
{
use SupportsStringTaxonomy;
case Success = 'success';
case Failed = 'failed';
case Denied = 'denied';
public function labelToken(): string
{
return match ($this) {
self::Success => 'Success',
self::Failed => 'Failed',
self::Denied => 'Denied',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Success => 'success',
self::Failed => 'danger',
self::Denied => 'warning',
};
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace MintyPHP\Domain\Taxonomy;
enum TenantStatus: string
{
use SupportsStringTaxonomy;
case Active = 'active';
case Inactive = 'inactive';
public function labelToken(): string
{
return match ($this) {
self::Active => 'Active',
self::Inactive => 'Inactive',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Active => 'success',
self::Inactive => 'danger',
};
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace MintyPHP\Domain\Taxonomy;
enum UserLifecycleAction: string
{
use SupportsStringTaxonomy;
case Deactivate = 'deactivate';
case Delete = 'delete';
case Restore = 'restore';
public function labelToken(): string
{
return match ($this) {
self::Deactivate => 'Deactivate',
self::Delete => 'Delete',
self::Restore => 'Restore',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Deactivate => 'warning',
self::Delete => 'danger',
self::Restore => 'success',
};
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace MintyPHP\Domain\Taxonomy;
enum UserLifecycleStatus: string
{
use SupportsStringTaxonomy;
case Success = 'success';
case Skipped = 'skipped';
case Failed = 'failed';
public function labelToken(): string
{
return match ($this) {
self::Success => 'Success',
self::Skipped => 'Skipped',
self::Failed => 'Failed',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Success => 'success',
self::Skipped => 'warning',
self::Failed => 'danger',
};
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace MintyPHP\Domain\Taxonomy;
enum UserLifecycleTriggerType: string
{
use SupportsStringTaxonomy;
case Manual = 'manual';
case Cron = 'cron';
case System = 'system';
public function labelToken(): string
{
return match ($this) {
self::Manual => 'Manual',
self::Cron => 'Cron',
self::System => 'System',
};
}
public function badgeVariant(): string
{
return match ($this) {
self::Manual => 'info',
self::Cron => 'neutral',
self::System => 'warning',
};
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,211 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
use MintyPHP\Service\Audit\SystemAuditService;
class ApiSystemAuditReporter
{
/**
* @var array{
* started_at: float,
* method: string,
* path: string,
* finished: bool
* }|null
*/
private ?array $context = null;
public function __construct(private readonly SystemAuditService $systemAuditService)
{
}
public function start(): void
{
if ($this->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;
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace MintyPHP\Http\Input;
final class FormErrors
{
/** @var array<string, array<int, string>> */
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<int, string> $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<int, string>|string>|array<int, string>|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<string, array<int, string>>
*/
public function toArray(): array
{
return $this->errors;
}
/**
* @return array<int, string>
*/
public function toFlatList(): array
{
$flat = [];
foreach ($this->errors as $messages) {
foreach ($messages as $message) {
$flat[] = $message;
}
}
return $flat;
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace MintyPHP\Http\Input;
use MintyPHP\Http\Request;
final class RequestInput
{
/**
* @param array<string, mixed> $query
* @param array<string, mixed> $body
* @param array<string, mixed> $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<string, mixed>
*/
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<int|string, mixed>
*/
public function queryArray(string $key): array
{
$value = $this->query($key, []);
return is_array($value) ? $value : [];
}
/**
* @return array<string, mixed>
*/
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<int|string, mixed>
*/
public function bodyArray(string $key): array
{
$value = $this->body($key, []);
return is_array($value) ? $value : [];
}
/**
* @return array<string, mixed>
*/
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();
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace MintyPHP\Http\Input;
final class RequestInputFactory
{
/** @var array<string, mixed>|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<string, mixed>
*/
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;
}
}

226
lib/Http/RequestContext.php Normal file
View File

@@ -0,0 +1,226 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\Repository\Support\RepoQuery;
final class RequestContext
{
/** @var array<string, mixed>|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<string, mixed>
*/
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);
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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<string, mixed>
*/
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;
}
}

View File

@@ -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<string, mixed>
*/
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;
}
}

View File

@@ -0,0 +1,357 @@
<?php
namespace MintyPHP\Repository\Audit;
use MintyPHP\Domain\Taxonomy\SystemAuditChannel;
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class SystemAuditLogRepository
{
private const FILTER_OPTIONS_LIMIT_MAX = 200;
public function create(array $data): int|false
{
$id = DB::insert(
'insert into system_audit_log (
event_uuid, request_id, channel, event_type, outcome, error_code,
actor_user_id, actor_tenant_id, target_type, target_id, target_uuid,
method, path, ip_hash, user_agent_hash, metadata_json, created_at
) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
(string) ($data['event_uuid'] ?? ''),
$data['request_id'] ?? null,
(string) ($data['channel'] ?? ''),
(string) ($data['event_type'] ?? ''),
(string) ($data['outcome'] ?? SystemAuditOutcome::Success->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<string, mixed>
*/
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;
}
}

View File

@@ -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<string, mixed>
*/
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;
}
}

View File

@@ -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'] ?? ''));

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -0,0 +1,37 @@
<?php
namespace MintyPHP\Repository\Search;
use MintyPHP\DB;
class SearchQueryRepository
{
/**
* @param array<int, mixed> $params
* @return array<int, mixed>
*/
public function selectRows(string $sql, array $params = []): array
{
if (trim($sql) === '') {
return [];
}
$rows = DB::select($sql, ...$params);
return is_array($rows) ? $rows : [];
}
/**
* @param array<int, mixed> $params
*/
public function selectCount(string $sql, array $params = []): int
{
if (trim($sql) === '') {
return 0;
}
$value = DB::selectValue($sql, ...$params);
return (int) ($value ?? 0);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
<?php
namespace MintyPHP\Repository\Support;
use MintyPHP\DB;
class DatabaseSessionRepository
{
public function acquireAdvisoryLock(string $lockName, int $timeoutSeconds = 0): bool
{
$lockName = trim($lockName);
if ($lockName === '') {
return false;
}
$timeoutSeconds = max(0, (int) $timeoutSeconds);
$got = DB::selectValue(
'select GET_LOCK(?, ?) as got_lock',
$lockName,
(string) $timeoutSeconds
);
return (int) $got === 1;
}
public function releaseAdvisoryLock(string $lockName): void
{
$lockName = trim($lockName);
if ($lockName === '') {
return;
}
DB::selectValue('select RELEASE_LOCK(?) as released_lock', $lockName);
}
public function beginTransaction(): void
{
DB::handle()->begin_transaction();
}
public function commitTransaction(): void
{
DB::handle()->commit();
}
public function rollbackTransaction(): void
{
DB::handle()->rollback();
}
}

Some files were not shown because too many files have changed in this diff Show More