feat: expose winter placements to n8n over an api endpoint

This commit is contained in:
fromme committed 2026-11-06 10:09:56 +01:00
1 parent 4ff963d1e2
commit 0bc77b7463
17 files changed
+977 -18

No files matched your search

+24 -16
View File
@@ -8,38 +8,46 @@ use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
/**
* Validates the shared key a sibling website posts with. There is no person to log in and no
* account to look up: the key is the identity, and the user it resolves to is the in-memory
* "api" user of the api_user_provider - nothing about it may reach the user table, where a row
* would be a login to this application's UI.
* Validates the shared key a machine caller sends. There is no person to log in and no account to
* look up: the key is the identity, and the user it resolves to is one of the in-memory users of
* the api_user_provider - nothing about it may reach the user table, where a row would be a login
* to this application's UI.
*
* One key per caller, each resolving to its own user and roles: the websites posting application
* forms must not read placements, and n8n reading placements must not post forms.
*/
class ApiKeyTokenHandler implements AccessTokenHandlerInterface
{
public const USER_IDENTIFIER = 'api';
/**
* @param array<string, string> $apiKeys user identifier => key
*/
public function __construct(
private readonly string $apiKey,
private readonly array $apiKeys,
private readonly LoggerInterface $logger,
) {
}
public function getUserBadgeFrom(string $accessToken): UserBadge
{
// An unconfigured key locks the endpoint instead of opening it: hash_equals('', '')
// is true, so without this check a deployment that forgot the secret would accept
// an empty key from anyone.
if ('' === $this->apiKey) {
// An unconfigured key locks its caller out instead of opening the door: hash_equals('', '')
// is true, so without this filter a deployment that forgot a secret would accept an empty
// key from anyone.
$configuredKeys = array_filter($this->apiKeys, fn (string $key) => '' !== $key);
if ([] === $configuredKeys) {
$this->logger->error('API key authentication attempted while no key is configured');
throw new BadCredentialsException('No API key configured.');
}
if (false === hash_equals($this->apiKey, $accessToken)) {
$this->logger->warning('API key authentication failed');
throw new BadCredentialsException('Invalid API key.');
foreach ($configuredKeys as $identifier => $key) {
if (true === hash_equals($key, $accessToken)) {
return new UserBadge((string) $identifier);
}
}
return new UserBadge(self::USER_IDENTIFIER);
$this->logger->warning('API key authentication failed');
throw new BadCredentialsException('Invalid API key.');
}
}
+3 -1
View File
@@ -48,8 +48,10 @@ class ApplicationVoter extends Voter
return match ($attribute) {
static::STATUS, static::DELETE => true,
static::VIEW => Application::STATUS_REJECTED !== $status,
// A called-off placement frees its place, so it does not count here: re-staffing
// must not take raising availableDispositions, which would add a place.
static::DISPOSE => Application::STATUS_REJECTED !== $status
&& $assignment->getAvailableDispositions() > $assignment->getDispositions()->count(),
&& $assignment->getAvailableDispositions() > $assignment->getActiveDispositions()->count(),
default => false,
};
}