Add ORPHAN_VISITS_EXCLUDED API key role

This commit is contained in:
Alejandro Celaya 2023-05-30 09:12:46 +02:00
parent 112b54ec7d
commit 8b03532ddb
11 changed files with 54 additions and 23 deletions

View file

@ -22,6 +22,7 @@ class RoleResolver implements RoleResolverInterface
{ {
$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());
$noOrphanVisits = $input->getOption(Role::NO_ORPHAN_VISITS->paramName());
$roleDefinitions = []; $roleDefinitions = [];
if ($author) { if ($author) {
@ -30,6 +31,9 @@ class RoleResolver implements RoleResolverInterface
if (is_string($domainAuthority)) { if (is_string($domainAuthority)) {
$roleDefinitions[] = $this->resolveRoleForAuthority($domainAuthority); $roleDefinitions[] = $this->resolveRoleForAuthority($domainAuthority);
} }
if ($noOrphanVisits) {
$roleDefinitions[] = RoleDefinition::forOrphanVisitsExcluded();
}
return $roleDefinitions; return $roleDefinitions;
} }

View file

@ -25,8 +25,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();
} }
@ -35,6 +35,8 @@ class GenerateKeyCommand extends Command
{ {
$authorOnly = Role::AUTHORED_SHORT_URLS->paramName(); $authorOnly = Role::AUTHORED_SHORT_URLS->paramName();
$domainOnly = Role::DOMAIN_SPECIFIC->paramName(); $domainOnly = Role::DOMAIN_SPECIFIC->paramName();
$noOrphanVisits = Role::NO_ORPHAN_VISITS->paramName();
$help = <<<HELP $help = <<<HELP
The <info>%command.name%</info> generates a new valid API key. The <info>%command.name%</info> generates a new valid API key.
@ -52,7 +54,8 @@ class GenerateKeyCommand extends Command
* Can interact with short URLs created with this API key: <info>%command.full_name% --{$authorOnly}</info> * Can interact with short URLs created with this API key: <info>%command.full_name% --{$authorOnly}</info>
* Can interact with short URLs for one domain: <info>%command.full_name% --{$domainOnly}=example.com</info> * Can interact with short URLs for one domain: <info>%command.full_name% --{$domainOnly}=example.com</info>
* Both: <info>%command.full_name% --{$authorOnly} --{$domainOnly}=example.com</info> * Cannot see orphan visits: <info>%command.full_name% --{$noOrphanVisits}</info>
* All: <info>%command.full_name% --{$authorOnly} --{$domainOnly}=example.com --{$noOrphanVisits}</info>
HELP; HELP;
$this $this
@ -85,6 +88,12 @@ class GenerateKeyCommand extends Command
Role::DOMAIN_SPECIFIC->value, Role::DOMAIN_SPECIFIC->value,
), ),
) )
->addOption(
$noOrphanVisits,
'o',
InputOption::VALUE_NONE,
sprintf('Adds the "%s" role to the new API key.', Role::NO_ORPHAN_VISITS->value),
)
->setHelp($help); ->setHelp($help);
} }

View file

@ -27,7 +27,7 @@ class ListKeysCommand extends Command
public const NAME = 'api-key:list'; public const NAME = 'api-key:list';
public function __construct(private ApiKeyServiceInterface $apiKeyService) public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
{ {
parent::__construct(); parent::__construct();
} }
@ -60,10 +60,7 @@ class ListKeysCommand extends Command
} }
$rowData[] = $expiration?->toAtomString() ?? '-'; $rowData[] = $expiration?->toAtomString() ?? '-';
$rowData[] = ApiKey::isAdmin($apiKey) ? 'Admin' : implode("\n", $apiKey->mapRoles( $rowData[] = ApiKey::isAdmin($apiKey) ? 'Admin' : implode("\n", $apiKey->mapRoles(
fn (Role $role, array $meta) => fn (Role $role, array $meta) => $role->toFriendlyName($meta),
empty($meta)
? $role->toFriendlyName()
: sprintf('%s: %s', $role->toFriendlyName(), Role::domainAuthorityFromMeta($meta)),
)); ));
return $rowData; return $rowData;

View file

@ -79,6 +79,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
yield from $apiKey?->mapRoles(fn (Role $role, array $meta) => match ($role) { yield from $apiKey?->mapRoles(fn (Role $role, array $meta) => match ($role) {
Role::DOMAIN_SPECIFIC => ['d', new IsDomain(Role::domainIdFromMeta($meta))], Role::DOMAIN_SPECIFIC => ['d', new IsDomain(Role::domainIdFromMeta($meta))],
Role::AUTHORED_SHORT_URLS => ['s', new BelongsToApiKey($apiKey)], Role::AUTHORED_SHORT_URLS => ['s', new BelongsToApiKey($apiKey)],
default => null,
}) ?? []; }) ?? [];
} }
} }

View file

@ -56,6 +56,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
Role::AUTHORED_SHORT_URLS => $qb->andWhere( Role::AUTHORED_SHORT_URLS => $qb->andWhere(
$qb->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())), $qb->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())),
), ),
default => $qb,
}); });
// For admins and when no API key is present, we'll return tags which are not linked to any short URL // For admins and when no API key is present, we'll return tags which are not linked to any short URL

View file

@ -44,7 +44,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
$builder->createOneToMany('roles', ApiKeyRole::class) $builder->createOneToMany('roles', ApiKeyRole::class)
->mappedBy('apiKey') ->mappedBy('apiKey')
->setIndexBy('roleName') ->setIndexBy('role')
->cascadePersist() ->cascadePersist()
->orphanRemoval() ->orphanRemoval()
->build(); ->build();

