# 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, …):

QR Code 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