Skip to content

Commit 85f0423

Browse files
committed
feat: gate canvas app behind entitlement
1 parent 8f56ce8 commit 85f0423

18 files changed

Lines changed: 355 additions & 44 deletions

File tree

apps/api/src/app/controllers/canvas.controller.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { salesforceCanvasOauthCallback, SalesforceCanvasOauthInit } from '@jetst
33
import { getErrorMessage, urlSearchParamsToJson } from '@jetstream/shared/utils';
44
import { Canvas } from '@jetstream/types';
55
import z from 'zod';
6-
import { escapeJsonForScript, getCanvasIndexFile, verifyAndDecodeAsJson } from '../utils/canvas.utils';
6+
import { isCanvasOrgEntitled } from '../db/canvas-entitlement.db';
7+
import { escapeJsonForScript, getCanvasAccessDeniedHtml, getCanvasIndexFile, verifyAndDecodeAsJson } from '../utils/canvas.utils';
78
import { createRoute } from '../utils/route.utils';
89

910
const INVALID_ACCESS = 'invalid';
@@ -147,6 +148,14 @@ const appHandler = createRoute(routeDefinition.appHandler.validators, async ({ b
147148
},
148149
'[CANVAS][SIGNED_REQUEST_VERIFIED]',
149150
);
151+
152+
// The signed request is cryptographically trusted at this point, so gate access by org entitlement
153+
const organizationId = envelope?.context?.organization?.organizationId;
154+
if (!organizationId || !(await isCanvasOrgEntitled(organizationId, envelope?.client?.instanceUrl))) {
155+
res.log.info({ organizationId, instanceUrl: envelope?.client?.instanceUrl }, '[CANVAS][ACCESS_DENIED]');
156+
res.status(403).send(getCanvasAccessDeniedHtml());
157+
return;
158+
}
150159
} else {
151160
res.status(400).send({
152161
status: 'error',
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { isCanvasOrgEntitled, parseMyDomainBase } from '../canvas-entitlement.db';
3+
4+
const prismaMock = vi.hoisted(() => ({
5+
salesforceCanvasOrg: {
6+
findMany: vi.fn(),
7+
},
8+
teamEntitlement: {
9+
count: vi.fn(),
10+
},
11+
entitlement: {
12+
count: vi.fn(),
13+
},
14+
}));
15+
16+
vi.mock('@jetstream/api-config', () => ({
17+
prisma: prismaMock,
18+
}));
19+
20+
const PROD_ORG_ID = '00D000000000001AAA';
21+
const SANDBOX_ORG_ID = '00D000000000999AAA';
22+
23+
describe('parseMyDomainBase', () => {
24+
it('returns the base for a production My Domain URL', () => {
25+
expect(parseMyDomainBase('https://acme.my.salesforce.com')).toBe('acme');
26+
});
27+
28+
it('strips the sandbox suffix to share the production base', () => {
29+
expect(parseMyDomainBase('https://acme--uat.sandbox.my.salesforce.com')).toBe('acme');
30+
});
31+
32+
it('returns null for an unparseable value', () => {
33+
expect(parseMyDomainBase('not-a-url')).toBeNull();
34+
});
35+
});
36+
37+
describe('isCanvasOrgEntitled', () => {
38+
beforeEach(() => {
39+
vi.clearAllMocks();
40+
prismaMock.teamEntitlement.count.mockResolvedValue(0);
41+
prismaMock.entitlement.count.mockResolvedValue(0);
42+
});
43+
44+
it('grants access when an ACTIVE authorization exists and the user owner holds salesforceCanvas', async () => {
45+
prismaMock.salesforceCanvasOrg.findMany.mockResolvedValue([{ jetstreamUserId: 'user-1', teamId: null }]);
46+
prismaMock.entitlement.count.mockResolvedValue(1);
47+
48+
const result = await isCanvasOrgEntitled(PROD_ORG_ID, 'https://acme.my.salesforce.com');
49+
50+
expect(result).toBe(true);
51+
// Only ACTIVE records, matched on the 15-char org id prefix (tolerates 15/18-char ids)
52+
expect(prismaMock.salesforceCanvasOrg.findMany).toHaveBeenCalledWith(
53+
expect.objectContaining({
54+
where: expect.objectContaining({
55+
status: 'ACTIVE',
56+
OR: expect.arrayContaining([{ organizationId: { startsWith: PROD_ORG_ID.slice(0, 15) } }]),
57+
}),
58+
}),
59+
);
60+
expect(prismaMock.entitlement.count).toHaveBeenCalledWith({ where: { userId: 'user-1', salesforceCanvas: true } });
61+
});
62+
63+
it('grants access for a team-owned authorization via the team entitlement', async () => {
64+
prismaMock.salesforceCanvasOrg.findMany.mockResolvedValue([{ jetstreamUserId: 'user-1', teamId: 'team-1' }]);
65+
prismaMock.teamEntitlement.count.mockResolvedValue(1);
66+
67+
expect(await isCanvasOrgEntitled(PROD_ORG_ID, 'https://acme.my.salesforce.com')).toBe(true);
68+
expect(prismaMock.teamEntitlement.count).toHaveBeenCalledWith({ where: { teamId: 'team-1', salesforceCanvas: true } });
69+
// Team takes precedence — the user entitlement is not consulted
70+
expect(prismaMock.entitlement.count).not.toHaveBeenCalled();
71+
});
72+
73+
it('grants a sandbox access by matching the My Domain base of an entitled production authorization', async () => {
74+
prismaMock.salesforceCanvasOrg.findMany.mockResolvedValue([{ jetstreamUserId: 'user-1', teamId: null }]);
75+
prismaMock.entitlement.count.mockResolvedValue(1);
76+
77+
const result = await isCanvasOrgEntitled(SANDBOX_ORG_ID, 'https://acme--uat.sandbox.my.salesforce.com');
78+
79+
expect(result).toBe(true);
80+
expect(prismaMock.salesforceCanvasOrg.findMany).toHaveBeenCalledWith(
81+
expect.objectContaining({ where: expect.objectContaining({ OR: expect.arrayContaining([{ myDomainBase: 'acme' }]) }) }),
82+
);
83+
});
84+
85+
it('denies access when the authorization exists but the owner is not entitled', async () => {
86+
prismaMock.salesforceCanvasOrg.findMany.mockResolvedValue([{ jetstreamUserId: 'user-1', teamId: null }]);
87+
88+
expect(await isCanvasOrgEntitled(PROD_ORG_ID, 'https://acme.my.salesforce.com')).toBe(false);
89+
});
90+
91+
it('denies access when no authorization record matches', async () => {
92+
prismaMock.salesforceCanvasOrg.findMany.mockResolvedValue([]);
93+
94+
expect(await isCanvasOrgEntitled(PROD_ORG_ID, 'https://acme.my.salesforce.com')).toBe(false);
95+
expect(prismaMock.entitlement.count).not.toHaveBeenCalled();
96+
});
97+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { prisma } from '@jetstream/api-config';
2+
3+
/**
4+
* Default number of Salesforce orgs an entitled subscription may authorize for the Canvas app
5+
* ("Canvas deployment to one org"). Enforced only in the web-app registration flow — the access
6+
* gate never checks this, so manually-inserted DB rows intentionally bypass the cap for one-off
7+
* edge cases (e.g. the occasional consultant working across several orgs).
8+
*/
9+
export const CANVAS_ORG_LIMIT_DEFAULT = 1;
10+
11+
/**
12+
* Resolves the authorized-org cap for a scope. Kept as a function so it can vary per plan later
13+
* without touching the registration controller.
14+
*/
15+
export function getCanvasOrgLimit(): number {
16+
return CANVAS_ORG_LIMIT_DEFAULT;
17+
}
18+
19+
/**
20+
* Parses the "My Domain" base from a Salesforce instance URL so an entitled production org can
21+
* extend access to its sandboxes (which share the My Domain base but get a brand-new org id).
22+
*
23+
* prod: https://acme.my.salesforce.com -> "acme"
24+
* sandbox: https://acme--uat.sandbox.my.salesforce.com -> "acme"
25+
*
26+
* This is a usability convenience, not a security boundary (admins can rename My Domain).
27+
*/
28+
export function parseMyDomainBase(instanceUrl: string): string | null {
29+
try {
30+
const { hostname } = new URL(instanceUrl);
31+
const [subdomain] = hostname.split('.');
32+
// Strip any sandbox suffix ("acme--uat" -> "acme")
33+
return subdomain?.split('--')[0] || null;
34+
} catch {
35+
return null;
36+
}
37+
}
38+
39+
/**
40+
* Confirms the owning scope of an authorized-org record still holds the `salesforceCanvas`
41+
* entitlement, so a cancelled subscription immediately revokes all of that owner's orgs.
42+
*/
43+
async function ownerHasCanvasEntitlement(owner: { jetstreamUserId: string | null; teamId: string | null }): Promise<boolean> {
44+
if (owner.teamId) {
45+
return prisma.teamEntitlement.count({ where: { teamId: owner.teamId, salesforceCanvas: true } }).then((result) => result > 0);
46+
}
47+
if (owner.jetstreamUserId) {
48+
return prisma.entitlement.count({ where: { userId: owner.jetstreamUserId, salesforceCanvas: true } }).then((result) => result > 0);
49+
}
50+
return false;
51+
}
52+
53+
/**
54+
* Determines whether a Salesforce org may use the Jetstream Canvas app.
55+
*
56+
* Access requires BOTH:
57+
* 1. an ACTIVE `SalesforceCanvasOrg` authorization record (matched by org id, or by My Domain base
58+
* so an entitled production org also covers its sandboxes), AND
59+
* 2. the owning user/team still holding the `salesforceCanvas` entitlement.
60+
*
61+
* Authorization records are created via the web-app management UI (cap-enforced) or inserted
62+
* manually for edge cases. This gate deliberately does not check the org cap.
63+
*/
64+
export async function isCanvasOrgEntitled(organizationId: string, instanceUrl?: string | null): Promise<boolean> {
65+
const orgIdPrefix = organizationId.slice(0, 15);
66+
const myDomainBase = instanceUrl ? parseMyDomainBase(instanceUrl) : null;
67+
68+
const authorizedOrgs = await prisma.salesforceCanvasOrg.findMany({
69+
where: {
70+
status: 'ACTIVE',
71+
OR: [{ organizationId: { startsWith: orgIdPrefix } }, ...(myDomainBase ? [{ myDomainBase }] : [])],
72+
},
73+
select: { jetstreamUserId: true, teamId: true },
74+
});
75+
76+
for (const owner of authorizedOrgs) {
77+
if (await ownerHasCanvasEntitlement(owner)) {
78+
return true;
79+
}
80+
}
81+
return false;
82+
}

apps/api/src/app/db/team.db.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ export const createTeam = async ({
396396
googleDrive: false,
397397
desktop: false,
398398
recordSync: false,
399+
salesforceCanvas: false,
399400
},
400401
},
401402
},

apps/api/src/app/services/stripe.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ export async function updateEntitlements(customerId: string, entitlements: Strip
295295
chromeExtension: false,
296296
recordSync: false,
297297
desktop: false,
298+
salesforceCanvas: false,
298299
},
299300
);
300301

apps/api/src/app/utils/canvas.utils.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,66 @@ export async function getCanvasIndexFile(): Promise<string> {
9090
const filePath = join(__dirname, '../jetstream-canvas/index.html');
9191
return await readFile(filePath, 'utf-8');
9292
}
93+
94+
/**
95+
* Branded screen rendered (inside the Salesforce Canvas iframe) when the org is not entitled
96+
* to the Canvas app. Returned as static HTML so it has no build/asset dependencies.
97+
*/
98+
export function getCanvasAccessDeniedHtml(): string {
99+
return `<!doctype html>
100+
<html lang="en">
101+
<head>
102+
<meta charset="utf-8" />
103+
<meta name="viewport" content="width=device-width, initial-scale=1" />
104+
<title>Jetstream</title>
105+
<style>
106+
html, body { height: 100%; margin: 0; }
107+
body {
108+
display: flex;
109+
align-items: center;
110+
justify-content: center;
111+
background: #f3f3f3;
112+
color: #16325c;
113+
font-family: 'Salesforce Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
114+
}
115+
.card {
116+
max-width: 30rem;
117+
margin: 2rem;
118+
padding: 2rem;
119+
background: #fff;
120+
border: 1px solid #dddbda;
121+
border-radius: 0.25rem;
122+
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.1);
123+
text-align: center;
124+
}
125+
.card h1 { font-size: 1.25rem; margin: 0 0 1rem; }
126+
.card p { font-size: 0.875rem; line-height: 1.5; margin: 0 0 1rem; color: #3e3e3c; }
127+
.btn {
128+
display: inline-block;
129+
margin-top: 0.5rem;
130+
padding: 0.5rem 1.25rem;
131+
background: #0176d3;
132+
color: #fff;
133+
text-decoration: none;
134+
border-radius: 0.25rem;
135+
font-size: 0.875rem;
136+
}
137+
.btn:hover { background: #014486; }
138+
</style>
139+
</head>
140+
<body>
141+
<main class="card">
142+
<h1>This org isn't authorized for Canvas</h1>
143+
<p>
144+
The Jetstream Canvas app requires an active Jetstream subscription with Canvas access, and this Salesforce org
145+
must be authorized for Canvas in Jetstream.
146+
</p>
147+
<p>
148+
An admin on your team can authorize this org from Jetstream settings. If your team doesn't have Canvas yet,
149+
start a plan to get going.
150+
</p>
151+
<a class="btn" href="https://getjetstream.app" target="_blank" rel="noopener">Open Jetstream</a>
152+
</main>
153+
</body>
154+
</html>`;
155+
}

apps/api/src/app/utils/server-process.utils.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,13 +192,15 @@ async function initExampleUserIfRequired() {
192192
lastLoggedIn: new Date(),
193193
preferences: { create: { skipFrontdoorLogin: false } },
194194
authFactors: { create: { type: '2fa-email', enabled: false } },
195-
entitlements: { create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false } },
195+
entitlements: {
196+
create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false, salesforceCanvas: false },
197+
},
196198
},
197199
update: {
198200
entitlements: {
199201
upsert: {
200-
create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false },
201-
update: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false },
202+
create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false, salesforceCanvas: false },
203+
update: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false, salesforceCanvas: false },
202204
},
203205
},
204206
},

