Merge pull request #1882 from acelaya-forks/feature/create-api-key

Feature/create api key
This commit is contained in:
Alejandro Celaya 2023-09-23 08:40:53 +02:00 committed by GitHub
commit abecf3be02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 268 additions and 222 deletions

View File

@ -4,12 +4,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased] ## [3.6.4] - 2023-09-22
### Added ### Added
* *Nothing* * *Nothing*
### Changed ### Changed
* *Nothing* * [#1866](https://github.com/shlinkio/shlink/issues/1866) The `INITIAL_API_KEY` env var is now only relevant for the official docker image.
Going forward, new non-docker Shlink installations provisioned with env vars that also wish to provide an initial API key, should do it by using the `vendor/bin/shlink-installer init --initial-api-key=%SOME_KEY%` command, instead of using `INITIAL_API_KEY`.
### Deprecated ### Deprecated
* *Nothing* * *Nothing*
@ -20,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
### Fixed ### Fixed
* [#1819](https://github.com/shlinkio/shlink/issues/1819) Fix incorrect timeout when running DB commands during Shlink start-up. * [#1819](https://github.com/shlinkio/shlink/issues/1819) Fix incorrect timeout when running DB commands during Shlink start-up.
* [#1870](https://github.com/shlinkio/shlink/issues/1870) Make sure shared locks include the cache prefix when using Redis. * [#1870](https://github.com/shlinkio/shlink/issues/1870) Make sure shared locks include the cache prefix when using Redis.
* [#1866](https://github.com/shlinkio/shlink/issues/1866) Fix error when starting docker image with `INITIAL_API_KEY` env var.
## [3.6.3] - 2023-06-14 ## [3.6.3] - 2023-06-14

View File

@ -45,11 +45,11 @@
"php-middleware/request-id": "^4.1", "php-middleware/request-id": "^4.1",
"pugx/shortid-php": "^1.1", "pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.7", "ramsey/uuid": "^4.7",
"shlinkio/shlink-common": "dev-main#5a8bd5a as 5.6", "shlinkio/shlink-common": "^5.6",
"shlinkio/shlink-config": "^2.4", "shlinkio/shlink-config": "^2.4",
"shlinkio/shlink-event-dispatcher": "^3.0", "shlinkio/shlink-event-dispatcher": "^3.0",
"shlinkio/shlink-importer": "^5.1", "shlinkio/shlink-importer": "^5.1",
"shlinkio/shlink-installer": "^8.4.2", "shlinkio/shlink-installer": "^8.5",
"shlinkio/shlink-ip-geolocation": "^3.2", "shlinkio/shlink-ip-geolocation": "^3.2",
"shlinkio/shlink-json": "^1.0", "shlinkio/shlink-json": "^1.0",
"spiral/roadrunner": "^2023.1", "spiral/roadrunner": "^2023.1",

View File

@ -86,6 +86,9 @@ return [
InstallationCommand::API_KEY_GENERATE->value => [ InstallationCommand::API_KEY_GENERATE->value => [
'command' => 'bin/cli ' . Command\Api\GenerateKeyCommand::NAME, 'command' => 'bin/cli ' . Command\Api\GenerateKeyCommand::NAME,
], ],
InstallationCommand::API_KEY_CREATE->value => [
'command' => 'bin/cli ' . Command\Api\InitialApiKeyCommand::NAME,
],
], ],
], ],

View File

@ -10,10 +10,15 @@ if [ -z "${GEOLITE_LICENSE_KEY}" ] || [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" == "t
flags="${flags} --skip-download-geolite" flags="${flags} --skip-download-geolite"
fi fi
# If INITIAL_API_KEY was provided, create an initial API key
if [ -n "${INITIAL_API_KEY}" ]; then
flags="${flags} --initial-api-key=${INITIAL_API_KEY}"
fi
php vendor/bin/shlink-installer init ${flags} php vendor/bin/shlink-installer init ${flags}
# Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided and running as root # Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided and running as root
# ENABLE_PERIODIC_VISIT_LOCATE is deprecated. Remove cron support in Shlink 4.0.0 # FIXME: ENABLE_PERIODIC_VISIT_LOCATE is deprecated. Remove cron support in Shlink 4.0.0
if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ] && [ "${SHLINK_USER_ID}" = "root" ]; then if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ] && [ "${SHLINK_USER_ID}" = "root" ]; then
echo "Configuring periodic visit location..." echo "Configuring periodic visit location..."
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root

