# 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**
```bash
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**
```yaml
# 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**
```yaml
# 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**
```yaml
# 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**
```twig
{# templates/security/login.html.twig #}
SuperSeller3000 — Login
SuperSeller3000
{% if error %}
{{ error.messageKey|trans(error.messageData, 'security') }}
{% endif %}
```
- [ ] **Step 6: 2FA-Template**
```twig
{# templates/security/2fa.html.twig #}
SuperSeller3000 — 2FA
Zwei-Faktor-Auth
Code aus deiner Authenticator-App eingeben.
{% if authenticationError %}
{{ authenticationError.messageKey|trans(authenticationError.messageData, 'security') }}
{% endif %}
```
- [ ] **Step 7: SecurityController**
```php
getUser()) {
return $this->redirectToRoute('admin');
}
return $this->render('security/login.html.twig', [
'last_username' => $authUtils->getLastUsername(),
'error' => $authUtils->getLastAuthenticationError(),
]);
}
}
```
- [ ] **Step 8: Commit**
```bash
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`:
```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**
```bash
docker compose run --rm app php bin/console doctrine:migrations:generate
```
```php
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');
}
}
```
```bash
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
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
*/
public function findActiveByPrefix(string $prefix): array
{
/** @var list */
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`:
```yaml
App\Domain\Auth\Repository\ApiKeyRepositoryInterface:
alias: App\Infrastructure\Persistence\Repository\DoctrineApiKeyRepository
```
- [ ] **Step 4: ApiKeyAuthenticator implementieren**
```php
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**
```bash
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`:
```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
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
*/
public function findAll(): array;
public function save(User $user): void;
public function remove(User $user): void;
}
```
Erstelle `src/Infrastructure/Persistence/Repository/DoctrineUserRepository.php`:
```php
em->find(User::class, $id);
}
public function findByEmail(string $email): ?User
{
return $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
}
/** @return list */
public function findAll(): array
{
/** @var list */
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`:
```yaml
App\Domain\Auth\Repository\UserRepositoryInterface:
alias: App\Infrastructure\Persistence\Repository\DoctrineUserRepository
```
- [ ] **Step 4: TOTP-Setup-Template**
```twig
{# templates/totp/setup.html.twig #}
2FA Setup
Zwei-Faktor-Authentifizierung
{% if enabled %}
✓ 2FA ist aktiv.
{% else %}
2FA ist noch nicht eingerichtet.
Scanne den QR-Code mit deiner Authenticator-App (Google Authenticator, Authy, …):
Manueller Code: {{ secret }}
Nach dem Scannen: beim nächsten Login wirst du nach dem Code gefragt.
{% endif %}
```
- [ ] **Step 5: Route ergänzen**
```yaml
# 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**
```bash
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
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**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Security/PermissionVoterTest.php
# Expected: FAIL
```
- [ ] **Step 3: PermissionVoter implementieren**
```php
denyAccessUnlessGranted('PERMISSION_article:view')
*
* @extends Voter
*/
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**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Security/PermissionVoterTest.php
# Expected: PASS (3 tests)
```
- [ ] **Step 5: PHPStan + Commit**
```bash
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
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
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
disable(Action::DELETE);
}
}
```
```php
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**
```bash
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
*/
#[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 */
public function getContext(): array { return $this->context; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
}
```
- [ ] **Step 2: Failing-Test schreiben**
```php
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**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Logging/DatabaseLogHandlerTest.php
# Expected: FAIL
```
- [ ] **Step 4: DatabaseLogHandler implementieren**
```php
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**
```yaml
# config/packages/monolog.yaml (ergänzen, unter handlers:)
database:
type: service
id: App\Infrastructure\Logging\DatabaseLogHandler
```
Für Prod (`config/packages/prod/monolog.yaml`):
```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**
```bash
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
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**
```bash
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
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:
```yaml
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**
```bash
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**
```bash
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
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**
```yaml
# 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**
```ini
# .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:
```yaml
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**
```bash
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
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**
```bash
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**
```bash
# Browser öffnen: http://localhost/login
# E-Mail + Passwort eingeben → sollte zu /admin weiterleiten
# (2FA übersprungen, da noch nicht eingerichtet)
```
- [ ] **Step 4: Commit**
```bash
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