Compare commits

...
16 changed files with 407 additions and 15 deletions
+9
View File
@@ -0,0 +1,9 @@
<?php
// Resource types available for console project migration keys
return [
'platforms' => 'platforms.read',
'devKeys' => 'devKeys.read',
'devKeysWrite' => 'devKeys.write',
];
+56
View File
@@ -1,5 +1,6 @@
<?php
use Ahc\Jwt\JWT;
use Appwrite\Event\Event;
use Appwrite\Event\Migration;
use Appwrite\Extend\Exception;
@@ -14,7 +15,9 @@ use Appwrite\Utopia\Response;
use Utopia\Compression\Algorithms\GZIP;
use Utopia\Compression\Algorithms\Zstd;
use Utopia\Compression\Compression;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
@@ -719,6 +722,59 @@ Http::get('/v1/migrations/:migrationId')
$response->dynamic($migration, Response::MODEL_MIGRATION);
});
Http::post('/v1/migrations/appwrite/console-key')
->groups(['api', 'migrations'])
->desc('Create console API key for migration')
->label('scope', 'migrations.write')
->label('sdk', new Method(
namespace: 'migrations',
group: null,
name: 'createAppwriteConsoleKey',
description: '/docs/references/migrations/migration-appwrite-console-key.md',
auth: [AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MIGRATION_KEY,
)
]
))
->param('resources', [], new ArrayList(new WhiteList(\array_keys(Config::getParam('consoleProjectScopes')))), 'List of resource types to request access for.', true)
->inject('response')
->inject('project')
->action(function (array $resources, Response $response, Document $project) {
$consoleProjectScopes = Config::getParam('consoleProjectScopes');
$scopes = empty($resources)
? \array_values($consoleProjectScopes)
: \array_values(\array_intersect_key($consoleProjectScopes, \array_flip($resources)));
if (empty($scopes)) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
}
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', APP_CONSOLE_KEY_TTL, 0);
$consoleKey = $jwt->encode([
'projectId' => 'console',
'name' => 'Migration Settings Key',
'source' => KEY_SOURCE_MIGRATION,
'scopes' => $scopes,
'disabledMetrics' => [
METRIC_DATABASES_OPERATIONS_READS,
METRIC_DATABASES_OPERATIONS_WRITES,
METRIC_NETWORK_REQUESTS,
METRIC_NETWORK_INBOUND,
METRIC_NETWORK_OUTBOUND,
],
'scopedProjectId' => $project->getId(),
]);
$response->dynamic(new Document([
'key' => API_KEY_DYNAMIC . '_' . $consoleKey,
'expire' => DateTime::addSeconds(new \DateTime(), APP_CONSOLE_KEY_TTL),
]), Response::MODEL_MIGRATION_KEY);
});
Http::get('/v1/migrations/appwrite/report')
->groups(['api', 'migrations'])
->desc('Get Appwrite migration report')
+17
View File
@@ -338,6 +338,23 @@ Http::init()
$scopes = \array_unique($scopes);
// Migration-sourced keys are scoped to a single project's console
// endpoints (e.g. /v1/projects/:projectId/platforms). Verify the
// URL :projectId matches the token's scopedProjectId.
if (!empty($apiKey) && $apiKey->getSource() === KEY_SOURCE_MIGRATION) {
$scopedProjectId = $apiKey->getScopedProjectId();
if (empty($scopedProjectId)) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
}
$pathValues = $route->getPathValues($request);
if (($pathValues['projectId'] ?? '') !== $scopedProjectId) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
}
}
$authorization->addRole($role);
foreach ($user->getRoles($authorization) as $authRole) {
$authorization->addRole($authRole);
+1
View File
@@ -25,6 +25,7 @@ Config::load('roles', __DIR__ . '/../config/roles.php', $configAdapter); // Use
Config::load('projectScopes', __DIR__ . '/../config/scopes/project.php', $configAdapter);
Config::load('organizationScopes', __DIR__ . '/../config/scopes/organization.php', $configAdapter);
Config::load('accountScopes', __DIR__ . '/../config/scopes/account.php', $configAdapter);
Config::load('consoleProjectScopes', __DIR__ . '/../config/scopes/consoleProject.php', $configAdapter);
Config::load('services', __DIR__ . '/../config/services.php', $configAdapter); // List of services
Config::load('variables', __DIR__ . '/../config/variables.php', $configAdapter); // List of env variables
Config::load('regions', __DIR__ . '/../config/regions.php', $configAdapter); // List of available regions
+3
View File
@@ -41,6 +41,7 @@ const APP_LIMIT_LIST_DEFAULT = 25; // Default maximum number of items to return
const APP_LIMIT_DATABASE_BATCH = 100; // Default maximum batch size for database operations
const APP_LIMIT_DATABASE_TRANSACTION = 100; // Default maximum operations per transaction
const APP_KEY_ACCESS = 24 * 60 * 60; // 24 hours
const APP_CONSOLE_KEY_TTL = 120; // 2 minutes
const APP_USER_ACCESS = 24 * 60 * 60; // 24 hours
const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours
const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours
@@ -256,6 +257,8 @@ const API_KEY_STANDARD = 'standard';
const API_KEY_DYNAMIC = 'dynamic';
const API_KEY_ORGANIZATION = 'organization';
const API_KEY_ACCOUNT = 'account';
// API key source identifiers
const KEY_SOURCE_MIGRATION = 'migration';
// Usage metrics
const METRIC_TEAMS = 'teams';
const METRIC_USERS = 'users';
+2
View File
@@ -101,6 +101,7 @@ use Appwrite\Utopia\Response\Model\MFARecoveryCodes;
use Appwrite\Utopia\Response\Model\MFAType;
use Appwrite\Utopia\Response\Model\Migration;
use Appwrite\Utopia\Response\Model\MigrationFirebaseProject;
use Appwrite\Utopia\Response\Model\MigrationKey;
use Appwrite\Utopia\Response\Model\MigrationReport;
use Appwrite\Utopia\Response\Model\Mock;
use Appwrite\Utopia\Response\Model\MockNumber;
@@ -379,6 +380,7 @@ Response::setModel(new Subscriber());
Response::setModel(new Target());
Response::setModel(new Migration());
Response::setModel(new MigrationReport());
Response::setModel(new MigrationKey());
Response::setModel(new MigrationFirebaseProject());
// Tests (keep last)
+1 -1
View File
@@ -70,7 +70,7 @@
"utopia-php/locale": "0.8.*",
"utopia-php/logger": "0.6.*",
"utopia-php/messaging": "0.20.*",
"utopia-php/migration": "1.8.0",
"utopia-php/migration": "dev-add-dev-key-migration as 1.8.0",
"utopia-php/platform": "0.7.*",
"utopia-php/pools": "1.*",
"utopia-php/span": "1.1.*",
Generated
+18 -9
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f6907572c33d73d7b98a29c4fe9ce652",
"content-hash": "a487482e621dfe0514f1fdcd78e6b70a",
"packages": [
{
"name": "adhocore/jwt",
@@ -4517,16 +4517,16 @@
},
{
"name": "utopia-php/migration",
"version": "1.8.0",
"version": "dev-add-dev-key-migration",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
"reference": "b5626b9b05026b381345a6fac554a123b33084aa"
"reference": "8577c62a77415ced8b70713901d00750c83495a9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/b5626b9b05026b381345a6fac554a123b33084aa",
"reference": "b5626b9b05026b381345a6fac554a123b33084aa",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/8577c62a77415ced8b70713901d00750c83495a9",
"reference": "8577c62a77415ced8b70713901d00750c83495a9",
"shasum": ""
},
"require": {
@@ -4566,9 +4566,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
"source": "https://github.com/utopia-php/migration/tree/1.8.0"
"source": "https://github.com/utopia-php/migration/tree/add-dev-key-migration"
},
"time": "2026-03-10T12:50:35+00:00"
"time": "2026-03-16T21:06:31+00:00"
},
{
"name": "utopia-php/mongo",
@@ -9105,9 +9105,18 @@
"time": "2024-03-07T20:33:40+00:00"
}
],
"aliases": [],
"aliases": [
{
"package": "utopia-php/migration",
"version": "dev-add-dev-key-migration",
"alias": "1.8.0",
"alias_normalized": "1.8.0.0"
}
],
"minimum-stability": "dev",
"stability-flags": {},
"stability-flags": {
"utopia-php/migration": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
@@ -0,0 +1 @@
Generate a temporary console-scoped API key for migrating project settings (platforms, keys, schedules).
+24 -2
View File
@@ -28,6 +28,8 @@ class Key
protected bool $projectCheckDisabled = false,
protected bool $previewAuthDisabled = false,
protected bool $deploymentStatusIgnored = false,
protected string $scopedProjectId = '',
protected string $source = '',
) {
}
@@ -103,6 +105,16 @@ class Key
return $this->projectCheckDisabled;
}
public function getScopedProjectId(): string
{
return $this->scopedProjectId;
}
public function getSource(): string
{
return $this->source;
}
/**
* Decode the given secret key into a Key object, containing the project ID, type, role, scopes, and name.
* Can be a stored API key or a dynamic key (JWT).
@@ -161,7 +173,15 @@ class Key
$projectCheckDisabled = $payload['projectCheckDisabled'] ?? false;
$previewAuthDisabled = $payload['previewAuthDisabled'] ?? false;
$deploymentStatusIgnored = $payload['deploymentStatusIgnored'] ?? false;
$scopes = \array_merge($payload['scopes'] ?? [], $scopes);
$scopedProjectId = $payload['scopedProjectId'] ?? '';
$source = $payload['source'] ?? '';
// Keys with a scoped project are restricted — only use explicit JWT scopes
if (!empty($scopedProjectId)) {
$scopes = $payload['scopes'] ?? [];
} else {
$scopes = \array_merge($payload['scopes'] ?? [], $scopes);
}
if (!$projectCheckDisabled && $projectId !== $project->getId()) {
return $guestKey;
@@ -181,7 +201,9 @@ class Key
$bannerDisabled,
$projectCheckDisabled,
$previewAuthDisabled,
$deploymentStatusIgnored
$deploymentStatusIgnored,
$scopedProjectId,
$source
);
case API_KEY_STANDARD:
$key = $project->find(
+3 -3
View File
@@ -269,6 +269,8 @@ class Migrations extends Action
$this->dbForProject,
$this->getDatabasesDB,
Config::getParam('collections', [])['databases']['collections'],
$this->dbForPlatform,
$this->project->getSequence(),
),
DestinationCSV::getName() => new DestinationCSV(
$this->deviceForFiles,
@@ -309,9 +311,6 @@ class Migrations extends Action
);
}
/**
* @throws Exception
*/
protected function generateAPIKey(Document $project): string
{
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400, 0);
@@ -354,6 +353,7 @@ class Migrations extends Action
'messages.write',
'targets.read',
'targets.write',
'migrations.write',
]
]);
+1
View File
@@ -241,6 +241,7 @@ class Response extends SwooleResponse
public const MODEL_MIGRATION = 'migration';
public const MODEL_MIGRATION_LIST = 'migrationList';
public const MODEL_MIGRATION_REPORT = 'migrationReport';
public const MODEL_MIGRATION_KEY = 'migrationKey';
public const MODEL_MIGRATION_FIREBASE_PROJECT = 'firebaseProject';
public const MODEL_MIGRATION_FIREBASE_PROJECT_LIST = 'firebaseProjectList';
@@ -0,0 +1,46 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class MigrationKey extends Model
{
public function __construct()
{
$this
->addRule('key', [
'type' => self::TYPE_STRING,
'description' => 'Temporary API key for settings migration.',
'default' => '',
'example' => 'dynamic_eyJ...',
])
->addRule('expire', [
'type' => self::TYPE_DATETIME,
'description' => 'Key expiration date in ISO 8601 format.',
'default' => '',
'example' => self::TYPE_DATETIME_EXAMPLE,
]);
}
/**
* Get Name
*
* @return string
*/
public function getName(): string
{
return 'Migration Key';
}
/**
* Get Type
*
* @return string
*/
public function getType(): string
{
return Response::MODEL_MIGRATION_KEY;
}
}
@@ -53,6 +53,18 @@ class MigrationReport extends Model
'default' => 0,
'example' => 20,
])
->addRule(Resource::TYPE_PLATFORM, [
'type' => self::TYPE_INTEGER,
'description' => 'Number of platforms to be migrated.',
'default' => 0,
'example' => 5,
])
->addRule(Resource::TYPE_DEV_KEY, [
'type' => self::TYPE_INTEGER,
'description' => 'Number of dev keys to be migrated.',
'default' => 0,
'example' => 5,
])
->addRule(Resource::TYPE_SITE, [
'type' => self::TYPE_INTEGER,
'description' => 'Number of sites to be migrated.',
@@ -1192,6 +1192,196 @@ trait MigrationsBase
return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath));
}
/**
* Integrations
*/
public function testGetAppwriteConsoleKey(): void
{
$response = $this->client->call(Client::METHOD_POST, '/migrations/appwrite/console-key', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['key']);
$this->assertStringStartsWith('dynamic_', $response['body']['key']);
$this->assertNotEmpty($response['body']['expire']);
$this->assertGreaterThan(new \DateTime(), new \DateTime($response['body']['expire']));
}
public function testAppwriteMigrationPlatform(): void
{
$consoleSessionHeaders = [
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
'origin' => 'http://localhost',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
];
// Create platform on source project
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $this->getProject()['$id'] . '/platforms', $consoleSessionHeaders, [
'type' => 'web',
'name' => 'Test Platform',
'hostname' => 'localhost',
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']);
$this->assertNotEmpty($response['body']['$id']);
$platform = $response['body'];
$result = $this->performMigrationSync([
'resources' => [
Resource::TYPE_PLATFORM,
],
'endpoint' => $this->webEndpoint,
'projectId' => $this->getProject()['$id'],
'apiKey' => $this->getProject()['apiKey'],
]);
$this->assertEquals('completed', $result['status']);
$this->assertEquals([Resource::TYPE_PLATFORM], $result['resources']);
$this->assertArrayHasKey(Resource::TYPE_PLATFORM, $result['statusCounters']);
$this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PLATFORM]['error']);
$this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PLATFORM]['pending']);
$this->assertEquals(1, $result['statusCounters'][Resource::TYPE_PLATFORM]['success']);
$this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PLATFORM]['processing']);
$this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PLATFORM]['warning']);
// Get a console key for the destination project to access console-scoped endpoints
$consoleKeyResponse = $this->client->call(Client::METHOD_POST, '/migrations/appwrite/console-key', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getDestinationProject()['$id'],
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
]);
$this->assertEquals(200, $consoleKeyResponse['headers']['status-code']);
$destConsoleKey = $consoleKeyResponse['body']['key'];
// Verify platform on destination project using console key
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $this->getDestinationProject()['$id'] . '/platforms', [
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
'x-appwrite-key' => $destConsoleKey,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']);
$this->assertGreaterThan(0, $response['body']['total']);
$foundPlatform = null;
foreach ($response['body']['platforms'] as $p) {
if ($p['name'] === 'Test Platform' && $p['type'] === 'web') {
$foundPlatform = $p;
break;
}
}
$this->assertNotNull($foundPlatform);
$this->assertEquals('web', $foundPlatform['type']);
$this->assertEquals('Test Platform', $foundPlatform['name']);
$this->assertEquals('localhost', $foundPlatform['hostname']);
// Cleanup on destination using console key
$this->client->call(Client::METHOD_DELETE, '/projects/' . $this->getDestinationProject()['$id'] . '/platforms/' . $foundPlatform['$id'], [
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
'x-appwrite-key' => $destConsoleKey,
]);
// Cleanup on source using console project + session auth
$this->client->call(Client::METHOD_DELETE, '/projects/' . $this->getProject()['$id'] . '/platforms/' . $platform['$id'], $consoleSessionHeaders);
}
public function testAppwriteMigrationDevKey(): void
{
$consoleSessionHeaders = [
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
'origin' => 'http://localhost',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
];
// Create dev key on source project
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $this->getProject()['$id'] . '/dev-keys', $consoleSessionHeaders, [
'name' => 'Test Dev Key',
'expire' => '2030-01-01T00:00:00.000+00:00',
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']);
$this->assertNotEmpty($response['body']['$id']);
$devKey = $response['body'];
$result = $this->performMigrationSync([
'resources' => [
Resource::TYPE_DEV_KEY,
],
'endpoint' => $this->webEndpoint,
'projectId' => $this->getProject()['$id'],
'apiKey' => $this->getProject()['apiKey'],
]);
$this->assertEquals('completed', $result['status']);
$this->assertEquals([Resource::TYPE_DEV_KEY], $result['resources']);
$this->assertArrayHasKey(Resource::TYPE_DEV_KEY, $result['statusCounters']);
$this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DEV_KEY]['error']);
$this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DEV_KEY]['pending']);
$this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DEV_KEY]['success']);
$this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DEV_KEY]['processing']);
$this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DEV_KEY]['warning']);
// Get a console key for the destination project to access console-scoped endpoints
$consoleKeyResponse = $this->client->call(Client::METHOD_POST, '/migrations/appwrite/console-key', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getDestinationProject()['$id'],
'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
]);
$this->assertEquals(200, $consoleKeyResponse['headers']['status-code']);
$destConsoleKey = $consoleKeyResponse['body']['key'];
// Verify dev key on destination project using console key
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $this->getDestinationProject()['$id'] . '/dev-keys', [
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
'x-appwrite-key' => $destConsoleKey,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']);
$this->assertGreaterThan(0, $response['body']['total']);
$foundKey = null;
foreach ($response['body']['devKeys'] as $k) {
if ($k['name'] === 'Test Dev Key') {
$foundKey = $k;
break;
}
}
$this->assertNotNull($foundKey);
$this->assertEquals('Test Dev Key', $foundKey['name']);
$this->assertEquals('2030-01-01T00:00:00.000+00:00', $foundKey['expire']);
// Cleanup on destination using console key
$this->client->call(Client::METHOD_DELETE, '/projects/' . $this->getDestinationProject()['$id'] . '/dev-keys/' . $foundKey['$id'], [
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
'x-appwrite-key' => $destConsoleKey,
]);
// Cleanup on source using console project + session auth
$this->client->call(Client::METHOD_DELETE, '/projects/' . $this->getProject()['$id'] . '/dev-keys/' . $devKey['$id'], $consoleSessionHeaders);
}
/**
* Import documents from a CSV file.
*/
+23
View File
@@ -49,6 +49,7 @@ class KeyTest extends TestCase
'projectCheckDisabled' => true,
'previewAuthDisabled' => true,
'deploymentStatusIgnored' => true,
'source' => KEY_SOURCE_MIGRATION,
];
$key = static::generateKey($projectId, $usage, $scopes, extra: $extra);
$decoded = Key::decode(
@@ -70,6 +71,28 @@ class KeyTest extends TestCase
$this->assertEquals(true, $decoded->isProjectCheckDisabled());
$this->assertEquals(true, $decoded->isPreviewAuthDisabled());
$this->assertEquals(true, $decoded->isDeploymentStatusIgnored());
$this->assertEquals(KEY_SOURCE_MIGRATION, $decoded->getSource());
// Decode dynamic key with scopedProjectId — scopes must NOT be merged with role scopes
$scopedProjectId = 'scoped-project-123';
$extra = [
'scopedProjectId' => $scopedProjectId,
'source' => KEY_SOURCE_MIGRATION,
];
$key = static::generateKey('console', $usage, $scopes, extra: $extra);
$decoded = Key::decode(
project: new Document(['$id' => 'console']),
team: new Document(),
user: new Document(),
key: $key,
);
$this->assertEquals('console', $decoded->getProjectId());
$this->assertEquals(API_KEY_DYNAMIC, $decoded->getType());
$this->assertEquals(User::ROLE_APPS, $decoded->getRole());
$this->assertEquals($scopes, $decoded->getScopes());
$this->assertNotEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes());
$this->assertEquals($scopedProjectId, $decoded->getScopedProjectId());
$this->assertEquals(KEY_SOURCE_MIGRATION, $decoded->getSource());
// Decode invalid dynamic key
$invalidKey = API_KEY_DYNAMIC . '_invalid_jwt_token';