diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f1daa21..ff105151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [#88](https://github.com/itk-dev/devops_itksites/pull/88) + - Let users use the API + - Add security to detection results API endpoint + - Add server and site collections API endpoints - [#80](https://github.com/itk-dev/devops_itksites/pull/80) 5566: Service agreements - Add security contract entity with crud controller - Add Abstract full crud controller and extend on it in some cases diff --git a/README.md b/README.md index fc1305f9..797838ed 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,24 @@ The system is build so that all analyzed data can be truncated safely and rebuil by "replaying" the DetectionResults. This means that care must be taken when manually maintained data and auto updated data must have cross references. +## API + +Authenticated users can access a simple read-only API – see the API documentation on `/api/docs` for details. + +### API keys + +Run the `app:user:set-api-key` console command to set the API for a user: + +``` shell +docker compose exec phpfpm php bin/console app:user:set-api-key +``` + +Use the API key to make an authenticated request, e.g. + +``` shell +curl --header 'accept: application/json' --header 'authorization: Apikey ' https://itksites.local.itkdev.dk/api/sites +``` + ## Development ```sh diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 6651fbaa..387fd872 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -1,6 +1,9 @@ api_platform: - title: 'ITKsites Detection API' - description: 'REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories.' + title: 'ITKsites API' + description: | + REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories. + + The API also provides read-only access to information on servers and sites created by processing detection results. version: '1.0.0' mapping: diff --git a/config/packages/security.yaml b/config/packages/security.yaml index f57e0e79..b7b82574 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -12,6 +12,11 @@ security: entity: class: App\Entity\User property: email + + app_api_users: + chain: + providers: [app_server_provider, app_user_provider] + firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ @@ -20,7 +25,7 @@ security: pattern: ^/api custom_authenticators: - App\Security\ApiKeyAuthenticator - provider: app_server_provider + provider: app_api_users main: custom_authenticators: @@ -41,7 +46,7 @@ security: # Note: Only the *first* access control that matches will be used access_control: - { path: ^/api/docs, roles: PUBLIC_ACCESS } - - { path: ^/api, roles: ROLE_SERVER } + - { path: ^/api, roles: [ROLE_SERVER, ROLE_USER] } - { path: ^/admin, roles: ROLE_ADMIN } # - { path: ^/profile, roles: ROLE_USER } diff --git a/fixtures/user.yaml b/fixtures/user.yaml index de04a82f..12605096 100644 --- a/fixtures/user.yaml +++ b/fixtures/user.yaml @@ -1,3 +1,5 @@ App\Entity\User: user_admin: - __construct: ["admin@example.com", "Admin", [ROLE_ADMIN]] + __construct: ["Admin", "admin@example.com", [ROLE_ADMIN]] + user_user: + __construct: ["User", "user@example.com", [ROLE_USER]] diff --git a/migrations/Version20260702123347.php b/migrations/Version20260702123347.php new file mode 100644 index 00000000..5552bbd6 --- /dev/null +++ b/migrations/Version20260702123347.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE user ADD api_key VARCHAR(255) NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649C912ED9D ON user (api_key)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX UNIQ_8D93D649C912ED9D ON user'); + $this->addSql('ALTER TABLE user DROP api_key'); + } +} diff --git a/public/api-spec-v1.json b/public/api-spec-v1.json index 47ce5887..bc5923d6 100644 --- a/public/api-spec-v1.json +++ b/public/api-spec-v1.json @@ -1,8 +1,8 @@ { "openapi": "3.1.0", "info": { - "title": "ITKsites Detection API", - "description": "REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories.", + "title": "ITKsites API", + "description": "REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories.\n\nThe API also provides read-only access to information on servers and sites created by processing detection results.", "version": "1.0.0" }, "servers": [ @@ -92,6 +92,144 @@ "required": true } } + }, + "/api/servers": { + "get": { + "operationId": "api_servers_get_collection", + "tags": [ + "Server" + ], + "responses": { + "200": { + "description": "Server collection", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Server-export" + } + } + }, + "application/ld+json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Server-export" + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves the collection of Server resources.", + "description": "Retrieves the collection of Server resources.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/sites": { + "get": { + "operationId": "api_sites_get_collection", + "tags": [ + "Site" + ], + "responses": { + "200": { + "description": "Site collection", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Site-export" + } + } + }, + "application/ld+json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Site-export" + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves the collection of Site resources.", + "description": "Retrieves the collection of Site resources.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + } + ] + } } }, "components": { @@ -353,6 +491,43 @@ ] } } + }, + "Server-export": { + "type": "object", + "properties": { + "Name": { + "default": "", + "type": "string" + } + } + }, + "Site-export": { + "type": "object", + "required": [ + "PHP version" + ], + "properties": { + "PHP version": { + "minLength": 1, + "maxLength": 10, + "default": "", + "type": "string" + }, + "Primary domain": { + "readOnly": true, + "type": "string" + }, + "Type": { + "default": "", + "type": "string" + }, + "Server": { + "$ref": "#/components/schemas/Server-export" + }, + "rootDir": { + "type": "string" + } + } } }, "responses": {}, @@ -378,6 +553,14 @@ { "name": "DetectionResult", "description": "Resource 'DetectionResult' operations." + }, + { + "name": "Server", + "description": "Resource 'Server' operations." + }, + { + "name": "Site", + "description": "Resource 'Site' operations." } ], "webhooks": {} diff --git a/public/api-spec-v1.yaml b/public/api-spec-v1.yaml index d2a01794..cdf73060 100755 --- a/public/api-spec-v1.yaml +++ b/public/api-spec-v1.yaml @@ -1,7 +1,10 @@ openapi: 3.1.0 info: - title: 'ITKsites Detection API' - description: 'REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories.' + title: 'ITKsites API' + description: |- + REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories. + + The API also provides read-only access to information on servers and sites created by processing detection results. version: 1.0.0 servers: - url: 'https://itksites.local.itkdev.dk' @@ -59,6 +62,98 @@ paths: schema: $ref: '#/components/schemas/DetectionResult-write' required: true + /api/servers: + get: + operationId: api_servers_get_collection + tags: + - Server + responses: + '200': + description: 'Server collection' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Server-export' + application/ld+json: + schema: + type: array + items: + $ref: '#/components/schemas/Server-export' + '403': + description: Forbidden + content: + application/ld+json: + schema: + $ref: '#/components/schemas/Error.jsonld' + application/problem+json: + schema: + $ref: '#/components/schemas/Error' + application/json: + schema: + $ref: '#/components/schemas/Error' + links: {} + summary: 'Retrieves the collection of Server resources.' + description: 'Retrieves the collection of Server resources.' + parameters: + - + name: page + in: query + description: 'The collection page number' + required: false + deprecated: false + schema: + type: integer + default: 1 + style: form + explode: true + /api/sites: + get: + operationId: api_sites_get_collection + tags: + - Site + responses: + '200': + description: 'Site collection' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Site-export' + application/ld+json: + schema: + type: array + items: + $ref: '#/components/schemas/Site-export' + '403': + description: Forbidden + content: + application/ld+json: + schema: + $ref: '#/components/schemas/Error.jsonld' + application/problem+json: + schema: + $ref: '#/components/schemas/Error' + application/json: + schema: + $ref: '#/components/schemas/Error' + links: {} + summary: 'Retrieves the collection of Site resources.' + description: 'Retrieves the collection of Site resources.' + parameters: + - + name: page + in: query + description: 'The collection page number' + required: false + deprecated: false + schema: + type: integer + default: 1 + style: form + explode: true components: schemas: ConstraintViolation: @@ -250,6 +345,32 @@ components: type: - string - 'null' + Server-export: + type: object + properties: + Name: + default: '' + type: string + Site-export: + type: object + required: + - 'PHP version' + properties: + 'PHP version': + minLength: 1 + maxLength: 10 + default: '' + type: string + 'Primary domain': + readOnly: true + type: string + Type: + default: '' + type: string + Server: + $ref: '#/components/schemas/Server-export' + rootDir: + type: string responses: {} parameters: {} examples: {} @@ -267,4 +388,8 @@ security: tags: - name: DetectionResult description: "Resource 'DetectionResult' operations." + - name: Server + description: "Resource 'Server' operations." + - name: Site + description: "Resource 'Site' operations." webhooks: {} diff --git a/src/Command/UserSetApiKeyCommand.php b/src/Command/UserSetApiKeyCommand.php new file mode 100644 index 00000000..e7926621 --- /dev/null +++ b/src/Command/UserSetApiKeyCommand.php @@ -0,0 +1,53 @@ +userRepository->findOneBy(['email' => $userId]) + ?? $this->userRepository->findOneBy(['name' => $userId]); + + if (null === $user) { + $io->error(sprintf('Cannot load user with id %s', $userId)); + + return Command::INVALID; + } + + $question = sprintf('Really set API key on user %s', $user->getUserIdentifier()); + if (!$io->confirm($question)) { + return Command::SUCCESS; + } + + $user->setApiKey($user->generateApiKey()); + $this->entityManager->flush(); + + $io->success([ + sprintf('API key for user %s set to', $user->getUserIdentifier()), + $user->getApiKey(), + ]); + + return Command::SUCCESS; + } +} diff --git a/src/Entity/DetectionResult.php b/src/Entity/DetectionResult.php index d47821fa..00769cd8 100644 --- a/src/Entity/DetectionResult.php +++ b/src/Entity/DetectionResult.php @@ -17,6 +17,7 @@ #[ApiResource( operations: [ new Post( + security: "is_granted('ROLE_SERVER')", status: 202, output: false, messenger: true, diff --git a/src/Entity/Server.php b/src/Entity/Server.php index cdf15b15..6b27d673 100644 --- a/src/Entity/Server.php +++ b/src/Entity/Server.php @@ -4,7 +4,10 @@ namespace App\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; use App\Repository\ServerRepository; +use App\Trait\ApiKeyEntityTrait; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -13,19 +16,17 @@ use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Validator\Constraints as Assert; +#[ApiResource( + normalizationContext: ['groups' => ['export']], + security: "is_granted('ROLE_USER')", +)] +#[GetCollection()] #[ORM\Entity(repositoryClass: ServerRepository::class)] class Server extends AbstractBaseEntity implements UserInterface, \Stringable { - private const array ROLES = ['ROLE_USER', 'ROLE_SERVER']; + use ApiKeyEntityTrait; - #[ORM\Column(type: 'string', length: 255, unique: true)] - #[Assert\Length( - min: 40, - max: 255, - minMessage: 'Api key must be at least {{ limit }} characters long', - maxMessage: 'Api key cannot be longer than {{ limit }} characters', - )] - private string $apiKey; + private const array ROLES = ['ROLE_USER', 'ROLE_SERVER']; #[ORM\Column(type: 'string', length: 255, unique: true)] #[Groups(['export'])] @@ -93,8 +94,8 @@ class Server extends AbstractBaseEntity implements UserInterface, \Stringable */ public function __construct() { + $this->setApiKey($this->generateApiKey()); $this->detectionResults = new ArrayCollection(); - $this->apiKey = sha1(\random_bytes(40)); $this->installations = new ArrayCollection(); } @@ -258,18 +259,6 @@ public function setUsedFor(?string $usedFor): self return $this; } - public function getApiKey(): string - { - return $this->apiKey; - } - - public function setApiKey(string $apiKey): self - { - $this->apiKey = $apiKey; - - return $this; - } - /** * @return Collection */ diff --git a/src/Entity/Site.php b/src/Entity/Site.php index dbfd6c7a..9c52cf9d 100644 --- a/src/Entity/Site.php +++ b/src/Entity/Site.php @@ -4,6 +4,8 @@ namespace App\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; use App\Repository\SiteRepository; use App\Types\SiteType; use Doctrine\Common\Collections\ArrayCollection; @@ -13,6 +15,11 @@ use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Validator\Constraints as Assert; +#[ApiResource( + normalizationContext: ['groups' => ['export']], + security: "is_granted('ROLE_USER')", +)] +#[GetCollection()] #[ORM\Entity(repositoryClass: SiteRepository::class)] #[ORM\UniqueConstraint(name: 'server_rootDir_configFilePath_idx', fields: ['server', 'rootDir', 'configFilePath'])] class Site extends AbstractHandlerResult implements \Stringable diff --git a/src/Entity/User.php b/src/Entity/User.php index 70ad5369..15de1c2a 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -5,12 +5,15 @@ namespace App\Entity; use App\Repository\UserRepository; +use App\Trait\ApiKeyEntityTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\UserInterface; #[ORM\Entity(repositoryClass: UserRepository::class)] class User extends AbstractBaseEntity implements UserInterface { + use ApiKeyEntityTrait; + public function __construct( #[ORM\Column(length: 255)] private string $name, @@ -19,6 +22,7 @@ public function __construct( #[ORM\Column(type: 'json')] private array $roles = [], ) { + $this->setApiKey($this->generateApiKey()); } #[\Override] diff --git a/src/Security/ApiKeyAuthenticator.php b/src/Security/ApiKeyAuthenticator.php index 8232e27f..f12e67ed 100644 --- a/src/Security/ApiKeyAuthenticator.php +++ b/src/Security/ApiKeyAuthenticator.php @@ -4,11 +4,14 @@ namespace App\Security; +use App\Repository\ServerRepository; +use App\Repository\UserRepository; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; @@ -20,6 +23,12 @@ class ApiKeyAuthenticator extends AbstractAuthenticator public const string AUTH_HEADER = 'Authorization'; public const string AUTH_HEADER_PREFIX = 'Apikey '; + public function __construct( + private readonly ServerRepository $serverRepository, + private readonly UserRepository $userRepository, + ) { + } + /** * Called on every request to decide if this authenticator should be used for the request. * @@ -40,7 +49,14 @@ public function authenticate(Request $request): Passport throw new CustomUserMessageAuthenticationException('No API token provided'); } - return new SelfValidatingPassport(new UserBadge($apiKey)); + // Users and servers can authenticate to use the API. + $apiUser = $this->serverRepository->findOneBy(['apiKey' => $apiKey]) + ?? $this->userRepository->findOneBy(['apiKey' => $apiKey]); + if (null !== $apiUser) { + return new SelfValidatingPassport(new UserBadge($apiUser->getUserIdentifier())); + } + + throw new BadCredentialsException('Invalid credentials.'); } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response diff --git a/src/Trait/ApiKeyEntityTrait.php b/src/Trait/ApiKeyEntityTrait.php new file mode 100644 index 00000000..7c65c811 --- /dev/null +++ b/src/Trait/ApiKeyEntityTrait.php @@ -0,0 +1,35 @@ +apiKey; + } + + public function setApiKey(string $apiKey): static + { + $this->apiKey = $apiKey; + + return $this; + } + + public function generateApiKey(): string + { + return sha1(\random_bytes(40)); + } +} diff --git a/tests/Command/UserSetApiKeyCommandTest.php b/tests/Command/UserSetApiKeyCommandTest.php new file mode 100644 index 00000000..3f782897 --- /dev/null +++ b/tests/Command/UserSetApiKeyCommandTest.php @@ -0,0 +1,67 @@ +setAutoExit(false); + + $applicationTester = new ApplicationTester($application); + $applicationTester->run([ + 'command' => 'app:user:set-api-key', + '--help' => true, + ]); + $applicationTester->assertCommandIsSuccessful(); + + // Missing user ID argument. + $applicationTester->run([ + 'command' => 'app:user:set-api-key', + ]); + $applicationTester->assertCommandFailed(); + + // Invalid user ID argument. + $applicationTester->run([ + 'command' => 'app:user:set-api-key', + 'user-id' => 'this-user-does-no-exist', + ]); + $applicationTester->assertCommandIsInvalid(); + $output = $applicationTester->getDisplay(); + $this->assertStringContainsString('Cannot load user with id this-user-does-no-exist', $output); + + // Valid user ID (name). + $applicationTester->run([ + 'command' => 'app:user:set-api-key', + 'user-id' => 'admin', + '--no-interaction' => true, + ]); + $applicationTester->assertCommandIsSuccessful(); + + $output = $applicationTester->getDisplay(); + $this->assertStringContainsString('API key for user admin@example.com set to', $output); + + // Valid user ID (email). + $applicationTester->run([ + 'command' => 'app:user:set-api-key', + 'user-id' => 'admin@example.com', + '--no-interaction' => true, + ]); + $applicationTester->assertCommandIsSuccessful(); + + $output = $applicationTester->getDisplay(); + $this->assertStringContainsString('API key for user admin@example.com set to', $output); + } +}