PHPUnit config (phpunit.dist.xml, bin/phpunit, bootstrap.php), PHP CS Fixer config, .editorconfig. Separate .env.dev/.env.test templates. Ollama tunnel setup script. Architecture and plan docs. Updated application-layer unit tests to match current service signatures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
55 KiB
SuperSeller3000 — Plan 3: Auth, ACL & Logging
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Vollständige Auth-Infrastruktur (Browser-Login mit TOTP-2FA, API-Key-Authentifizierung), Permission-basiertes ACL, EasyAdmin-Adminpanel mit Artikel-Freigabe-Workflow, PostgreSQL-basiertes Logging mit Rotation und täglichem Backup-Command.
Architecture: Symfony Security mit zwei Firewalls (api / main). ApiKey-Authenticator liest X-Api-Key-Header, verifiziert gegen bcrypt-Hash nach Prefix-Lookup. PermissionVoter prüft User- und ApiKey-Permissions einheitlich. Monolog DatabaseLogHandler schreibt asynchron in logs.log_entry. EasyAdmin für Admin-UI.
Tech Stack: PHP 8.4, Symfony 7, scheb/2fa-bundle, scheb/2fa-totp, easycorp/easyadmin-bundle, Monolog, PHPStan Level 9
Dateistruktur (gesamter Plan)
src/
Infrastructure/
Http/
Security/
ApiKeyAuthenticator.php
Controller/
SecurityController.php # Login, Logout, 2FA
TotpSetupController.php # TOTP einrichten
Admin/
DashboardController.php
ArticleCrudController.php # Freigabe-Workflow
ArticleTypeCrudController.php
PlatformCrudController.php
UserCrudController.php
AIPipelineJobCrudController.php
LogEntryCrudController.php
Logging/
DatabaseLogHandler.php
LogEntry.php # Doctrine entity → logs.log_entry
RotateLogsCommand.php
Security/
PermissionVoter.php
Command/
BackupCommand.php
config/
packages/
security.yaml
scheb_two_factor.yaml
monolog.yaml # ergänzen
routes/
security.yaml
admin.yaml
migrations/
Version20260513000003.php # ApiKey.key_prefix column
templates/
security/
login.html.twig
2fa.html.twig
totp/
setup.html.twig
tests/
Unit/
Infrastructure/
Security/
PermissionVoterTest.php
Logging/
DatabaseLogHandlerTest.php
Task 1: Pakete installieren + Security-Grundkonfiguration
Files:
-
Create:
config/packages/security.yaml -
Create:
config/routes/security.yaml -
Create:
templates/security/login.html.twig -
Create:
templates/security/2fa.html.twig -
Step 1: Pakete installieren
docker compose run --rm app composer require \
scheb/2fa-bundle \
scheb/2fa-totp \
scheb/2fa-backup-code \
easycorp/easyadmin-bundle \
endroid/qr-code \
endroid/qr-code-bundle
docker compose run --rm app composer require --dev \
symfony/browser-kit \
symfony/css-selector
- Step 2: security.yaml schreiben
# config/packages/security.yaml
security:
password_hashers:
App\Domain\Auth\User:
algorithm: bcrypt
cost: 12
providers:
user_provider:
entity:
class: App\Domain\Auth\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api/
stateless: true
custom_authenticators:
- App\Infrastructure\Http\Security\ApiKeyAuthenticator
main:
lazy: true
provider: user_provider
form_login:
login_path: app_login
check_path: app_login
default_target_path: /admin
enable_csrf: true
logout:
path: app_logout
target: app_login
two_factor:
auth_form_path: 2fa_login
check_path: 2fa_login_check
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/2fa, roles: IS_AUTHENTICATED_2FA_IN_PROGRESS }
- { path: ^/api/, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/admin, roles: ROLE_USER }
- Step 3: Scheb 2FA konfigurieren
# config/packages/scheb_two_factor.yaml
scheb_two_factor:
security_tokens:
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
ip_whitelist: []
totp:
enabled: true
issuer: SuperSeller3000
digits: 6
period: 30
algorithm: sha1
backup_codes:
enabled: true
trusted_device:
enabled: false
two_factor_finish_url: /admin
login_check_path: app_login
auth_code_parameter_name: _auth_code
trusted_parameter_name: _trusted
- Step 4: Route-Konfiguration
# config/routes/security.yaml
app_login:
path: /login
controller: App\Infrastructure\Http\Controller\SecurityController::login
app_logout:
path: /logout
methods: [POST]
2fa_login:
path: /2fa
controller: scheb_two_factor.form_renderer::renderForm
2fa_login_check:
path: /2fa_check
- Step 5: Login-Template
{# templates/security/login.html.twig #}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>SuperSeller3000 — Login</title>
<style>
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f7; }
.card { background: #fff; border-radius: 12px; padding: 2rem; width: 340px; box-shadow: 0 4px 24px rgba(0,0,0,.08); }
h1 { margin: 0 0 1.5rem; font-size: 1.25rem; }
input { width: 100%; box-sizing: border-box; padding: .625rem; border: 1px solid #d1d1d6; border-radius: 6px; margin-bottom: 1rem; font-size: 1rem; }
button { width: 100%; padding: .75rem; background: #0071e3; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; }
.error { color: #ff3b30; font-size: .85rem; margin-bottom: 1rem; }
</style>
</head>
<body>
<div class="card">
<h1>SuperSeller3000</h1>
{% if error %}
<div class="error">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}
<form method="post" action="{{ path('app_login') }}">
<input type="email" name="_username" value="{{ last_username }}" placeholder="E-Mail" autofocus required>
<input type="password" name="_password" placeholder="Passwort" required>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<button type="submit">Anmelden</button>
</form>
</div>
</body>
</html>
- Step 6: 2FA-Template
{# templates/security/2fa.html.twig #}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>SuperSeller3000 — 2FA</title>
<style>
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f7; }
.card { background: #fff; border-radius: 12px; padding: 2rem; width: 340px; box-shadow: 0 4px 24px rgba(0,0,0,.08); }
h1 { margin: 0 0 .5rem; font-size: 1.25rem; }
p { color: #86868b; font-size: .9rem; margin: 0 0 1.5rem; }
input { width: 100%; box-sizing: border-box; padding: .625rem; border: 1px solid #d1d1d6; border-radius: 6px; margin-bottom: 1rem; font-size: 1.5rem; letter-spacing: .5rem; text-align: center; }
button { width: 100%; padding: .75rem; background: #0071e3; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; }
.error { color: #ff3b30; font-size: .85rem; margin-bottom: 1rem; }
</style>
</head>
<body>
<div class="card">
<h1>Zwei-Faktor-Auth</h1>
<p>Code aus deiner Authenticator-App eingeben.</p>
{% if authenticationError %}
<div class="error">{{ authenticationError.messageKey|trans(authenticationError.messageData, 'security') }}</div>
{% endif %}
<form method="post" action="{{ path('2fa_login_check') }}">
<input type="text" name="{{ twoFactorAuthCodeParameter }}" autocomplete="one-time-code" inputmode="numeric" maxlength="6" autofocus>
<button type="submit">Bestätigen</button>
</form>
</div>
</body>
</html>
- Step 7: SecurityController
<?php
// src/Infrastructure/Http/Controller/SecurityController.php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
final class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login', methods: ['GET', 'POST'])]
public function login(AuthenticationUtils $authUtils): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('admin');
}
return $this->render('security/login.html.twig', [
'last_username' => $authUtils->getLastUsername(),
'error' => $authUtils->getLastAuthenticationError(),
]);
}
}
- Step 8: Commit
git add config/packages/security.yaml config/packages/scheb_two_factor.yaml config/routes/security.yaml templates/security/ src/Infrastructure/Http/Controller/SecurityController.php composer.json composer.lock
git commit -m "feat: add Symfony Security, form login, 2FA config, login templates"
Task 2: ApiKey-Migration + Authenticator
Files:
-
Create:
migrations/Version20260513000003.php -
Create:
src/Infrastructure/Http/Security/ApiKeyAuthenticator.php -
Modify:
src/Domain/Auth/ApiKey.php -
Step 1: ApiKey-Entity um key_prefix erweitern
Ergänze src/Domain/Auth/ApiKey.php:
#[ORM\Column(type: 'string', length: 16)]
private string $keyPrefix;
// In __construct hinzufügen (nach $keyHash):
$this->keyPrefix = \substr($rawKey, 0, 16);
// Neuer Konstruktor-Parameter:
public function __construct(User $user, string $label, string $rawKey, string $keyHash)
{
$this->id = Uuid::v7();
$this->user = $user;
$this->label = $label;
$this->keyPrefix = \substr($rawKey, 0, 16);
$this->keyHash = $keyHash;
}
public function getKeyPrefix(): string { return $this->keyPrefix; }
- Step 2: Migration schreiben
docker compose run --rm app php bin/console doctrine:migrations:generate
<?php
// migrations/Version20260513000003.php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260513000003 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add key_prefix column to api_keys for fast lookup';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE app.api_keys ADD COLUMN key_prefix VARCHAR(16) NOT NULL DEFAULT ''");
$this->addSql('CREATE INDEX idx_api_keys_prefix ON app.api_keys (key_prefix)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX idx_api_keys_prefix');
$this->addSql('ALTER TABLE app.api_keys DROP COLUMN key_prefix');
}
}
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
- Step 3: ApiKey-Repository-Interface erweitern
Erstelle src/Domain/Auth/Repository/ApiKeyRepositoryInterface.php:
<?php
// src/Domain/Auth/Repository/ApiKeyRepositoryInterface.php
declare(strict_types=1);
namespace App\Domain\Auth\Repository;
use App\Domain\Auth\ApiKey;
use Symfony\Component\Uid\Uuid;
interface ApiKeyRepositoryInterface
{
/** @return list<ApiKey> active keys with this prefix */
public function findActiveByPrefix(string $prefix): array;
public function findById(Uuid $id): ?ApiKey;
public function save(ApiKey $apiKey): void;
public function remove(ApiKey $apiKey): void;
}
Erstelle src/Infrastructure/Persistence/Repository/DoctrineApiKeyRepository.php:
<?php
// src/Infrastructure/Persistence/Repository/DoctrineApiKeyRepository.php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Repository;
use App\Domain\Auth\ApiKey;
use App\Domain\Auth\Repository\ApiKeyRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;
final class DoctrineApiKeyRepository implements ApiKeyRepositoryInterface
{
public function __construct(private readonly EntityManagerInterface $em) {}
/** @return list<ApiKey> */
public function findActiveByPrefix(string $prefix): array
{
/** @var list<ApiKey> */
return $this->em->getRepository(ApiKey::class)
->createQueryBuilder('k')
->where('k.keyPrefix = :prefix')
->andWhere('k.isActive = :active')
->setParameter('prefix', $prefix)
->setParameter('active', true)
->getQuery()
->getResult();
}
public function findById(Uuid $id): ?ApiKey
{
return $this->em->find(ApiKey::class, $id);
}
public function save(ApiKey $apiKey): void
{
$this->em->persist($apiKey);
$this->em->flush();
}
public function remove(ApiKey $apiKey): void
{
$this->em->remove($apiKey);
$this->em->flush();
}
}
Ergänze config/services.yaml:
App\Domain\Auth\Repository\ApiKeyRepositoryInterface:
alias: App\Infrastructure\Persistence\Repository\DoctrineApiKeyRepository
- Step 4: ApiKeyAuthenticator implementieren
<?php
// src/Infrastructure/Http/Security/ApiKeyAuthenticator.php
declare(strict_types=1);
namespace App\Infrastructure\Http\Security;
use App\Domain\Auth\Repository\ApiKeyRepositoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
final class ApiKeyAuthenticator extends AbstractAuthenticator
{
public function __construct(
private readonly ApiKeyRepositoryInterface $apiKeyRepository,
) {}
public function supports(Request $request): ?bool
{
return $request->headers->has('X-Api-Key');
}
public function authenticate(Request $request): Passport
{
$rawKey = $request->headers->get('X-Api-Key', '');
if (\strlen($rawKey) < 16) {
throw new CustomUserMessageAuthenticationException('Invalid API key format');
}
$prefix = \substr($rawKey, 0, 16);
$candidates = $this->apiKeyRepository->findActiveByPrefix($prefix);
foreach ($candidates as $apiKey) {
if (\password_verify($rawKey, $apiKey->getKeyHash())) {
if ($apiKey->isExpired()) {
throw new CustomUserMessageAuthenticationException('API key has expired');
}
$apiKey->markUsed();
$this->apiKeyRepository->save($apiKey);
return new SelfValidatingPassport(
new UserBadge($apiKey->getUser()->getUserIdentifier()),
);
}
}
throw new CustomUserMessageAuthenticationException('Invalid API key');
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(['error' => $exception->getMessageKey()], Response::HTTP_UNAUTHORIZED);
}
}
- Step 5: Commit
git add migrations/ src/Domain/Auth/ src/Infrastructure/Http/Security/ src/Infrastructure/Persistence/Repository/DoctrineApiKeyRepository.php config/services.yaml
git commit -m "feat: add ApiKey prefix-based authenticator with bcrypt verification"
Task 3: TOTP 2FA Setup
Files:
-
Create:
src/Infrastructure/Http/Controller/TotpSetupController.php -
Create:
templates/totp/setup.html.twig -
Step 1: User für 2FA konfigurieren
Die User-Entity muss TwoFactorInterface implementieren. Ergänze src/Domain/Auth/User.php:
use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration;
use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface;
use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface;
// Implements-Liste um TwoFactorInterface erweitern:
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
{
// Neue Methoden:
public function isTotpAuthenticationEnabled(): bool
{
return null !== $this->totpSecret;
}
public function getTotpAuthenticationUsername(): string
{
return $this->email;
}
public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface
{
if (null === $this->totpSecret) {
return null;
}
return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);
}
}
- Step 2: TotpSetupController
<?php
// src/Infrastructure/Http/Controller/TotpSetupController.php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller;
use App\Domain\Auth\Repository\UserRepositoryInterface;
use App\Domain\Auth\User;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\Writer\PngWriter;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/totp', name: 'totp_')]
#[IsGranted('ROLE_USER')]
final class TotpSetupController extends AbstractController
{
public function __construct(
private readonly TotpAuthenticatorInterface $totpAuthenticator,
private readonly UserRepositoryInterface $userRepository,
) {}
#[Route('/setup', name: 'setup', methods: ['GET'])]
public function setup(): Response
{
/** @var User $user */
$user = $this->getUser();
if (null === $user->getTotpSecret()) {
$secret = $this->totpAuthenticator->generateSecret();
$user->setTotpSecret($secret);
$this->userRepository->save($user);
}
$qrContent = $this->totpAuthenticator->getQRContent($user);
$qrCode = Builder::create()
->writer(new PngWriter())
->data($qrContent)
->encoding(new Encoding('UTF-8'))
->errorCorrectionLevel(ErrorCorrectionLevel::High)
->size(200)
->margin(10)
->build();
return $this->render('totp/setup.html.twig', [
'qrCodeDataUri' => $qrCode->getDataUri(),
'secret' => $user->getTotpSecret(),
'enabled' => $user->isTotpAuthenticationEnabled(),
]);
}
#[Route('/disable', name: 'disable', methods: ['POST'])]
public function disable(Request $request): Response
{
if (!$this->isCsrfTokenValid('totp_disable', $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token');
}
/** @var User $user */
$user = $this->getUser();
$user->setTotpSecret(null);
$this->userRepository->save($user);
$this->addFlash('success', '2FA deaktiviert.');
return $this->redirectToRoute('totp_setup');
}
}
- Step 3: UserRepositoryInterface ergänzen
Erstelle src/Domain/Auth/Repository/UserRepositoryInterface.php:
<?php
// src/Domain/Auth/Repository/UserRepositoryInterface.php
declare(strict_types=1);
namespace App\Domain\Auth\Repository;
use App\Domain\Auth\User;
use Symfony\Component\Uid\Uuid;
interface UserRepositoryInterface
{
public function findById(Uuid $id): ?User;
public function findByEmail(string $email): ?User;
/** @return list<User> */
public function findAll(): array;
public function save(User $user): void;
public function remove(User $user): void;
}
Erstelle src/Infrastructure/Persistence/Repository/DoctrineUserRepository.php:
<?php
// src/Infrastructure/Persistence/Repository/DoctrineUserRepository.php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Repository;
use App\Domain\Auth\Repository\UserRepositoryInterface;
use App\Domain\Auth\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;
final class DoctrineUserRepository implements UserRepositoryInterface
{
public function __construct(private readonly EntityManagerInterface $em) {}
public function findById(Uuid $id): ?User
{
return $this->em->find(User::class, $id);
}
public function findByEmail(string $email): ?User
{
return $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
}
/** @return list<User> */
public function findAll(): array
{
/** @var list<User> */
return $this->em->getRepository(User::class)->findAll();
}
public function save(User $user): void
{
$this->em->persist($user);
$this->em->flush();
}
public function remove(User $user): void
{
$this->em->remove($user);
$this->em->flush();
}
}
Ergänze config/services.yaml:
App\Domain\Auth\Repository\UserRepositoryInterface:
alias: App\Infrastructure\Persistence\Repository\DoctrineUserRepository
- Step 4: TOTP-Setup-Template
{# templates/totp/setup.html.twig #}
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><title>2FA Setup</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 3rem auto; padding: 0 1rem; }
.card { background: #fff; border: 1px solid #d1d1d6; border-radius: 12px; padding: 1.5rem; }
h1 { font-size: 1.25rem; margin: 0 0 1rem; }
img { display: block; margin: 1rem auto; }
code { display: block; text-align: center; font-size: 0.85rem; color: #86868b; margin: 0.5rem 0 1.5rem; }
button { background: #ff3b30; color: #fff; border: none; padding: .5rem 1rem; border-radius: 6px; cursor: pointer; }
.enabled { color: #34c759; font-weight: 600; }
.disabled { color: #86868b; }
</style>
</head>
<body>
<div class="card">
<h1>Zwei-Faktor-Authentifizierung</h1>
{% if enabled %}
<p class="enabled">✓ 2FA ist aktiv.</p>
<form method="post" action="{{ path('totp_disable') }}">
<input type="hidden" name="_token" value="{{ csrf_token('totp_disable') }}">
<button type="submit">2FA deaktivieren</button>
</form>
{% else %}
<p class="disabled">2FA ist noch nicht eingerichtet.</p>
<p>Scanne den QR-Code mit deiner Authenticator-App (Google Authenticator, Authy, …):</p>
<img src="{{ qrCodeDataUri }}" alt="QR Code" width="200" height="200">
<code>Manueller Code: {{ secret }}</code>
<p>Nach dem Scannen: beim nächsten Login wirst du nach dem Code gefragt.</p>
{% endif %}
</div>
</body>
</html>
- Step 5: Route ergänzen
# config/routes/security.yaml (ergänzen)
totp_setup:
path: /totp/setup
controller: App\Infrastructure\Http\Controller\TotpSetupController::setup
totp_disable:
path: /totp/disable
controller: App\Infrastructure\Http\Controller\TotpSetupController::disable
methods: [POST]
- Step 6: Commit
git add src/Domain/Auth/ src/Infrastructure/Http/Controller/TotpSetupController.php src/Infrastructure/Persistence/Repository/DoctrineUserRepository.php templates/totp/ config/routes/security.yaml config/services.yaml
git commit -m "feat: add TOTP 2FA setup, UserRepository"
Task 4: PermissionVoter
Files:
-
Create:
src/Infrastructure/Security/PermissionVoter.php -
Test:
tests/Unit/Infrastructure/Security/PermissionVoterTest.php -
Step 1: Failing-Test schreiben
<?php
// tests/Unit/Infrastructure/Security/PermissionVoterTest.php
declare(strict_types=1);
namespace App\Tests\Unit\Infrastructure\Security;
use App\Domain\Auth\User;
use App\Infrastructure\Security\PermissionVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
final class PermissionVoterTest extends TestCase
{
private PermissionVoter $voter;
protected function setUp(): void
{
$this->voter = new PermissionVoter();
}
private function tokenFor(User $user): UsernamePasswordToken
{
return new UsernamePasswordToken($user, 'main', $user->getRoles());
}
public function test_grants_permission_to_user_with_it(): void
{
$user = new User('test@example.com', 'hash');
$user->grantPermission('article:view');
$result = $this->voter->vote($this->tokenFor($user), null, ['PERMISSION_article:view']);
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
}
public function test_denies_permission_user_lacks(): void
{
$user = new User('test@example.com', 'hash');
$result = $this->voter->vote($this->tokenFor($user), null, ['PERMISSION_order:delete']);
$this->assertSame(VoterInterface::ACCESS_DENIED, $result);
}
public function test_abstains_for_non_permission_attribute(): void
{
$user = new User('test@example.com', 'hash');
$result = $this->voter->vote($this->tokenFor($user), null, ['ROLE_ADMIN']);
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result);
}
}
- Step 2: Test ausführen — muss fehlschlagen
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Security/PermissionVoterTest.php
# Expected: FAIL
- Step 3: PermissionVoter implementieren
<?php
// src/Infrastructure/Security/PermissionVoter.php
declare(strict_types=1);
namespace App\Infrastructure\Security;
use App\Domain\Auth\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Grants access when the authenticated User has the given permission.
* Usage: $this->denyAccessUnlessGranted('PERMISSION_article:view')
*
* @extends Voter<string, mixed>
*/
final class PermissionVoter extends Voter
{
private const PREFIX = 'PERMISSION_';
protected function supports(string $attribute, mixed $subject): bool
{
return \str_starts_with($attribute, self::PREFIX);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
$permission = \substr($attribute, \strlen(self::PREFIX));
return $user->hasPermission($permission);
}
}
- Step 4: Test ausführen
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Security/PermissionVoterTest.php
# Expected: PASS (3 tests)
- Step 5: PHPStan + Commit
docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Security/ --no-progress
git add src/Infrastructure/Security/PermissionVoter.php tests/Unit/Infrastructure/Security/PermissionVoterTest.php
git commit -m "feat: add PermissionVoter (PERMISSION_* attribute → User.hasPermission)"
Task 5: EasyAdmin Setup
Files:
-
Create:
src/Infrastructure/Http/Admin/DashboardController.php -
Create:
src/Infrastructure/Http/Admin/ArticleCrudController.php -
Create:
src/Infrastructure/Http/Admin/ArticleTypeCrudController.php -
Create:
src/Infrastructure/Http/Admin/UserCrudController.php -
Create:
src/Infrastructure/Http/Admin/AIPipelineJobCrudController.php -
Create:
config/routes/admin.yaml -
Step 1: DashboardController
<?php
// src/Infrastructure/Http/Admin/DashboardController.php
declare(strict_types=1);
namespace App\Infrastructure\Http\Admin;
use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
use Symfony\Component\HttpFoundation\Response;
#[AdminDashboard(routePath: '/admin', routeName: 'admin')]
final class DashboardController extends AbstractDashboardController
{
public function index(): Response
{
return $this->render('@EasyAdmin/page/content.html.twig');
}
public function configureDashboard(): Dashboard
{
return Dashboard::new()->setTitle('SuperSeller3000');
}
public function configureMenuItems(): iterable
{
yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home');
yield MenuItem::section('Artikel');
yield MenuItem::linkToCrud('Artikel', 'fa fa-box', \App\Domain\Article\Article::class);
yield MenuItem::linkToCrud('Artikel-Typen', 'fa fa-tags', \App\Domain\Article\ArticleType::class);
yield MenuItem::section('Plattformen');
yield MenuItem::linkToCrud('Plattformen', 'fa fa-plug', \App\Domain\Channel\Platform::class);
yield MenuItem::section('Monitoring');
yield MenuItem::linkToCrud('KI-Jobs', 'fa fa-robot', \App\Domain\Pipeline\AIPipelineJob::class);
yield MenuItem::linkToCrud('Logs', 'fa fa-list', \App\Infrastructure\Logging\LogEntry::class);
yield MenuItem::section('Administration');
yield MenuItem::linkToCrud('Benutzer', 'fa fa-users', \App\Domain\Auth\User::class);
}
}
- Step 2: ArticleCrudController (Freigabe-Workflow)
<?php
// src/Infrastructure/Http/Admin/ArticleCrudController.php
declare(strict_types=1);
namespace App\Infrastructure\Http\Admin;
use App\Application\Article\ArticleService;
use App\Domain\Article\Article;
use App\Domain\Article\ArticleStatus;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use EasyCorp\Bundle\EasyAdminBundle\Filter\ChoiceFilter;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class ArticleCrudController extends AbstractCrudController
{
public function __construct(
private readonly ArticleService $articleService,
private readonly AdminUrlGenerator $adminUrlGenerator,
) {}
public static function getEntityFqcn(): string
{
return Article::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Artikel')
->setEntityLabelInPlural('Artikel')
->setDefaultSort(['status' => 'ASC']);
}
public function configureFields(string $pageName): iterable
{
yield TextField::new('sku', 'SKU');
yield TextField::new('inventoryNumber', 'Inventar-Nr.');
yield ChoiceField::new('status', 'Status')->setChoices(ArticleStatus::class);
yield TextField::new('articleType.name', 'Typ');
yield ChoiceField::new('condition', 'Zustand');
yield MoneyField::new('listingPrice', 'Preis')->setCurrency('EUR');
yield DateTimeField::new('createdAt', 'Erstellt')->onlyOnIndex();
}
public function configureFilters(Filters $filters): Filters
{
return $filters->add(ChoiceFilter::new('status')->setChoices(\array_column(ArticleStatus::cases(), 'value', 'value')));
}
public function configureActions(Actions $actions): Actions
{
$activate = Action::new('activate', 'Freigeben', 'fa fa-check')
->linkToRoute('admin_article_activate', fn (Article $a) => ['id' => $a->getId()->toRfc4122()])
->displayIf(static fn (Article $a) => $a->getStatus() === ArticleStatus::Draft);
return $actions
->add(Crud::PAGE_INDEX, $activate)
->disable(Action::DELETE);
}
#[Route('/admin/articles/{id}/activate', name: 'admin_article_activate')]
public function activateAction(string $id, Request $request): RedirectResponse
{
$result = $this->articleService->activate(\Symfony\Component\Uid\Uuid::fromString($id));
if ([] !== $result['missing']) {
$this->addFlash('danger', 'Fehlende Pflichtfelder: '.\implode(', ', $result['missing']));
} else {
$this->addFlash('success', 'Artikel freigegeben.');
}
$url = $this->adminUrlGenerator->setController(self::class)->setAction(Action::INDEX)->generateUrl();
return $this->redirect($url);
}
}
- Step 3: Weitere CRUDs (ArticleType, User, AIPipelineJob)
<?php
// src/Infrastructure/Http/Admin/ArticleTypeCrudController.php
declare(strict_types=1);
namespace App\Infrastructure\Http\Admin;
use App\Domain\Article\ArticleType;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
final class ArticleTypeCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string { return ArticleType::class; }
public function configureFields(string $pageName): iterable
{
yield TextField::new('name', 'Name');
}
}
<?php
// src/Infrastructure/Http/Admin/UserCrudController.php
declare(strict_types=1);
namespace App\Infrastructure\Http\Admin;
use App\Domain\Auth\User;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField;
final class UserCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string { return User::class; }
public function configureFields(string $pageName): iterable
{
yield EmailField::new('email', 'E-Mail');
yield BooleanField::new('isActive', 'Aktiv');
}
public function configureActions(Actions $actions): Actions
{
return $actions->disable(Action::DELETE);
}
}
<?php
// src/Infrastructure/Http/Admin/AIPipelineJobCrudController.php
declare(strict_types=1);
namespace App\Infrastructure\Http\Admin;
use App\Domain\Pipeline\AIPipelineJob;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
final class AIPipelineJobCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string { return AIPipelineJob::class; }
public function configureCrud(Crud $crud): Crud
{
return $crud->setDefaultSort(['createdAt' => 'DESC']);
}
public function configureFields(string $pageName): iterable
{
yield ChoiceField::new('type', 'Typ');
yield ChoiceField::new('status', 'Status');
yield IntegerField::new('attemptCount', 'Versuche');
yield TextField::new('errorMessage', 'Fehler')->onlyOnDetail();
yield DateTimeField::new('createdAt', 'Erstellt');
yield DateTimeField::new('completedAt', 'Abgeschlossen');
}
public function configureActions(Actions $actions): Actions
{
return $actions->disable(Action::NEW, Action::EDIT, Action::DELETE);
}
}
- Step 4: PHPStan + Commit
docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Http/Admin/ --no-progress
docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/Infrastructure/Http/Admin/ --dry-run --diff
git add src/Infrastructure/Http/Admin/ config/routes/
git commit -m "feat: add EasyAdmin dashboard with Article approval workflow, ArticleType, User, AIPipelineJob CRUDs"
Task 6: DatabaseLogHandler + LogEntry
Files:
-
Create:
src/Infrastructure/Logging/LogEntry.php -
Create:
src/Infrastructure/Logging/DatabaseLogHandler.php -
Test:
tests/Unit/Logging/DatabaseLogHandlerTest.php -
Step 1: LogEntry-Entity erstellen
Plan 1 hat die Tabellen logs.log_entry und logs_archive.log_entry bereits angelegt. Jetzt erstellen wir die Doctrine-Entity für die aktive Tabelle:
<?php
// src/Infrastructure/Logging/LogEntry.php
declare(strict_types=1);
namespace App\Infrastructure\Logging;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'log_entry', schema: 'logs')]
class LogEntry
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\Column(type: 'string', length: 20)]
private string $level;
#[ORM\Column(type: 'string', length: 100)]
private string $channel;
#[ORM\Column(type: 'text')]
private string $message;
/** @var array<string, mixed> */
#[ORM\Column(type: 'json')]
private array $context = [];
// message_search is a GENERATED ALWAYS column — never written from PHP
#[ORM\Column(type: 'string', insertable: false, updatable: false, nullable: true)]
private ?string $messageSearch = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct(string $level, string $channel, string $message, array $context = [])
{
$this->id = Uuid::v7();
$this->level = $level;
$this->channel = $channel;
$this->message = $message;
$this->context = $context;
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): Uuid { return $this->id; }
public function getLevel(): string { return $this->level; }
public function getChannel(): string { return $this->channel; }
public function getMessage(): string { return $this->message; }
/** @return array<string, mixed> */
public function getContext(): array { return $this->context; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
}
- Step 2: Failing-Test schreiben
<?php
// tests/Unit/Logging/DatabaseLogHandlerTest.php
declare(strict_types=1);
namespace App\Tests\Unit\Logging;
use App\Infrastructure\Logging\DatabaseLogHandler;
use Doctrine\DBAL\Connection;
use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class DatabaseLogHandlerTest extends TestCase
{
private Connection&MockObject $connection;
private DatabaseLogHandler $handler;
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->handler = new DatabaseLogHandler($this->connection);
}
public function test_writes_record_to_database(): void
{
$this->connection->expects($this->once())
->method('insert')
->with(
'logs.log_entry',
$this->callback(static fn (array $data) =>
$data['level'] === 'ERROR' &&
$data['channel'] === 'app' &&
$data['message'] === 'Something went wrong' &&
isset($data['id']) &&
isset($data['created_at'])
),
);
$record = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: 'app',
level: Level::Error,
message: 'Something went wrong',
context: [],
extra: [],
);
$this->handler->handle($record);
}
public function test_handles_below_min_level_by_ignoring(): void
{
$this->connection->expects($this->never())->method('insert');
$record = new LogRecord(
datetime: new \DateTimeImmutable(),
channel: 'app',
level: Level::Debug,
message: 'Debug message',
context: [],
extra: [],
);
$this->handler->handle($record);
}
}
- Step 3: Test ausführen — muss fehlschlagen
docker compose run --rm app ./vendor/bin/pest tests/Unit/Logging/DatabaseLogHandlerTest.php
# Expected: FAIL
- Step 4: DatabaseLogHandler implementieren
<?php
// src/Infrastructure/Logging/DatabaseLogHandler.php
declare(strict_types=1);
namespace App\Infrastructure\Logging;
use Doctrine\DBAL\Connection;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Level;
use Monolog\LogRecord;
use Symfony\Component\Uid\Uuid;
final class DatabaseLogHandler extends AbstractProcessingHandler
{
public function __construct(
private readonly Connection $connection,
Level $level = Level::Info,
) {
parent::__construct($level);
}
protected function write(LogRecord $record): void
{
try {
$this->connection->insert('logs.log_entry', [
'id' => Uuid::v7()->toRfc4122(),
'level' => $record->level->name,
'channel' => $record->channel,
'message' => $record->message,
'context' => \json_encode($record->context, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'created_at' => $record->datetime->format('Y-m-d H:i:s.u'),
]);
} catch (\Throwable) {
// Never let logging break the application
}
}
}
- Step 5: Monolog konfigurieren
# config/packages/monolog.yaml (ergänzen, unter handlers:)
database:
type: service
id: App\Infrastructure\Logging\DatabaseLogHandler
Für Prod (config/packages/prod/monolog.yaml):
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
nested:
type: rotating_file
path: '%kernel.logs_dir%/%kernel.environment%.log'
level: debug
max_files: 7
database:
type: service
id: App\Infrastructure\Logging\DatabaseLogHandler
level: info
- Step 6: Test ausführen
docker compose run --rm app ./vendor/bin/pest tests/Unit/Logging/DatabaseLogHandlerTest.php
# Expected: PASS (2 tests)
- Step 7: EasyAdmin LogEntry CRUD ergänzen
<?php
// src/Infrastructure/Http/Admin/LogEntryCrudController.php
declare(strict_types=1);
namespace App\Infrastructure\Http\Admin;
use App\Infrastructure\Logging\LogEntry;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
final class LogEntryCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string { return LogEntry::class; }
public function configureCrud(Crud $crud): Crud
{
return $crud->setDefaultSort(['createdAt' => 'DESC']);
}
public function configureFields(string $pageName): iterable
{
yield DateTimeField::new('createdAt', 'Zeit');
yield ChoiceField::new('level', 'Level')->setChoices(['DEBUG' => 'DEBUG', 'INFO' => 'INFO', 'WARNING' => 'WARNING', 'ERROR' => 'ERROR', 'CRITICAL' => 'CRITICAL']);
yield TextField::new('channel', 'Channel');
yield TextField::new('message', 'Meldung');
}
public function configureActions(Actions $actions): Actions
{
return $actions->disable(Action::NEW, Action::EDIT);
}
}
- Step 8: Commit
git add src/Infrastructure/Logging/ tests/Unit/Logging/ config/packages/monolog.yaml
git commit -m "feat: add DatabaseLogHandler, LogEntry entity, EasyAdmin log viewer"
Task 7: Log-Rotations-Command
Files:
-
Create:
src/Infrastructure/Logging/RotateLogsCommand.php -
Step 1: RotateLogsCommand implementieren
<?php
// src/Infrastructure/Logging/RotateLogsCommand.php
declare(strict_types=1);
namespace App\Infrastructure\Logging;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:logs:rotate', description: 'Archiviert Logs älter als 90 Tage (level > DEBUG), löscht alle alten Einträge')]
final class RotateLogsCommand extends Command
{
public function __construct(private readonly Connection $connection)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$cutoff = (new \DateTimeImmutable('-90 days'))->format('Y-m-d H:i:s');
// Archive non-DEBUG entries older than 90 days
$archived = $this->connection->executeStatement(
"INSERT INTO logs_archive.log_entry (id, level, channel, message, context, created_at)
SELECT id, level, channel, message, context, created_at
FROM logs.log_entry
WHERE created_at < :cutoff AND level != 'DEBUG'
ON CONFLICT (id) DO NOTHING",
['cutoff' => $cutoff],
);
// Delete all entries older than 90 days from active log
$deleted = $this->connection->executeStatement(
'DELETE FROM logs.log_entry WHERE created_at < :cutoff',
['cutoff' => $cutoff],
);
$io->success("Archiviert: {$archived} Einträge. Gelöscht: {$deleted} Einträge.");
return Command::SUCCESS;
}
}
- Step 2: Cron-Eintrag im Docker-Container sicherstellen
In docker-compose.yml wird der cron-Container mit diesem Befehl konfiguriert. Stelle sicher, dass er vorhanden ist und der Job eingetragen ist:
cron:
build: docker/app
volumes:
- .:/var/www
command: >
sh -c "echo '0 2 * * * cd /var/www && php bin/console app:logs:rotate >> /proc/1/fd/1 2>&1' | crontab - && crond -f -l 2"
env_file: .env.local
depends_on:
- postgres
- Step 3: Command testen
docker compose run --rm app php bin/console app:logs:rotate
# Expected: "[OK] Archiviert: 0 Einträge. Gelöscht: 0 Einträge." (leere DB)
- Step 4: Commit
git add src/Infrastructure/Logging/RotateLogsCommand.php docker-compose.yml
git commit -m "feat: add log rotation command (archive >90d non-DEBUG, delete all >90d)"
Task 8: Backup-Command
Files:
-
Create:
src/Infrastructure/Command/BackupCommand.php -
Step 1: BackupCommand implementieren
<?php
// src/Infrastructure/Command/BackupCommand.php
declare(strict_types=1);
namespace App\Infrastructure\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:backup:run', description: 'Erstellt pg_dump und Gitea-Backup')]
final class BackupCommand extends Command
{
public function __construct(
private readonly string $backupDir,
private readonly string $pgDumpDsn,
private readonly string $giteaDataDir,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$date = \date('Y-m-d');
if (!\is_dir($this->backupDir)) {
\mkdir($this->backupDir, 0750, true);
}
// PostgreSQL dump
$pgFile = "{$this->backupDir}/postgres-{$date}.sql.gz";
$pgCmd = "pg_dump \"{$this->pgDumpDsn}\" | gzip > \"{$pgFile}\"";
\exec($pgCmd, result_code: $pgCode);
if ($pgCode !== 0) {
$io->error("pg_dump fehlgeschlagen (exit code: {$pgCode})");
return Command::FAILURE;
}
$io->success("PostgreSQL-Backup: {$pgFile}");
// Gitea dump (nur wenn gitea CLI verfügbar)
$giteaFile = "{$this->backupDir}/gitea-{$date}.zip";
$giteaCmd = "gitea dump -c /etc/gitea/app.ini --file \"{$giteaFile}\" 2>&1";
\exec($giteaCmd, result_code: $giteaCode);
if ($giteaCode === 0) {
$io->success("Gitea-Backup: {$giteaFile}");
} else {
$io->warning('Gitea-Backup übersprungen (gitea CLI nicht verfügbar oder Fehler).');
}
// Lösche Backups älter als 14 Tage
$cutoffTime = \time() - (14 * 86400);
foreach (\glob("{$this->backupDir}/*.gz") ?: [] as $file) {
if (\filemtime($file) < $cutoffTime) {
\unlink($file);
}
}
foreach (\glob("{$this->backupDir}/*.zip") ?: [] as $file) {
if (\filemtime($file) < $cutoffTime) {
\unlink($file);
}
}
return Command::SUCCESS;
}
}
- Step 2: Service in services.yaml binden
# config/services.yaml (ergänzen)
App\Infrastructure\Command\BackupCommand:
arguments:
$backupDir: '%env(BACKUP_DIR)%'
$pgDumpDsn: '%env(DATABASE_URL)%'
$giteaDataDir: '%env(GITEA_DATA_DIR)%'
- Step 3: .env ergänzen
# .env (Defaults)
BACKUP_DIR=/var/backups/superseller
GITEA_DATA_DIR=/var/lib/gitea
- Step 4: Cron ergänzen
Im cron-Container-Command (docker-compose.yml) den Backup-Job hinzufügen:
command: >
sh -c "
echo '0 2 * * * cd /var/www && php bin/console app:logs:rotate >> /proc/1/fd/1 2>&1' >> /tmp/crontab
echo '0 3 * * * cd /var/www && php bin/console app:backup:run >> /proc/1/fd/1 2>&1' >> /tmp/crontab
crontab /tmp/crontab && crond -f -l 2
"
- Step 5: Commit
git add src/Infrastructure/Command/BackupCommand.php config/services.yaml .env docker-compose.yml
git commit -m "feat: add backup command (pg_dump + gitea dump, 14-day retention)"
Task 9: Ersten Admin-User anlegen (Console Command)
Files:
-
Create:
src/Infrastructure/Command/CreateUserCommand.php -
Step 1: CreateUserCommand implementieren
<?php
// src/Infrastructure/Command/CreateUserCommand.php
declare(strict_types=1);
namespace App\Infrastructure\Command;
use App\Domain\Auth\Repository\UserRepositoryInterface;
use App\Domain\Auth\User;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(name: 'app:user:create', description: 'Erstellt einen neuen Admin-Benutzer')]
final class CreateUserCommand extends Command
{
public function __construct(
private readonly UserRepositoryInterface $userRepository,
private readonly UserPasswordHasherInterface $hasher,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('email', InputArgument::REQUIRED, 'E-Mail-Adresse');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$email = $input->getArgument('email');
\assert(\is_string($email));
$password = $io->askHidden('Passwort (wird nicht angezeigt)');
if (!\is_string($password) || \strlen($password) < 12) {
$io->error('Passwort muss mindestens 12 Zeichen haben.');
return Command::FAILURE;
}
$user = new User($email, 'placeholder');
$hash = $this->hasher->hashPassword($user, $password);
$user2 = new User($email, $hash);
$user2->grantPermission('article:view');
$user2->grantPermission('article:edit');
$user2->grantPermission('order:view');
$user2->grantPermission('log:view');
$this->userRepository->save($user2);
$io->success("Benutzer {$email} erstellt. Bitte 2FA unter /totp/setup einrichten.");
return Command::SUCCESS;
}
}
- Step 2: User anlegen
docker compose run --rm app php bin/console app:user:create admin@superseller.local
# Passwort eingeben, mindestens 12 Zeichen
# Expected: "[OK] Benutzer admin@superseller.local erstellt."
- Step 3: Login testen
# Browser öffnen: http://localhost/login
# E-Mail + Passwort eingeben → sollte zu /admin weiterleiten
# (2FA übersprungen, da noch nicht eingerichtet)
- Step 4: Commit
git add src/Infrastructure/Command/CreateUserCommand.php
git commit -m "feat: add app:user:create console command for initial admin setup"
Selbstreview
Spec-Abdeckung:
- Browser-Login mit Form + CSRF ✓ (Task 1)
- TOTP 2FA ✓ (Task 3)
- API-Key-Auth mit bcrypt + Prefix-Lookup ✓ (Task 2)
- PermissionVoter ✓ (Task 4)
- EasyAdmin mit Artikel-Freigabe-Workflow ✓ (Task 5)
- DatabaseLogHandler → PostgreSQL ✓ (Task 6)
- Log-Rotation (90 Tage, DEBUG bleibt nicht im Archiv) ✓ (Task 7)
- Backups (pg_dump, Gitea, 14-Tage-Retention) ✓ (Task 8)
- Erster Admin-User anlegen ✓ (Task 9)
Noch offen:
- Log-Admin-Panel-Suche (tsvector-Fulltext via EasyAdmin custom filter — späterer Enhancement)
- API-Endpoints mit
#[IsGranted]absichern → kann in diesem Plan oder Plan 4/5 ergänzt werden