Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 70e22ce10b chore: improve monitoring for fix: handle stale API key role cache gracefully in
**Monitoring fix in `permission-graphql-api-exception-handler.util.ts`:** Moved `API_KEY_ROLE_NOT_FOUND` and `APPLICATION_ROLE_NOT_FOUND` from the raw re-throw group (line 74) to a new `ForbiddenError` mapping. This ensures these exceptions are converted to proper GraphQL errors before reaching the client, matching the pattern used for similar auth/permission errors (e.g., `PERMISSION_DENIED`, `NO_AUTHENTICATION_CONTEXT`).

Previously, these two codes fell through to `throw error` which propagated the raw `PermissionsException` up the stack, causing Sentry to capture it as an unhandled error. Now they're wrapped as `ForbiddenError` GraphQL responses, which:
1. Return a clean, structured error to the API consumer
2. Prevent unnecessary Sentry noise since `ForbiddenError` is a handled GraphQL error type
3. Are consistent with how `API_KEY_NO_ROLE_ASSIGNED` is handled in `api-key-graphql-api-exception-handler.util.ts`
2026-04-06 18:25:37 +00:00
Sonarly Claude Code 431b197d05 fix: handle stale API key role cache gracefully in permissions check
https://sonarly.com/issue/22241?type=bug

An API key used to call the metadata API (createOneField) has a cached role assignment pointing to a role that no longer exists in the database, causing a permission check failure.

Fix: **Code fix in `permissions.service.ts`:** When `roleRepository.findOne` returns null for a cached API key role, the code now invalidates and recomputes the `apiKeyRoleMap` cache, then retries the role lookup once. This handles the stale cache scenario where a role was deleted but the cache still contains the old roleId→roleTarget mapping. After the retry, if the role is still not found (meaning the API key genuinely has no valid role), the original `API_KEY_ROLE_NOT_FOUND` exception is thrown.

The fix uses `let` instead of `const` for the `role` variable to allow reassignment after the cache refresh. The retry path calls `getRoleIdForApiKeyId` again after cache invalidation, which will either return a fresh roleId or throw `API_KEY_NO_ROLE_ASSIGNED` if the roleTarget was also cleaned up.
2026-04-06 18:25:37 +00:00
2 changed files with 22 additions and 3 deletions
@@ -148,11 +148,29 @@ export class PermissionsService {
workspaceId,
);
const role = await this.roleRepository.findOne({
let role = await this.roleRepository.findOne({
where: { id: roleId, workspaceId },
relations: ['permissionFlags'],
});
// Role from cache may be stale — recompute and retry once
if (!isDefined(role)) {
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'apiKeyRoleMap',
]);
const refreshedRoleId =
await this.apiKeyRoleService.getRoleIdForApiKeyId(
apiKeyId,
workspaceId,
);
role = await this.roleRepository.findOne({
where: { id: refreshedRoleId, workspaceId },
relations: ['permissionFlags'],
});
}
if (!isDefined(role)) {
throw new PermissionsException(
PermissionsExceptionMessage.API_KEY_ROLE_NOT_FOUND,
@@ -51,6 +51,9 @@ export const permissionGraphqlApiExceptionHandler = (
case PermissionsExceptionCode.FIELD_PERMISSION_NOT_FOUND:
case PermissionsExceptionCode.PERMISSION_NOT_FOUND:
throw new NotFoundError(error);
case PermissionsExceptionCode.API_KEY_ROLE_NOT_FOUND:
case PermissionsExceptionCode.APPLICATION_ROLE_NOT_FOUND:
throw new ForbiddenError(error);
case PermissionsExceptionCode.UPSERT_FIELD_PERMISSION_FAILED:
case PermissionsExceptionCode.DEFAULT_ROLE_NOT_FOUND:
case PermissionsExceptionCode.WORKSPACE_ID_ROLE_USER_WORKSPACE_MISMATCH:
@@ -66,11 +69,9 @@ export const permissionGraphqlApiExceptionHandler = (
case PermissionsExceptionCode.METHOD_NOT_ALLOWED:
case PermissionsExceptionCode.RAW_SQL_NOT_ALLOWED:
case PermissionsExceptionCode.OBJECT_PERMISSION_NOT_FOUND:
case PermissionsExceptionCode.API_KEY_ROLE_NOT_FOUND:
case PermissionsExceptionCode.JOIN_COLUMN_NAME_REQUIRED:
case PermissionsExceptionCode.COMPOSITE_TYPE_NOT_FOUND:
case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND:
case PermissionsExceptionCode.APPLICATION_ROLE_NOT_FOUND:
throw error;
default: {
return assertUnreachable(error.code);