diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 36b60c02..1b7c4d23 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -50,6 +50,7 @@ use Utopia\Migration\Resources\Functions\Deployment; use Utopia\Migration\Resources\Functions\EnvVar; use Utopia\Migration\Resources\Functions\Func; +use Utopia\Migration\Resources\Integrations\ApiKey; use Utopia\Migration\Resources\Integrations\Platform; use Utopia\Migration\Resources\Messaging\Message; use Utopia\Migration\Resources\Messaging\Provider; @@ -275,6 +276,7 @@ public static function getSupportedResources(): array // Integrations Resource::TYPE_PLATFORM, + Resource::TYPE_API_KEY, // Backups Resource::TYPE_BACKUP_POLICY, @@ -3074,6 +3076,10 @@ public function importIntegrationsResource(Resource $resource): Resource /** @var Platform $resource */ $this->createPlatform($resource); break; + case Resource::TYPE_API_KEY: + /** @var ApiKey $resource */ + $this->createApiKey($resource); + break; } if ($resource->getStatus() !== Resource::STATUS_SKIPPED) { @@ -3126,6 +3132,49 @@ protected function createPlatform(Platform $resource): bool return true; } + protected function createApiKey(ApiKey $resource): bool + { + $existing = $this->dbForPlatform->findOne('keys', [ + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$this->projectInternalId]), + Query::equal('name', [$resource->getApiKeyName()]), + ]); + + if ($existing !== false && !$existing->isEmpty()) { + $resource->setStatus(Resource::STATUS_SKIPPED, 'API key already exists'); + return false; + } + + $createdAt = $this->normalizeDateTime($resource->getCreatedAt()); + $updatedAt = $this->normalizeDateTime($resource->getUpdatedAt(), $createdAt); + $expire = $resource->getExpire(); + + try { + $this->dbForPlatform->createDocument('keys', new UtopiaDocument([ + '$id' => ID::unique(), + '$permissions' => $resource->getPermissions(), + 'resourceInternalId' => $this->projectInternalId, + 'resourceId' => $this->project, + 'resourceType' => 'projects', + 'name' => $resource->getApiKeyName(), + 'scopes' => $resource->getScopes(), + 'expire' => empty($expire) ? null : $expire, + 'sdks' => $resource->getSdks(), + 'accessedAt' => null, + 'secret' => 'standard_' . \bin2hex(\random_bytes(128)), + '$createdAt' => $createdAt, + '$updatedAt' => $updatedAt, + ])); + } catch (DuplicateException) { + $resource->setStatus(Resource::STATUS_SKIPPED, 'API key already exists'); + return false; + } + + $this->dbForPlatform->purgeCachedDocument('projects', $this->project); + + return true; + } + private function validateFieldsForIndexes(Index $resource, UtopiaDocument $table, array &$lengths) { /** diff --git a/src/Migration/Resource.php b/src/Migration/Resource.php index eb579ebe..14452570 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -73,6 +73,7 @@ abstract class Resource implements \JsonSerializable // Integrations public const TYPE_PLATFORM = 'platform'; + public const TYPE_API_KEY = 'api-key'; public const TYPE_SUBSCRIBER = 'subscriber'; public const TYPE_MESSAGE = 'message'; @@ -112,6 +113,7 @@ abstract class Resource implements \JsonSerializable self::TYPE_TEAM, self::TYPE_MEMBERSHIP, self::TYPE_PLATFORM, + self::TYPE_API_KEY, self::TYPE_PROVIDER, self::TYPE_TOPIC, self::TYPE_SUBSCRIBER, diff --git a/src/Migration/Resources/Integrations/ApiKey.php b/src/Migration/Resources/Integrations/ApiKey.php new file mode 100644 index 00000000..f3d40295 --- /dev/null +++ b/src/Migration/Resources/Integrations/ApiKey.php @@ -0,0 +1,110 @@ + $scopes + * @param string $expire + * @param string $accessedAt + * @param array $sdks + * @param string $createdAt + * @param string $updatedAt + */ + public function __construct( + string $id, + private readonly string $name, + private readonly array $scopes = [], + private readonly string $expire = '', + private readonly string $accessedAt = '', + private readonly array $sdks = [], + string $createdAt = '', + string $updatedAt = '', + ) { + $this->id = $id; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + } + + /** + * @param array $array + * @return self + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + $array['name'], + $array['scopes'] ?? [], + $array['expire'] ?? '', + $array['accessedAt'] ?? '', + $array['sdks'] ?? [], + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'scopes' => $this->scopes, + 'expire' => $this->expire, + 'accessedAt' => $this->accessedAt, + 'sdks' => $this->sdks, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_API_KEY; + } + + public function getGroup(): string + { + return Transfer::GROUP_INTEGRATIONS; + } + + public function getApiKeyName(): string + { + return $this->name; + } + + /** + * @return array + */ + public function getScopes(): array + { + return $this->scopes; + } + + public function getExpire(): string + { + return $this->expire; + } + + public function getAccessedAt(): string + { + return $this->accessedAt; + } + + /** + * @return array + */ + public function getSdks(): array + { + return $this->sdks; + } +} diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 97b7ec0d..a2196520 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -55,6 +55,7 @@ use Utopia\Migration\Resources\Functions\Deployment; use Utopia\Migration\Resources\Functions\EnvVar; use Utopia\Migration\Resources\Functions\Func; +use Utopia\Migration\Resources\Integrations\ApiKey; use Utopia\Migration\Resources\Integrations\Platform; use Utopia\Migration\Resources\Messaging\Message; use Utopia\Migration\Resources\Messaging\Provider; @@ -207,6 +208,7 @@ public static function getSupportedResources(): array // Integrations Resource::TYPE_PLATFORM, + Resource::TYPE_API_KEY, // Backups Resource::TYPE_BACKUP_POLICY, @@ -2239,6 +2241,19 @@ private function reportIntegrations(array $resources, array &$report, array $res $report[Resource::TYPE_PLATFORM] = 0; } } + + if (\in_array(Resource::TYPE_API_KEY, $resources)) { + $keyQueries = $this->buildQueries( + resourceType: Resource::TYPE_API_KEY, + resourceIds: $resourceIds, + limit: 1 + ); + try { + $report[Resource::TYPE_API_KEY] = $this->project->listKeys($keyQueries)->total; + } catch (\Throwable) { + $report[Resource::TYPE_API_KEY] = 0; + } + } } /** @@ -2284,6 +2299,20 @@ protected function exportGroupIntegrations(int $batchSize, array $resources): vo )); } } + + if (\in_array(Resource::TYPE_API_KEY, $resources)) { + try { + $this->exportApiKeys($batchSize); + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_API_KEY, + Transfer::GROUP_INTEGRATIONS, + message: $e->getMessage(), + code: $e->getCode(), + previous: $e + )); + } + } } /** @@ -2367,6 +2396,55 @@ private function exportPlatforms(int $batchSize): void } } + /** + * @throws AppwriteException + */ + private function exportApiKeys(int $batchSize): void + { + $lastId = null; + + while (true) { + $queries = [Query::limit($batchSize)]; + + if ($this->rootResourceId !== '' && $this->rootResourceType === Resource::TYPE_API_KEY) { + $queries[] = Query::equal('$id', $this->rootResourceId); + $queries[] = Query::limit(1); + } + + if ($lastId !== null) { + $queries[] = Query::cursorAfter($lastId); + } + + $response = $this->project->listKeys($queries); + if ($response->total === 0) { + break; + } + + $apiKeys = []; + + foreach ($response->keys as $key) { + $apiKeys[] = new ApiKey( + $key->id, + $key->name, + $key->scopes, + $key->expire, + $key->accessedAt, + $key->sdks, + createdAt: $key->createdAt, + updatedAt: $key->updatedAt, + ); + + $lastId = $key->id; + } + + $this->callback($apiKeys); + + if (\count($response->keys) < $batchSize) { + break; + } + } + } + /** * eg.,documents/attributes * @param string $databaseType diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 7925fb32..15a0c8ae 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -62,6 +62,7 @@ class Transfer public const GROUP_INTEGRATIONS_RESOURCES = [ Resource::TYPE_PLATFORM, + Resource::TYPE_API_KEY, ]; public const GROUP_DOCUMENTSDB_RESOURCES = [ Resource::TYPE_DATABASE_DOCUMENTSDB, @@ -129,6 +130,7 @@ class Transfer // Integrations Resource::TYPE_PLATFORM, + Resource::TYPE_API_KEY, // legacy Resource::TYPE_DOCUMENT, @@ -146,6 +148,7 @@ class Transfer Resource::TYPE_USER, Resource::TYPE_TEAM, Resource::TYPE_PLATFORM, + Resource::TYPE_API_KEY, Resource::TYPE_PROVIDER, Resource::TYPE_TOPIC, Resource::TYPE_MESSAGE, diff --git a/tests/Migration/Unit/Adapters/MockDestination.php b/tests/Migration/Unit/Adapters/MockDestination.php index 2aa528a8..68f0eec9 100644 --- a/tests/Migration/Unit/Adapters/MockDestination.php +++ b/tests/Migration/Unit/Adapters/MockDestination.php @@ -52,6 +52,7 @@ public static function getSupportedResources(): array Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, Resource::TYPE_PLATFORM, + Resource::TYPE_API_KEY, Resource::TYPE_PROVIDER, Resource::TYPE_TOPIC, Resource::TYPE_SUBSCRIBER, diff --git a/tests/Migration/Unit/Adapters/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index a309f91e..27ac181b 100644 --- a/tests/Migration/Unit/Adapters/MockSource.php +++ b/tests/Migration/Unit/Adapters/MockSource.php @@ -81,6 +81,7 @@ public static function getSupportedResources(): array Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, Resource::TYPE_PLATFORM, + Resource::TYPE_API_KEY, Resource::TYPE_PROVIDER, Resource::TYPE_TOPIC, Resource::TYPE_SUBSCRIBER,