Skip to content
Merged
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
185 changes: 183 additions & 2 deletions apps/api/src/routes/scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,11 +904,16 @@ describe('scripts routes', () => {

// ── Task 8: Partner-wide create + edit/delete guard ───────────────────────
describe('Task 8: partner-wide create + org-user read-only guard', () => {
function makePartnerAuth() {
// #3262: partner-wide writes need the CAPABILITY (org_access = 'all'), not
// just partner scope. These helpers default to a full-partner admin so the
// existing positive cases keep describing the user they always meant; pass
// 'selected' to exercise the denial.
function makePartnerAuth(partnerOrgAccess: 'all' | 'selected' = 'all') {
return {
user: { id: 'user-123', email: 'test@example.com', name: 'Test User' },
scope: 'partner' as const,
partnerId: PARTNER_ID,
partnerOrgAccess,
orgId: null,
token: {
sub: 'user-123', email: 'test@example.com', roleId: 'role-123',
Expand Down Expand Up @@ -974,6 +979,31 @@ describe('scripts routes', () => {
expect(insertedValues?.partnerId).toBe(PARTNER_ID);
});

// #3262: the reported vector — partner scope alone was enough to create a
// script that runs as SYSTEM across every org under the partner.
it('#3262: a selected-access partner user cannot create a partner-wide script', async () => {
const { authMiddleware } = await import('../middleware/auth');
vi.mocked(authMiddleware).mockImplementationOnce((c: any, next: any) => {
c.set('auth', makePartnerAuth('selected'));
return next();
});

const res = await app.request('/scripts', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer valid-token' },
body: JSON.stringify({
name: 'Partner Script',
osTypes: ['windows'],
language: 'powershell',
content: 'echo hi',
availability: 'partner'
})
});

expect(res.status).toBe(403);
expect(vi.mocked(db.insert)).not.toHaveBeenCalled();
});

it('partner user with availability=org + orgId creates an org-specific script', async () => {
const { authMiddleware } = await import('../middleware/auth');
vi.mocked(authMiddleware).mockImplementationOnce((c: any, next: any) => {
Expand Down Expand Up @@ -1015,6 +1045,51 @@ describe('scripts routes', () => {
expect(insertedValues?.partnerId).toBe(PARTNER_ID);
});

// #3262 control: the capability gate covers ONLY partner-wide writes. A
// selected-access user must keep every org-scoped ability — without this,
// a refactor that hoists the gate above the availability branch (denying
// ALL partner-scope writes) would pass the suite green.
it('#3262: a selected-access partner user still creates an org-scoped script', async () => {
const { authMiddleware } = await import('../middleware/auth');
vi.mocked(authMiddleware).mockImplementationOnce((c: any, next: any) => {
c.set('auth', makePartnerAuth('selected'));
return next();
});

let insertedValues: any;
vi.mocked(db.insert).mockReturnValue({
values: vi.fn().mockImplementation((vals: any) => {
insertedValues = vals;
return {
returning: vi.fn().mockResolvedValue([{
id: SCRIPT_ID_1,
name: 'Org Script',
orgId: ORG_ID,
partnerId: PARTNER_ID,
isSystem: false
}])
};
})
} as any);

const res = await app.request('/scripts', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer valid-token' },
body: JSON.stringify({
name: 'Org Script',
osTypes: ['windows'],
language: 'powershell',
content: 'echo hi',
orgId: ORG_ID,
availability: 'org'
})
});

expect(res.status).toBe(201);
expect(insertedValues?.orgId).toBe(ORG_ID);
expect(insertedValues?.partnerId).toBe(PARTNER_ID);
});

it('org user editing a partner-wide script (org_id=null, partner_id set) → 403', async () => {
const { authMiddleware } = await import('../middleware/auth');
vi.mocked(authMiddleware).mockImplementationOnce((c: any, next: any) => {
Expand Down Expand Up @@ -1117,11 +1192,16 @@ describe('scripts routes', () => {
});

describe('Task 9: re-scope on edit (issue #1734)', () => {
function makePartnerAuth() {
// #3262: partner-wide writes need the CAPABILITY (org_access = 'all'), not
// just partner scope. These helpers default to a full-partner admin so the
// existing positive cases keep describing the user they always meant; pass
// 'selected' to exercise the denial.
function makePartnerAuth(partnerOrgAccess: 'all' | 'selected' = 'all') {
return {
user: { id: 'user-123', email: 'test@example.com', name: 'Test User' },
scope: 'partner' as const,
partnerId: PARTNER_ID,
partnerOrgAccess,
orgId: null,
token: {
sub: 'user-123', email: 'test@example.com', roleId: 'role-123',
Expand Down Expand Up @@ -1214,6 +1294,107 @@ describe('scripts routes', () => {
expect(getSet().partnerId).toBe(PARTNER_ID);
});

// ── #3262: partner-wide writes require org_access = 'all' ───────────────
// Scripts run as SYSTEM on every endpoint, and a partner-wide script covers
// every org under the partner including ones onboarded later. Partner SCOPE
// is not the capability; `partnerOrgAccess: 'all'` is.
it('#3262: a selected-access partner user cannot widen a script to partner-wide', async () => {
await withAuth(makePartnerAuth('selected'));
mockScriptLookup({
id: SCRIPT_ID_1, name: 'Org Script', orgId: ORG_ID, partnerId: PARTNER_ID,
isSystem: false, content: 'echo hi', version: 1,
}, 0);

const res = await app.request(`/scripts/${SCRIPT_ID_1}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer valid-token' },
body: JSON.stringify({ availability: 'partner' }),
});

expect(res.status).toBe(403);
expect(vi.mocked(db.update)).not.toHaveBeenCalled();
});

it('#3262: a selected-access partner user cannot edit an existing partner-wide script', async () => {
await withAuth(makePartnerAuth('selected'));
mockScriptLookup({
id: SCRIPT_ID_1, name: 'Partner Script', orgId: null, partnerId: PARTNER_ID,
isSystem: false, content: 'echo hi', version: 1,
}, 0);

const res = await app.request(`/scripts/${SCRIPT_ID_1}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer valid-token' },
// A plain content edit — no re-scope. The script is already partner-wide,
// so rewriting its body is rewriting code that runs everywhere.
body: JSON.stringify({ content: 'echo pwned' }),
});

expect(res.status).toBe(403);
expect(vi.mocked(db.update)).not.toHaveBeenCalled();
});

it('#3262: a selected-access partner user cannot delete an existing partner-wide script', async () => {
await withAuth(makePartnerAuth('selected'));
mockScriptLookup({
id: SCRIPT_ID_1, name: 'Partner Script', orgId: null, partnerId: PARTNER_ID,
isSystem: false, content: 'echo hi', version: 1,
}, 0);

const res = await app.request(`/scripts/${SCRIPT_ID_1}`, {
method: 'DELETE',
headers: { Authorization: 'Bearer valid-token' },
});

expect(res.status).toBe(403);
expect(vi.mocked(db.update)).not.toHaveBeenCalled();
});

// Control: the gate denies on capability, not on partner scope generally —
// without this, all three assertions above would pass on a handler that
// simply rejected every partner write.
it('#3262: a full-partner admin still can widen a script to partner-wide', async () => {
await withAuth(makePartnerAuth('all'));
mockScriptLookup({
id: SCRIPT_ID_1, name: 'Org Script', orgId: ORG_ID, partnerId: PARTNER_ID,
isSystem: false, content: 'echo hi', version: 1,
}, 0);
const getSet = captureUpdate({
id: SCRIPT_ID_1, name: 'Org Script', orgId: null, partnerId: PARTNER_ID, version: 1,
});

const res = await app.request(`/scripts/${SCRIPT_ID_1}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer valid-token' },
body: JSON.stringify({ availability: 'partner' }),
});

expect(res.status).toBe(200);
expect(getSet().orgId).toBeNull();
});

// Control for the DELETE gate: it must deny on capability, not on partner
// scope generally — a gate that 403'd every partner delete of a
// partner-wide script would pass the denial tests above.
it('#3262: a full-partner admin still deletes a partner-wide script', async () => {
await withAuth(makePartnerAuth('all'));
// Serves the script lookup AND the active-executions count (0 = none).
mockScriptLookup({
id: SCRIPT_ID_1, name: 'Partner Script', orgId: null, partnerId: PARTNER_ID,
isSystem: false, content: 'echo hi', version: 1,
}, 0);
const getSet = captureUpdate({ id: SCRIPT_ID_1 });

const res = await app.request(`/scripts/${SCRIPT_ID_1}`, {
method: 'DELETE',
headers: { Authorization: 'Bearer valid-token' },
});

expect(res.status).toBe(200);
// Soft delete: the handler stamps deletedAt rather than issuing a DELETE.
expect(getSet().deletedAt).toBeInstanceOf(Date);
});

it('partner user moves a script org→org (when no references exist)', async () => {
await withAuth(makePartnerAuth());
mockScriptLookup({
Expand Down
41 changes: 39 additions & 2 deletions apps/api/src/routes/scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@ import {
configPolicyFeatureLinks,
configurationPolicies
} from '../db/schema';
import { authMiddleware, requireMfa, requirePermission, requireScope } from '../middleware/auth';
import { authMiddleware, requireMfa, requirePermission, requireScope, type AuthContext } from '../middleware/auth';
import { canAccessSite, PERMISSIONS, type UserPermissions } from '../services/permissions';
import { writeRouteAudit } from '../services/auditEvents';
import { executeScriptOnDevices } from '../services/scriptExecution';
import {
canManagePartnerWidePolicies,
PARTNER_WIDE_WRITE_DENIED_MESSAGE,
} from '../services/partnerWideAccess';

export const scriptRoutes = new Hono();

Expand Down Expand Up @@ -70,8 +74,13 @@ function resolveScriptAuditOrgId(
}

type RescopeAuth = {
scope: string;
scope: AuthContext['scope'];
partnerId: string | null;
// #3262: the partner-wide capability is NOT derivable from the other fields —
// a 'selected' user whose selection happens to cover every current org still
// must not administer partner-wide state. Carried explicitly so the widening
// branch below can check it.
partnerOrgAccess?: AuthContext['partnerOrgAccess'];
accessibleOrgIds: string[] | null;
canAccessOrg: (orgId: string) => boolean;
};
Expand Down Expand Up @@ -119,6 +128,13 @@ function resolveRescopeTarget(
}

if (availability === 'partner') {
// #3262: widening an existing script to partner-wide is a second creation
// vector for the same privilege — it ends with a script running as SYSTEM
// on every org under the partner, including orgs onboarded later. Gate it
// exactly like the create path.
if (!canManagePartnerWidePolicies(auth)) {
return { error: PARTNER_WIDE_WRITE_DENIED_MESSAGE, status: 403 };
}
return { orgId: null, partnerId };
}

Expand Down Expand Up @@ -505,6 +521,14 @@ scriptRoutes.post(
partnerId = auth.partnerId ?? null; // denormalized for RLS
} else if (auth.scope === 'partner') {
if (data.availability === 'partner') {
// #3262: partner SCOPE is not the same as partner-wide CAPABILITY. A
// partner user with org_access = 'selected' may be scoped to three of
// eighty customers; without this gate they could create a script that
// runs as SYSTEM across all eighty, including orgs they hold no grant
// for and orgs created later.
if (!canManagePartnerWidePolicies(auth)) {
return c.json({ error: PARTNER_WIDE_WRITE_DENIED_MESSAGE }, 403);
}
orgId = null;
partnerId = auth.partnerId ?? null;
if (!partnerId) return c.json({ error: 'Partner context required' }, 403);
Expand Down Expand Up @@ -592,6 +616,13 @@ scriptRoutes.put(
if (script.orgId === null && script.partnerId !== null && auth.scope === 'organization') {
return c.json({ error: 'This script is shared across your organization and is read-only here' }, 403);
}
// #3262: and within the partner, only a full-partner admin. Someone who
// cannot create a partner-wide script must not be able to edit one either —
// otherwise the body of a script already running as SYSTEM everywhere is
// rewritable by a 'selected'-access user.
if (script.orgId === null && script.partnerId !== null && !canManagePartnerWidePolicies(auth)) {
return c.json({ error: PARTNER_WIDE_WRITE_DENIED_MESSAGE }, 403);
}
// Cannot edit system scripts unless system scope
if (script.isSystem && auth.scope !== 'system') {
return c.json({ error: 'System scripts are read-only' }, 403);
Expand Down Expand Up @@ -760,6 +791,12 @@ scriptRoutes.delete(
if (script.orgId === null && script.partnerId !== null && auth.scope === 'organization') {
return c.json({ error: 'This script is shared across your organization and is read-only here' }, 403);
}
// #3262: and within the partner, only a full-partner admin — same reasoning
// as the edit path. Deleting a partner-wide script removes automation from
// every org under the partner.
if (script.orgId === null && script.partnerId !== null && !canManagePartnerWidePolicies(auth)) {
return c.json({ error: PARTNER_WIDE_WRITE_DENIED_MESSAGE }, 403);
}
// Cannot delete system scripts unless system scope
if (script.isSystem && auth.scope !== 'system') {
return c.json({ error: 'System scripts are read-only' }, 403);
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getUserPermissions,
PERMISSIONS
} from '../services/permissions';
import { canManagePartnerWidePolicies } from '../services/partnerWideAccess';
import {
getScopeContext,
getScopedRole,
Expand Down Expand Up @@ -393,6 +394,11 @@ userRoutes.get('/me', async (c) => {
scope: auth.scope,
partnerDefaultLocale,
permissions: userPerms?.permissions ?? [],
// #3262: lets forms with an "All organizations" owner option (scripts,
// policies) hide/disable it for partner users without org_access = 'all'.
// UX only — every partner-wide write is still gated server-side via
// canManagePartnerWidePolicies.
canManagePartnerWide: canManagePartnerWidePolicies(auth),
requiresSetup
});
});
Expand Down
Loading
Loading