major update
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
27
CLAUDE.md
27
CLAUDE.md
@@ -19,15 +19,12 @@ Multi-tenant admin application built on MintyPHP. Manages tenants, users, depart
|
||||
docker compose up --build -d
|
||||
# App: http://localhost:8080 | phpMyAdmin: http://localhost:8081
|
||||
|
||||
# Run PHPUnit tests
|
||||
docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php
|
||||
# Run PHPUnit tests (phpunit.xml configures bootstrap + testsuite)
|
||||
docker compose exec php vendor/bin/phpunit
|
||||
|
||||
# Run PHPStan (level 5)
|
||||
docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
|
||||
|
||||
# Run ESLint
|
||||
npx eslint web/js
|
||||
|
||||
# Check unused Composer packages
|
||||
docker compose exec php vendor/bin/composer-unused
|
||||
```
|
||||
@@ -36,9 +33,11 @@ docker compose exec php vendor/bin/composer-unused
|
||||
|
||||
```
|
||||
lib/ # All backend PHP (namespace MintyPHP\)
|
||||
App/ # DI container + bootstrap (AppContainer, Container/Registrars/)
|
||||
Repository/ # SQL queries, prepared statements, filters/paging
|
||||
Service/ # Business logic, validation, orchestration
|
||||
Http/ # Auth guards, API auth, request helpers
|
||||
Input/ # Request input handling (RequestInput, FormErrors)
|
||||
Support/ # Crypto, flash, guard, search, helpers
|
||||
pages/ # Actions (controllers) — file-based routing
|
||||
pages/**/*.php # Action files (read input, check permissions, call services)
|
||||
@@ -52,11 +51,14 @@ web/ # Document root (entry point, CSS, JS, static assets)
|
||||
js/core/ # DOM utilities, Grid.js factory
|
||||
js/components/ # Reusable UI components
|
||||
css/ # Layered CSS (base → components → layout → pages)
|
||||
storage/ # Uploaded files (branding/, imports/, tenants/, users/)
|
||||
db/init/init.sql # Full schema + seed data (no migration framework)
|
||||
db/updates/ # Idempotent SQL update scripts for existing installs
|
||||
tests/ # PHPUnit tests (namespace MintyPHP\Tests\)
|
||||
docs/ # German-language documentation (Markdown)
|
||||
i18n/ # Translation files (de, en)
|
||||
bin/ # CLI scripts (scheduler)
|
||||
bin/ # CLI scripts (scheduler, doctor.php health check)
|
||||
tools/ # Dev tooling scripts
|
||||
docker/ # Dockerfiles, Nginx configs, PHP configs
|
||||
```
|
||||
|
||||
@@ -64,11 +66,12 @@ docker/ # Dockerfiles, Nginx configs, PHP configs
|
||||
|
||||
### Strict Layering (enforce in all changes)
|
||||
|
||||
1. **Repository** (`lib/Repository/`): All SQL lives here. No HTTP, session, or rendering.
|
||||
2. **Service** (`lib/Service/`): Business rules and validation. No HTML. Uses factory pattern (`*ServicesFactory`).
|
||||
3. **Action** (`pages/**/*.php`): Reads input, checks permissions + tenant scope, calls services, sets view vars.
|
||||
4. **View** (`pages/**/*.phtml`): Pure rendering. **No DB calls.** HTML escaping via `e(...)`.
|
||||
5. **Template** (`templates/`): Layouts and partials.
|
||||
1. **App** (`lib/App/`): DI container bootstrap. Registers all service/repository factories. No business logic.
|
||||
2. **Repository** (`lib/Repository/`): All SQL lives here. No HTTP, session, or rendering.
|
||||
3. **Service** (`lib/Service/`): Business rules and validation. No HTML. Uses factory pattern (`*ServicesFactory`).
|
||||
4. **Action** (`pages/**/*.php`): Reads input, checks permissions + tenant scope, calls services, sets view vars.
|
||||
5. **View** (`pages/**/*.phtml`): Pure rendering. **No DB calls.** HTML escaping via `e(...)`.
|
||||
6. **Template** (`templates/`): Layouts and partials.
|
||||
|
||||
### Routing
|
||||
|
||||
@@ -110,7 +113,7 @@ docker/ # Dockerfiles, Nginx configs, PHP configs
|
||||
|
||||
- **PHPStan level 5** — scans `config/`, `lib/`, `pages/`, `tests/`
|
||||
- **PHPUnit 11** — bootstrap: `tests/bootstrap.php`
|
||||
- **ESLint** — ES2022, strict `eqeqeq`, `curly`, `no-unused-vars`, `no-console` (warn, allow warn/error)
|
||||
- **Frontend Smoke-Check** — browser console bleibt fehlerfrei in Kernflows
|
||||
- **composer-unused** — periodic check for unused dependencies
|
||||
|
||||
## Environment
|
||||
|
||||
31
README.md
31
README.md
@@ -106,6 +106,9 @@ Standard-Demo-User:
|
||||
- SQL bleibt in Repositories.
|
||||
- Geschaeftslogik bleibt in Services.
|
||||
- `pages/.../*.php` fungiert als Action-/Controller-Schicht.
|
||||
- Service-Aufloesung in Actions/Helpern ueber `app(Foo::class)`.
|
||||
- `new ...Factory()` nur im Composition Root (`web/index.php` + `lib/App/registerContainer.php`) oder in `*Factory.php`.
|
||||
- `pages/**/data().php` sind GET-only (`gridRequireGetRequest()`) und normalisieren `limit`/`offset`/`order`/`dir` ueber Grid-Helper.
|
||||
- Permissions + Tenant-Scope werden in Actions/Services erzwungen.
|
||||
- Wiederkehrende UI-Bloecke als Partials zentralisieren.
|
||||
|
||||
@@ -190,27 +193,21 @@ docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php tests
|
||||
docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
|
||||
```
|
||||
|
||||
### ESLint (Frontend-JS)
|
||||
### Frontend-JS Smoke-Check (ohne Node)
|
||||
|
||||
```bash
|
||||
npx eslint web/js
|
||||
```
|
||||
- Browser-DevTools öffnen (Console + Preserve log)
|
||||
- Kernflows klicken: Users, Address-Book, Audit-Listen, Filter setzen/zurücksetzen
|
||||
- Keine JavaScript-Errors/Unhandled-Rejections in der Console
|
||||
|
||||
## Dokumentation
|
||||
|
||||
- `/docs/index.md` Einstieg und Dokumentationsindex
|
||||
- `/docs/architektur.md` Request-Flow und Layering
|
||||
- `/docs/konventionen.md` Coding- und UI-Konventionen
|
||||
- `/docs/sicherheitsmodell.md` Auth/Permission/Tenant-Scope
|
||||
- `/docs/entwickler-checkliste.md` konkrete Checkliste fuer Aenderungen
|
||||
- `/docs/benutzerdefinierte-felder.md` Quick Reference fuer Tenant/User-Zusatzfelder
|
||||
- `/docs/api.md` API v1 Referenz (inkl. Self-Service Token-Endpunkte)
|
||||
- `/docs/einstellungen-speicherung.md` Globale Settings: DB als Quelle, `config/settings.php` als Teil-Cache
|
||||
- `/docs/frontend-javascript.md` Frontend-JS-Struktur
|
||||
- `/docs/frontend-css.md` Frontend-CSS-Struktur
|
||||
- `/docs/docker-lokal.md` Docker lokal (Entwicklung)
|
||||
- `/docs/docker-produktiv.md` Docker produktiv (Serverbetrieb)
|
||||
- `/docs/docker-betrieb.md` Dev/Prod Docker-Betrieb
|
||||
- `/docs/index.md` Dokumentationsstart mit chronologischem Lernpfad und Referenzbereichen
|
||||
- `/docs/00-systemueberblick.md` Einstieg in Produktbild und Kernprinzipien
|
||||
- `/docs/01-setup-und-erster-login.md` lokales Setup und erster Smoke-Test
|
||||
- `/docs/02-domain-glossar.md` zentrale Begriffe fuer Tenant/Role/Scope
|
||||
- `/docs/03-architektur-request-flow.md` Request-Flow und Schichtmodell
|
||||
- `/docs/06-security-tenant-scope.md` Security-Grundlagen vor Feature-Arbeit
|
||||
- `/docs/07-api-erweitern.md` verbindlicher API-Aenderungsablauf inkl. OpenAPI
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
115
bin/codex-skills-sync.sh
Executable file
115
bin/codex-skills-sync.sh
Executable 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
323
bin/doctor.php
Executable 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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
return [
|
||||
'app_title' => 'Malphite',
|
||||
'app_title' => 'CoreCore',
|
||||
'app_locale' => 'de',
|
||||
'app_theme' => 'light',
|
||||
'app_theme_user' => '1',
|
||||
|
||||
@@ -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`);
|
||||
|
||||
86
db/updates/2026-02-25-system-audit.sql
Normal file
86
db/updates/2026-02-25-system-audit.sql
Normal 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`);
|
||||
222
db/updates/2026-03-03-status-taxonomy-hard-cut.sql
Normal file
222
db/updates/2026-03-03-status-taxonomy-hard-cut.sql
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
42
docs/00-systemueberblick.md
Normal file
42
docs/00-systemueberblick.md
Normal 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`
|
||||
52
docs/01-setup-und-erster-login.md
Normal file
52
docs/01-setup-und-erster-login.md
Normal 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
32
docs/02-domain-glossar.md
Normal 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`
|
||||
86
docs/03-architektur-request-flow.md
Normal file
86
docs/03-architektur-request-flow.md
Normal 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`
|
||||
35
docs/04-erste-aenderung-ui.md
Normal file
35
docs/04-erste-aenderung-ui.md
Normal 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`
|
||||
34
docs/05-erste-aenderung-backend.md
Normal file
34
docs/05-erste-aenderung-backend.md
Normal 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`
|
||||
31
docs/06-security-tenant-scope.md
Normal file
31
docs/06-security-tenant-scope.md
Normal 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
32
docs/07-api-erweitern.md
Normal 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`
|
||||
36
docs/08-advanced-scheduler-sso.md
Normal file
36
docs/08-advanced-scheduler-sso.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
15
docs/api.md
15
docs/api.md
@@ -1,6 +1,6 @@
|
||||
# REST-API (v1)
|
||||
|
||||
Letzte Aktualisierung: 2026-02-21
|
||||
Letzte Aktualisierung: 2026-02-25
|
||||
|
||||
## Single Source of Truth
|
||||
|
||||
@@ -15,6 +15,19 @@ Diese Datei ist die verbindliche technische Referenz für:
|
||||
- Statuscodes
|
||||
- Auth-Header
|
||||
|
||||
## Fehlerhülle (API-Standard)
|
||||
|
||||
Fehlerantworten nutzen ein einheitliches Schema:
|
||||
|
||||
- `ok: false`
|
||||
- `request_id: <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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
56
docs/betriebscheck-doctor.md
Normal file
56
docs/betriebscheck-doctor.md
Normal 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
65
docs/codex-prompts.md
Normal 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
106
docs/di-container.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
113
docs/importe.md
113
docs/importe.md
@@ -1,88 +1,63 @@
|
||||
# Importe
|
||||
|
||||
Letzte Aktualisierung: 2026-02-22
|
||||
Letzte Aktualisierung: 2026-02-24
|
||||
|
||||
Diese Seite beschreibt den V1 CSV-Import unter `/admin/imports`.
|
||||
## Ziel
|
||||
|
||||
## Technischer Aufbau
|
||||
CSV-Import (V1) für `users` und `departments` kurz und belastbar dokumentieren.
|
||||
|
||||
- Das Import-Modul ist instanzbasiert über `lib/Service/Import/ImportServicesFactory.php` verdrahtet.
|
||||
- `ImportService` orchestriert Analyze/Preview/Commit.
|
||||
- `ImportStateStoreService` kapselt den tokenbasierten Session-State (`admin_imports_v1`, TTL 2h).
|
||||
- `CsvReaderService` und `ImportTempFileService` sind eigene Instanzservices.
|
||||
- Import-Audit läuft über `ImportAuditService` + `ImportAuditRunRepository`.
|
||||
## Architektur
|
||||
|
||||
- Einstieg: `/admin/imports`
|
||||
- Orchestrierung: `ImportService`
|
||||
- Upload/Temp: `ImportTempFileService`
|
||||
- CSV-Analyse/Stream: `CsvReaderService`
|
||||
- State (tokenbasiert, Session): `ImportStateStoreService`
|
||||
- Audit: `ImportAuditService`
|
||||
|
||||
## Scope (V1)
|
||||
|
||||
- Profile: `users`, `departments`.
|
||||
- Create-only: bestehende Einträge werden übersprungen.
|
||||
- CSV mit `,` oder `;`, UTF-8 (BOM wird toleriert).
|
||||
- Limits: 10MB und 20.000 Datenzeilen.
|
||||
- Fehlerausgabe nur in der UI (kein Download in V1).
|
||||
- Profile: `users`, `departments`
|
||||
- Create-only: bestehende Datensätze werden übersprungen
|
||||
- CSV-Delimiter: `,` oder `;`
|
||||
- BOM wird toleriert
|
||||
- Limits:
|
||||
- 10 MB Upload
|
||||
- 20.000 Datenzeilen
|
||||
- max. 500 Fehlerzeilen in der UI
|
||||
|
||||
## Permissions
|
||||
## Berechtigungen
|
||||
|
||||
- `imports.view`: Zugriff auf die Import-Seite.
|
||||
- `imports.audit.view`: Zugriff auf Import-Protokolle unter `/admin/import-audit`.
|
||||
- `users.import`: User-Import ausführen.
|
||||
- `users.import_assignments`: Tenant/Role/Department aus CSV verwenden.
|
||||
- `departments.import`: Department-Import ausführen.
|
||||
- `imports.view`
|
||||
- `users.import`
|
||||
- `departments.import`
|
||||
- `users.import_assignments` (nur wenn Assignment-Spalten gemappt werden)
|
||||
- `imports.audit.view` (Audit lesen)
|
||||
- `settings.update` (Audit-Purge)
|
||||
|
||||
## Ablauf
|
||||
|
||||
1. Upload
|
||||
- Entität wählen (`Users` oder `Departments`) und CSV hochladen.
|
||||
- Header/Delimiter/Zeilenlimit werden direkt geprüft.
|
||||
2. Mapping
|
||||
- CSV-Header auf Zielfelder mappen.
|
||||
- Pflichtziele sind profilabhängig.
|
||||
3. Preview (Dry run)
|
||||
- Keine DB-Schreibvorgänge.
|
||||
- Summary + erste Fehler (maximal 500 Einträge).
|
||||
4. Commit
|
||||
- Zeilenweise Verarbeitung (`best effort`).
|
||||
- Gültige Zeilen werden erstellt, fehlerhafte reportet.
|
||||
1. Analyze: Upload + Header/Delimiter/Limit prüfen.
|
||||
2. Mapping: CSV-Spalten auf Zielfelder mappen.
|
||||
3. Preview: Dry-Run ohne DB-Write.
|
||||
4. Commit: zeilenweise Verarbeitung mit Fehleraggregation.
|
||||
|
||||
## Assignment-Regeln
|
||||
## Assignment-Regeln (wichtig)
|
||||
|
||||
- Users: optional `tenant`, `role`, `department` (jeweils 1:1).
|
||||
- Departments: optional `tenant`.
|
||||
- Identifier-Auflösung:
|
||||
- Users: UUID zuerst, ID als Fallback.
|
||||
- Departments: `tenant` ist UUID-only (kein ID-Fallback).
|
||||
- Nur aktive Datensätze sind gültig.
|
||||
- Department muss zum Tenant passen.
|
||||
- Fehlen Assignment-Werte, greifen Defaults aus Settings.
|
||||
- Ohne `users.import_assignments` wird ein Mapping mit Assignment-Spalten sofort abgelehnt.
|
||||
- Users: optionale `tenant`/`role`/`department`-Zuordnung.
|
||||
- Users: Auflösung über UUID, numerische ID als Fallback.
|
||||
- Departments: `tenant` ist UUID-only.
|
||||
- Ohne gültige Assignment-Werte greifen Defaults aus Settings.
|
||||
- Out-of-scope Tenants werden blockiert (`assignment_out_of_scope`).
|
||||
|
||||
## Duplikate und Skip-Verhalten
|
||||
## Temp-Dateien und State
|
||||
|
||||
- In-File-Duplikate: erste Zeile gewinnt, spätere `duplicate_in_file`.
|
||||
- Users:
|
||||
- bestehende E-Mail: `email_exists`
|
||||
- Departments:
|
||||
- bestehender Code: `code_exists`
|
||||
- bestehende Kombination aus Tenant + Description: `department_exists`
|
||||
- Temp-Pfad: `APP_STORAGE_PATH/imports/tmp`
|
||||
- Alte Temp-Dateien werden opportunistisch abgeräumt (24h)
|
||||
- Import-State-Token läuft nach 2h ab
|
||||
|
||||
## Temporäre Dateien
|
||||
## Audit
|
||||
|
||||
- Uploads liegen unter `APP_STORAGE_PATH/imports/tmp`.
|
||||
- Dateinamen werden randomisiert.
|
||||
- Cleanup entfernt alte Temp-Dateien opportunistisch (älter als 24h).
|
||||
|
||||
## Import-Audit (V1)
|
||||
|
||||
- Pro Commit-Lauf wird ein Audit-Run gespeichert (`import_audit_runs`).
|
||||
- Analyze/Preview werden bewusst nicht protokolliert.
|
||||
- Gespeichert werden nur Metadaten:
|
||||
- Profil (`users`/`departments`), Status (`success`/`partial`/`failed`)
|
||||
- Laufzeit, Counter (`rows_total`, `created`, `skipped`, `failed`)
|
||||
- User, aktueller Tenant, Dateiname (basename), gemappte Felder
|
||||
- aggregierte Fehlercodes (kein Zeileninhalt)
|
||||
- Keine CSV-Zeilenpayload oder PII-Details im Audit.
|
||||
- Retention: 90 Tage, bereinigbar über `/admin/import-audit` (Purge-Action mit `settings.update`).
|
||||
|
||||
## Fehlercodes (V1)
|
||||
|
||||
- Struktur: `upload_missing`, `upload_too_large`, `invalid_file_type`, `invalid_csv_header`, `empty_csv`, `max_rows_exceeded`, `mapping_required_missing`, `assignment_permission_required`
|
||||
- Zeilenvalidierung: `invalid_email`, `invalid_tenant_uuid`, `duplicate_in_file`, `email_exists`, `code_exists`, `department_exists`, `invalid_locale`, `invalid_active`, `invalid_hire_date`, `tenant_not_found`, `role_not_found`, `department_not_found`, `department_tenant_mismatch`, `assignment_out_of_scope`, `create_failed`, `unexpected_error`
|
||||
- Nur Commit-Läufe werden protokolliert (nicht Analyze/Preview).
|
||||
- Gespeichert: Metadaten, Counter, Fehlercodes, User/Tenant-Kontext.
|
||||
- Retention: 90 Tage.
|
||||
|
||||
134
docs/index.md
134
docs/index.md
@@ -1,75 +1,93 @@
|
||||
# Dokumentation
|
||||
|
||||
Letzte Aktualisierung: 2026-02-23
|
||||
Letzte Aktualisierung: 2026-03-04
|
||||
|
||||
Diese Dokumente sind für Entwickler gedacht, die die Anwendung warten oder erweitern.
|
||||
Diese Dokumentation folgt einem klaren Lernpfad (einfach -> fortgeschritten) und trennt Lernen von Nachschlagen.
|
||||
|
||||
## Start hier (neue Entwickler)
|
||||
## Lernpfad (chronologisch)
|
||||
|
||||
1. `/docs/erste-schritte.md`
|
||||
- Setup in 30-45 Minuten, erster Login, erster Smoke-Test.
|
||||
2. `/docs/lokale-entwicklung.md`
|
||||
- Täglicher Workflow, Schema-Updates und Qualitätschecks.
|
||||
3. `/docs/erste-aenderung.md`
|
||||
- Schritt-für-Schritt für den ersten kleinen Code-Change.
|
||||
4. `/docs/dokumentation-erweitern.md`
|
||||
- Wie neue Doku-Dateien sauber erstellt, einsortiert und suchbar gemacht werden.
|
||||
5. `/docs/entwickler-checkliste.md`
|
||||
- Finale Checkliste vor Merge/Review.
|
||||
1. `/docs/00-systemueberblick.md`
|
||||
- Produktbild, Kernmodule und Lernziel in 10 Minuten.
|
||||
2. `/docs/01-setup-und-erster-login.md`
|
||||
- Lokales Setup, erster Login, erster Smoke-Test.
|
||||
3. `/docs/02-domain-glossar.md`
|
||||
- Zentrale Begriffe (Tenant, Role, Permission, Scope, Policy).
|
||||
4. `/docs/03-architektur-request-flow.md`
|
||||
- Request-Flow, Schichtmodell, Do/Don't.
|
||||
5. `/docs/04-erste-aenderung-ui.md`
|
||||
- Erste sichere UI-Änderung im Admin-Bereich.
|
||||
6. `/docs/05-erste-aenderung-backend.md`
|
||||
- Erste Backend-/Service-/Repository-Änderung.
|
||||
7. `/docs/06-security-tenant-scope.md`
|
||||
- Sicherheitsgrundlagen, Tenant-Scope, Pflichtchecks.
|
||||
8. `/docs/07-api-erweitern.md`
|
||||
- API-Änderung sauber mit OpenAPI und Tests umsetzen.
|
||||
9. `/docs/08-advanced-scheduler-sso.md`
|
||||
- Fortgeschrittene Themen: Scheduler, Lifecycle, SSO.
|
||||
|
||||
## Architektur und Regeln
|
||||
## Konzepte (Explanation)
|
||||
|
||||
- `/docs/architektur.md`
|
||||
- Startpunkt mit Glossar, Zielbild, Schichtregeln und Sicherheitsleitplanken.
|
||||
- Architektur-Kompass mit verbindlichen Leitplanken.
|
||||
- `/docs/di-container.md`
|
||||
- Wie der AppContainer funktioniert und neue Services registriert werden.
|
||||
- `/docs/sicherheitsmodell.md`
|
||||
- Auth, Permissions, Tenant-Scope, Public/API-Zugriffe.
|
||||
- `/docs/konventionen.md`
|
||||
- Kurze, verbindliche Arbeitsregeln für Code, API und Qualität.
|
||||
- `/docs/lib-standards.md`
|
||||
- Konkrete Do/Don't-Regeln für `lib/**`.
|
||||
|
||||
## Frontend
|
||||
|
||||
- `/docs/frontend-javascript.md`
|
||||
- Frontend-JS-Aufbau, Init-Flow, Grid-Integration und URL-State-Sync.
|
||||
- `/docs/frontend-css.md`
|
||||
- CSS-Struktur, Layering, Vendor-Overrides und Asset-Einbindung.
|
||||
|
||||
## Fach- und Feature-Dokumentation
|
||||
|
||||
- `/docs/benutzerdefinierte-felder.md`
|
||||
- Tenant/User-Zusatzfelder (Typen, Pflege, Filterbarkeit).
|
||||
- `/docs/importe.md`
|
||||
- CSV-Import (V1), Mapping, Fehlercodes, Permission-Modell und Import-Audit.
|
||||
- Vollständiges Sicherheitsmodell (Auth, Permission, Scope).
|
||||
- `/docs/einstellungen-speicherung.md`
|
||||
- Source of truth für Settings (DB) und Datei-Cache-Verhalten.
|
||||
- `/docs/anfragelimits.md`
|
||||
- Login/API-Limits, Scopes, Block-Verhalten.
|
||||
- `/docs/benutzer-lifecycle-policy.md`
|
||||
- Automatische Benutzer-Deaktivierung/Löschung (Policy, Cron, manueller Run).
|
||||
- `/docs/geplante-aufgaben.md`
|
||||
- Zentrale geplante Aufgaben (Scheduler), Job-Konfiguration und Runner-Betrieb.
|
||||
- `/docs/globale-suche.md`
|
||||
- Globale Suche erweitern (Resource, Permission, Mapping, Aside-Integration).
|
||||
- Warum Settings in DB + Datei-Cache koexistieren.
|
||||
|
||||
## API Referenz
|
||||
## Aufgabenanleitungen (How-to)
|
||||
|
||||
- `/docs/globale-suche.md`
|
||||
- Neue Resource in der globalen Suche integrieren.
|
||||
- `/docs/importe.md`
|
||||
- Importfluss, Regeln und Fehlercodes.
|
||||
- `/docs/benutzerdefinierte-felder.md`
|
||||
- Tenant/User-Zusatzfelder korrekt erweitern.
|
||||
- `/docs/geplante-aufgaben.md`
|
||||
- Neue Scheduler-Jobs registrieren und betreiben.
|
||||
- `/docs/auth-sso-smoke-test.md`
|
||||
- SSO-Funktionalität manuell verifizieren.
|
||||
- `/docs/anfragelimits.md`
|
||||
- Rate-Limits verstehen und testen.
|
||||
- `/docs/rbac-permissions-playbook.md`
|
||||
- RBAC-Rechte sauber erweitern (neue Permission, neue Ability, UI/API-Fälle).
|
||||
- `/docs/request-input-validation.md`
|
||||
- Einheitliches Request/Input- und Validation-Muster für Web + API.
|
||||
|
||||
## Referenz (Reference)
|
||||
|
||||
- `/docs/api.md`
|
||||
- API Quick Reference, Auth, Flows, Beispielaufrufe.
|
||||
- `/docs/openapi.yaml`
|
||||
- API Source of truth für Swagger UI.
|
||||
|
||||
## Betriebswissen
|
||||
|
||||
- API-Referenz-Einstieg.
|
||||
- `/docs/konfiguration.md`
|
||||
- Alle ENV-Variablen mit Beschreibung, Standardwerten und Produktions-Checkliste.
|
||||
- `/docs/fehlerbehebung.md`
|
||||
- Häufige Fehlerbilder und schnelle Lösungen.
|
||||
- `/docs/auth-sso-smoke-test.md`
|
||||
- Manueller Smoke-Runbook für Login, Microsoft-Start/Callback, Tenant-SSO-Save und Logout.
|
||||
- ENV-Variablen und Produktions-Defaults.
|
||||
- `/docs/konventionen.md`
|
||||
- Projektweite Konventionen (kurz und verbindlich).
|
||||
- `/docs/lib-standards.md`
|
||||
- Regeln speziell für `lib/**`.
|
||||
- `/docs/benutzer-lifecycle-policy.md`
|
||||
- Policy- und Laufzeitreferenz für User Lifecycle.
|
||||
|
||||
## Betrieb (Operations)
|
||||
|
||||
- `/docs/docker-lokal.md`
|
||||
- Schneller lokaler Docker-Start inkl. typischer Befehle und Troubleshooting.
|
||||
- Lokaler Docker-Betrieb.
|
||||
- `/docs/docker-produktiv.md`
|
||||
- Produktionsbetrieb mit Domain, TLS und `docker-compose.prod.yml`.
|
||||
- Produktivbetrieb mit Docker.
|
||||
- `/docs/docker-betrieb.md`
|
||||
- Einstieg und Navigation zwischen lokaler und produktiver Docker-Doku.
|
||||
- Übersicht und Entscheidungshilfe für Dev/Prod.
|
||||
- `/docs/betriebscheck-doctor.md`
|
||||
- Schneller Systemcheck mit `bin/doctor.php`.
|
||||
- `/docs/fehlerbehebung.md`
|
||||
- Häufige Fehler und schnelle Lösungen.
|
||||
|
||||
## Mitwirken (Contributor Guide)
|
||||
|
||||
- `/docs/lokale-entwicklung.md`
|
||||
- Tages-Workflow für Entwicklung und Checks.
|
||||
- `/docs/entwickler-checkliste.md`
|
||||
- Definition of Done vor Merge.
|
||||
- `/docs/codex-prompts.md`
|
||||
- Standard-Prompts für Skill-basierte Planung und Implementierung.
|
||||
- `/docs/dokumentation-erweitern.md`
|
||||
- Regeln für konsistente, wachsende Doku.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
78
docs/rbac-permissions-playbook.md
Normal file
78
docs/rbac-permissions-playbook.md
Normal 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.
|
||||
58
docs/request-input-validation.md
Normal file
58
docs/request-input-validation.md
Normal 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.
|
||||
@@ -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`
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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
38
lib/App/AppContainer.php
Normal 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];
|
||||
}
|
||||
}
|
||||
330
lib/App/Bootstrap/EnvValidator.php
Normal file
330
lib/App/Bootstrap/EnvValidator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
10
lib/App/Container/ContainerRegistrar.php
Normal file
10
lib/App/Container/ContainerRegistrar.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
|
||||
interface ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void;
|
||||
}
|
||||
33
lib/App/Container/Registrars/AccessRegistrar.php
Normal file
33
lib/App/Container/Registrars/AccessRegistrar.php
Normal 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());
|
||||
}
|
||||
}
|
||||
66
lib/App/Container/Registrars/AppServicesRegistrar.php
Normal file
66
lib/App/Container/Registrars/AppServicesRegistrar.php
Normal 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());
|
||||
}
|
||||
}
|
||||
48
lib/App/Container/Registrars/AuthRegistrar.php
Normal file
48
lib/App/Container/Registrars/AuthRegistrar.php
Normal 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());
|
||||
}
|
||||
}
|
||||
33
lib/App/Container/Registrars/DirectoryRegistrar.php
Normal file
33
lib/App/Container/Registrars/DirectoryRegistrar.php
Normal 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());
|
||||
}
|
||||
}
|
||||
41
lib/App/Container/Registrars/RepositoryFactoryRegistrar.php
Normal file
41
lib/App/Container/Registrars/RepositoryFactoryRegistrar.php
Normal 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());
|
||||
}
|
||||
}
|
||||
144
lib/App/Container/Registrars/ServiceFactoryRegistrar.php
Normal file
144
lib/App/Container/Registrars/ServiceFactoryRegistrar.php
Normal 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
44
lib/App/Container/Registrars/SettingsRegistrar.php
Normal file
44
lib/App/Container/Registrars/SettingsRegistrar.php
Normal 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());
|
||||
}
|
||||
}
|
||||
66
lib/App/Container/Registrars/UserRegistrar.php
Normal file
66
lib/App/Container/Registrars/UserRegistrar.php
Normal 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());
|
||||
}
|
||||
}
|
||||
30
lib/App/registerContainer.php
Normal file
30
lib/App/registerContainer.php
Normal 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;
|
||||
33
lib/Domain/Taxonomy/ImportAuditStatus.php
Normal file
33
lib/Domain/Taxonomy/ImportAuditStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
30
lib/Domain/Taxonomy/MailLogStatus.php
Normal file
30
lib/Domain/Taxonomy/MailLogStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
30
lib/Domain/Taxonomy/ScheduledJobRunStatus.php
Normal file
30
lib/Domain/Taxonomy/ScheduledJobRunStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
33
lib/Domain/Taxonomy/ScheduledJobStatus.php
Normal file
33
lib/Domain/Taxonomy/ScheduledJobStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
27
lib/Domain/Taxonomy/ScheduledJobTriggerType.php
Normal file
27
lib/Domain/Taxonomy/ScheduledJobTriggerType.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
30
lib/Domain/Taxonomy/SchedulerRuntimeResult.php
Normal file
30
lib/Domain/Taxonomy/SchedulerRuntimeResult.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
38
lib/Domain/Taxonomy/SupportsStringTaxonomy.php
Normal file
38
lib/Domain/Taxonomy/SupportsStringTaxonomy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
33
lib/Domain/Taxonomy/SystemAuditChannel.php
Normal file
33
lib/Domain/Taxonomy/SystemAuditChannel.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
30
lib/Domain/Taxonomy/SystemAuditOutcome.php
Normal file
30
lib/Domain/Taxonomy/SystemAuditOutcome.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
27
lib/Domain/Taxonomy/TenantStatus.php
Normal file
27
lib/Domain/Taxonomy/TenantStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
30
lib/Domain/Taxonomy/UserLifecycleAction.php
Normal file
30
lib/Domain/Taxonomy/UserLifecycleAction.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
30
lib/Domain/Taxonomy/UserLifecycleStatus.php
Normal file
30
lib/Domain/Taxonomy/UserLifecycleStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
30
lib/Domain/Taxonomy/UserLifecycleTriggerType.php
Normal file
30
lib/Domain/Taxonomy/UserLifecycleTriggerType.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
211
lib/Http/ApiSystemAuditReporter.php
Normal file
211
lib/Http/ApiSystemAuditReporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
101
lib/Http/Input/FormErrors.php
Normal file
101
lib/Http/Input/FormErrors.php
Normal 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;
|
||||
}
|
||||
}
|
||||
137
lib/Http/Input/RequestInput.php
Normal file
137
lib/Http/Input/RequestInput.php
Normal 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();
|
||||
}
|
||||
}
|
||||
47
lib/Http/Input/RequestInputFactory.php
Normal file
47
lib/Http/Input/RequestInputFactory.php
Normal 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
226
lib/Http/RequestContext.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
357
lib/Repository/Audit/SystemAuditLogRepository.php
Normal file
357
lib/Repository/Audit/SystemAuditLogRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'] ?? ''));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
37
lib/Repository/Search/SearchQueryRepository.php
Normal file
37
lib/Repository/Search/SearchQueryRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
1090
lib/Repository/Stats/AdminStatsRepository.php
Normal file
1090
lib/Repository/Stats/AdminStatsRepository.php
Normal file
File diff suppressed because it is too large
Load Diff
50
lib/Repository/Support/DatabaseSessionRepository.php
Normal file
50
lib/Repository/Support/DatabaseSessionRepository.php
Normal 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
Reference in New Issue
Block a user