Skip to content

Commit

Permalink
Merge pull request #1184 from cultuurnet/PPF-434/keycloak-integration
Browse files Browse the repository at this point in the history
PPF-434 Keycloak integration
  • Loading branch information
grubolsch authored Jun 7, 2024
2 parents 49cda73 + 1f9e5c5 commit b6b790f
Show file tree
Hide file tree
Showing 69 changed files with 3,642 additions and 133 deletions.
23 changes: 23 additions & 0 deletions .env.ci
Original file line number Diff line number Diff line change
Expand Up @@ -150,5 +150,28 @@ E2E_TEST_ADMIN_PASSWORD=
UITPAS_INTEGRATION_TYPE_ENABLED=false
VITE_UITPAS_INTEGRATION_TYPE_ENABLED=${UITPAS_INTEGRATION_TYPE_ENABLED}

KEYCLOAK_ENABLED=true

KEYCLOAK_ACC_BASE_URL='https://account.kcpoc.lodgon.com/'
KEYCLOAK_ACC_REALM_NAME='uitidpoc'
KEYCLOAK_ACC_CLIENT_ID='php_client'
KEYCLOAK_ACC_CLIENT_SECRET='xxx'

KEYCLOAK_TEST_BASE_URL='https://account.kcpoc.lodgon.com/'
KEYCLOAK_TEST_REALM_NAME='uitidpoc'
KEYCLOAK_TEST_CLIENT_ID='php_client'
KEYCLOAK_TEST_CLIENT_SECRET='xxx'

KEYCLOAK_PROD_BASE_URL='https://account.kcpoc.lodgon.com/'
KEYCLOAK_PROD_REALM_NAME='uitidpoc'
KEYCLOAK_PROD_CLIENT_ID='php_client'
KEYCLOAK_PROD_CLIENT_SECRET='xxx'

# Incorrect values, but need to contain a valid UUID formatted string
KEYCLOAK_SCOPE_SEARCH_API_ID='bcfb28cc-454f-488a-b080-6a29d9c0158e' #publiq-api-sapi-scope
KEYCLOAK_SCOPE_ENTRY_API_ID='bcfb28cc-454f-488a-b080-6a29d9c0158e' #publiq-api-entry-scope
KEYCLOAK_SCOPE_WIDGETS_ID='bcfb28cc-454f-488a-b080-6a29d9c0158e'#publiq-widget-scope
KEYCLOAK_SCOPE_UITPAS_ID='bcfb28cc-454f-488a-b080-6a29d9c0158e'#uitpas-scope

SEARCH_BASE_URI=https://search-acc.uitdatabank.be/
SEARCH_API_KEY=
14 changes: 14 additions & 0 deletions app/Domain/Integrations/Environments.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace App\Domain\Integrations;

use Illuminate\Support\Collection;

/**
* @extends Collection<int, Environment>
*/
final class Environments extends Collection
{
}
24 changes: 22 additions & 2 deletions app/Domain/Integrations/Integration.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use App\Domain\KeyVisibilityUpgrades\KeyVisibilityUpgrade;
use App\Domain\Organizations\Organization;
use App\Domain\Subscriptions\Subscription;
use App\Keycloak\Client as KeycloakClient;
use App\UiTiDv1\UiTiDv1Consumer;
use Ramsey\Uuid\UuidInterface;

Expand All @@ -25,6 +26,9 @@ final class Integration
/** @var array<Auth0Client> */
private array $auth0Clients;

/** @var array<KeycloakClient> */
private array $keycloakClients;

private ?Organization $organization;

/** @var array<UiTiDv1Consumer> */
Expand Down Expand Up @@ -52,6 +56,7 @@ public function __construct(
$this->urls = [];
$this->uiTiDv1Consumers = [];
$this->auth0Clients = [];
$this->keycloakClients = [];
$this->organization = null;
$this->keyVisibility = KeyVisibility::v2;
$this->keyVisibilityUpgrade = null;
Expand Down Expand Up @@ -110,6 +115,13 @@ public function withAuth0Clients(Auth0Client ...$auth0Clients): self
return $clone;
}

public function withKeycloakClients(KeycloakClient ...$keycloakClients): self
{
$clone = clone $this;
$clone->keycloakClients = $keycloakClients;
return $clone;
}

public function withSubscription(Subscription $subscription): self
{
$clone = clone $this;
Expand Down Expand Up @@ -166,6 +178,12 @@ public function auth0Clients(): array
return $this->auth0Clients;
}

/** @return array<KeycloakClient> */
public function keycloakClients(): array
{
return $this->keycloakClients;
}

