diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index d3248b54ce..1798d31c58 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -10,6 +10,7 @@ use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; +use Appwrite\Event\Message\Audit as AuditMessage; use Appwrite\Event\Message\Usage as UsageMessage; use Appwrite\Event\Messaging; use Appwrite\Event\Publisher\Audit; @@ -64,7 +65,6 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar if (array_key_exists($replace, $params)) { $replacement = $params[$replace]; - // Convert to string if it's not already a string if (! is_string($replacement)) { if (is_array($replacement)) { $replacement = json_encode($replacement); @@ -104,83 +104,27 @@ Http::init() throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND); } - /** - * Handle user authentication and session validation. - * - * This function follows a series of steps to determine the appropriate user session - * based on cookies, headers, and JWT tokens. - * - * Process: - * - * Project & Role Validation: - * 1. Check if the project is empty. If so, throw an exception. - * 2. Get the roles configuration. - * 3. Determine the role for the user based on the user document. - * 4. Get the scopes for the role. - * - * API Key Authentication: - * 5. If there is an API key: - * - Verify no user session exists simultaneously - * - Check if key is expired - * - Set role and scopes from API key - * - Handle special app role case - * - For standard keys, update last accessed time - * - * User Activity: - * 6. If the project is not the console and user is not admin: - * - Update user's last activity timestamp - * - * Access Control: - * 7. Get the method from the route - * 8. Validate namespace permissions - * 9. Validate scope permissions - * 10. Check if user is blocked - * - * Security Checks: - * 11. Verify password status (check if reset required) - * 12. Validate MFA requirements: - * - Check if MFA is enabled - * - Verify email status - * - Verify phone status - * - Verify authenticator status - * 13. Handle Multi-Factor Authentication: - * - Check remaining required factors - * - Validate factor completion - * - Throw exception if factors incomplete - */ - - // Step 1: Check if project is empty if ($project->isEmpty()) { throw new Exception(Exception::PROJECT_NOT_FOUND); } - // Step 2: Get roles configuration $roles = Config::getParam('roles', []); - // Step 3: Determine role for user - // TODO get scopes from the identity instead of the user roles config. The identity will containn the scopes the user authorized for the access token. - $role = $user->isEmpty() ? Role::guests()->toString() : Role::users()->toString(); - // Step 4: Get scopes for the role $scopes = $roles[$role]['scopes']; - // Step 5: API Key Authentication if (! empty($apiKey)) { - // Check if key is expired if ($apiKey->isExpired()) { throw new Exception(Exception::PROJECT_KEY_EXPIRED); } - // Set role and scopes from API key $role = $apiKey->getRole(); $scopes = $apiKey->getScopes(); - // Handle special app role case if ($apiKey->getRole() === User::ROLE_APPS) { - // Disable authorization checks for project API keys if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) { $authorization->setDefaultStatus(false); } @@ -197,7 +141,6 @@ Http::init() $auditContext->user = $user; } - // For standard keys, update last accessed time if (\in_array($apiKey->getType(), [API_KEY_STANDARD, API_KEY_ORGANIZATION, API_KEY_ACCOUNT])) { $dbKey = null; if (! empty($apiKey->getProjectId())) { @@ -268,7 +211,6 @@ Http::init() $auditContext->user = $userClone; } - // Apply permission if ($apiKey->getType() === API_KEY_ORGANIZATION) { $authorization->addRole(Role::team($team->getId())->toString()); $authorization->addRole(Role::team($team->getId(), 'owner')->toString()); @@ -296,8 +238,7 @@ Http::init() $authorization->addRole('label:' . $nodeLabel); } } - } // Admin User Authentication - elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) { + } elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) { $teamId = $team->getId(); $adminRoles = []; $memberships = $user->getAttribute('memberships', []); @@ -318,8 +259,6 @@ Http::init() $projectId = explode('/', $uri)[3]; } - // Base scopes for admin users to allow listing teams and projects. - // Useful for those who have project-specific roles but don't have team-wide role. $scopes = ['teams.read', 'projects.read']; foreach ($adminRoles as $adminRole) { $isTeamWideRole = ! str_starts_with($adminRole, 'project-'); @@ -336,22 +275,15 @@ Http::init() } } - /** - * For console projects resource, we use platform DB. - * Enabling authorization restricts admin user to the projects they have access to. - */ if ($project->getId() === 'console' && ($route->getPath() === '/v1/projects' || $route->getPath() === '/v1/projects/:projectId')) { $authorization->setDefaultStatus(true); } else { - // Otherwise, disable authorization checks. $authorization->setDefaultStatus(false); } } $scopes = \array_unique($scopes); - // Intentional: impersonators get users.read so they can discover a target user - // before impersonation starts, and keep that access while impersonating. if ( !$user->isEmpty() && ( @@ -368,11 +300,6 @@ Http::init() $authorization->addRole($authRole); } - /** - * We disable authorization checks above to ensure other endpoints (list teams, members, etc.) will continue working. - * But, for actions on resources (sites, functions, etc.) in a non-console project, we explicitly check - * whether the admin user has necessary permission on the project (sites, functions, etc. don't have permissions associated to them). - */ if (empty($apiKey) && ! $user->isEmpty() && $project->getId() !== 'console' && $mode === APP_MODE_ADMIN) { $input = new Input(Database::PERMISSION_READ, $project->getPermissionsByType(Database::PERMISSION_READ)); $initialStatus = $authorization->getStatus(); @@ -383,7 +310,6 @@ Http::init() $authorization->setStatus($initialStatus); } - // Step 6: Update project and user last activity if (! $project->isEmpty() && $project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { @@ -397,7 +323,6 @@ Http::init() $impersonatorUserId = $user->getAttribute('impersonatorUserId'); $accessedAt = $user->getAttribute('accessedAt', 0); - // Skip updating accessedAt for impersonated requests so we don't attribute activity to the target user. if (! $impersonatorUserId && DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCESS)) > $accessedAt) { $user->setAttribute('accessedAt', DateTime::now()); @@ -413,14 +338,8 @@ Http::init() } } - // Steps 7-9: Access Control - Method, Namespace and Scope Validation - /** - * @var ?Method $method - */ $method = $route->getLabel('sdk', false); - // Take the first method if there's more than one, - // namespace can not differ between methods on the same route if (\is_array($method)) { $method = $method[0]; } @@ -437,7 +356,6 @@ Http::init() } } - // Step 8b: Check REST protocol status if ( array_key_exists('rest', $project->getAttribute('apis', [])) && ! $project->getAttribute('apis', [])['rest'] @@ -446,23 +364,19 @@ Http::init() throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } - // Step 9: Validate scope permissions $allowed = (array) $route->getLabel('scope', 'none'); if (empty(\array_intersect($allowed, $scopes))) { throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scopes (' . \json_encode($allowed) . ')'); } - // Step 10: Check if user is blocked - if ($user->getAttribute('status') === false) { // Account is blocked + if ($user->getAttribute('status') === false) { throw new Exception(Exception::USER_BLOCKED); } - // Step 11: Verify password status if ($user->getAttribute('reset')) { throw new Exception(Exception::USER_PASSWORD_RESET_REQUIRED); } - // Step 12: Validate MFA requirements $mfaEnabled = $user->getAttribute('mfa', false); $hasVerifiedEmail = $user->getAttribute('emailVerification', false); $hasVerifiedPhone = $user->getAttribute('phoneVerification', false); @@ -470,7 +384,6 @@ Http::init() $hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator; $minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1; - // Step 13: Handle Multi-Factor Authentication if (! in_array('mfa', $route->getGroups())) { if ($session && \count($session->getAttribute('factors', [])) < $minimumFactors) { throw new Exception(Exception::USER_MORE_FACTORS_REQUIRED); @@ -515,83 +428,36 @@ Http::init() } $path = $route->getMatchedPath(); - $databaseType = match (true) { - str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, - str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, - default => '', - }; - - /* - * Abuse Check - */ - - $abuseKeyLabel = $route->getLabel('abuse-key', 'url:{url},ip:{ip}'); - $timeLimitArray = []; - - $abuseKeyLabel = (! is_array($abuseKeyLabel)) ? [$abuseKeyLabel] : $abuseKeyLabel; - - foreach ($abuseKeyLabel as $abuseKey) { - $start = $request->getContentRangeStart(); - $end = $request->getContentRangeEnd(); - $timeLimit = $timelimit($abuseKey, $route->getLabel('abuse-limit', 0), $route->getLabel('abuse-time', 3600)); - $timeLimit - ->setParam('{projectId}', $project->getId()) - ->setParam('{userId}', $user->getId()) - ->setParam('{userAgent}', $request->getUserAgent('')) - ->setParam('{ip}', $request->getIP()) - ->setParam('{url}', $request->getHostname() . $route->getPath()) - ->setParam('{method}', $request->getMethod()) - ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); - $timeLimitArray[] = $timeLimit; + if (strpos($request->getProtocol(), 'http') === 0) { + $path = $request->getProtocol() . '://' . $request->getHostname() . $path; } - - $closestLimit = null; - - $roles = $authorization->getRoles(); - $isPrivilegedUser = $user->isPrivileged($roles); - $isAppUser = $user->isApp($roles); - - foreach ($timeLimitArray as $timeLimit) { - foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys - if (! empty($value)) { - $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); - } - } - - $abuse = new Abuse($timeLimit); - $remaining = $timeLimit->remaining(); - - $limit = $timeLimit->limit(); - $time = $timeLimit->time() + $route->getLabel('abuse-time', 3600); - - if ($limit && ($remaining < $closestLimit || is_null($closestLimit))) { - $closestLimit = $remaining; - $response - ->addHeader('X-RateLimit-Limit', $limit) - ->addHeader('X-RateLimit-Remaining', $remaining) - ->addHeader('X-RateLimit-Reset', $time); - } - - $enabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'; - - if ( - $enabled // Abuse is enabled - && ! $isAppUser // User is not API key - && ! $isPrivilegedUser // User is not an admin - && $devKey->isEmpty() // request doesn't not contain development key - && $abuse->check() // Route is rate-limited - ) { - throw new Exception(Exception::GENERAL_RATE_LIMIT_EXCEEDED); + if (strpos($path, ':') !== false) { + $params = $route->getParams(); + foreach ($params as $key => $param) { + $path = str_replace(':' . $key, $param, $path); } } - /** - * TODO: (@loks0n) - * Avoid mutating the message across file boundaries - it's difficult to reason about at scale. - */ - /* - * Background Jobs - */ + $response + ->addHeader('X-Debug-Speed', APP_VERSION_STABLE) + ->addHeader('X-Appwrite-Project', $project->getId()) + ->addHeader('X-Appwrite-Region', System::getEnv('_APP_REGION', 'fra')); + + if (! empty(APP_OPTIONS_ABUSE)) { + $response->addHeader('X-Appwrite-Abuse-Limit', APP_OPTIONS_ABUSE); + } + + $request + ->setProtocol($request->getProtocol()) + ->setHostname($request->getHostname()) + ->setPath($path) + ->setMethod($request->getMethod()) + ->setProject($project) + ->setUser($user); + + $route->setLabel('sdk.url', $path); + $route->setLabel('sdk.name', $project->getAttribute('name')); + $queueForEvents ->setEvent($route->getLabel('event', '')) ->setProject($project) @@ -604,17 +470,14 @@ Http::init() $auditContext->event = $route->getLabel('audits.event', ''); $auditContext->project = $project; - /* If a session exists, use the user associated with the session */ if (! $user->isEmpty()) { $userClone = clone $user; - // $user doesn't support `type` and can cause unintended effects. if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } $auditContext->user = $userClone; } - /* Auto-set projects */ $queueForDeletes->setProject($project); $queueForDatabase->setProject($project); $queueForMessaging->setProject($project); @@ -622,7 +485,6 @@ Http::init() $queueForBuilds->setProject($project); $queueForMails->setProject($project); - /* Auto-set platforms */ $queueForFunctions->setPlatform($platform); $queueForBuilds->setPlatform($platform); $queueForMails->setPlatform($platform); @@ -640,7 +502,7 @@ Http::init() $cache = new Cache( new Filesystem(APP_STORAGE_CACHE . DIRECTORY_SEPARATOR . 'app-' . $project->getId()) ); - $timestamp = 60 * 60 * 24 * 180; // Temporarily increase the TTL to 180 day to ensure files in the cache are still fetched. + $timestamp = 60 * 60 * 24 * 180; $data = $cache->load($key, $timestamp); if (! empty($data) && ! $cacheLog->isEmpty()) { @@ -686,7 +548,6 @@ Http::init() } Span::add('storage.bucket.id', $bucketId); Span::add('storage.file.id', $fileId); - // Do not update transformedAt if it's a console user if (! $user->isPrivileged($authorization->getRoles())) { $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { @@ -703,7 +564,6 @@ Http::init() $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([ 'accessedAt' => DateTime::now(), ]))); - // Refresh the filesystem file's mtime so TTL-based expiry in cache->load() stays valid $cache->save($key, $data); } @@ -742,12 +602,6 @@ Http::init() } }); -/** - * Limit user session - * - * Delete older sessions if the number of sessions have crossed - * the session limit set for the project - */ Http::shutdown() ->groups(['session']) ->inject('utopia') @@ -817,11 +671,9 @@ Http::shutdown() $queueForEvents->setPayload($responsePayload); } - // Get project and function/webhook events (cached) $functionsEvents = $eventProcessor->getFunctionsEvents($project, $dbForProject); $webhooksEvents = $eventProcessor->getWebhooksEvents($project); - // Generate events for this operation $generatedEvents = Event::generateEvents( $queueForEvents->getEvent(), $queueForEvents->getParams() @@ -833,7 +685,6 @@ Http::shutdown() ->trigger(); } - // Only trigger functions if there are matching function events if (! empty($functionsEvents)) { foreach ($generatedEvents as $event) { if (isset($functionsEvents[$event])) { @@ -845,7 +696,6 @@ Http::shutdown() } } - // Only trigger webhooks if there are matching webhook events if (! empty($webhooksEvents)) { foreach ($generatedEvents as $event) { if (isset($webhooksEvents[$event])) { @@ -861,9 +711,6 @@ Http::shutdown() $route = $utopia->getRoute(); $requestParams = $route->getParamsValues(); - /** - * Abuse labels - */ $abuseEnabled = System::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled'; $abuseResetCode = $route->getLabel('abuse-reset', []); $abuseResetCode = \is_array($abuseResetCode) ? $abuseResetCode : [$abuseResetCode]; @@ -885,7 +732,7 @@ Http::shutdown() ->setParam('{method}', $request->getMethod()) ->setParam('{chunkId}', (int) ($start / ($end + 1 - $start))); - foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys + foreach ($request->getParams() as $key => $value) { if (! empty($value)) { $timeLimit->setParam('{param-' . $key . '}', (\is_array($value)) ? \json_encode($value) : $value); } @@ -896,9 +743,6 @@ Http::shutdown() } } - /** - * Audit labels - */ $pattern = $route->getLabel('audits.resource', null); if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); @@ -909,20 +753,11 @@ Http::shutdown() if (! $user->isEmpty()) { $userClone = clone $user; - // $user doesn't support `type` and can cause unintended effects. if (empty($user->getAttribute('type'))) { $userClone->setAttribute('type', $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER); } $auditContext->user = $userClone; } elseif ($auditContext->user === null || $auditContext->user->isEmpty()) { - /** - * User in the request is empty, and no user was set for auditing previously. - * This indicates: - * - No API Key was used. - * - No active session exists. - * - * Therefore, we consider this an anonymous request and create a relevant user. - */ $user = new User([ '$id' => '', 'status' => true, @@ -937,26 +772,12 @@ Http::shutdown() $auditUser = $auditContext->user; if (! empty($auditContext->resource) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { - /** - * audits.payload is switched to default true - * in order to auto audit payload for all endpoints - */ $pattern = $route->getLabel('audits.payload', true); if (! empty($pattern)) { $auditContext->payload = $responsePayload; } - $publisherForAudits->enqueue(new \Appwrite\Event\Message\Audit( - project: $auditContext->project ?? new Document(), - user: $auditUser, - payload: $auditContext->payload, - resource: $auditContext->resource, - mode: $auditContext->mode, - ip: $auditContext->ip, - userAgent: $auditContext->userAgent, - event: $auditContext->event, - hostname: $auditContext->hostname, - )); + $publisherForAudits->enqueue(AuditMessage::fromContext($auditContext)); } if (! empty($queueForDeletes->getType())) { @@ -975,7 +796,6 @@ Http::shutdown() $queueForMessaging->trigger(); } - // Cache label $useCache = $route->getLabel('cache', false); if ($useCache) { $resource = $resourceType = null; @@ -1011,7 +831,6 @@ Http::shutdown() 'signature' => $signature, ]))); } catch (DuplicateException) { - // Race condition: another concurrent request already created the cache document $cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key)); } } elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) { @@ -1019,7 +838,6 @@ Http::shutdown() $authorization->skip(fn () => $dbForProject->updateDocument('cache', $cacheLog->getId(), new Document([ 'accessedAt' => $cacheLog->getAttribute('accessedAt') ]))); - // Overwrite the file every APP_CACHE_UPDATE seconds to update the file modified time that is used in the TTL checks in cache->load() $cache->save($key, $data['payload']); } @@ -1038,11 +856,9 @@ Http::shutdown() )); } - // Publish usage metrics if context has data if (! $usage->isEmpty()) { $metrics = $usage->getMetrics(); - // Filter out API key disabled metrics using suffix pattern matching $disabledMetrics = $apiKey?->getDisabledMetrics() ?? []; if (! empty($disabledMetrics)) { $metrics = array_values(array_filter($metrics, function ($metric) use ($disabledMetrics) { diff --git a/src/Appwrite/Event/Message/Audit.php b/src/Appwrite/Event/Message/Audit.php index febd96b072..ae5831c3b9 100644 --- a/src/Appwrite/Event/Message/Audit.php +++ b/src/Appwrite/Event/Message/Audit.php @@ -2,20 +2,21 @@ namespace Appwrite\Event\Message; +use Appwrite\Event\Context\Audit as AuditContext; use Utopia\Database\Document; final class Audit extends Base { public function __construct( - public readonly Document $project, - public readonly Document $user, - public readonly array $payload, - public readonly string $resource, - public readonly string $mode, - public readonly string $ip, - public readonly string $userAgent, public readonly string $event, - public readonly string $hostname, + public readonly array $payload, + public readonly Document $project = new Document(), + public readonly Document $user = new Document(), + public readonly string $resource = '', + public readonly string $mode = '', + public readonly string $ip = '', + public readonly string $userAgent = '', + public readonly string $hostname = '', ) { } @@ -41,15 +42,30 @@ final class Audit extends Base public static function fromArray(array $data): static { return new self( + event: $data['event'] ?? '', + payload: $data['payload'] ?? [], project: new Document($data['project'] ?? []), user: new Document($data['user'] ?? []), - payload: $data['payload'] ?? [], resource: $data['resource'] ?? '', mode: $data['mode'] ?? '', ip: $data['ip'] ?? '', userAgent: $data['userAgent'] ?? '', - event: $data['event'] ?? '', hostname: $data['hostname'] ?? '', ); } + + public static function fromContext(AuditContext $context): static + { + return new self( + event: $context->event, + payload: $context->payload, + project: $context->project ?? new Document(), + user: $context->user ?? new Document(), + resource: $context->resource, + mode: $context->mode, + ip: $context->ip, + userAgent: $context->userAgent, + hostname: $context->hostname, + ); + } }