View File

@ -2,7 +2,7 @@
# Run docker containers if they are not up yet # Run docker containers if they are not up yet
if ! [[ $(docker ps | grep shlink_swoole) ]]; then if ! [[ $(docker ps | grep shlink_swoole) ]]; then
docker-compose up -d docker compose up -d
fi fi
docker exec -it shlink_swoole /bin/sh -c "$*" docker exec -it shlink_swoole /bin/sh -c "$*"

View File

@ -24,6 +24,7 @@ return [
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class, Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class, Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class, Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class,
Command\Api\InitialApiKeyCommand::NAME => Command\Api\InitialApiKeyCommand::class,
Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class, Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class,
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class, Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,

View File

@ -53,6 +53,7 @@ return [
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class, Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class, Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class, Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
Command\Api\InitialApiKeyCommand::class => ConfigAbstractFactory::class,
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class, Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class, Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
@ -105,6 +106,7 @@ return [
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class], Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class], Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
Command\Api\ListKeysCommand::class => [ApiKeyService::class], Command\Api\ListKeysCommand::class => [ApiKeyService::class],
Command\Api\InitialApiKeyCommand::class => [ApiKeyService::class],
Command\Tag\ListTagsCommand::class => [TagService::class], Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [TagService::class], Command\Tag\RenameTagCommand::class => [TagService::class],

View File

@ -14,24 +14,23 @@ use function is_string;
class RoleResolver implements RoleResolverInterface class RoleResolver implements RoleResolverInterface
{ {
public function __construct(private DomainServiceInterface $domainService, private string $defaultDomain) public function __construct(
{ private readonly DomainServiceInterface $domainService,
private readonly string $defaultDomain,
) {
} }
public function determineRoles(InputInterface $input): array public function determineRoles(InputInterface $input): iterable
{ {
$domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName()); $domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName());
$author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName()); $author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName());
$roleDefinitions = [];
if ($author) { if ($author) {
$roleDefinitions[] = RoleDefinition::forAuthoredShortUrls(); yield RoleDefinition::forAuthoredShortUrls();
} }
if (is_string($domainAuthority)) { if (is_string($domainAuthority)) {
$roleDefinitions[] = $this->resolveRoleForAuthority($domainAuthority); yield $this->resolveRoleForAuthority($domainAuthority);
} }
return $roleDefinitions;
} }
private function resolveRoleForAuthority(string $domainAuthority): RoleDefinition private function resolveRoleForAuthority(string $domainAuthority): RoleDefinition

View File

@ -10,7 +10,7 @@ use Symfony\Component\Console\Input\InputInterface;
interface RoleResolverInterface interface RoleResolverInterface
{ {
/** /**
* @return RoleDefinition[] * @return iterable<RoleDefinition>
*/ */
public function determineRoles(InputInterface $input): array; public function determineRoles(InputInterface $input): iterable;
} }

View File

