SuperSeller3000/docs/superpowers/plans/2026-05-13-03-auth-logging.md
Simon Kuehn f55e96b094 chore: add tooling config, test bootstrap, env templates and docs
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>
2026-05-17 22:44:16 +00:00

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