From 1461c131c1c30ddc2a5d715036838718a7ef4284 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 16 Mar 2026 22:29:11 +0000 Subject: [PATCH 01/11] Add API key migration support --- src/Migration/Destinations/Appwrite.php | 81 ++++++++++++- src/Migration/Resource.php | 2 + .../Resources/Integrations/ApiKey.php | 110 ++++++++++++++++++ src/Migration/Sources/Appwrite.php | 55 ++++++++- src/Migration/Transfer.php | 3 + .../Unit/Adapters/MockDestination.php | 1 + tests/Migration/Unit/Adapters/MockSource.php | 1 + 7 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 src/Migration/Resources/Integrations/ApiKey.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 32de9758..34ad58d2 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; @@ -81,6 +82,11 @@ class Appwrite extends Destination */ protected $getDatabasesDB; + private bool $consoleKeyFetched = false; + + private ?string $consoleKey = null; + + /** * @var array */ @@ -120,9 +126,39 @@ public function __construct( $this->teams = new Teams($this->client); $this->users = new Users($this->client); + $this->headers['x-appwrite-project'] = $this->project; + $this->headers['x-appwrite-key'] = $this->key; + $this->getDatabasesDB = $getDatabasesDB; } + /** + * @return array|null + */ + protected function getConsoleHeaders(): ?array + { + if (!$this->consoleKeyFetched) { + $this->consoleKeyFetched = true; + + try { + $response = $this->call('POST', '/migrations/appwrite/console-key'); + $this->consoleKey = $response['key'] ?? null; + } catch (\Throwable) { + $this->consoleKey = null; + } + } + + if ($this->consoleKey === null) { + return null; + } + + return [ + 'Content-Type' => 'application/json', + 'x-appwrite-project' => 'console', + 'x-appwrite-key' => $this->consoleKey, + ]; + } + public static function getName(): string { return 'Appwrite'; @@ -175,6 +211,7 @@ public static function getSupportedResources(): array // Integrations Resource::TYPE_PLATFORM, + Resource::TYPE_API_KEY, ]; } @@ -2206,9 +2243,13 @@ 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) { + if ($resource->getStatus() !== Resource::STATUS_SKIPPED && $resource->getStatus() !== Resource::STATUS_ERROR) { $resource->setStatus(Resource::STATUS_SUCCESS); } @@ -2258,6 +2299,44 @@ protected function createPlatform(Platform $resource): bool return true; } + protected function createApiKey(ApiKey $resource): bool + { + $consoleHeaders = $this->getConsoleHeaders(); + + if ($consoleHeaders === null) { + throw new \Exception('Failed to get console headers'); + } + + try { + $params = [ + 'keyId' => ID::unique(), + 'name' => $resource->getApiKeyName(), + 'scopes' => $resource->getScopes(), + ]; + + $expire = $resource->getExpire(); + if (!empty($expire)) { + $params['expire'] = $expire; + } + + $this->call( + 'POST', + '/projects/' . $this->project . '/keys', + $consoleHeaders, + $params + ); + } catch (\Throwable $e) { + if ($e->getCode() === 409) { + $resource->setStatus(Resource::STATUS_SKIPPED, 'API key already exists'); + return false; + } + + throw $e; + } + + return true; + } + private function validateFieldsForIndexes(Index $resource, UtopiaDocument $table, array &$lengths) { /** diff --git a/src/Migration/Resource.php b/src/Migration/Resource.php index e624fe2c..1eb4f77d 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'; @@ -109,6 +110,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 1f51fa6b..cd235810 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -53,6 +53,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; @@ -239,6 +240,7 @@ public static function getSupportedResources(): array // Integrations Resource::TYPE_PLATFORM, + Resource::TYPE_API_KEY, ]; } @@ -2234,13 +2236,13 @@ private function exportSiteDeploymentData(Site $site, array $deployment): void */ private function reportIntegrations(array $resources, array &$report, array $resourceIds = []): void { - if (\in_array(Resource::TYPE_PLATFORM, $resources)) { - $consoleHeaders = $this->getConsoleHeaders(); + $consoleHeaders = $this->getConsoleHeaders(); - if ($consoleHeaders === null) { - return; - } + if ($consoleHeaders === null) { + return; + } + if (\in_array(Resource::TYPE_PLATFORM, $resources)) { try { $response = $this->call('GET', '/projects/' . $this->project . '/platforms', $consoleHeaders); $report[Resource::TYPE_PLATFORM] = $response['total'] ?? 0; @@ -2248,6 +2250,15 @@ private function reportIntegrations(array $resources, array &$report, array $res $report[Resource::TYPE_PLATFORM] = 0; } } + + if (\in_array(Resource::TYPE_API_KEY, $resources)) { + try { + $response = $this->call('GET', '/projects/' . $this->project . '/keys', $consoleHeaders); + $report[Resource::TYPE_API_KEY] = $response['total'] ?? 0; + } catch (\Throwable) { + $report[Resource::TYPE_API_KEY] = 0; + } + } } /** @@ -2287,6 +2298,14 @@ protected function exportGroupIntegrations(int $batchSize, array $resources): vo $this->exportPlatforms(...) ); } + + if (\in_array(Resource::TYPE_API_KEY, $resources)) { + $this->exportWithConsoleHeaders( + Resource::TYPE_API_KEY, + Transfer::GROUP_INTEGRATIONS, + $this->exportApiKeys(...) + ); + } } protected function exportWithConsoleHeaders(string $resourceType, string $group, callable $callback): void @@ -2374,6 +2393,32 @@ private function exportPlatforms(array $consoleHeaders): void $this->callback($platforms); } + private function exportApiKeys(array $consoleHeaders): void + { + $response = $this->call('GET', '/projects/' . $this->project . '/keys', $consoleHeaders); + + if (empty($response['keys'])) { + return; + } + + $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'] ?? '', + ); + } + + $this->callback($apiKeys); + } + /** * eg.,documents/attributes * @param string $databaseType diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 59ed486c..92bf53c6 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -60,6 +60,7 @@ class Transfer public const GROUP_INTEGRATIONS_RESOURCES = [ Resource::TYPE_PLATFORM, + Resource::TYPE_API_KEY, ]; public const GROUP_DOCUMENTSDB_RESOURCES = [ Resource::TYPE_DATABASE_DOCUMENTSDB, @@ -122,6 +123,7 @@ class Transfer // Integrations Resource::TYPE_PLATFORM, + Resource::TYPE_API_KEY, // legacy Resource::TYPE_DOCUMENT, @@ -139,6 +141,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 1524dea8..f7caa6e3 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, From 9e54ff0c20f0f765d89bbda1cfb94dab8a9a6fcb Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 14 May 2026 09:25:38 +0100 Subject: [PATCH 02/11] Drop console-key fetch from Appwrite source/destination Source uses Project SDK (listKeys with cursor pagination); destination writes keys directly via dbForPlatform, matching the platform pattern. --- src/Migration/Destinations/Appwrite.php | 92 +++++++----------- src/Migration/Sources/Appwrite.php | 120 ++++++++++++------------ 2 files changed, 96 insertions(+), 116 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 3a29e8ea..7c5ffe76 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -105,11 +105,6 @@ class Appwrite extends Destination */ protected $getDatabasesDB; - private bool $consoleKeyFetched = false; - - private ?string $consoleKey = null; - - /** * Resolves the DSN written into the destination's `_databases.database` * for a migrated database. When the source and destination projects don't @@ -232,33 +227,6 @@ private function resetRunState(): void $this->processedTwoWayPairs = []; } - /** - * @return array|null - */ - protected function getConsoleHeaders(): ?array - { - if (!$this->consoleKeyFetched) { - $this->consoleKeyFetched = true; - - try { - $response = $this->call('POST', '/migrations/appwrite/console-key'); - $this->consoleKey = $response['key'] ?? null; - } catch (\Throwable) { - $this->consoleKey = null; - } - } - - if ($this->consoleKey === null) { - return null; - } - - return [ - 'Content-Type' => 'application/json', - 'x-appwrite-project' => 'console', - 'x-appwrite-key' => $this->consoleKey, - ]; - } - public static function getName(): string { return 'Appwrite'; @@ -3169,39 +3137,49 @@ protected function createPlatform(Platform $resource): bool protected function createApiKey(ApiKey $resource): bool { - $consoleHeaders = $this->getConsoleHeaders(); + $existing = $this->dbForPlatform->findOne('keys', [ + Query::equal('projectInternalId', [$this->projectInternalId]), + Query::equal('name', [$resource->getApiKeyName()]), + ]); - if ($consoleHeaders === null) { - throw new \Exception('Failed to get console headers'); + 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 { - $params = [ - 'keyId' => ID::unique(), + $this->dbForPlatform->createDocument('keys', new UtopiaDocument([ + '$id' => ID::unique(), + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'projectInternalId' => $this->projectInternalId, + 'projectId' => $this->project, + 'resourceInternalId' => $this->projectInternalId, + 'resourceId' => $this->project, + 'resourceType' => 'projects', 'name' => $resource->getApiKeyName(), 'scopes' => $resource->getScopes(), - ]; - - $expire = $resource->getExpire(); - if (!empty($expire)) { - $params['expire'] = $expire; - } - - $this->call( - 'POST', - '/projects/' . $this->project . '/keys', - $consoleHeaders, - $params - ); - } catch (\Throwable $e) { - if ($e->getCode() === 409) { - $resource->setStatus(Resource::STATUS_SKIPPED, 'API key already exists'); - return false; - } - - throw $e; + '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; } diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index b3054221..a2196520 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -2243,16 +2243,15 @@ private function reportIntegrations(array $resources, array &$report, array $res } if (\in_array(Resource::TYPE_API_KEY, $resources)) { - $consoleHeaders = $this->getConsoleHeaders(); - if ($consoleHeaders === null) { + $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; - } else { - try { - $response = $this->call('GET', '/projects/' . $this->project . '/keys', $consoleHeaders); - $report[Resource::TYPE_API_KEY] = $response['total'] ?? 0; - } catch (\Throwable) { - $report[Resource::TYPE_API_KEY] = 0; - } } } } @@ -2302,37 +2301,17 @@ protected function exportGroupIntegrations(int $batchSize, array $resources): vo } if (\in_array(Resource::TYPE_API_KEY, $resources)) { - $this->exportWithConsoleHeaders( - Resource::TYPE_API_KEY, - Transfer::GROUP_INTEGRATIONS, - $this->exportApiKeys(...) - ); - } - } - - protected function exportWithConsoleHeaders(string $resourceType, string $group, callable $callback): void - { - $consoleHeaders = $this->getConsoleHeaders(); - - if ($consoleHeaders === null) { - $this->addError(new Exception( - $resourceType, - $group, - message: 'Console key unavailable for source instance', - )); - return; - } - - try { - $callback($consoleHeaders); - } catch (\Throwable $e) { - $this->addError(new Exception( - $resourceType, - $group, - message: $e->getMessage(), - code: $e->getCode(), - previous: $e - )); + 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 + )); + } } } @@ -2417,30 +2396,53 @@ private function exportPlatforms(int $batchSize): void } } - private function exportApiKeys(array $consoleHeaders): void + /** + * @throws AppwriteException + */ + private function exportApiKeys(int $batchSize): void { - $response = $this->call('GET', '/projects/' . $this->project . '/keys', $consoleHeaders); + $lastId = null; - if (empty($response['keys'])) { - return; - } + while (true) { + $queries = [Query::limit($batchSize)]; - $apiKeys = []; + if ($this->rootResourceId !== '' && $this->rootResourceType === Resource::TYPE_API_KEY) { + $queries[] = Query::equal('$id', $this->rootResourceId); + $queries[] = Query::limit(1); + } - 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'] ?? '', - ); - } + if ($lastId !== null) { + $queries[] = Query::cursorAfter($lastId); + } + + $response = $this->project->listKeys($queries); + if ($response->total === 0) { + break; + } + + $apiKeys = []; - $this->callback($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; + } + } } /** From 8286322009ba1424248110add1074de91e2f84c5 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 14 May 2026 09:34:55 +0100 Subject: [PATCH 03/11] Drop dead headers/key plumbing and redundant status check $this->headers/endpoint/key were only read by the inherited call() method which the destination no longer invokes. STATUS_ERROR is never set inside createPlatform/createApiKey (they throw), so the guard only needs SKIPPED. --- src/Migration/Destinations/Appwrite.php | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 7c5ffe76..e63826dc 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -91,8 +91,6 @@ class Appwrite extends Destination protected Client $client; protected string $project; - protected string $key; - private Functions $functions; private Messaging $messaging; private Sites $sites; @@ -169,8 +167,6 @@ public function __construct( ?callable $getDatabaseDSN = null, ) { $this->project = $project; - $this->endpoint = $endpoint; - $this->key = $key; $this->client = (new Client()) ->setEndpoint($endpoint) @@ -184,9 +180,6 @@ public function __construct( $this->teams = new Teams($this->client); $this->users = new Users($this->client); - $this->headers['x-appwrite-project'] = $this->project; - $this->headers['x-appwrite-key'] = $this->key; - $this->getDatabasesDB = $getDatabasesDB; $this->getDatabaseDSN = $getDatabaseDSN; } @@ -3085,7 +3078,7 @@ public function importIntegrationsResource(Resource $resource): Resource break; } - if ($resource->getStatus() !== Resource::STATUS_SKIPPED && $resource->getStatus() !== Resource::STATUS_ERROR) { + if ($resource->getStatus() !== Resource::STATUS_SKIPPED) { $resource->setStatus(Resource::STATUS_SUCCESS); } From 2c27ede63d89441649510058f4567fdeb6aa7156 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 14 May 2026 09:39:23 +0100 Subject: [PATCH 04/11] Restore endpoint/key fields; keep them as inherited state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverting the wider cleanup — those properties are part of the inherited Target surface, not the headers issue under review. --- src/Migration/Destinations/Appwrite.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index e63826dc..8f300cdd 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -91,6 +91,8 @@ class Appwrite extends Destination protected Client $client; protected string $project; + protected string $key; + private Functions $functions; private Messaging $messaging; private Sites $sites; @@ -167,6 +169,8 @@ public function __construct( ?callable $getDatabaseDSN = null, ) { $this->project = $project; + $this->endpoint = $endpoint; + $this->key = $key; $this->client = (new Client()) ->setEndpoint($endpoint) From 207ab82a6ab0afc4bcd4fbff311c57e0f8cbc533 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 14 May 2026 09:44:44 +0100 Subject: [PATCH 05/11] Mirror createPlatform: use resource-supplied permissions for keys Matches the existing platform pattern. Source-side SDK Key model doesn't expose $permissions today so this is [] in practice (same as platforms), but the hook is in place for when source can populate it. --- src/Migration/Destinations/Appwrite.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 8f300cdd..a97b5717 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -3151,11 +3151,7 @@ protected function createApiKey(ApiKey $resource): bool try { $this->dbForPlatform->createDocument('keys', new UtopiaDocument([ '$id' => ID::unique(), - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], + '$permissions' => $resource->getPermissions(), 'projectInternalId' => $this->projectInternalId, 'projectId' => $this->project, 'resourceInternalId' => $this->projectInternalId, From 893d45d9ea4d4adf71ab994d56be8df6c1c7d54a Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 14 May 2026 09:51:31 +0100 Subject: [PATCH 06/11] Restore Role::any() perms on migrated keys with rationale comment SDK strips $permissions from Key responses so we can't copy from source; match the upstream createKey controller default since dbForPlatform.keys is gated by endpoint scope, not document perms. --- src/Migration/Destinations/Appwrite.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index a97b5717..3430d6f4 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -3151,7 +3151,14 @@ protected function createApiKey(ApiKey $resource): bool try { $this->dbForPlatform->createDocument('keys', new UtopiaDocument([ '$id' => ID::unique(), - '$permissions' => $resource->getPermissions(), + // SDK's Key model doesn't expose $permissions, so we can't read the source doc's perms. + // Mirror appwrite/appwrite's createKey controller default — `dbForPlatform.keys` is + // gated by endpoint scope, not document perms, so this is the upstream invariant. + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], 'projectInternalId' => $this->projectInternalId, 'projectId' => $this->project, 'resourceInternalId' => $this->projectInternalId, From ffce3ca400e2407b78cc0ca3f2aa7b1fbc40c693 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 14 May 2026 10:03:28 +0100 Subject: [PATCH 07/11] Tighten permissions comment on createApiKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the endpoint-scope tangent — the comment is about why we picked this value, not about whether doc perms get enforced. --- src/Migration/Destinations/Appwrite.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 3430d6f4..f62fa09e 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -3151,9 +3151,8 @@ protected function createApiKey(ApiKey $resource): bool try { $this->dbForPlatform->createDocument('keys', new UtopiaDocument([ '$id' => ID::unique(), - // SDK's Key model doesn't expose $permissions, so we can't read the source doc's perms. - // Mirror appwrite/appwrite's createKey controller default — `dbForPlatform.keys` is - // gated by endpoint scope, not document perms, so this is the upstream invariant. + // SDK's Key model doesn't expose $permissions, so we can't copy source perms through. + // Mirror appwrite/appwrite's createKey controller so migrated docs match natively-created keys. '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), From 59479d04d2ab2ed94ed795a97bd08829486595ba Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 14 May 2026 10:48:55 +0100 Subject: [PATCH 08/11] Trim createApiKey permissions comment --- src/Migration/Destinations/Appwrite.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index f62fa09e..304366fb 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -3151,8 +3151,7 @@ protected function createApiKey(ApiKey $resource): bool try { $this->dbForPlatform->createDocument('keys', new UtopiaDocument([ '$id' => ID::unique(), - // SDK's Key model doesn't expose $permissions, so we can't copy source perms through. - // Mirror appwrite/appwrite's createKey controller so migrated docs match natively-created keys. + // Match upstream createKey — keys carry no per-doc perm semantics in Appwrite. '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), From f4718e26cb073b145f9333dd6a1655b402797ab2 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 14 May 2026 14:14:25 +0100 Subject: [PATCH 09/11] Query keys by resourceInternalId/resourceType, not projectInternalId projectInternalId is a written-but-not-queryable legacy column on the keys collection (upstream filter uses resourceInternalId + resourceType). Mirror that here so the duplicate-check find() doesn't error out. --- src/Migration/Destinations/Appwrite.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 304366fb..f71a2561 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -3135,7 +3135,8 @@ protected function createPlatform(Platform $resource): bool protected function createApiKey(ApiKey $resource): bool { $existing = $this->dbForPlatform->findOne('keys', [ - Query::equal('projectInternalId', [$this->projectInternalId]), + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$this->projectInternalId]), Query::equal('name', [$resource->getApiKeyName()]), ]); From 93715c256bf57cd93a1ef92e366c19f9a53ff21a Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 14 May 2026 15:53:20 +0100 Subject: [PATCH 10/11] Match upstream createKey schema in createApiKey Drop the removed projectInternalId/projectId legacy fields and use empty permissions, matching the current server-ce Project/Keys/Create action exactly. --- src/Migration/Destinations/Appwrite.php | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index f71a2561..04e6cf96 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -3152,14 +3152,7 @@ protected function createApiKey(ApiKey $resource): bool try { $this->dbForPlatform->createDocument('keys', new UtopiaDocument([ '$id' => ID::unique(), - // Match upstream createKey — keys carry no per-doc perm semantics in Appwrite. - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'projectInternalId' => $this->projectInternalId, - 'projectId' => $this->project, + '$permissions' => [], 'resourceInternalId' => $this->projectInternalId, 'resourceId' => $this->project, 'resourceType' => 'projects', From 0bfdd5320e2d37dcb4bee6b778a563cbc9d7ad54 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 14 May 2026 16:17:54 +0100 Subject: [PATCH 11/11] Pass through resource permissions to createApiKey Mirror createPlatform's pattern. Resolves to [] today since the SDK doesn't expose $permissions on Key responses, matching the upstream createKey endpoint default. Hook is in place if/when the SDK starts emitting permissions. --- src/Migration/Destinations/Appwrite.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 04e6cf96..1b7c4d23 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -3152,7 +3152,7 @@ protected function createApiKey(ApiKey $resource): bool try { $this->dbForPlatform->createDocument('keys', new UtopiaDocument([ '$id' => ID::unique(), - '$permissions' => [], + '$permissions' => $resource->getPermissions(), 'resourceInternalId' => $this->projectInternalId, 'resourceId' => $this->project, 'resourceType' => 'projects',