Restrict migration console key with targetProjectId enforcement

- Short-lived JWT (120s TTL) for console key
- No APPS scope merge when targetProjectId is set in Key.php
- Generalized route restriction: migration keys only work on routes with matching :projectId param
- Remove per-endpoint migrationKey labels and targetProjectId checks
This commit is contained in:
Prem Palanisamy
2026-02-12 20:39:05 +00:00
parent 0cee469d73
commit 97fb4cb4b6
5 changed files with 46 additions and 41 deletions
+1 -1
View File
@@ -724,7 +724,7 @@ Http::get('/v1/migrations/appwrite/settings-key')
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
}
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400, 0);
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 120, 0);
$consoleKey = $jwt->encode([
'projectId' => 'console',
'scopes' => $scopes,
+8 -29
View File
@@ -1,7 +1,6 @@
<?php
use Ahc\Jwt\JWT;
use Appwrite\Auth\Key;
use Appwrite\Auth\Validator\MockNumber;
use Appwrite\Event\Delete;
use Appwrite\Event\Mail;
@@ -1151,6 +1150,7 @@ Http::get('/v1/projects/:projectId/keys')
->desc('List keys')
->groups(['api', 'projects'])
->label('scope', 'keys.read')
->label('sdk', new Method(
namespace: 'projects',
group: 'keys',
@@ -1169,13 +1169,7 @@ Http::get('/v1/projects/:projectId/keys')
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForPlatform')
->inject('apiKey')
->action(function (string $projectId, array $queries, bool $includeTotal, Response $response, Database $dbForPlatform, ?Key $apiKey) {
if ($apiKey !== null && !empty($apiKey->getTargetProjectId()) && $apiKey->getTargetProjectId() !== $projectId) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
}
->action(function (string $projectId, array $queries, bool $includeTotal, Response $response, Database $dbForPlatform) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
@@ -1229,6 +1223,7 @@ Http::get('/v1/projects/:projectId/keys/:keyId')
->desc('Get key')
->groups(['api', 'projects'])
->label('scope', 'keys.read')
->label('sdk', new Method(
namespace: 'projects',
group: 'keys',
@@ -1246,13 +1241,7 @@ Http::get('/v1/projects/:projectId/keys/:keyId')
->param('keyId', '', new UID(), 'Key unique ID.')
->inject('response')
->inject('dbForPlatform')
->inject('apiKey')
->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform, ?Key $apiKey) {
if ($apiKey !== null && !empty($apiKey->getTargetProjectId()) && $apiKey->getTargetProjectId() !== $projectId) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
}
->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
@@ -1501,6 +1490,7 @@ Http::get('/v1/projects/:projectId/platforms')
->desc('List platforms')
->groups(['api', 'projects'])
->label('scope', 'platforms.read')
->label('sdk', new Method(
namespace: 'projects',
group: 'platforms',
@@ -1518,13 +1508,7 @@ Http::get('/v1/projects/:projectId/platforms')
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForPlatform')
->inject('apiKey')
->action(function (string $projectId, bool $includeTotal, Response $response, Database $dbForPlatform, ?Key $apiKey) {
if ($apiKey !== null && !empty($apiKey->getTargetProjectId()) && $apiKey->getTargetProjectId() !== $projectId) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
}
->action(function (string $projectId, bool $includeTotal, Response $response, Database $dbForPlatform) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
@@ -1546,6 +1530,7 @@ Http::get('/v1/projects/:projectId/platforms/:platformId')
->desc('Get platform')
->groups(['api', 'projects'])
->label('scope', 'platforms.read')
->label('sdk', new Method(
namespace: 'projects',
group: 'platforms',
@@ -1563,13 +1548,7 @@ Http::get('/v1/projects/:projectId/platforms/:platformId')
->param('platformId', '', new UID(), 'Platform unique ID.')
->inject('response')
->inject('dbForPlatform')
->inject('apiKey')
->action(function (string $projectId, string $platformId, Response $response, Database $dbForPlatform, ?Key $apiKey) {
if ($apiKey !== null && !empty($apiKey->getTargetProjectId()) && $apiKey->getTargetProjectId() !== $projectId) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
}
->action(function (string $projectId, string $platformId, Response $response, Database $dbForPlatform) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
+11
View File
@@ -273,6 +273,17 @@ Http::init()
$scopes = \array_unique($scopes);
// Migration-scoped keys (with targetProjectId) can only access routes
// that have a :projectId param matching the key's target project.
// This allows any /v1/projects/:projectId/* endpoint to be used during
// migration without needing per-route labels, while blocking all other routes.
if (!empty($apiKey) && !empty($apiKey->getTargetProjectId())) {
$routeProjectId = $request->getParam('projectId', '');
if (empty($routeProjectId) || $routeProjectId !== $apiKey->getTargetProjectId()) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE);
}
}
$authorization->addRole($role);
foreach ($user->getRoles($authorization) as $authRole) {
$authorization->addRole($authRole);
Generated
+19 -10
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": "57189be7990142e6a44a13eefdfe3043",
"content-hash": "1988c2437549ecb68bf6dfa294598a5a",
"packages": [
{
"name": "adhocore/jwt",
@@ -4464,16 +4464,16 @@
},
{
"name": "utopia-php/migration",
"version": "1.5.2",
"version": "dev-feat-platform-key-migration",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
"reference": "93904948f6dd07491821615fd9b9acbcaadec12e"
"reference": "aee64c99d03bd04d2961c678bc934bd727c081ec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/93904948f6dd07491821615fd9b9acbcaadec12e",
"reference": "93904948f6dd07491821615fd9b9acbcaadec12e",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/aee64c99d03bd04d2961c678bc934bd727c081ec",
"reference": "aee64c99d03bd04d2961c678bc934bd727c081ec",
"shasum": ""
},
"require": {
@@ -4513,9 +4513,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
"source": "https://github.com/utopia-php/migration/tree/1.5.2"
"source": "https://github.com/utopia-php/migration/tree/feat-platform-key-migration"
},
"time": "2026-02-11T06:19:35+00:00"
"time": "2026-02-12T15:26:26+00:00"
},
{
"name": "utopia-php/mongo",
@@ -8893,9 +8893,18 @@
"time": "2024-03-07T20:33:40+00:00"
}
],
"aliases": [],
"aliases": [
{
"package": "utopia-php/migration",
"version": "dev-feat-platform-key-migration",
"alias": "1.5.0",
"alias_normalized": "1.5.0.0"
}
],
"minimum-stability": "stable",
"stability-flags": {},
"stability-flags": {
"utopia-php/migration": 20
},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
@@ -8919,5 +8928,5 @@
"platform-overrides": {
"php": "8.3"
},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}
+7 -1
View File
@@ -168,7 +168,13 @@ class Key
$previewAuthDisabled = $payload['previewAuthDisabled'] ?? false;
$deploymentStatusIgnored = $payload['deploymentStatusIgnored'] ?? false;
$targetProjectId = $payload['targetProjectId'] ?? '';
$scopes = \array_merge($payload['scopes'] ?? [], $scopes);
// Keys with targetProjectId are restricted — only use explicit JWT scopes
if (!empty($targetProjectId)) {
$scopes = $payload['scopes'] ?? [];
} else {
$scopes = \array_merge($payload['scopes'] ?? [], $scopes);
}
if (!$projectCheckDisabled && $projectId !== $project->getId()) {
return $guestKey;