apps/jetstream-desktop/src/services/persistence.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ export function getFullUserProfile() {
285285
desktop: false,
286286
chromeExtension: false,
287287
recordSync: true,
288+
salesforceCanvas: false,
288289
},
289290
teamMembership: appData.userProfile.teamMembership,
290291
subscriptions: [],

apps/jetstream-e2e/src/utils/auth-fixtures.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ export async function createSsoFixture(options: SsoFixtureOptions = {}): Promise
5555
tosAcceptedVersion: CURRENT_TOS_VERSION,
5656
name: 'Test User',
5757
preferences: { create: { skipFrontdoorLogin: false } },
58-
entitlements: { create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false } },
58+
entitlements: {
59+
create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false, salesforceCanvas: false },
60+
},
5961
identities: {
6062
create: {
6163
type: 'credentials',

libs/auth/server/src/lib/auth.db.service.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,7 +1288,9 @@ async function createUserFromProvider(
12881288
// picture: providerUser.picture,
12891289
lastLoggedIn: new Date(),
12901290
preferences: { create: { skipFrontdoorLogin: false } },
1291-
entitlements: { create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false } },
1291+
entitlements: {
1292+
create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false, salesforceCanvas: false },
1293+
},
12921294
identities: {
12931295
create: {
12941296
type: 'oauth',
@@ -1450,7 +1452,9 @@ async function createUserFromUserInfo(
14501452
tosAcceptedVersion: tosVersion,
14511453
tosAcceptedAt: new Date(),
14521454
preferences: { create: { skipFrontdoorLogin: false } },
1453-
entitlements: { create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false } },
1455+
entitlements: {
1456+
create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false, salesforceCanvas: false },
1457+
},
14541458
authFactors: {
14551459
create: {
14561460
type: '2fa-email',

0 commit comments

Comments
 (0)