feat: add security layer, domain repositories and infrastructure services

ApiKeyAuthenticator, PermissionVoter and UserProvider implement
Symfony Security for the API (Bearer token) and admin (session) flows.
Domain repository interfaces added for ApiKey, User, AIPipelineJob and
Invoice; Doctrine implementations provided.

Also adds DatabaseLogHandler for structured DB logging, SerpApiWebSearch,
and the LogEntry domain entity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-17 22:43:58 +00:00
parent 46cff4553f
commit 487c7f8da1
16 changed files with 655 additions and 0 deletions

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Domain\Auth\Repository;
use App\Domain\Auth\ApiKey;
use Symfony\Component\Uid\Uuid;
interface ApiKeyRepositoryInterface
{
public function findById(Uuid $id): ?ApiKey;
public function findByPrefix(string $prefix): ?ApiKey;
/** @return list<ApiKey> */
public function findByUser(Uuid $userId): array;
public function save(ApiKey $key): void;
public function remove(ApiKey $key): void;
}

View file

@ -0,0 +1,17 @@
<?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;
public function save(User $user): void;
}

102
src/Domain/Log/LogEntry.php Normal file
View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Domain\Log;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'log_entries', schema: 'logs')]
class LogEntry
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'bigint')]
private int $id;
#[ORM\Column(type: 'string', length: 50)]
private string $channel;
#[ORM\Column(type: 'integer')]
private int $level;
#[ORM\Column(type: 'string', length: 50)]
private string $levelName;
#[ORM\Column(type: 'text')]
private string $message;
/** @var array<string, mixed> */
#[ORM\Column(type: 'json')]
private array $context;
/** @var array<string, mixed> */
#[ORM\Column(type: 'json')]
private array $extra;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $loggedAt;
/** @param array<string, mixed> $context
* @param array<string, mixed> $extra */
public function __construct(
string $channel,
int $level,
string $levelName,
string $message,
array $context,
array $extra,
\DateTimeImmutable $loggedAt,
) {
$this->channel = $channel;
$this->level = $level;
$this->levelName = $levelName;
$this->message = $message;
$this->context = $context;
$this->extra = $extra;
$this->loggedAt = $loggedAt;
}
public function getId(): int
{
return $this->id;
}
public function getChannel(): string
{
return $this->channel;
}
public function getLevel(): int
{
return $this->level;
}
public function getLevelName(): string
{
return $this->levelName;
}
public function getMessage(): string
{
return $this->message;
}
/** @return array<string, mixed> */
public function getContext(): array
{
return $this->context;
}
/** @return array<string, mixed> */
public function getExtra(): array
{
return $this->extra;
}
public function getLoggedAt(): \DateTimeImmutable
{
return $this->loggedAt;
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Domain\Order\Repository;
use App\Domain\Order\Invoice;
use Symfony\Component\Uid\Uuid;
interface InvoiceRepositoryInterface
{
public function findById(Uuid $id): ?Invoice;
public function findByFrappeInvoiceId(string $frappeInvoiceId): ?Invoice;
public function save(Invoice $invoice): void;
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Domain\Pipeline\Repository;
use App\Domain\Pipeline\AIPipelineJob;
use App\Domain\Pipeline\AIPipelineJobStatus;
use Symfony\Component\Uid\Uuid;
interface AIPipelineJobRepositoryInterface
{
public function findById(Uuid $id): ?AIPipelineJob;
/** @return list<AIPipelineJob> */
public function findByStatus(AIPipelineJobStatus $status): array;
public function save(AIPipelineJob $job): void;
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Logging;
use App\Domain\Log\LogEntry;
use Doctrine\ORM\EntityManagerInterface;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Level;
use Monolog\LogRecord;
final class DatabaseLogHandler extends AbstractProcessingHandler
{
public function __construct(
private readonly EntityManagerInterface $em,
Level $level = Level::Debug,
bool $bubble = true,
) {
parent::__construct($level, $bubble);
}
protected function write(LogRecord $record): void
{
$entry = new LogEntry(
$record->channel,
$record->level->value,
$record->level->getName(),
$record->message,
$record->context,
$record->extra,
\DateTimeImmutable::createFromInterface($record->datetime),
);
$this->em->persist($entry);
$this->em->flush();
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Repository;
use App\Domain\Pipeline\AIPipelineJob;
use App\Domain\Pipeline\AIPipelineJobStatus;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;
final class DoctrineAIPipelineJobRepository implements AIPipelineJobRepositoryInterface
{
public function __construct(private readonly EntityManagerInterface $em)
{
}
public function findById(Uuid $id): ?AIPipelineJob
{
/** @var AIPipelineJob|null */
return $this->em->find(AIPipelineJob::class, $id);
}
public function findByStatus(AIPipelineJobStatus $status): array
{
/** @var list<AIPipelineJob> */
return $this->em->getRepository(AIPipelineJob::class)->findBy(['status' => $status]);
}
public function save(AIPipelineJob $job): void
{
$this->em->persist($job);
$this->em->flush();
}
}

View file

@ -0,0 +1,59 @@
<?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)
{
}
public function findById(Uuid $id): ?ApiKey
{
/** @var ApiKey|null */
return $this->em->find(ApiKey::class, $id);
}
public function findByPrefix(string $prefix): ?ApiKey
{
/** @var ApiKey|null */
return $this->em->getRepository(ApiKey::class)
->createQueryBuilder('k')
->where('k.keyPrefix = :prefix')
->setParameter('prefix', $prefix)
->getQuery()
->getOneOrNullResult();
}
public function findByUser(Uuid $userId): array
{
/** @var list<ApiKey> */
return $this->em->getRepository(ApiKey::class)
->createQueryBuilder('k')
->join('k.user', 'u')
->where('u.id = :userId')
->setParameter('userId', $userId)
->orderBy('k.id', 'ASC')
->getQuery()
->getResult();
}
public function save(ApiKey $key): void
{
$this->em->persist($key);
$this->em->flush();
}
public function remove(ApiKey $key): void
{
$this->em->remove($key);
$this->em->flush();
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Repository;
use App\Domain\Order\Invoice;
use App\Domain\Order\Repository\InvoiceRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;
final class DoctrineInvoiceRepository implements InvoiceRepositoryInterface
{
public function __construct(private readonly EntityManagerInterface $em)
{
}
public function findById(Uuid $id): ?Invoice
{
return $this->em->find(Invoice::class, $id);
}
public function findByFrappeInvoiceId(string $frappeInvoiceId): ?Invoice
{
return $this->em->getRepository(Invoice::class)->findOneBy(['frappeInvoiceId' => $frappeInvoiceId]);
}
public function save(Invoice $invoice): void
{
$this->em->persist($invoice);
$this->em->flush();
}
}

View file

@ -0,0 +1,40 @@
<?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
{
/** @var User|null */
return $this->em->find(User::class, $id);
}
public function findByEmail(string $email): ?User
{
/** @var User|null */
return $this->em->getRepository(User::class)
->createQueryBuilder('u')
->where('u.email = :email')
->setParameter('email', $email)
->getQuery()
->getOneOrNullResult();
}
public function save(User $user): void
{
$this->em->persist($user);
$this->em->flush();
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Search;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class SerpApiWebSearch implements WebSearchInterface
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $serpApiKey,
) {
}
public function search(string $query): string
{
$response = $this->httpClient->request('GET', 'https://serpapi.com/search', [
'query' => [
'q' => $query,
'api_key' => $this->serpApiKey,
'num' => 5,
'hl' => 'de',
],
'timeout' => 15,
]);
/** @var array{organic_results?: list<array{title?: string, snippet?: string}>} $data */
$data = $response->toArray();
$results = $data['organic_results'] ?? [];
if ([] === $results) {
return '';
}
$texts = [];
foreach ($results as $result) {
$title = $result['title'] ?? '';
$snippet = $result['snippet'] ?? '';
if ('' !== $title || '' !== $snippet) {
$texts[] = $title."\n".$snippet;
}
}
return implode("\n\n", $texts);
}
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Search;
interface WebSearchInterface
{
public function search(string $query): string;
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\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\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 ('' === $rawKey) {
throw new CustomUserMessageAuthenticationException('API key is missing.');
}
$prefix = substr($rawKey, 0, 8);
$apiKey = $this->apiKeyRepository->findByPrefix($prefix);
if (null === $apiKey || !$apiKey->isActive() || $apiKey->isExpired()) {
throw new CustomUserMessageAuthenticationException('Invalid or expired API key.');
}
if (!password_verify($rawKey, $apiKey->getKeyHash())) {
throw new CustomUserMessageAuthenticationException('Invalid API key.');
}
$apiKey->markUsed();
$user = $apiKey->getUser();
return new SelfValidatingPassport(new UserBadge($user->getUserIdentifier()));
}
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);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Security;
use App\Domain\Auth\ApiKey;
use App\Domain\Auth\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/** @extends Voter<string, null> */
final class PermissionVoter extends Voter
{
public const PREFIX = 'PERM_';
protected function supports(string $attribute, mixed $subject): bool
{
return str_starts_with($attribute, self::PREFIX);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$permission = substr($attribute, \strlen(self::PREFIX));
$user = $token->getUser();
if ($user instanceof User) {
return $user->hasPermission($permission);
}
// Support API key-based authentication via token attributes
$apiKey = $token->getAttribute('api_key');
if ($apiKey instanceof ApiKey) {
return $apiKey->hasPermission($permission);
}
return false;
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Security;
use App\Domain\Auth\Repository\UserRepositoryInterface;
use App\Domain\Auth\User;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/** @implements UserProviderInterface<User> */
final class UserProvider implements UserProviderInterface
{
public function __construct(private readonly UserRepositoryInterface $userRepository)
{
}
public function loadUserByIdentifier(string $identifier): UserInterface
{
$user = $this->userRepository->findByEmail($identifier);
if (null === $user) {
throw new UserNotFoundException(\sprintf('User "%s" not found.', $identifier));
}
return $user;
}
public function refreshUser(UserInterface $user): UserInterface
{
if (!$user instanceof User) {
throw new UnsupportedUserException(\sprintf('Invalid user class "%s".', $user::class));
}
$refreshed = $this->userRepository->findById($user->getId());
if (null === $refreshed) {
throw new UserNotFoundException('User not found.');
}
return $refreshed;
}
public function supportsClass(string $class): bool
{
return User::class === $class || is_subclass_of($class, User::class);
}
}

View file

@ -0,0 +1,60 @@
<?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();
}
public function testGrantsWhenUserHasPermission(): void
{
$user = new User('admin@test.com', 'hash');
$user->grantPermission('articles.publish');
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
self::assertSame(VoterInterface::ACCESS_GRANTED, $this->voter->vote($token, null, ['PERM_articles.publish']));
}
public function testDeniesWhenUserLacksPermission(): void
{
$user = new User('user@test.com', 'hash');
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
self::assertSame(VoterInterface::ACCESS_DENIED, $this->voter->vote($token, null, ['PERM_articles.publish']));
}
public function testAbstainsForNonPrefixedAttribute(): void
{
$user = new User('user@test.com', 'hash');
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
self::assertSame(VoterInterface::ACCESS_ABSTAIN, $this->voter->vote($token, null, ['ROLE_ADMIN']));
}
public function testDeniesAfterPermissionRevoked(): void
{
$user = new User('user@test.com', 'hash');
$user->grantPermission('articles.delete');
$user->revokePermission('articles.delete');
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
self::assertSame(VoterInterface::ACCESS_DENIED, $this->voter->vote($token, null, ['PERM_articles.delete']));
}
}