From 6e47fb6c7021e775ea468b0dcd42da5d10c91e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 23 Dec 2025 13:06:19 +0100 Subject: [PATCH] Implement auth for organization and account keys --- app/config/scopes/account.php | 13 +++ app/config/scopes/organization.php | 42 +++++++++ app/config/{scopes.php => scopes/project.php} | 0 app/controllers/api/projects.php | 6 +- app/controllers/mock.php | 2 +- app/init/configs.php | 4 +- app/init/constants.php | 2 + app/init/resources.php | 6 +- src/Appwrite/Auth/Key.php | 86 +++++++++++++++++++ .../Functions/Http/Functions/Create.php | 2 +- .../Functions/Http/Functions/Update.php | 2 +- src/Appwrite/Platform/Tasks/Screenshot.php | 2 +- 12 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 app/config/scopes/account.php create mode 100644 app/config/scopes/organization.php rename app/config/{scopes.php => scopes/project.php} (100%) diff --git a/app/config/scopes/account.php b/app/config/scopes/account.php new file mode 100644 index 0000000000..f11e49ca76 --- /dev/null +++ b/app/config/scopes/account.php @@ -0,0 +1,13 @@ + [ + "description" => 'Access to manage account, it\'s organizations, sessions, tokens, and billing.', + ],"teams.read" => [ + "description" => 'Access to read account\'s organizations.', + ],"teams.write" => [ + "description" => 'Access to create, update and delete account\'s organizations and it\'s memberships.', + ], +]; diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php new file mode 100644 index 0000000000..ca4160881d --- /dev/null +++ b/app/config/scopes/organization.php @@ -0,0 +1,42 @@ + [ + "description" => 'Access to read project\'s platforms', + ], + "platforms.write" => [ + "description" => + 'Access to create, update, and delete project\'s platforms', + ], + "projects.read" => [ + "description" => 'Access to read organization\'s projects', + ], + "projects.write" => [ + "description" => + "Access to create, update, and delete projects in organization", + ], + "keys.read" => [ + "description" => 'Access to read project\'s API keys', + ], + "keys.write" => [ + "description" => + "Access to create, update, and delete project\'s API keys", + ], + "devKeys.read" => [ + "description" => 'Access to read project\'s development keys', + ], + "devKeys.write" => [ + "description" => + "Access to create, update, and delete project\'s development keys", + ], + "webhooks.read" => [ + "description" => + "Access to read project\'s webhooks", + ], + "webhooks.write" => [ + "description" => + "Access to create, update, and delete project\'s webhooks", + ], +]; diff --git a/app/config/scopes.php b/app/config/scopes/project.php similarity index 100% rename from app/config/scopes.php rename to app/config/scopes/project.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 45a63e4966..c23ac05a6e 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1478,7 +1478,7 @@ App::post('/v1/projects/:projectId/keys') )) ->param('projectId', '', new UID(), 'Project unique ID.') ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('dbForPlatform') @@ -1620,7 +1620,7 @@ App::put('/v1/projects/:projectId/keys/:keyId') ->param('projectId', '', new UID(), 'Project unique ID.') ->param('keyId', '', new UID(), 'Key unique ID.') ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('dbForPlatform') @@ -1721,7 +1721,7 @@ App::post('/v1/projects/:projectId/jwts') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) ->inject('response') ->inject('dbForPlatform') diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 2c0ef443ee..29f35e9c3c 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -191,7 +191,7 @@ App::post('/v1/mock/api-key-unprefixed') throw new Exception(Exception::PROJECT_NOT_FOUND); } - $scopes = array_keys(Config::getParam('scopes')); + $scopes = array_keys(Config::getParam('projectScopes')); $key = new Document([ '$id' => ID::unique(), diff --git a/app/init/configs.php b/app/init/configs.php index 19be7755dd..d5748707cf 100644 --- a/app/init/configs.php +++ b/app/init/configs.php @@ -22,7 +22,9 @@ Config::load('collections', __DIR__ . '/../config/collections.php', $configAdapt Config::load('frameworks', __DIR__ . '/../config/frameworks.php', $configAdapter); Config::load('usage', __DIR__ . '/../config/usage.php', $configAdapter); Config::load('roles', __DIR__ . '/../config/roles.php', $configAdapter); // User roles and scopes -Config::load('scopes', __DIR__ . '/../config/scopes.php', $configAdapter); // User roles and scopes +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('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 diff --git a/app/init/constants.php b/app/init/constants.php index 78b8e3a5ae..3a8eb72e62 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -243,6 +243,8 @@ const MESSAGE_TYPE_PUSH = 'push'; // API key types const API_KEY_STANDARD = 'standard'; const API_KEY_DYNAMIC = 'dynamic'; +const API_KEY_ORGANIZATION = 'organization'; +const API_KEY_ACCOUNT = 'account'; // Usage metrics const METRIC_TEAMS = 'teams'; const METRIC_USERS = 'users'; diff --git a/app/init/resources.php b/app/init/resources.php index c59ef5553a..1db546e0d0 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -1072,15 +1072,15 @@ App::setResource('previewHostname', function (Request $request, ?Key $apiKey) { return ''; }, ['request', 'apiKey']); -App::setResource('apiKey', function (Request $request, Document $project): ?Key { +App::setResource('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key { $key = $request->getHeader('x-appwrite-key'); if (empty($key)) { return null; } - return Key::decode($project, $key); -}, ['request', 'project']); + return Key::decode($project, $team, $user, $key); +}, ['request', 'project', 'team', 'user']); App::setResource('executor', fn () => new Executor()); diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index b23f2cc816..7f2d27ed5e 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -15,6 +15,8 @@ class Key { public function __construct( protected string $projectId, + protected string $teamId, + protected string $userId, protected string $type, protected string $role, protected array $scopes, @@ -99,6 +101,8 @@ class Key */ public static function decode( Document $project, + Document $team, + Document $user, string $key ): Key { if (\str_contains($key, '_')) { @@ -115,6 +119,8 @@ class Key $guestKey = new Key( $project->getId(), + '', + '', $type, User::ROLE_GUESTS, $roles[User::ROLE_GUESTS]['scopes'] ?? [], @@ -152,6 +158,8 @@ class Key return new Key( $projectId, + '', + '', $type, $role, $scopes, @@ -185,12 +193,90 @@ class Key return new Key( $project->getId(), + '', + '', $type, $role, $scopes, $name, $expired ); + case API_KEY_ACCOUNT: + $key = $user->find( + key: 'secret', + find: $key, + subject: 'keys' + ); + + // Invalid key + if (!$key) { + return $guestKey; + } + + $expire = $key->getAttribute('expire'); + $expired = false; + if (!empty($expire) && $expire < DateTime::formatTz(DateTime::now())) { + $expired = true; + } + + $name = $key->getAttribute('name', 'UNKNOWN'); + + $role = User::ROLE_USERS; + + $roles = Config::getParam('roles', []); + $scopes = $roles[$role]['scopes'] ?? []; + $scopes = $key->getAttribute('scopes', []); + + $key = new Key( + '', + '', + $user->getId(), + $type, + $role, + $scopes, + $name, + $expired + ); + + return $key; + case API_KEY_ORGANIZATION: + $key = $team->find( + key: 'secret', + find: $key, + subject: 'keys' + ); + + // Invalid key + if (!$key) { + return $guestKey; + } + + $expire = $key->getAttribute('expire'); + $expired = false; + if (!empty($expire) && $expire < DateTime::formatTz(DateTime::now())) { + $expired = true; + } + + $name = $key->getAttribute('name', 'UNKNOWN'); + + $role = User::ROLE_APPS; + + $roles = Config::getParam('roles', []); + $scopes = $roles[$role]['scopes'] ?? []; + $scopes = $key->getAttribute('scopes', []); + + $key = new Key( + '', + $team->getId(), + '', + $type, + $role, + $scopes, + $name, + $expired + ); + + return $key; default: return $guestKey; } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 5c226c5925..94667a9fac 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -87,7 +87,7 @@ class Create extends Base ->param('logging', true, new Boolean(), 'When disabled, executions will exclude logs and errors, and will be slightly faster.', true) ->param('entrypoint', '', new Text(1028, 0), 'Entrypoint File. This path is relative to the "providerRootDirectory".', true) ->param('commands', '', new Text(8192, 0), 'Build Commands.', true) - ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API key auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true) + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API key auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true) ->param('installationId', '', new Text(128, 0), 'Appwrite Installation ID for VCS (Version Control System) deployment.', true) ->param('providerRepositoryId', '', new Text(128, 0), 'Repository ID of the repo linked to the function.', true) ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function.', true) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index adb29bc533..227ec3f026 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -83,7 +83,7 @@ class Update extends Base ->param('logging', true, new Boolean(), 'When disabled, executions will exclude logs and errors, and will be slightly faster.', true) ->param('entrypoint', '', new Text(1028, 0), 'Entrypoint File. This path is relative to the "providerRootDirectory".', true) ->param('commands', '', new Text(8192, 0), 'Build Commands.', true) - ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API Key auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true) + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API Key auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true) ->param('installationId', '', new Text(128, 0), 'Appwrite Installation ID for VCS (Version Controle System) deployment.', true) ->param('providerRepositoryId', null, new Nullable(new Text(128, 0)), 'Repository ID of the repo linked to the function', true) ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function', true) diff --git a/src/Appwrite/Platform/Tasks/Screenshot.php b/src/Appwrite/Platform/Tasks/Screenshot.php index 7ad95c6e72..4df3ab91df 100644 --- a/src/Appwrite/Platform/Tasks/Screenshot.php +++ b/src/Appwrite/Platform/Tasks/Screenshot.php @@ -190,7 +190,7 @@ class Screenshot extends Action 'cookie' => $cookieConsole ], [ 'name' => 'Screenshot API key', - 'scopes' => \array_keys(Config::getParam('scopes', [])) + 'scopes' => \array_keys(Config::getParam('projectScopes', [])) ]); if ($response['headers']['status-code'] !== 201) {