Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

### Added

- New app setting `strictImpersonationPermissions` (default `false`) controlling how `checkUserPermission` evaluates impersonation sessions. When enabled, only the impersonated profile's permissions are returned, so the acting user's permissions never leak into the storefront. When disabled, the acting user's and the impersonated profile's permissions are aggregated. [B2BTEAM-3566]
- Documentation: `checkUserPermission` now documents its contract during impersonation sessions, for both the `vtex.telemarketing` and the B2B Organizations impersonation flows. [B2BTEAM-3566]

### Changed

- **Reverts the 3.5.1 default.** Aggregated permissions during impersonation are the default again, because 3.5.1 made strict scoping unconditional and that silently removes permissions the default B2B Suite configuration depends on - `can-checkout` is not granted to the `customer-buyer` role, so a sales representative impersonating an Organization Buyer could no longer complete checkout. Stores that want the strict behavior of 3.5.1 must now enable `strictImpersonationPermissions`. [B2BTEAM-3566]

## [3.6.0] - 2026-07-31

### Added
Expand Down
18 changes: 18 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,24 @@ Sample response:
}
```

##### Behavior during impersonation

An impersonation session has two identities: the **acting user** (the Operator, sales representative or approver who started the session, kept in `authentication.storeUserEmail`) and the **impersonated profile** (kept in the `profile` namespace). This applies to both impersonation flows: `vtex.telemarketing` and the [B2B Organizations](https://developers.vtex.com/vtex-developer-docs/docs/vtex-b2b-organizations#impersonate-users) app.

Which identity `checkUserPermission` evaluates is controlled by the `strictImpersonationPermissions` app setting:

| Setting | Result during impersonation |
|---|---|
| `false` (default) | The deduplicated **union** of the acting user's and the impersonated profile's permissions. `role` is the acting user's role, falling back to the impersonated profile's role when the acting user has none. |
| `true` | **Only** the impersonated profile's permissions and role. The acting user's permissions are never returned. |

Outside impersonation sessions both modes behave identically, returning the authenticated user's permissions.

Pick the mode that matches your store:

- Enable `strictImpersonationPermissions` when the storefront must render exactly what the impersonated user is allowed to do, and elevated permissions from the acting user would produce incorrect gating.
- Keep it disabled when a flow depends on the acting user's rights while impersonating. Note that some of these flows exist in the default B2B Suite configuration: `can-checkout` is not granted to the `customer-buyer` role, so a sales representative impersonating an Organization Buyer relies on aggregation to complete the purchase. Review your role configuration before enabling strict mode.



#### hasUsers
Expand Down
6 changes: 6 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@
"description": "When enabled, if the organization has no salesChannel set, this app does not patch the session's public.sc or restamp the cart's sales channel, leaving that value to whatever already set it (e.g. vtex.binding-selector). When disabled, an organization with no salesChannel set falls back to the account's first active sales channel (backward compatible).",
"type": "boolean",
"default": false
},
"strictImpersonationPermissions": {
"title": "Strict impersonation permissions",
"description": "When enabled, checkUserPermission returns only the impersonated profile's permissions during an impersonation session, so the acting user's (Operator, sales representative, approver) permissions never leak into the storefront. When disabled (default), the permissions of the acting user and the impersonated profile are aggregated, which is required by flows that rely on the acting user's rights while impersonating - for example a sales representative completing checkout for a buyer role that has no can-checkout permission, or an approver retaining approval power.",
"type": "boolean",
"default": false
}
}
},
Expand Down
69 changes: 61 additions & 8 deletions node/resolvers/Queries/Users.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { removeVersionFromAppId } from '@vtex/api'

import { getCachedAppSettings } from '../../services/appSettingsCache'
import type { GetOrganizationsPaginatedByEmailResponse } from '../../typings/custom'
import { currentSchema } from '../../utils'
import {
Expand Down Expand Up @@ -679,18 +680,70 @@ export const checkUserPermission = async (

const module = removeVersionFromAppId(sender)

// During impersonation (authEmail !== profileEmail), permissions must be
// scoped to the impersonated profile only, never the acting Operator's.
// Both impersonation flows (vtex.telemarketing and the Organizations app)
// switch the profile namespace to the impersonated user while
// authentication.storeUserEmail keeps holding the acting user, so a
// divergence between the two is what identifies an impersonation session.
const isImpersonating = Boolean(profileEmail) && authEmail !== profileEmail

const targetEmail = isImpersonating ? profileEmail : authEmail
if (!isImpersonating) {
return getRoleAndPermissionsByEmail({
ctx,
email: authEmail,
module,
skipError: true,
})
}

// Only impersonation sessions need the setting, so regular sessions never
// pay for reading it (cached for 5 minutes when they do).
const appSettings = await getCachedAppSettings(ctx).catch((error) => {
logger.warn({ error, message: 'checkUserPermission-getAppSettingsError' })

return getRoleAndPermissionsByEmail({
ctx,
email: targetEmail,
module,
skipError: true,
return {} as Record<string, unknown>
})

// Strict mode: scope the evaluation to the impersonated profile so the
// acting user's elevated permissions never reach the storefront.
if ((appSettings as any)?.strictImpersonationPermissions) {
return getRoleAndPermissionsByEmail({
ctx,
email: profileEmail,
module,
skipError: true,
})
}

// Aggregated mode (default): keep the legacy union, which flows relying on
// the acting user's rights while impersonating depend on - for example a
// sales representative completing checkout for a buyer role that has no
// can-checkout permission, or an approver retaining approval power.
const [authPermissions, profilePermissions] = await Promise.all([
getRoleAndPermissionsByEmail({
ctx,
email: authEmail,
module,
skipError: true,
}),
getRoleAndPermissionsByEmail({
ctx,
email: profileEmail,
module,
skipError: true,
}),
])

return {
permissions: [
...new Set([
...authPermissions.permissions,
...profilePermissions.permissions,
]),
],
role: authPermissions.role.id
? authPermissions.role
: profilePermissions.role,
}
}

export const checkImpersonation = async (_: any, __: any, ctx: Context) => {
Expand Down