View file

@ -25,7 +25,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->build(); ->build();
(new FieldBuilder($builder, [ (new FieldBuilder($builder, [
'fieldName' => 'roleName', 'fieldName' => 'role',
'type' => Types::STRING, 'type' => Types::STRING,
'enumType' => Role::class, 'enumType' => Role::class,
]))->columnName('role_name') ]))->columnName('role_name')

View file

@ -25,4 +25,9 @@ final class RoleDefinition
['domain_id' => $domain->getId(), 'authority' => $domain->authority], ['domain_id' => $domain->getId(), 'authority' => $domain->authority],
); );
} }
public static function forOrphanVisitsExcluded(): self
{
return new self(Role::NO_ORPHAN_VISITS, []);
}
} }

View file

@ -12,16 +12,20 @@ use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomain;
use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomainInlined; use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomainInlined;
use Shlinkio\Shlink\Rest\Entity\ApiKeyRole; use Shlinkio\Shlink\Rest\Entity\ApiKeyRole;
use function sprintf;
enum Role: string enum Role: string
{ {
case AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS'; case AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS';
case DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC'; case DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC';
case NO_ORPHAN_VISITS = 'NO_ORPHAN_VISITS';
public function toFriendlyName(): string public function toFriendlyName(array $meta): string
{ {
return match ($this) { return match ($this) {
self::AUTHORED_SHORT_URLS => 'Author only', self::AUTHORED_SHORT_URLS => 'Author only',
self::DOMAIN_SPECIFIC => 'Domain only', self::DOMAIN_SPECIFIC => sprintf('Domain only: %s', Role::domainAuthorityFromMeta($meta)),
self::NO_ORPHAN_VISITS => 'No orphan visits',
}; };
} }
@ -30,6 +34,7 @@ enum Role: string
return match ($this) { return match ($this) {
self::AUTHORED_SHORT_URLS => 'author-only', self::AUTHORED_SHORT_URLS => 'author-only',
self::DOMAIN_SPECIFIC => 'domain-only', self::DOMAIN_SPECIFIC => 'domain-only',
self::NO_ORPHAN_VISITS => 'no-orphan-visits',
}; };
} }
@ -38,6 +43,7 @@ enum Role: string
return match ($role->role()) { return match ($role->role()) {
self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey(), $context), self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey(), $context),
self::DOMAIN_SPECIFIC => new BelongsToDomain(self::domainIdFromMeta($role->meta()), $context), self::DOMAIN_SPECIFIC => new BelongsToDomain(self::domainIdFromMeta($role->meta()), $context),
default => Spec::andX(),
}; };
} }
@ -46,6 +52,7 @@ enum Role: string
return match ($role->role()) { return match ($role->role()) {
self::AUTHORED_SHORT_URLS => Spec::andX(new BelongsToApiKeyInlined($role->apiKey())), self::AUTHORED_SHORT_URLS => Spec::andX(new BelongsToApiKeyInlined($role->apiKey())),
self::DOMAIN_SPECIFIC => Spec::andX(new BelongsToDomainInlined(self::domainIdFromMeta($role->meta()))), self::DOMAIN_SPECIFIC => Spec::andX(new BelongsToDomainInlined(self::domainIdFromMeta($role->meta()))),
default => Spec::andX(),
}; };
} }

View file

@ -9,13 +9,24 @@ use Shlinkio\Shlink\Rest\ApiKey\Role;
class ApiKeyRole extends AbstractEntity class ApiKeyRole extends AbstractEntity
{ {
public function __construct(private Role $roleName, private array $meta, private ApiKey $apiKey) public function __construct(public readonly Role $role, private array $meta, public readonly ApiKey $apiKey)
{ {
} }
/**
* @deprecated Use property access directly
*/
public function role(): Role public function role(): Role
{ {
return $this->roleName; return $this->role;
}
/**
* @deprecated Use property access directly
*/
public function apiKey(): ApiKey
{
return $this->apiKey;
} }
public function meta(): array public function meta(): array
@ -27,9 +38,4 @@ class ApiKeyRole extends AbstractEntity
{ {
$this->meta = $newMeta; $this->meta = $newMeta;
} }
public function apiKey(): ApiKey
{
return $this->apiKey;
}
} }

View file

@ -86,14 +86,15 @@ class RoleTest extends TestCase
} }
#[Test, DataProvider('provideRoleNames')] #[Test, DataProvider('provideRoleNames')]
public function getsExpectedRoleFriendlyName(Role $role, string $expectedFriendlyName): void public function getsExpectedRoleFriendlyName(Role $role, array $meta, string $expectedFriendlyName): void
{ {
self::assertEquals($expectedFriendlyName, $role->toFriendlyName()); self::assertEquals($expectedFriendlyName, $role->toFriendlyName($meta));
} }
public static function provideRoleNames(): iterable public static function provideRoleNames(): iterable
{ {
yield Role::AUTHORED_SHORT_URLS->value => [Role::AUTHORED_SHORT_URLS, 'Author only']; yield Role::AUTHORED_SHORT_URLS->value => [Role::AUTHORED_SHORT_URLS, [], 'Author only'];
yield Role::DOMAIN_SPECIFIC->value => [Role::DOMAIN_SPECIFIC, 'Domain only']; yield Role::DOMAIN_SPECIFIC->value => [Role::DOMAIN_SPECIFIC, ['authority' => 's.test'], 'Domain only: s.test'];
yield Role::NO_ORPHAN_VISITS->value => [Role::NO_ORPHAN_VISITS, [], 'No orphan visits'];
} }
} }