public function withUrls(IntegrationUrl ...$urls): self
{
$clone = clone $this;
Expand All @@ -186,10 +204,11 @@ public function urls(): array
*/
public function urlsForTypeAndEnvironment(IntegrationUrlType $type, Environment $environment): array
{
return array_filter(
// Wrapped this with array_values, so we don't retain the indexes
return array_values(array_filter(
$this->urls,
fn (IntegrationUrl $url) => $url->type->value === $type->value && $url->environment->value === $environment->value
);
));
}

public function toArray(): array
Expand All @@ -209,6 +228,7 @@ public function toArray(): array
'organization' => $this->organization,
'authClients' => $this->auth0Clients,
'legacyAuthConsumers' => $this->uiTiDv1Consumers,
'keycloakClients' => $this->keycloakClients,
'subscription' => $this->subscription,
'website' => $this->website->value ?? null,
'coupon' => $this->coupon ?? null,
Expand Down
20 changes: 20 additions & 0 deletions app/Domain/Integrations/Models/IntegrationModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\Auth0\Models\Auth0ClientModel;
use App\Domain\Contacts\Models\ContactModel;
use App\Domain\Coupons\Models\CouponModel;
use App\Domain\Integrations\Environment;
use App\Domain\Integrations\Events\IntegrationActivated;
use App\Domain\Integrations\Events\IntegrationActivationRequested;
use App\Domain\Integrations\Events\IntegrationBlocked;
Expand All @@ -26,6 +27,7 @@
use App\Domain\Subscriptions\Models\SubscriptionModel;
use App\Insightly\Models\InsightlyMappingModel;
use App\Insightly\Resources\ResourceType;
use App\Keycloak\Models\KeycloakClientModel;
use App\Models\UuidModel;
use App\UiTiDv1\Models\UiTiDv1ConsumerModel;
use App\UiTiDv1\UiTiDv1Environment;
Expand Down Expand Up @@ -286,6 +288,14 @@ public function uiTiDv1Consumers(): HasMany
return $this->hasMany(UiTiDv1ConsumerModel::class, 'integration_id');
}

/**
* @return HasMany<KeycloakClientModel>
*/
public function keycloakClients(): HasMany
{
return $this->hasMany(KeycloakClientModel::class, 'integration_id');
}

public function hasMissingAuth0Clients(): bool
{
return $this->auth0Clients()->count() < count(Auth0Tenant::cases());
Expand All @@ -296,6 +306,11 @@ public function hasMissingUiTiDv1Consumers(): bool
return $this->uiTiDv1Consumers()->count() < count(UiTiDv1Environment::cases());
}

public function hasMissingKeycloakConsumers(): bool
{
return $this->keycloakClients()->count() < count(Environment::cases());
}

public function toDomain(): Integration
{
$foundOrganization = $this->organization()->first();
Expand Down Expand Up @@ -330,6 +345,11 @@ public function toDomain(): Integration
->get()
->map(fn (Auth0ClientModel $auth0ClientModel) => $auth0ClientModel->toDomain())
->toArray()
)->withKeycloakClients(
...$this->keycloakClients()
->get()
->map(fn (KeycloakClientModel $keycloakClientModel) => $keycloakClientModel->toDomain())
->toArray()
);

if ($this->keyVisibilityUpgrade) {
Expand Down
30 changes: 30 additions & 0 deletions app/Keycloak/CachedKeycloakClientStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace App\Keycloak;

use App\Keycloak\Client\ApiClient;
use Psr\Log\LoggerInterface;

final class CachedKeycloakClientStatus
{
private array $statuses = [];

public function __construct(private readonly ApiClient $apiClient, private readonly LoggerInterface $logger)
{
}

public function isClientBlocked(Client $client): bool
{
$uuid = $client->id->toString();

if(! isset($this->statuses[$uuid])) {
$this->statuses[$uuid] = $this->apiClient->fetchIsClientActive($client);
} else {
$this->logger->info(self::class . ' - ' . $uuid . ': cache hit: ' . ($this->statuses[$uuid] ? 'Active' : 'Blocked'));
}

return ! $this->statuses[$uuid];
}
}
64 changes: 64 additions & 0 deletions app/Keycloak/Client.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace App\Keycloak;

use App\Domain\Integrations\Environment;
use Illuminate\Support\Facades\App;
use InvalidArgumentException;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;

final readonly class Client
{
public function __construct(
public UuidInterface $id,
public UuidInterface $integrationId,
public string $clientId,
public string $clientSecret,
public Environment $environment,
) {
}

public static function createFromJson(
Realm $realm,
UuidInterface $integrationId,
array $data
): self {
if (empty($data['secret'])) {
throw new InvalidArgumentException('Missing secret');
}

return new self(
Uuid::fromString($data['id']),
$integrationId,
$data['clientId'],
$data['secret'],
$realm->environment,
);
}

public function getKeycloakUrl(): string
{
$baseUrl = $this->getRealm()->baseUrl;

return $baseUrl . 'admin/master/console/#/' . $this->getRealm()->internalName . '/clients/' . $this->id->toString() . '/settings';
}

public function getRealm(): Realm
{
/** @var Realms $realmCollection */
$realmCollection = App::get(Realms::class);

foreach ($realmCollection as $realm) {
if ($realm->environment === $this->environment) {
return $realm;
}
}

throw new InvalidArgumentException(
sprintf('Could not convert environment %s to realm:', $this->environment->value)
);
}
}
28 changes: 28 additions & 0 deletions app/Keycloak/Client/ApiClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Keycloak\Client;

use App\Domain\Integrations\Integration;
use App\Keycloak\Client;
use App\Keycloak\ClientId\ClientIdFactory;
use App\Keycloak\Realm;
use Ramsey\Uuid\UuidInterface;

interface ApiClient
{
public function createClient(Realm $realm, Integration $integration, ClientIdFactory $clientIdFactory): Client;

public function addScopeToClient(Client $client, UuidInterface $scopeId): void;

public function fetchIsClientActive(Client $client): bool;

public function unblockClient(Client $client): void;

public function blockClient(Client $client): void;

public function updateClient(Client $client, array $body): void;

public function deleteScopes(Client $client): void;
}
Loading

0 comments on commit b6b790f

Please sign in to comment.