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'])); + } +}