@ -8,6 +8,7 @@ use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
@ -25,8 +26,8 @@ class GenerateKeyCommand extends Command
public const NAME = 'api-key:generate'; public const NAME = 'api-key:generate';
public function __construct( public function __construct(
private ApiKeyServiceInterface $apiKeyService, private readonly ApiKeyServiceInterface $apiKeyService,
private RoleResolverInterface $roleResolver, private readonly RoleResolverInterface $roleResolver,
) { ) {
parent::__construct(); parent::__construct();
} }
@ -57,7 +58,7 @@ class GenerateKeyCommand extends Command
$this $this
->setName(self::NAME) ->setName(self::NAME)
->setDescription('Generates a new valid API key.') ->setDescription('Generate a new valid API key.')
->addOption( ->addOption(
'name', 'name',
'm', 'm',
@ -91,11 +92,12 @@ class GenerateKeyCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): ?int protected function execute(InputInterface $input, OutputInterface $output): ?int
{ {
$expirationDate = $input->getOption('expiration-date'); $expirationDate = $input->getOption('expiration-date');
$apiKey = $this->apiKeyService->create(
isset($expirationDate) ? Chronos::parse($expirationDate) : null, $apiKey = $this->apiKeyService->create(ApiKeyMeta::fromParams(
$input->getOption('name'), name: $input->getOption('name'),
...$this->roleResolver->determineRoles($input), expirationDate: isset($expirationDate) ? Chronos::parse($expirationDate) : null,
); roleDefinitions: $this->roleResolver->determineRoles($input),
));
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString())); $io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class InitialApiKeyCommand extends Command
{
public const NAME = 'api-key:initial';
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setHidden()
->setName(self::NAME)
->setDescription('Tries to create initial API key')
->addArgument('apiKey', InputArgument::REQUIRED, 'The initial API to create');
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$key = $input->getArgument('apiKey');
$result = $this->apiKeyService->createInitial($key);
if ($result === null && $output->isVerbose()) {
$output->writeln('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
}
return ExitCode::EXIT_SUCCESS;
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace ShlinkioCliTest\Shlink\CLI\Command;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\Api\InitialApiKeyCommand;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
class InitialApiKeyTest extends CliTestCase
{
#[Test]
public function createsNoKeyWhenOtherApiKeysAlreadyExist(): void
{
[$output] = $this->exec([InitialApiKeyCommand::NAME, 'new_api_key', '-v']);
self::assertEquals(
<<<OUT
Other API keys already exist. Initial API key creation skipped.
OUT,
$output,
);
}
}

View File

@ -40,7 +40,7 @@ class RoleResolverTest extends TestCase
'example.com', 'example.com',
)->willReturn(self::domainWithId(Domain::withAuthority('example.com'))); )->willReturn(self::domainWithId(Domain::withAuthority('example.com')));
$result = $this->resolver->determineRoles($input); $result = [...$this->resolver->determineRoles($input)];
self::assertEquals($expectedRoles, $result); self::assertEquals($expectedRoles, $result);
} }
@ -111,7 +111,7 @@ class RoleResolverTest extends TestCase
$this->expectException(InvalidRoleConfigException::class); $this->expectException(InvalidRoleConfigException::class);
$this->resolver->determineRoles($input); [...$this->resolver->determineRoles($input)];
} }
private static function domainWithId(Domain $domain): Domain private static function domainWithId(Domain $domain): Domain

View File

