From 487c7f8da18f7e9099e00a0c8cd313fde62b0f35 Mon Sep 17 00:00:00 2001 From: Simon Kuehn Date: Sun, 17 May 2026 22:43:58 +0000 Subject: [PATCH] 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 --- .../Repository/ApiKeyRepositoryInterface.php | 22 ++++ .../Repository/UserRepositoryInterface.php | 17 +++ src/Domain/Log/LogEntry.php | 102 ++++++++++++++++++ .../Repository/InvoiceRepositoryInterface.php | 17 +++ .../AIPipelineJobRepositoryInterface.php | 19 ++++ .../Logging/DatabaseLogHandler.php | 38 +++++++ .../DoctrineAIPipelineJobRepository.php | 36 +++++++ .../Repository/DoctrineApiKeyRepository.php | 59 ++++++++++ .../Repository/DoctrineInvoiceRepository.php | 33 ++++++ .../Repository/DoctrineUserRepository.php | 40 +++++++ .../Search/SerpApiWebSearch.php | 48 +++++++++ .../Search/WebSearchInterface.php | 10 ++ .../Security/ApiKeyAuthenticator.php | 64 +++++++++++ .../Security/PermissionVoter.php | 41 +++++++ src/Infrastructure/Security/UserProvider.php | 49 +++++++++ .../Security/PermissionVoterTest.php | 60 +++++++++++ 16 files changed, 655 insertions(+) create mode 100644 src/Domain/Auth/Repository/ApiKeyRepositoryInterface.php create mode 100644 src/Domain/Auth/Repository/UserRepositoryInterface.php create mode 100644 src/Domain/Log/LogEntry.php create mode 100644 src/Domain/Order/Repository/InvoiceRepositoryInterface.php create mode 100644 src/Domain/Pipeline/Repository/AIPipelineJobRepositoryInterface.php create mode 100644 src/Infrastructure/Logging/DatabaseLogHandler.php create mode 100644 src/Infrastructure/Persistence/Repository/DoctrineAIPipelineJobRepository.php create mode 100644 src/Infrastructure/Persistence/Repository/DoctrineApiKeyRepository.php create mode 100644 src/Infrastructure/Persistence/Repository/DoctrineInvoiceRepository.php create mode 100644 src/Infrastructure/Persistence/Repository/DoctrineUserRepository.php create mode 100644 src/Infrastructure/Search/SerpApiWebSearch.php create mode 100644 src/Infrastructure/Search/WebSearchInterface.php create mode 100644 src/Infrastructure/Security/ApiKeyAuthenticator.php create mode 100644 src/Infrastructure/Security/PermissionVoter.php create mode 100644 src/Infrastructure/Security/UserProvider.php create mode 100644 tests/Unit/Infrastructure/Security/PermissionVoterTest.php diff --git a/src/Domain/Auth/Repository/ApiKeyRepositoryInterface.php b/src/Domain/Auth/Repository/ApiKeyRepositoryInterface.php new file mode 100644 index 0000000..442e09e --- /dev/null +++ b/src/Domain/Auth/Repository/ApiKeyRepositoryInterface.php @@ -0,0 +1,22 @@ + */ + public function findByUser(Uuid $userId): array; + + public function save(ApiKey $key): void; + + public function remove(ApiKey $key): void; +} diff --git a/src/Domain/Auth/Repository/UserRepositoryInterface.php b/src/Domain/Auth/Repository/UserRepositoryInterface.php new file mode 100644 index 0000000..4cb4119 --- /dev/null +++ b/src/Domain/Auth/Repository/UserRepositoryInterface.php @@ -0,0 +1,17 @@ + */ + #[ORM\Column(type: 'json')] + private array $context; + + /** @var array */ + #[ORM\Column(type: 'json')] + private array $extra; + + #[ORM\Column(type: 'datetime_immutable')] + private \DateTimeImmutable $loggedAt; + + /** @param array $context + * @param array $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 */ + public function getContext(): array + { + return $this->context; + } + + /** @return array */ + public function getExtra(): array + { + return $this->extra; + } + + public function getLoggedAt(): \DateTimeImmutable + { + return $this->loggedAt; + } +} diff --git a/src/Domain/Order/Repository/InvoiceRepositoryInterface.php b/src/Domain/Order/Repository/InvoiceRepositoryInterface.php new file mode 100644 index 0000000..d3b31e2 --- /dev/null +++ b/src/Domain/Order/Repository/InvoiceRepositoryInterface.php @@ -0,0 +1,17 @@ + */ + public function findByStatus(AIPipelineJobStatus $status): array; + + public function save(AIPipelineJob $job): void; +} diff --git a/src/Infrastructure/Logging/DatabaseLogHandler.php b/src/Infrastructure/Logging/DatabaseLogHandler.php new file mode 100644 index 0000000..ed40144 --- /dev/null +++ b/src/Infrastructure/Logging/DatabaseLogHandler.php @@ -0,0 +1,38 @@ +channel, + $record->level->value, + $record->level->getName(), + $record->message, + $record->context, + $record->extra, + \DateTimeImmutable::createFromInterface($record->datetime), + ); + + $this->em->persist($entry); + $this->em->flush(); + } +} diff --git a/src/Infrastructure/Persistence/Repository/DoctrineAIPipelineJobRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineAIPipelineJobRepository.php new file mode 100644 index 0000000..456b4d6 --- /dev/null +++ b/src/Infrastructure/Persistence/Repository/DoctrineAIPipelineJobRepository.php @@ -0,0 +1,36 @@ +em->find(AIPipelineJob::class, $id); + } + + public function findByStatus(AIPipelineJobStatus $status): array + { + /** @var list */ + return $this->em->getRepository(AIPipelineJob::class)->findBy(['status' => $status]); + } + + public function save(AIPipelineJob $job): void + { + $this->em->persist($job); + $this->em->flush(); + } +} diff --git a/src/Infrastructure/Persistence/Repository/DoctrineApiKeyRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineApiKeyRepository.php new file mode 100644 index 0000000..31d73fe --- /dev/null +++ b/src/Infrastructure/Persistence/Repository/DoctrineApiKeyRepository.php @@ -0,0 +1,59 @@ +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 */ + 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(); + } +} diff --git a/src/Infrastructure/Persistence/Repository/DoctrineInvoiceRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineInvoiceRepository.php new file mode 100644 index 0000000..ae5cd4c --- /dev/null +++ b/src/Infrastructure/Persistence/Repository/DoctrineInvoiceRepository.php @@ -0,0 +1,33 @@ +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(); + } +} diff --git a/src/Infrastructure/Persistence/Repository/DoctrineUserRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineUserRepository.php new file mode 100644 index 0000000..b2fb854 --- /dev/null +++ b/src/Infrastructure/Persistence/Repository/DoctrineUserRepository.php @@ -0,0 +1,40 @@ +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(); + } +} diff --git a/src/Infrastructure/Search/SerpApiWebSearch.php b/src/Infrastructure/Search/SerpApiWebSearch.php new file mode 100644 index 0000000..a9b6724 --- /dev/null +++ b/src/Infrastructure/Search/SerpApiWebSearch.php @@ -0,0 +1,48 @@ +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} $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); + } +} diff --git a/src/Infrastructure/Search/WebSearchInterface.php b/src/Infrastructure/Search/WebSearchInterface.php new file mode 100644 index 0000000..32e62b3 --- /dev/null +++ b/src/Infrastructure/Search/WebSearchInterface.php @@ -0,0 +1,10 @@ +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); + } +} diff --git a/src/Infrastructure/Security/PermissionVoter.php b/src/Infrastructure/Security/PermissionVoter.php new file mode 100644 index 0000000..4f75a96 --- /dev/null +++ b/src/Infrastructure/Security/PermissionVoter.php @@ -0,0 +1,41 @@ + */ +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; + } +} diff --git a/src/Infrastructure/Security/UserProvider.php b/src/Infrastructure/Security/UserProvider.php new file mode 100644 index 0000000..118ce0c --- /dev/null +++ b/src/Infrastructure/Security/UserProvider.php @@ -0,0 +1,49 @@ + */ +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); + } +} diff --git a/tests/Unit/Infrastructure/Security/PermissionVoterTest.php b/tests/Unit/Infrastructure/Security/PermissionVoterTest.php new file mode 100644 index 0000000..e46ae24 --- /dev/null +++ b/tests/Unit/Infrastructure/Security/PermissionVoterTest.php @@ -0,0 +1,60 @@ +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'])); + } +}