coveo-1.0.0-alpha1/modules/coveo_secured_search/src/Plugin/search_api/backend/CoveoIdentityBackend.php
modules/coveo_secured_search/src/Plugin/search_api/backend/CoveoIdentityBackend.php
<?php
declare(strict_types=1);
namespace Drupal\coveo_secured_search\Plugin\search_api\backend;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Link;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\coveo\Entity\CoveoOrganizationInterface;
use Drupal\coveo\Plugin\CoveoSecurityProviderManagerInterface;
use Drupal\coveo_secured_search\Event\CoveoIdentitiesAlter;
use Drupal\coveo_secured_search\Plugin\CoveoCustomSecurityProviderPluginInterface;
use Drupal\search_api\Backend\BackendPluginBase;
use Drupal\search_api\IndexInterface;
use Drupal\search_api\Plugin\PluginFormTrait;
use Drupal\search_api\Query\QueryInterface;
use NecLimDul\Coveo\PushApi\Api\SecurityIdentityApi;
use NecLimDul\Coveo\PushApi\Model\BaseIdentityBody;
use NecLimDul\Coveo\PushApi\Model\Identity;
use NecLimDul\Coveo\PushApi\Model\IdentityBody;
use Neclimdul\OpenapiPhp\Helper\Logging\Error;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* Coveo search API backend plugin.
*
* @SearchApiBackend(
* id = "coveo_identity",
* label = @Translation("Coveo Identity Provider"),
* description = @Translation("Index identity items in Coveo. IMPORTANT: The SearchAPI item ID will be the identity and all fields will be ignored."),
* )
*/
class CoveoIdentityBackend extends BackendPluginBase implements PluginFormInterface {
use PluginFormTrait;
/**
* Constructs a Coveo Identity Search API backend.
*/
public function __construct(
array $configuration,
$plugin_id,
array $plugin_definition,
LoggerInterface $logger,
private readonly EntityTypeManagerInterface $entityTypeManager,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly TimeInterface $time,
private readonly CoveoSecurityProviderManagerInterface $securityProviderManager,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->setLogger($logger);
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
$plugin = new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('logger.channel.coveo'),
$container->get('entity_type.manager'),
$container->get('event_dispatcher'),
$container->get('datetime.time'),
$container->get('plugin.manager.coveo_security_provider'),
);
// @todo correctly set these up in the constructor.
$plugin->setFieldsHelper($container->get('search_api.fields_helper'));
$plugin->setMessenger($container->get('messenger'));
$plugin->setStringTranslation($container->get('string_translation'));
return $plugin;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration(): array {
return [
'organization_name' => '',
'provider_id' => '',
'identity_type' => 'USER',
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
$form['organization_name'] = [
'#type' => 'select',
'#title' => 'Organization',
'#default_value' => $this->getOrganizationName(),
'#options' => array_map(
fn(CoveoOrganizationInterface $org) => $org->label(),
$this->getOrganizations()
),
'#required' => TRUE,
];
$form['provider_id'] = [
'#type' => 'select',
'#title' => $this->t('Security Provider'),
// @todo link to creating a security provider to help lost users.
'#description' => $this->t('The custom security provider that will connected to the pushed identity.'),
'#default_value' => $this->getProviderPluginId(),
'#options' => $this->getSecurityProviders(),
'#required' => TRUE,
];
$form['identity_type'] = [
'#type' => 'select',
'#title' => $this->t('Identity Type'),
// @todo find some real documentation to link to. It's hidden in swagger but that's not clear.
'#description' => $this->t('The type of identity being pushed.'),
'#default_value' => $this->getIdentityType(),
'#options' => $this->getIdentityTypes(),
'#required' => TRUE,
];
return $form;
}
/**
* Get a list of identity types keyed by the internal id.
*
* @return array<string,string>
* List security provider options.
*/
private function getSecurityProviders(): array {
return array_map(
fn($definition) => $definition['title'],
array_filter(
$this->securityProviderManager->getDefinitions(),
fn(array $definition) => $definition['category'] === 'custom',
),
);
}
/**
* Get a list of identity types keyed by the internal id.
*
* @return array<string, \Drupal\Core\StringTranslation\TranslatableMarkup|string>
* List identity type options.
*/
private function getIdentityTypes(): array {
return [
'USER' => $this->t('User'),
'GROUP' => $this->t('Group'),
'VIRTUAL_GROUP' => $this->t('Virtual Group'),
];
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state): void {
// @todo validate the values allow us to connect to an API.
}
/**
* {@inheritdoc}
*/
public function viewSettings(): array {
$info = [];
$org = $this->getOrganization();
$info[] = [
'label' => $this->t('Organization'),
'info' => Link::createFromRoute(
$org?->label(),
'entity.coveo_organization.view',
['coveo_organization' => $org?->id()],
),
];
$info[] = [
'label' => $this->t('Security Provider'),
'info' => $this->getSecurityProviders()[$this->getProviderPluginId()] ?? 'Broken',
];
$info[] = [
'label' => $this->t('Identity type'),
'info' => $this->getIdentityTypes()[$this->getIdentityType()],
];
// @todo audit indexes to make sure they conform to our hacky field mapping logic.
return $info;
}
/**
* {@inheritdoc}
*/
public function getSupportedFeatures(): array {
return [];
}
/**
* {@inheritdoc}
*/
public function indexItems(IndexInterface $index, array $items) {
// If read only, just tell SearchAPI we finished without sending anything.
if ($this->isReadOnly()) {
return array_keys($items);
}
$identities = [];
foreach ($items as $key => $item) {
$identities[$key] = new IdentityBody([
'identity' => new Identity([
'name' => $this->getSecurityProvider()->getNameFromId($key),
'type' => $this->getIdentityType(),
]),
]);
}
// Let other modules alter identities before sending them to Coveo.
// This is a great place for them to interact with more complex features.
$this->eventDispatcher->dispatch(new CoveoIdentitiesAlter($identities, $items, $index));
if (count($identities) > 0) {
$api = $this->getOrganization()->pushApiCreate(SecurityIdentityApi::class);
$provider_id = $this->getIdentityProviderId();
$organization_id = $this->getOrganizationId();
// @todo Probably better if this was re-written to batch.
foreach ($identities as $identity) {
$response = $api->organizationsOrganizationIdProvidersProviderIdPermissionsPut(
$provider_id,
$organization_id,
$identity,
);
if (!$response->isSuccess()) {
// $this->getLogger()->error('Something wrong here. Log it.');
Error::logError(
$this->getLogger(),
$response,
LogLevel::ERROR,
'Identity item index error: ' . Error::DEFAULT_ERROR_MESSAGE,
);
}
}
}
return array_keys($identities);
}
/**
* {@inheritdoc}
*/
public function deleteItems(IndexInterface $index, array $item_ids) {
// If read only, just tell SearchAPI we finished without sending anything.
if ($this->isReadOnly()) {
return;
}
$organization = $this->getOrganization();
$api = $organization->pushApiCreate(SecurityIdentityApi::class);
$provider_id = $this->getIdentityProviderId();
foreach ($item_ids as $id) {
// @todo This doesn't actually work because the ID could be something
// other then the item ID (some field value) and the type is arbitrary.
$this->getIdentityProviderId();
$response = $api->organizationsOrganizationIdProvidersProviderIdPermissionsDelete(
$provider_id,
$this->getOrganizationId(),
new BaseIdentityBody([
'identity' => new Identity([
'name' => $this->getSecurityProvider()->getNameFromId($id),
'type' => $this->getIdentityType(),
]),
]),
);
if (!$response->isSuccess()) {
// $this->getLogger()->error('Something wrong here. Log it.');
Error::logError(
$this->getLogger(),
$response,
LogLevel::ERROR,
'Identity item delete error: ' . Error::DEFAULT_ERROR_MESSAGE,
);
}
}
}
/**
* {@inheritdoc}
*/
public function deleteAllIndexItems(?IndexInterface $index = NULL, $datasource_id = NULL): void {
if ($index && !$this->isReadOnly()) {
$api = $this->getOrganization()->pushApiCreate(SecurityIdentityApi::class);
$provider_id = $this->getIdentityProviderId();
$api->organizationsOrganizationIdProvidersProviderIdPermissionsOlderthanDelete(
$provider_id,
$this->getOrganizationId(),
// Convert current time to microseconds. We don't use
// getCurrentMicroTime so we can round to avoid any server clock skew
// creating a value in the future.
(int) (1000 * $this->time->getCurrentTime()),
// Shorter queue delay to avoid confusion. 15min might take unexpectedly
// long.
1
);
}
}
/**
* {@inheritdoc}
*/
public function search(QueryInterface $query): void {
// Not searchable...
}
/**
* Get the organization configuration machine name.
*/
public function getOrganizationName() {
return $this->configuration['organization_name'];
}
/**
* Get the security provider plugin id.
*/
public function getProviderPluginId() {
return $this->configuration['provider_id'];
}
/**
* Check if backend is in a read only state.
*
* @return bool
* True if the backend is in a read only state.
*/
public function isReadOnly(): bool {
// If read only, just tell SearchAPI we finished without sending anything.
$organization = $this->getOrganization();
// Check if organization is null to be safe. Some edge cases during config
// import can lead to this happening.
return $organization === NULL || $organization->isReadOnly();
}
/**
* Get the associated security provider ID.
*
* @return string
* The security provider ID used to communicated with Coveo.
*/
public function getIdentityProviderId(): string {
return $this->getSecurityProvider()->getIdentityProviderId();
}
/**
* Get the identity type that will be pushed to coveo.
*/
private function getIdentityType() {
return $this->configuration['identity_type'];
}
/**
* Get the associated security provider plugin.
*
* @return \Drupal\coveo_secured_search\Plugin\CoveoCustomSecurityProviderPluginInterface
* The security provider plugin.
*/
public function getSecurityProvider(): CoveoCustomSecurityProviderPluginInterface {
return $this
->securityProviderManager
->createInstance($this->getProviderPluginId())
->getCustomSecurityProviderPlugin();
}
/**
* Get the organization ID (provided by Coveo).
*/
public function getOrganizationId(): string|null {
return $this->getOrganization()?->getOrganizationId();
}
/**
* Retrieves all available Coveo organizations.
*
* @return \Drupal\coveo\Entity\CoveoOrganizationInterface[]
* The available organizations.
*/
private function getOrganizations(): array {
try {
return $this->entityTypeManager
->getStorage('coveo_organization')
->loadMultiple();
}
catch (InvalidPluginDefinitionException | PluginNotFoundException) {
// This should never happen.
return [];
}
}
/**
* Get the organization config associated with the identity backend.
*/
public function getOrganization(): CoveoOrganizationInterface|null {
$id = $this->getOrganizationName();
if ($id) {
return $this->entityTypeManager
->getStorage('coveo_organization')
->load($id);
}
return NULL;
}
}