@ -10,6 +10,7 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand; use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
@ -37,8 +38,7 @@ class GenerateKeyCommandTest extends TestCase
public function noExpirationDateIsDefinedIfNotProvided(): void public function noExpirationDateIsDefinedIfNotProvided(): void
{ {
$this->apiKeyService->expects($this->once())->method('create')->with( $this->apiKeyService->expects($this->once())->method('create')->with(
$this->isNull(), $this->callback(fn (ApiKeyMeta $meta) => $meta->name === null && $meta->expirationDate === null),
$this->isNull(),
)->willReturn(ApiKey::create()); )->willReturn(ApiKey::create());
$this->commandTester->execute([]); $this->commandTester->execute([]);
@ -51,8 +51,7 @@ class GenerateKeyCommandTest extends TestCase
public function expirationDateIsDefinedIfProvided(): void public function expirationDateIsDefinedIfProvided(): void
{ {
$this->apiKeyService->expects($this->once())->method('create')->with( $this->apiKeyService->expects($this->once())->method('create')->with(
$this->isInstanceOf(Chronos::class), $this->callback(fn (ApiKeyMeta $meta) => $meta->expirationDate instanceof Chronos),
$this->isNull(),
)->willReturn(ApiKey::create()); )->willReturn(ApiKey::create());
$this->commandTester->execute([ $this->commandTester->execute([
@ -64,8 +63,7 @@ class GenerateKeyCommandTest extends TestCase
public function nameIsDefinedIfProvided(): void public function nameIsDefinedIfProvided(): void
{ {
$this->apiKeyService->expects($this->once())->method('create')->with( $this->apiKeyService->expects($this->once())->method('create')->with(
$this->isNull(), $this->callback(fn (ApiKeyMeta $meta) => $meta->name === 'Alice'),
$this->isType('string'),
)->willReturn(ApiKey::create()); )->willReturn(ApiKey::create());
$this->commandTester->execute([ $this->commandTester->execute([

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Api\InitialApiKeyCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
class InitialApiKeyCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & ApiKeyServiceInterface $apiKeyService;
public function setUp(): void
{
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
$this->commandTester = $this->testerForCommand(new InitialApiKeyCommand($this->apiKeyService));
}
#[Test, DataProvider('provideParams')]
public function initialKeyIsCreatedWithProvidedValue(?ApiKey $result, bool $verbose, string $expectedOutput): void
{
$this->apiKeyService->expects($this->once())->method('createInitial')->with('the_key')->willReturn($result);
$this->commandTester->execute(
['apiKey' => 'the_key'],
['verbosity' => $verbose ? OutputInterface::VERBOSITY_VERBOSE : OutputInterface::VERBOSITY_NORMAL],
);
$output = $this->commandTester->getDisplay();
self::assertEquals($expectedOutput, $output);
}
public static function provideParams(): iterable
{
yield 'api key created, no verbose' => [ApiKey::create(), false, ''];
yield 'api key created, verbose' => [ApiKey::create(), true, ''];
yield 'no api key created, no verbose' => [null, false, ''];
yield 'no api key created, verbose' => [null, true, <<<OUT
Other API keys already exist. Initial API key creation skipped.
OUT,
];
}
}

View File

@ -49,7 +49,7 @@ class ListKeysCommandTest extends TestCase
yield 'all keys' => [ yield 'all keys' => [
[ [
$apiKey1 = ApiKey::create()->disable(), $apiKey1 = ApiKey::create()->disable(),
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($dateInThePast)), $apiKey2 = ApiKey::fromMeta(ApiKeyMeta::fromParams(expirationDate: $dateInThePast)),
$apiKey3 = ApiKey::create(), $apiKey3 = ApiKey::create(),
], ],
false, false,
@ -117,9 +117,9 @@ class ListKeysCommandTest extends TestCase
]; ];
yield 'with names' => [ yield 'with names' => [
[ [
$apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withName('Alice')), $apiKey1 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice')),
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withName('Alice and Bob')), $apiKey2 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice and Bob')),
$apiKey3 = ApiKey::fromMeta(ApiKeyMeta::withName('')), $apiKey3 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: '')),
$apiKey4 = ApiKey::create(), $apiKey4 = ApiKey::create(),
], ],
true, true,

View File

@ -138,7 +138,7 @@ class ListShortUrlsCommandTest extends TestCase
public static function provideOptionalFlags(): iterable public static function provideOptionalFlags(): iterable
{ {
$apiKey = ApiKey::fromMeta(ApiKeyMeta::withName('my api key')); $apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'my api key'));
$key = $apiKey->toString(); $key = $apiKey->toString();
yield 'tags only' => [ yield 'tags only' => [

View File

@ -47,7 +47,6 @@ enum EnvVars: string
case PORT = 'PORT'; case PORT = 'PORT';
case TASK_WORKER_NUM = 'TASK_WORKER_NUM'; case TASK_WORKER_NUM = 'TASK_WORKER_NUM';
case WEB_WORKER_NUM = 'WEB_WORKER_NUM'; case WEB_WORKER_NUM = 'WEB_WORKER_NUM';
case INITIAL_API_KEY = 'INITIAL_API_KEY';
case ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR'; case ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR';
case TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS'; case TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS';
case DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM'; case DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM';

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest;
use Mezzio\Application;
use Shlinkio\Shlink\Core\Config\EnvVars;
use const PHP_SAPI;
return [
// We will try to load the initial API key only for openswoole and RoadRunner.
// For php-fpm, the check against the database would happen on every request, resulting in a very bad performance.
'initial_api_key' => PHP_SAPI !== 'cli' ? null : EnvVars::INITIAL_API_KEY->loadFromEnv(),
'dependencies' => [
'delegators' => [
Application::class => [
ApiKey\InitialApiKeyDelegator::class,
],
],
],
];

View File

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\ApiKey;
use Doctrine\ORM\EntityManager;
use Mezzio\Application;
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class InitialApiKeyDelegator
{
public function __invoke(ContainerInterface $container, string $serviceName, callable $callback): Application
{
$initialApiKey = $container->get('config')['initial_api_key'] ?? null;
if (! empty($initialApiKey)) {
$this->createInitialApiKey($initialApiKey, $container);
}
return $callback();
}
private function createInitialApiKey(string $initialApiKey, ContainerInterface $container): void
{
/** @var ApiKeyRepositoryInterface $repo */
$repo = $container->get(EntityManager::class)->getRepository(ApiKey::class);
$repo->createInitialApiKey($initialApiKey);
}
}

View File

@ -5,36 +5,45 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\ApiKey\Model; namespace Shlinkio\Shlink\Rest\ApiKey\Model;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Ramsey\Uuid\Uuid;
final class ApiKeyMeta final class ApiKeyMeta
{ {
/** /**
* @param RoleDefinition[] $roleDefinitions * @param iterable<RoleDefinition> $roleDefinitions
*/ */
private function __construct( private function __construct(
public readonly string $key,
public readonly ?string $name, public readonly ?string $name,
public readonly ?Chronos $expirationDate, public readonly ?Chronos $expirationDate,
public readonly array $roleDefinitions, public readonly iterable $roleDefinitions,
) { ) {
} }
public static function withName(string $name): self public static function empty(): self
{ {
return new self($name, null, []); return self::fromParams();
} }
public static function withExpirationDate(Chronos $expirationDate): self /**
{ * @param iterable<RoleDefinition> $roleDefinitions
return new self(null, $expirationDate, []); */
} public static function fromParams(
?string $key = null,
public static function withNameAndExpirationDate(string $name, Chronos $expirationDate): self ?string $name = null,
{ ?Chronos $expirationDate = null,
return new self($name, $expirationDate, []); iterable $roleDefinitions = [],
): self {
return new self(
key: $key ?? Uuid::uuid4()->toString(),
name: $name,
expirationDate: $expirationDate,
roleDefinitions: $roleDefinitions,
);
} }
public static function withRoles(RoleDefinition ...$roleDefinitions): self public static function withRoles(RoleDefinition ...$roleDefinitions): self
{ {
return new self(null, null, $roleDefinitions); return self::fromParams(roleDefinitions: $roleDefinitions);
} }
} }

View File

@ -6,14 +6,18 @@ namespace Shlinkio\Shlink\Rest\ApiKey\Repository;
use Doctrine\DBAL\LockMode; use Doctrine\DBAL\LockMode;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRepositoryInterface class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRepositoryInterface
{ {
public function createInitialApiKey(string $apiKey): void /**
* Will create provided API key with admin permissions, only if there's no other API keys yet
*/
public function createInitialApiKey(string $apiKey): ?ApiKey
{ {
$em = $this->getEntityManager(); $em = $this->getEntityManager();
$em->wrapInTransaction(function () use ($apiKey, $em): void { return $em->wrapInTransaction(function () use ($apiKey, $em): ?ApiKey {
// Ideally this would be a SELECT COUNT(...), but MsSQL and Postgres do not allow locking on aggregates // Ideally this would be a SELECT COUNT(...), but MsSQL and Postgres do not allow locking on aggregates
// Because of that we check if at least one result exists // Because of that we check if at least one result exists
$firstResult = $em->createQueryBuilder()->select('a.id') $firstResult = $em->createQueryBuilder()->select('a.id')
@ -23,10 +27,16 @@ class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRe
->setLockMode(LockMode::PESSIMISTIC_WRITE) ->setLockMode(LockMode::PESSIMISTIC_WRITE)
->getOneOrNullResult(); ->getOneOrNullResult();
if ($firstResult === null) { // Do not create an initial API key if other keys already exist
$em->persist(ApiKey::fromKey($apiKey)); if ($firstResult !== null) {
$em->flush(); return null;
} }
$new = ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $apiKey));
$em->persist($new);
$em->flush();
return $new;
}); });
} }
} }

View File

@ -6,11 +6,12 @@ namespace Shlinkio\Shlink\Rest\ApiKey\Repository;
use Doctrine\Persistence\ObjectRepository; use Doctrine\Persistence\ObjectRepository;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface ApiKeyRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface interface ApiKeyRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{ {
/** /**
* Will create provided API key only if there's no API keys yet * Will create provided API key only if there's no API keys yet
*/ */
public function createInitialApiKey(string $apiKey): void; public function createInitialApiKey(string $apiKey): ?ApiKey;
} }

View File

@ -10,7 +10,6 @@ use Doctrine\Common\Collections\Collection;
use Exception; use Exception;
use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\Specification; use Happyr\DoctrineSpecification\Specification\Specification;
use Ramsey\Uuid\Uuid;
use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
@ -28,21 +27,27 @@ class ApiKey extends AbstractEntity
/** /**
* @throws Exception * @throws Exception
*/ */
private function __construct(?string $key = null) private function __construct(string $key)
{ {
$this->key = $key ?? Uuid::uuid4()->toString(); $this->key = $key;
$this->enabled = true; $this->enabled = true;
$this->roles = new ArrayCollection(); $this->roles = new ArrayCollection();
} }
/**
* @throws Exception
*/
public static function create(): ApiKey public static function create(): ApiKey
{ {
return new self(); return self::fromMeta(ApiKeyMeta::empty());
} }
/**
* @throws Exception
*/
public static function fromMeta(ApiKeyMeta $meta): self public static function fromMeta(ApiKeyMeta $meta): self
{ {
$apiKey = self::create(); $apiKey = new self($meta->key);
$apiKey->name = $meta->name; $apiKey->name = $meta->name;
$apiKey->expirationDate = $meta->expirationDate; $apiKey->expirationDate = $meta->expirationDate;
@ -53,11 +58,6 @@ class ApiKey extends AbstractEntity
return $apiKey; return $apiKey;
} }
public static function fromKey(string $key): self
{
return new self($key);
}
public function getExpirationDate(): ?Chronos public function getExpirationDate(): ?Chronos
{ {
return $this->expirationDate; return $this->expirationDate;

View File

@ -4,47 +4,35 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Service; namespace Shlinkio\Shlink\Rest\Service;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function sprintf; use function sprintf;
class ApiKeyService implements ApiKeyServiceInterface class ApiKeyService implements ApiKeyServiceInterface
{ {
public function __construct(private EntityManagerInterface $em) public function __construct(private readonly EntityManagerInterface $em)
{ {
} }
public function create( public function create(ApiKeyMeta $apiKeyMeta): ApiKey
?Chronos $expirationDate = null, {
?string $name = null, $apiKey = ApiKey::fromMeta($apiKeyMeta);
RoleDefinition ...$roleDefinitions,
): ApiKey {
$key = $this->buildApiKeyWithParams($expirationDate, $name);
foreach ($roleDefinitions as $definition) {
$key->registerRole($definition);
}
$this->em->persist($key); $this->em->persist($apiKey);
$this->em->flush(); $this->em->flush();
return $key; return $apiKey;
} }
private function buildApiKeyWithParams(?Chronos $expirationDate, ?string $name): ApiKey public function createInitial(string $key): ?ApiKey
{ {
return match (true) { /** @var ApiKeyRepositoryInterface $repo */
$expirationDate !== null && $name !== null => ApiKey::fromMeta( $repo = $this->em->getRepository(ApiKey::class);
ApiKeyMeta::withNameAndExpirationDate($name, $expirationDate), return $repo->createInitialApiKey($key);
),
$expirationDate !== null => ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($expirationDate)),
$name !== null => ApiKey::fromMeta(ApiKeyMeta::withName($name)),
default => ApiKey::create(),
};
} }
public function check(string $key): ApiKeyCheckResult public function check(string $key): ApiKeyCheckResult

View File

@ -4,18 +4,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Service; namespace Shlinkio\Shlink\Rest\Service;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface ApiKeyServiceInterface interface ApiKeyServiceInterface
{ {
public function create( public function create(ApiKeyMeta $apiKeyMeta): ApiKey;
?Chronos $expirationDate = null,
?string $name = null, public function createInitial(string $key): ?ApiKey;
RoleDefinition ...$roleDefinitions,
): ApiKey;
public function check(string $key): ApiKeyCheckResult; public function check(string $key): ApiKeyCheckResult;

View File

@ -43,7 +43,7 @@ class ApiKeyFixture extends AbstractFixture implements DependentFixtureInterface
private function buildApiKey(string $key, bool $enabled, ?Chronos $expiresAt = null): ApiKey private function buildApiKey(string $key, bool $enabled, ?Chronos $expiresAt = null): ApiKey
{ {
$apiKey = $expiresAt !== null ? ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($expiresAt)) : ApiKey::create(); $apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(expirationDate: $expiresAt));
$ref = new ReflectionObject($apiKey); $ref = new ReflectionObject($apiKey);
$keyProp = $ref->getProperty('key'); $keyProp = $ref->getProperty('key');
$keyProp->setAccessible(true); $keyProp->setAccessible(true);

View File

@ -22,10 +22,10 @@ class ApiKeyRepositoryTest extends DatabaseTestCase
public function initialApiKeyIsCreatedOnlyOfNoApiKeysExistYet(): void public function initialApiKeyIsCreatedOnlyOfNoApiKeysExistYet(): void
{ {
self::assertCount(0, $this->repo->findAll()); self::assertCount(0, $this->repo->findAll());
$this->repo->createInitialApiKey('initial_value'); self::assertNotNull($this->repo->createInitialApiKey('initial_value'));
self::assertCount(1, $this->repo->findAll()); self::assertCount(1, $this->repo->findAll());
self::assertCount(1, $this->repo->findBy(['key' => 'initial_value'])); self::assertCount(1, $this->repo->findBy(['key' => 'initial_value']));
$this->repo->createInitialApiKey('another_one'); self::assertNull($this->repo->createInitialApiKey('another_one'));
self::assertCount(1, $this->repo->findAll()); self::assertCount(1, $this->repo->findAll());
self::assertCount(0, $this->repo->findBy(['key' => 'another_one'])); self::assertCount(0, $this->repo->findBy(['key' => 'another_one']));
} }

View File

@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\ApiKey;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Mezzio\Application;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\Rest\ApiKey\InitialApiKeyDelegator;
use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class InitialApiKeyDelegatorTest extends TestCase
{
private InitialApiKeyDelegator $delegator;
private MockObject & ContainerInterface $container;
protected function setUp(): void
{
$this->delegator = new InitialApiKeyDelegator();
$this->container = $this->createMock(ContainerInterface::class);
}
#[Test, DataProvider('provideConfigs')]
public function apiKeyIsInitializedWhenAppropriate(array $config, int $expectedCalls): void
{
$app = $this->createMock(Application::class);
$apiKeyRepo = $this->createMock(ApiKeyRepositoryInterface::class);
$apiKeyRepo->expects($this->exactly($expectedCalls))->method('createInitialApiKey');
$em = $this->createMock(EntityManagerInterface::class);
$em->expects($this->exactly($expectedCalls))->method('getRepository')->with(ApiKey::class)->willReturn(
$apiKeyRepo,
);
$this->container->expects($this->exactly($expectedCalls + 1))->method('get')->willReturnMap([
['config', $config],
[EntityManager::class, $em],
]);
$result = ($this->delegator)($this->container, '', fn () => $app);
self::assertSame($result, $app);
}
public static function provideConfigs(): iterable
{
yield 'no api key' => [[], 0];
yield 'null api key' => [['initial_api_key' => null], 0];
yield 'empty api key' => [['initial_api_key' => ''], 0];
yield 'valid api key' => [['initial_api_key' => 'the_initial_key'], 1];
}
}

View File

@ -24,11 +24,10 @@ class ConfigProviderTest extends TestCase
{ {
$config = ($this->configProvider)(); $config = ($this->configProvider)();
self::assertCount(5, $config); self::assertCount(4, $config);
self::assertArrayHasKey('dependencies', $config); self::assertArrayHasKey('dependencies', $config);
self::assertArrayHasKey('auth', $config); self::assertArrayHasKey('auth', $config);
self::assertArrayHasKey('entity_manager', $config); self::assertArrayHasKey('entity_manager', $config);
self::assertArrayHasKey('initial_api_key', $config);
self::assertArrayHasKey(ConfigAbstractFactory::class, $config); self::assertArrayHasKey(ConfigAbstractFactory::class, $config);
} }

View File

@ -6,7 +6,6 @@ namespace ShlinkioTest\Shlink\Rest\Service;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
@ -15,6 +14,7 @@ use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService; use Shlinkio\Shlink\Rest\Service\ApiKeyService;
@ -22,12 +22,12 @@ class ApiKeyServiceTest extends TestCase
{ {
private ApiKeyService $service; private ApiKeyService $service;
private MockObject & EntityManager $em; private MockObject & EntityManager $em;
private MockObject & EntityRepository $repo; private MockObject & ApiKeyRepositoryInterface $repo;
protected function setUp(): void protected function setUp(): void
{ {
$this->em = $this->createMock(EntityManager::class); $this->em = $this->createMock(EntityManager::class);
$this->repo = $this->createMock(EntityRepository::class); $this->repo = $this->createMock(ApiKeyRepositoryInterface::class);
$this->service = new ApiKeyService($this->em); $this->service = new ApiKeyService($this->em);
} }
@ -40,7 +40,9 @@ class ApiKeyServiceTest extends TestCase
$this->em->expects($this->once())->method('flush'); $this->em->expects($this->once())->method('flush');
$this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ApiKey::class)); $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ApiKey::class));
$key = $this->service->create($date, $name, ...$roles); $key = $this->service->create(
ApiKeyMeta::fromParams(name: $name, expirationDate: $date, roleDefinitions: $roles),
);
self::assertEquals($date, $key->getExpirationDate()); self::assertEquals($date, $key->getExpirationDate());
self::assertEquals($name, $key->name()); self::assertEquals($name, $key->name());
@ -81,7 +83,7 @@ class ApiKeyServiceTest extends TestCase
{ {
yield 'non-existent api key' => [null]; yield 'non-existent api key' => [null];
yield 'disabled api key' => [ApiKey::create()->disable()]; yield 'disabled api key' => [ApiKey::create()->disable()];
yield 'expired api key' => [ApiKey::fromMeta(ApiKeyMeta::withExpirationDate(Chronos::now()->subDay()))]; yield 'expired api key' => [ApiKey::fromMeta(ApiKeyMeta::fromParams(expirationDate: Chronos::now()->subDay()))];
} }
#[Test] #[Test]
@ -144,8 +146,25 @@ class ApiKeyServiceTest extends TestCase
$this->repo->expects($this->once())->method('findBy')->with(['enabled' => true])->willReturn($expectedApiKeys); $this->repo->expects($this->once())->method('findBy')->with(['enabled' => true])->willReturn($expectedApiKeys);
$this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo);
$result = $this->service->listKeys(true); $result = $this->service->listKeys(enabledOnly: true);
self::assertEquals($expectedApiKeys, $result); self::assertEquals($expectedApiKeys, $result);
} }
#[Test, DataProvider('provideInitialApiKeys')]
public function createInitialDelegatesToRepository(?ApiKey $apiKey): void
{
$this->repo->expects($this->once())->method('createInitialApiKey')->with('the_key')->willReturn($apiKey);
$this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo);
$result = $this->service->createInitial('the_key');
self::assertSame($result, $apiKey);
}
public static function provideInitialApiKeys(): iterable
{
yield 'first api key' => [ApiKey::create()];
yield 'existing api keys' => [null];
}
} }