diff --git a/apps/api/src/middleware/bodyLimit.test.ts b/apps/api/src/middleware/bodyLimit.test.ts index 2d7555506..d66b56f1b 100644 --- a/apps/api/src/middleware/bodyLimit.test.ts +++ b/apps/api/src/middleware/bodyLimit.test.ts @@ -68,6 +68,23 @@ describe('bodyLimitForPath', () => { expect(bodyLimitForPath('/api/v1/software/catalog/cat-123/versions/upload/extra').maxSize).toBe(1 * MB); }); + // Script bundle intake (#3245): a bundle can carry a whole script library; + // the schema caps scripts-per-bundle and per-content size, and 20MB is the + // effective total-bundle cap. + it('carves out script bundle import/preview at 20MB', () => { + expect(bodyLimitForPath('/api/v1/scripts/bundle/import')).toEqual({ + maxSize: 20 * MB, + error: 'Bundle too large (max 20MB)', + }); + expect(bodyLimitForPath('/api/v1/scripts/bundle/preview')).toEqual({ + maxSize: 20 * MB, + error: 'Bundle too large (max 20MB)', + }); + // Export (GET, no body) and the rest of /scripts stay on the default. + expect(bodyLimitForPath('/api/v1/scripts/bundle/export').maxSize).toBe(1 * MB); + expect(bodyLimitForPath('/api/v1/scripts').maxSize).toBe(1 * MB); + }); + // Chunked package uploads (#2951): each chunk is a raw octet-stream request // of at most 8MB (the client's UPLOAD_CHUNK_SIZE); 9MB gives the route's own // per-chunk size check headroom to answer with its specific message. diff --git a/apps/api/src/middleware/bodyLimit.ts b/apps/api/src/middleware/bodyLimit.ts index b46744e06..60cfb1424 100644 --- a/apps/api/src/middleware/bodyLimit.ts +++ b/apps/api/src/middleware/bodyLimit.ts @@ -44,5 +44,12 @@ export function bodyLimitForPath(path: string): { maxSize: number; error: string if (path.match(/^\/api\/v1\/agents\/[^/]+\/commands\/[^/]+\/result$/)) { return { maxSize: 12 * 1024 * 1024, error: 'Command result too large (max 12MB)' }; } + // Script bundle import/preview (#3245): a bundle carries whole script + // libraries (up to 200 scripts x 256KB content, both capped by the bundle + // schema). 20MB is the effective total-bundle cap; the schema's per-field + // caps answer with specific messages below it. + if (path === '/api/v1/scripts/bundle/import' || path === '/api/v1/scripts/bundle/preview') { + return { maxSize: 20 * 1024 * 1024, error: 'Bundle too large (max 20MB)' }; + } return { maxSize: 1024 * 1024, error: 'Request body too large' }; } diff --git a/apps/api/src/routes/scriptBundle.test.ts b/apps/api/src/routes/scriptBundle.test.ts new file mode 100644 index 000000000..3d9dc6590 --- /dev/null +++ b/apps/api/src/routes/scriptBundle.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Hono } from 'hono'; + +const ORG_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const OTHER_ORG_ID = '99999999-9999-4999-8999-999999999999'; +const PARTNER_ID = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; +const SCRIPT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +// --------------------------------------------------------------------------- +// Mutable auth + queue-driven db mock (real Drizzle schema, mocked db module). +// --------------------------------------------------------------------------- +const h = vi.hoisted(() => { + const state = { + auth: {} as Record, + selectQueue: [] as unknown[][], + inserts: [] as Array<{ table: unknown; values: unknown }>, + updates: [] as Array<{ table: unknown; values: unknown }> + }; + function chain(get: () => unknown) { + const c: Record = {}; + for (const m of ['from', 'where', 'limit', 'orderBy', 'offset', 'innerJoin', 'leftJoin']) { + c[m] = () => c; + } + (c as { then: unknown }).then = (res: (v: unknown) => unknown, rej: (e: unknown) => unknown) => + Promise.resolve().then(get).then(res, rej); + return c; + } + return { state, chain }; +}); + +// Mirror routes/scripts.test.ts: stub the services barrel so the transitive +// service graph (queues, config validation) never loads in the test fork. +vi.mock('../services', () => ({})); + +vi.mock('../db', () => ({ + db: { + select: vi.fn(() => h.chain(() => h.state.selectQueue.shift() ?? [])), + insert: vi.fn((table: unknown) => ({ + values: vi.fn((values: unknown) => { + h.state.inserts.push({ table, values }); + const rows = Array.isArray(values) + ? (values as Record[]).map((v, i) => ({ id: `generated-${i}`, ...v })) + : [{ id: SCRIPT_ID, ...(values as Record) }]; + const p = Promise.resolve(rows) as Promise & { returning?: unknown }; + p.returning = () => Promise.resolve(rows); + return p; + }) + })), + update: vi.fn((table: unknown) => ({ + set: vi.fn((values: unknown) => { + h.state.updates.push({ table, values }); + return { where: vi.fn(() => Promise.resolve()) }; + }) + })) + }, + runOutsideDbContext: vi.fn((fn: () => unknown) => fn()), + withSystemDbAccessContext: vi.fn(async (fn: () => unknown) => fn()) +})); + +vi.mock('../services/auditEvents', () => ({ + requestLikeFromSnapshot: vi.fn(() => ({ req: { header: () => undefined } })), + writeRouteAudit: vi.fn() +})); + +vi.mock('../middleware/auth', () => ({ + authMiddleware: vi.fn((c: { set: (k: string, v: unknown) => void }, next: () => Promise) => { + c.set('auth', h.state.auth); + return next(); + }), + requireScope: vi.fn(() => async (_c: unknown, next: () => Promise) => next()), + requirePermission: vi.fn(() => async (_c: unknown, next: () => Promise) => next()), + requireMfa: vi.fn(() => async (_c: unknown, next: () => Promise) => next()) +})); + +import { scriptRoutes } from './scripts'; +import { scripts as scriptsTable } from '../db/schema'; +import { writeRouteAudit } from '../services/auditEvents'; +import { PARTNER_WIDE_WRITE_DENIED_MESSAGE } from '../services/partnerWideAccess'; + +function setAuth(overrides: Record = {}) { + h.state.auth = { + user: { id: 'user-123', email: 'test@example.com', name: 'Test User' }, + scope: 'organization', + partnerId: PARTNER_ID, + orgId: ORG_ID, + partnerOrgAccess: undefined, + accessibleOrgIds: [ORG_ID], + canAccessOrg: (orgId: string) => orgId === ORG_ID, + ...overrides + }; +} + +const baseEntry = { + name: 'Clear print spooler', + osTypes: ['windows'], + language: 'powershell', + content: 'Restart-Service Spooler' +}; + +function importRequest(app: Hono, body: Record) { + return app.request('/scripts/bundle/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer valid-token' }, + body: JSON.stringify(body) + }); +} + +describe('script bundle routes', () => { + let app: Hono; + + beforeEach(() => { + vi.clearAllMocks(); + h.state.selectQueue = []; + h.state.inserts = []; + h.state.updates = []; + setAuth(); + app = new Hono(); + app.route('/scripts', scriptRoutes); + }); + + describe('POST /scripts/bundle/import — partner-wide gate (#3262)', () => { + it("rejects availability 'partner' for a partner user without canManagePartnerWidePolicies (403, nothing written)", async () => { + setAuth({ scope: 'partner', orgId: null, partnerOrgAccess: 'selected' }); + const res = await importRequest(app, { + bundle: { bundleVersion: 1, scripts: [baseEntry] }, + mode: 'skip', + availability: 'partner' + }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe(PARTNER_WIDE_WRITE_DENIED_MESSAGE); + expect(h.state.inserts).toHaveLength(0); + expect(writeRouteAudit).not.toHaveBeenCalled(); + }); + + it("rejects availability 'partner' for an org-scope caller", async () => { + setAuth({ scope: 'organization' }); + const res = await importRequest(app, { + bundle: { bundleVersion: 1, scripts: [baseEntry] }, + mode: 'skip', + availability: 'partner' + }); + expect(res.status).toBe(403); + expect(h.state.inserts).toHaveLength(0); + }); + + it("imports partner-wide (org_id NULL, partner_id = caller's partner) for a full-partner admin", async () => { + setAuth({ scope: 'partner', orgId: null, partnerOrgAccess: 'all' }); + h.state.selectQueue.push([]); // no name conflict + const res = await importRequest(app, { + bundle: { bundleVersion: 1, scripts: [baseEntry] }, + mode: 'skip', + availability: 'partner' + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.imported).toBe(1); + const scriptInsert = h.state.inserts.find((i) => i.table === scriptsTable); + const values = scriptInsert!.values as Record; + expect(values.orgId).toBeNull(); + expect(values.partnerId).toBe(PARTNER_ID); + expect(values.isSystem).toBe(false); + }); + }); + + it("defaults availability to 'org': an org caller's import lands in their org", async () => { + h.state.selectQueue.push([]); + const res = await importRequest(app, { + bundle: { bundleVersion: 1, scripts: [baseEntry] }, + mode: 'skip' + }); + expect(res.status).toBe(200); + const values = h.state.inserts.find((i) => i.table === scriptsTable)!.values as Record; + expect(values.orgId).toBe(ORG_ID); + expect(values.partnerId).toBe(PARTNER_ID); + }); + + it('strips isSystem and foreign tenancy from bundle entries — even for a system-scope caller', async () => { + setAuth({ scope: 'system', orgId: null, partnerId: null, accessibleOrgIds: null }); + h.state.selectQueue.push([]); + const res = await importRequest(app, { + bundle: { + bundleVersion: 1, + scripts: [{ ...baseEntry, isSystem: true, orgId: OTHER_ORG_ID, partnerId: PARTNER_ID }] + }, + mode: 'skip', + availability: 'org', + orgId: ORG_ID + }); + expect(res.status).toBe(200); + const values = h.state.inserts.find((i) => i.table === scriptsTable)!.values as Record; + expect(values.isSystem).toBe(false); + expect(values.orgId).toBe(ORG_ID); + expect(values.partnerId).toBeNull(); + }); + + it('rejects an unknown bundleVersion with 400', async () => { + const res = await importRequest(app, { + bundle: { bundleVersion: 99, scripts: [baseEntry] }, + mode: 'skip' + }); + expect(res.status).toBe(400); + expect(h.state.inserts).toHaveLength(0); + }); + + it('audits every imported script with the bundle identity', async () => { + h.state.selectQueue.push([], []); + const res = await importRequest(app, { + bundle: { + bundleVersion: 1, + scripts: [baseEntry, { ...baseEntry, name: 'Second script' }] + }, + mode: 'skip' + }); + expect(res.status).toBe(200); + expect(writeRouteAudit).toHaveBeenCalledTimes(2); + const call = vi.mocked(writeRouteAudit).mock.calls[0]![1] as unknown as Record; + expect(call.action).toBe('script.bundle.import'); + const details = call.details as Record; + expect(typeof details.bundleSha256).toBe('string'); + expect((details.bundleSha256 as string).length).toBe(64); + expect(details.bundleScriptCount).toBe(2); + expect(details.mode).toBe('skip'); + }); + + describe('GET /scripts/bundle/export', () => { + it('returns a clean bundle for readable scripts', async () => { + h.state.selectQueue.push( + [ + { + id: SCRIPT_ID, + orgId: ORG_ID, + partnerId: PARTNER_ID, + name: 'Mine', + description: null, + category: null, + osTypes: ['windows'], + language: 'powershell', + content: 'Write-Host hi', + parameters: null, + timeoutSeconds: 300, + runAs: 'system', + isSystem: false, + version: 1, + exitCodeSeverityMapping: null, + deletedAt: null + } + ], + [] // tags + ); + const res = await app.request(`/scripts/bundle/export?ids=${SCRIPT_ID}`, { + headers: { Authorization: 'Bearer valid-token' } + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.bundleVersion).toBe(1); + expect(body.scripts).toHaveLength(1); + expect(body.scripts[0]).not.toHaveProperty('orgId'); + expect(body.scripts[0]).not.toHaveProperty('isSystem'); + }); + + it('rejects malformed ids', async () => { + const res = await app.request('/scripts/bundle/export?ids=not-a-guid', { + headers: { Authorization: 'Bearer valid-token' } + }); + expect(res.status).toBe(400); + }); + }); + + describe('POST /scripts/bundle/preview', () => { + it('annotates conflicts without writing', async () => { + h.state.selectQueue.push([{ id: SCRIPT_ID, name: baseEntry.name, version: 2 }]); + const res = await app.request('/scripts/bundle/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer valid-token' }, + body: JSON.stringify({ bundle: { bundleVersion: 1, scripts: [baseEntry] } }) + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.entries[0].status).toBe('name-conflict'); + expect(h.state.inserts).toHaveLength(0); + }); + + it('applies the partner-wide gate to preview as well', async () => { + setAuth({ scope: 'partner', orgId: null, partnerOrgAccess: 'selected' }); + const res = await app.request('/scripts/bundle/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer valid-token' }, + body: JSON.stringify({ + bundle: { bundleVersion: 1, scripts: [baseEntry] }, + availability: 'partner' + }) + }); + expect(res.status).toBe(403); + }); + }); +}); diff --git a/apps/api/src/routes/scriptBundle.ts b/apps/api/src/routes/scriptBundle.ts new file mode 100644 index 000000000..9285d9e91 --- /dev/null +++ b/apps/api/src/routes/scriptBundle.ts @@ -0,0 +1,199 @@ +/** + * Script bundle routes (#3245): /scripts/bundle/{export,preview,import}. + * + * Mounted from routes/scripts.ts under `/scripts/bundle` BEFORE the + * parameterized `/:id` routes, and inherits that router's authMiddleware — + * this file must not be mounted anywhere else. + * + * Security posture (see services/scriptBundle for the full story): a bundle + * is untrusted input whose contents run as SYSTEM on customer endpoints. + * These routes carry the same gating as the script write routes they compose + * (scope + permission + MFA on the write paths), bound the payload at intake, + * never execute anything, and audit every imported script individually with + * the bundle's identity so a later abuse finding traces back to the import + * that introduced it. + */ +import { Hono } from 'hono'; +import { createHash } from 'crypto'; +import { z } from 'zod'; +import { zValidator } from '../lib/validation'; +import { authMiddleware, requireMfa, requirePermission, requireScope } from '../middleware/auth'; +import { PERMISSIONS } from '../services/permissions'; +import { writeRouteAudit } from '../services/auditEvents'; +import { + canManagePartnerWidePolicies, + PARTNER_WIDE_WRITE_DENIED_MESSAGE +} from '../services/partnerWideAccess'; +import { exportBundle, importBundle, previewBundle } from '../services/scriptBundle'; +import { MAX_BUNDLE_SCRIPTS, scriptBundleEnvelopeSchema } from '../services/scriptBundle/schema'; + +export const scriptBundleRoutes = new Hono(); + +// Defense in depth: routes/scripts.ts already applies authMiddleware via +// use('*') before mounting this router (so it runs twice on the mounted +// path — harmless, it just re-derives the same context). Having it here too +// keeps this router safe if it is ever mounted standalone (e.g. in tests). +scriptBundleRoutes.use('*', authMiddleware); + +const exportQuerySchema = z.object({ + // Comma-separated script ids. Individual ids are guid-validated below. + ids: z.string().min(1) +}); + +const bundleTargetFields = { + // Envelope only — entries are validated PER ENTRY inside the service so one + // bad entry fails individually instead of rejecting the whole bundle. + bundle: scriptBundleEnvelopeSchema, + // Default 'org' — partner-wide fan-out must always be an explicit ask. + availability: z.enum(['org', 'partner']).default('org'), + orgId: z.string().guid().optional() +}; + +const previewBodySchema = z.object(bundleTargetFields); +const importBodySchema = z.object({ + ...bundleTargetFields, + mode: z.enum(['skip', 'rename', 'new-version']) +}); + +type PartnerGateAuth = Parameters[0]; + +/** + * Route-level fail-fast for `availability: 'partner'` (#3262). The service + * chokepoint (`resolveScriptCreateScope`) enforces the same capability gate; + * this check exists so the whole request is rejected up front instead of + * failing per-entry, and so partner-wide import is only expressible by + * partner-scope callers (system tokens carry no partnerId to fan out under). + */ +function partnerAvailabilityError(auth: PartnerGateAuth): string | null { + if (auth.scope !== 'partner' || !canManagePartnerWidePolicies(auth)) { + return PARTNER_WIDE_WRITE_DENIED_MESSAGE; + } + return null; +} + +function bundleSha256(bundle: unknown): string { + return createHash('sha256').update(JSON.stringify(bundle)).digest('hex'); +} + +// GET /scripts/bundle/export?ids=a,b,c — bundle for the selected scripts, +// scoped to what the caller can already read. Emits no tenancy identifiers +// and no isSystem flag. +scriptBundleRoutes.get( + '/export', + requireScope('organization', 'partner', 'system'), + requirePermission(PERMISSIONS.SCRIPTS_READ.resource, PERMISSIONS.SCRIPTS_READ.action), + zValidator('query', exportQuerySchema), + async (c) => { + const auth = c.get('auth'); + const raw = c.req.valid('query').ids.split(',').map((s) => s.trim()).filter(Boolean); + + if (raw.length === 0 || raw.length > MAX_BUNDLE_SCRIPTS) { + return c.json({ error: `ids must contain between 1 and ${MAX_BUNDLE_SCRIPTS} script ids` }, 400); + } + const parsedIds = z.array(z.string().guid()).safeParse(raw); + if (!parsedIds.success) { + return c.json({ error: 'ids must be a comma-separated list of script ids' }, 400); + } + + const bundle = await exportBundle(auth, parsedIds.data); + + writeRouteAudit(c, { + orgId: auth.orgId ?? null, + action: 'script.bundle.export', + resourceType: 'script_bundle', + details: { + requestedIds: parsedIds.data.length, + exportedScripts: bundle.scripts.length + } + }); + + return c.json(bundle); + } +); + +// POST /scripts/bundle/preview — annotate entries new/name-conflict. No writes, +// but gated like the import it previews. +scriptBundleRoutes.post( + '/preview', + requireScope('organization', 'partner', 'system'), + requirePermission(PERMISSIONS.SCRIPTS_WRITE.resource, PERMISSIONS.SCRIPTS_WRITE.action), + requireMfa(), + zValidator('json', previewBodySchema), + async (c) => { + const auth = c.get('auth'); + const body = c.req.valid('json'); + + if (body.availability === 'partner') { + const err = partnerAvailabilityError({ ...auth, partnerOrgAccess: auth.partnerOrgAccess }); + if (err) return c.json({ error: err }, 403); + } + + const result = await previewBundle(auth, body.bundle, { + availability: body.availability, + orgId: body.orgId + }); + if ('error' in result) { + return c.json({ error: result.error }, result.status); + } + + return c.json(result); + } +); + +// POST /scripts/bundle/import — commit a bundle into the caller's scope. +scriptBundleRoutes.post( + '/import', + requireScope('organization', 'partner', 'system'), + requirePermission(PERMISSIONS.SCRIPTS_WRITE.resource, PERMISSIONS.SCRIPTS_WRITE.action), + requireMfa(), + zValidator('json', importBodySchema), + async (c) => { + const auth = c.get('auth'); + const body = c.req.valid('json'); + + if (body.availability === 'partner') { + const err = partnerAvailabilityError({ ...auth, partnerOrgAccess: auth.partnerOrgAccess }); + if (err) return c.json({ error: err }, 403); + } + + const result = await importBundle(auth, body.bundle, { + availability: body.availability, + orgId: body.orgId, + mode: body.mode + }); + if ('error' in result) { + return c.json({ error: result.error }, result.status); + } + + // Audit every script the import wrote (imported/renamed/versioned; skipped + // entries wrote nothing), tagged with the bundle's identity. + const sha256 = bundleSha256(body.bundle); + for (const entry of result.scripts) { + if (entry.action === 'skipped') continue; + writeRouteAudit(c, { + orgId: result.target.orgId ?? auth.orgId ?? null, + action: 'script.bundle.import', + resourceType: 'script', + resourceId: entry.scriptId, + resourceName: entry.finalName ?? entry.name, + details: { + bundleSha256: sha256, + bundleScriptCount: body.bundle.scripts.length, + mode: body.mode, + availability: body.availability, + entryAction: entry.action, + ...(entry.finalName ? { originalName: entry.name } : {}) + } + }); + } + + return c.json({ + imported: result.imported, + skipped: result.skipped, + renamed: result.renamed, + versioned: result.versioned, + errors: result.errors, + scripts: result.scripts + }); + } +); diff --git a/apps/api/src/routes/scripts.ts b/apps/api/src/routes/scripts.ts index 60f9daead..16d5ebeb2 100644 --- a/apps/api/src/routes/scripts.ts +++ b/apps/api/src/routes/scripts.ts @@ -24,6 +24,12 @@ import { canManagePartnerWidePolicies, PARTNER_WIDE_WRITE_DENIED_MESSAGE, } from '../services/partnerWideAccess'; +import { + insertScriptRow, + isScriptScopeError, + resolveScriptCreateScope, +} from '../services/scriptWrite'; +import { scriptBundleRoutes } from './scriptBundle'; export const scriptRoutes = new Hono(); @@ -293,6 +299,11 @@ const scriptIdParamSchema = z.object({ id: z.string().guid() }); // Apply auth middleware to all routes scriptRoutes.use('*', authMiddleware); +// Bundle import/export (#3245). Mounted BEFORE the parameterized /:id routes +// so /scripts/bundle/* never falls through to the guid param validator. +// Inherits authMiddleware from the use('*') above. +scriptRoutes.route('/bundle', scriptBundleRoutes); + // GET /scripts - List scripts with filters scriptRoutes.get( '/', @@ -548,69 +559,26 @@ scriptRoutes.post( const auth = c.get('auth'); const data = c.req.valid('json'); - // Determine orgId and partnerId - let orgId: string | null = data.orgId ?? null; - let partnerId: string | null = null; - - if (auth.scope === 'organization') { - if (!auth.orgId) { - return c.json({ error: 'Organization context required' }, 403); - } - orgId = auth.orgId; - 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); - } else { - if (!orgId) { - const singleOrg = auth.accessibleOrgIds?.[0]; - if (auth.accessibleOrgIds?.length === 1 && singleOrg) { - orgId = singleOrg; - } else { - return c.json({ error: 'orgId is required when partner has multiple organizations' }, 400); - } - } - if (!ensureOrgAccess(orgId!, auth)) { - return c.json({ error: 'Access to this organization denied' }, 403); - } - partnerId = auth.partnerId ?? null; - } + // Tenancy resolution, the partner-wide capability gate (#3262: partner + // SCOPE is not the same as partner-wide CAPABILITY — a 'selected'-access + // user must not push SYSTEM-level code to every org under the partner), + // and the isSystem clamp all live in services/scriptWrite.ts. That module + // is the single chokepoint shared with the bundle importer (#3245), so + // the two intakes can never diverge (#3263 review). + const scope = resolveScriptCreateScope( + // partnerOrgAccess is an optional KEY on AuthContext but a required one + // on ScriptWriteAuth — spell it out so the compiler proves it threaded. + { ...auth, partnerOrgAccess: auth.partnerOrgAccess }, + data.availability, + data.orgId + ); + if (isScriptScopeError(scope)) { + return c.json({ error: scope.error }, scope.status); } - // System scope can create system scripts without orgId or specify any orgId - - // Only system scope can create system scripts - const isSystem = auth.scope === 'system' ? (data.isSystem ?? false) : false; - const [script] = await db - .insert(scripts) - .values({ - orgId: isSystem && !orgId ? null : orgId, - partnerId, - name: data.name, - description: data.description, - category: data.category, - osTypes: data.osTypes, - language: data.language, - content: data.content, - parameters: data.parameters, - timeoutSeconds: data.timeoutSeconds, - runAs: data.runAs, - isSystem, - version: 1, - exitCodeSeverityMapping: data.exitCodeSeverityMapping ?? null, - createdBy: auth.user.id - }) - .returning(); + const script = await insertScriptRow(auth, scope, data, { + requestedIsSystem: data.isSystem + }); writeRouteAudit(c, { orgId: resolveScriptAuditOrgId(auth, script?.orgId ?? null), diff --git a/apps/api/src/services/scriptBundle/index.test.ts b/apps/api/src/services/scriptBundle/index.test.ts new file mode 100644 index 000000000..8938594ba --- /dev/null +++ b/apps/api/src/services/scriptBundle/index.test.ts @@ -0,0 +1,581 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const ORG_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const OTHER_ORG_ID = '99999999-9999-4999-8999-999999999999'; +const PARTNER_ID = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; +const OTHER_PARTNER_ID = 'abababab-abab-4bab-8bab-abababababab'; +const SCRIPT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const TAG_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + +// --------------------------------------------------------------------------- +// db mock: queue-driven chains. Each db.select() consumes the next entry of +// selectQueue when awaited; inserts/updates are captured with their table. +// The REAL Drizzle schema is used (pure table definitions, no connection). +// --------------------------------------------------------------------------- +const h = vi.hoisted(() => { + const state = { + selectQueue: [] as unknown[][], + selectWheres: [] as unknown[], + inserts: [] as Array<{ table: unknown; values: unknown }>, + updates: [] as Array<{ table: unknown; values: unknown }> + }; + function chain(get: () => unknown) { + const c: Record = {}; + for (const m of ['from', 'limit', 'orderBy', 'offset', 'innerJoin', 'leftJoin']) { + c[m] = () => c; + } + c.where = (cond: unknown) => { + state.selectWheres.push(cond); + return c; + }; + (c as { then: unknown }).then = (res: (v: unknown) => unknown, rej: (e: unknown) => unknown) => + Promise.resolve().then(get).then(res, rej); + return c; + } + return { state, chain }; +}); + +vi.mock('../../db', () => ({ + db: { + select: vi.fn(() => h.chain(() => h.state.selectQueue.shift() ?? [])), + insert: vi.fn((table: unknown) => ({ + values: vi.fn((values: unknown) => { + h.state.inserts.push({ table, values }); + const rows = Array.isArray(values) + ? (values as Record[]).map((v, i) => ({ id: `generated-${h.state.inserts.length}-${i}`, ...v })) + : [{ id: `generated-${h.state.inserts.length}`, ...(values as Record) }]; + const p = Promise.resolve(rows) as Promise & { returning?: unknown }; + p.returning = () => Promise.resolve(rows); + return p; + }) + })), + update: vi.fn((table: unknown) => ({ + set: vi.fn((values: unknown) => { + h.state.updates.push({ table, values }); + return { where: vi.fn(() => Promise.resolve()) }; + }) + })) + } +})); + +import { scripts, scriptTags, scriptToTags, scriptVersions } from '../../db/schema'; +import { exportBundle, importBundle, previewBundle, type BundleAuth } from './index'; +import { + scriptBundleSchema, + MAX_BUNDLE_SCRIPTS, + MAX_BUNDLE_CONTENT_LENGTH +} from './schema'; +import { PARTNER_WIDE_WRITE_DENIED_MESSAGE } from '../partnerWideAccess'; + +function makeAuth(overrides: Partial = {}): BundleAuth { + return { + scope: 'organization', + orgId: ORG_ID, + partnerId: PARTNER_ID, + partnerOrgAccess: undefined, + accessibleOrgIds: [ORG_ID], + canAccessOrg: (orgId: string) => orgId === ORG_ID, + user: { id: 'user-123', email: 'test@example.com' } as BundleAuth['user'], + ...overrides + } as BundleAuth; +} + +function validBundle(entries: Array>) { + return scriptBundleSchema.parse({ bundleVersion: 1, scripts: entries }); +} + +const baseEntry = { + name: 'Clear print spooler', + osTypes: ['windows'], + language: 'powershell', + content: 'Restart-Service Spooler' +}; + +beforeEach(() => { + vi.clearAllMocks(); + h.state.selectQueue = []; + h.state.selectWheres = []; + h.state.inserts = []; + h.state.updates = []; +}); + +/** + * Recursively walk a Drizzle SQL condition tree looking for a column named + * `columnName` (used to prove a WHERE clause filters on it). Handles the + * circular table<->column references with a seen-set. + */ +function conditionMentionsColumn(cond: unknown, columnName: string, seen = new Set()): boolean { + if (!cond || typeof cond !== 'object') return false; + const obj = cond as Record; + if (seen.has(obj)) return false; + seen.add(obj); + if (obj.name === columnName && 'table' in obj) return true; + for (const value of Object.values(obj)) { + if (Array.isArray(value)) { + if (value.some((v) => conditionMentionsColumn(v, columnName, seen))) return true; + } else if (value && typeof value === 'object' && !('columns' in (value as object) && seen.has(value as object))) { + if (conditionMentionsColumn(value, columnName, seen)) return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Schema (intake hardening — Task 4) +// --------------------------------------------------------------------------- +describe('scriptBundleSchema', () => { + it('rejects an unknown bundleVersion instead of best-effort parsing', () => { + const result = scriptBundleSchema.safeParse({ bundleVersion: 2, scripts: [baseEntry] }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain('Unsupported bundleVersion'); + }); + + it('strips isSystem, id, orgId, partnerId and createdBy from entries', () => { + const parsed = scriptBundleSchema.parse({ + bundleVersion: 1, + scripts: [ + { + ...baseEntry, + isSystem: true, + id: SCRIPT_ID, + orgId: OTHER_ORG_ID, + partnerId: OTHER_PARTNER_ID, + createdBy: 'attacker' + } + ] + }); + const entry = parsed.scripts[0] as Record; + expect(entry).not.toHaveProperty('isSystem'); + expect(entry).not.toHaveProperty('id'); + expect(entry).not.toHaveProperty('orgId'); + expect(entry).not.toHaveProperty('partnerId'); + expect(entry).not.toHaveProperty('createdBy'); + }); + + it('applies defaults: timeoutSeconds 300, runAs system', () => { + const parsed = scriptBundleSchema.parse({ bundleVersion: 1, scripts: [baseEntry] }); + expect(parsed.scripts[0]!.timeoutSeconds).toBe(300); + expect(parsed.scripts[0]!.runAs).toBe('system'); + }); + + it('rejects oversized parameters at intake', () => { + const result = scriptBundleSchema.safeParse({ + bundleVersion: 1, + scripts: [{ ...baseEntry, parameters: { blob: 'x'.repeat(65 * 1024) } }] + }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain('parameters too large'); + }); + + it('rejects deeply nested parameters at intake', () => { + let nested: unknown = 'leaf'; + for (let i = 0; i < 12; i++) nested = { inner: nested }; + const result = scriptBundleSchema.safeParse({ + bundleVersion: 1, + scripts: [{ ...baseEntry, parameters: nested }] + }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain('nested too deeply'); + }); + + it('rejects an exitCodeSeverityMapping that maps every exit code to null', () => { + const result = scriptBundleSchema.safeParse({ + bundleVersion: 1, + scripts: [{ ...baseEntry, exitCodeSeverityMapping: { '0': null, '1': null } }] + }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain('never alert'); + }); + + it('accepts a mapping with at least one real severity', () => { + const result = scriptBundleSchema.safeParse({ + bundleVersion: 1, + scripts: [{ ...baseEntry, exitCodeSeverityMapping: { '0': null, '1': 'high' } }] + }); + expect(result.success).toBe(true); + }); + + it('enforces content and bundle-size caps', () => { + expect( + scriptBundleSchema.safeParse({ + bundleVersion: 1, + scripts: [{ ...baseEntry, content: 'x'.repeat(MAX_BUNDLE_CONTENT_LENGTH + 1) }] + }).success + ).toBe(false); + expect( + scriptBundleSchema.safeParse({ + bundleVersion: 1, + scripts: Array.from({ length: MAX_BUNDLE_SCRIPTS + 1 }, () => ({ ...baseEntry })) + }).success + ).toBe(false); + expect(scriptBundleSchema.safeParse({ bundleVersion: 1, scripts: [] }).success).toBe(false); + }); + + it('enforces the same timeout bounds as createScriptSchema', () => { + expect( + scriptBundleSchema.safeParse({ + bundleVersion: 1, + scripts: [{ ...baseEntry, timeoutSeconds: 99999 }] + }).success + ).toBe(false); + expect( + scriptBundleSchema.safeParse({ bundleVersion: 1, scripts: [{ ...baseEntry, osTypes: [] }] }) + .success + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// importBundle +// --------------------------------------------------------------------------- +describe('importBundle', () => { + it('never writes isSystem: true — even when the caller is system scope and the raw bundle asked for it', async () => { + // RAW (attacker-supplied) envelope, not pre-parsed: the service's own + // per-entry validation must strip isSystem + foreign tenancy. + const bundle = { + bundleVersion: 1 as const, + scripts: [{ ...baseEntry, isSystem: true, orgId: OTHER_ORG_ID, partnerId: OTHER_PARTNER_ID }] + }; + const auth = makeAuth({ scope: 'system', orgId: null, partnerId: null, accessibleOrgIds: null }); + + h.state.selectQueue.push([]); // findExistingByName → none + const result = await importBundle(auth, bundle, { + mode: 'skip', + availability: 'org', + orgId: ORG_ID + }); + + expect('error' in result).toBe(false); + const scriptInsert = h.state.inserts.find((i) => i.table === scripts); + expect(scriptInsert).toBeDefined(); + const values = scriptInsert!.values as Record; + expect(values.isSystem).toBe(false); + expect(values.orgId).toBe(ORG_ID); // caller-resolved, not the bundle's foreign org + expect(values.partnerId).toBeNull(); // system scope carries no partner + expect(values.createdBy).toBe('user-123'); + }); + + it('lands scripts in the caller scope, ignoring tenancy in the bundle (org caller)', async () => { + const bundle = validBundle([{ ...baseEntry, orgId: OTHER_ORG_ID }]); + h.state.selectQueue.push([]); // no name conflict + const result = await importBundle(makeAuth(), bundle, { mode: 'skip', availability: 'org' }); + + expect('error' in result).toBe(false); + const values = h.state.inserts.find((i) => i.table === scripts)!.values as Record; + expect(values.orgId).toBe(ORG_ID); + expect(values.partnerId).toBe(PARTNER_ID); + if ('imported' in result) expect(result.imported).toBe(1); + }); + + it("denies availability 'partner' for a partner caller without the partner-wide capability (service chokepoint)", async () => { + const auth = makeAuth({ + scope: 'partner', + orgId: null, + partnerOrgAccess: 'selected', + accessibleOrgIds: [ORG_ID] + }); + const result = await importBundle(auth, validBundle([baseEntry]), { + mode: 'skip', + availability: 'partner' + }); + expect(result).toEqual({ error: PARTNER_WIDE_WRITE_DENIED_MESSAGE, status: 403 }); + expect(h.state.inserts).toHaveLength(0); + }); + + it("creates partner-wide rows (org_id NULL, partner_id set) for a full-partner admin importing with availability 'partner'", async () => { + const auth = makeAuth({ + scope: 'partner', + orgId: null, + partnerOrgAccess: 'all', + accessibleOrgIds: [ORG_ID] + }); + h.state.selectQueue.push([]); // no conflict among partner-wide rows + const result = await importBundle(auth, validBundle([baseEntry]), { + mode: 'skip', + availability: 'partner' + }); + expect('error' in result).toBe(false); + const values = h.state.inserts.find((i) => i.table === scripts)!.values as Record; + expect(values.orgId).toBeNull(); + expect(values.partnerId).toBe(PARTNER_ID); + expect(values.isSystem).toBe(false); + }); + + it('skip mode leaves the existing script untouched', async () => { + const bundle = validBundle([baseEntry]); + h.state.selectQueue.push([ + { id: SCRIPT_ID, name: baseEntry.name, version: 3, content: 'old', orgId: ORG_ID } + ]); + const result = await importBundle(makeAuth(), bundle, { mode: 'skip', availability: 'org' }); + expect('error' in result).toBe(false); + if ('skipped' in result) { + expect(result.skipped).toBe(1); + expect(result.imported).toBe(0); + } + expect(h.state.inserts).toHaveLength(0); + expect(h.state.updates).toHaveLength(0); + }); + + it('rename mode suffixes until a free name is found (one candidate query per entry)', async () => { + const bundle = validBundle([baseEntry]); + h.state.selectQueue.push( + [{ id: SCRIPT_ID, name: baseEntry.name, version: 1, content: 'old' }], // conflict + [{ name: `${baseEntry.name} (2)` }] // single candidates query: (2) taken, (3) free + ); + const result = await importBundle(makeAuth(), bundle, { mode: 'rename', availability: 'org' }); + expect('error' in result).toBe(false); + const values = h.state.inserts.find((i) => i.table === scripts)!.values as Record; + expect(values.name).toBe(`${baseEntry.name} (3)`); + if ('renamed' in result) { + expect(result.renamed).toBe(1); + expect(result.scripts[0]!.finalName).toBe(`${baseEntry.name} (3)`); + } + }); + + it('new-version mode appends the previous content to scriptVersions and bumps the version', async () => { + const bundle = validBundle([{ ...baseEntry, content: 'new content' }]); + h.state.selectQueue.push([ + { + id: SCRIPT_ID, + name: baseEntry.name, + version: 4, + content: 'old content', + description: 'd', + category: null, + parameters: null, + exitCodeSeverityMapping: null + } + ]); + const result = await importBundle(makeAuth(), bundle, { + mode: 'new-version', + availability: 'org' + }); + expect('error' in result).toBe(false); + + const versionInsert = h.state.inserts.find((i) => i.table === scriptVersions); + expect(versionInsert).toBeDefined(); + const snapshot = versionInsert!.values as Record; + expect(snapshot.scriptId).toBe(SCRIPT_ID); + expect(snapshot.version).toBe(4); + expect(snapshot.content).toBe('old content'); + + const update = h.state.updates.find((u) => u.table === scripts); + expect(update).toBeDefined(); + const set = update!.values as Record; + expect(set.content).toBe('new content'); + expect(set.version).toBe(5); + if ('versioned' in result) expect(result.versioned).toBe(1); + }); + + it('records per-entry failures and continues with the rest', async () => { + const bundle = validBundle([baseEntry, { ...baseEntry, name: 'Second script' }]); + h.state.selectQueue = [[], []]; // both entries: no name conflict + // Poison the FIRST insert (entry 1's script row); entry 2 proceeds normally. + const { db } = await import('../../db'); + (db.insert as unknown as ReturnType).mockImplementationOnce(() => ({ + values: vi.fn(() => ({ returning: vi.fn(() => Promise.reject(new Error('boom'))) })) + })); + const result = await importBundle(makeAuth(), bundle, { mode: 'skip', availability: 'org' }); + expect('error' in result).toBe(false); + if ('errors' in result) { + expect(result.errors).toHaveLength(1); + expect(result.errors[0]!.error).toBe('boom'); + expect(result.imported).toBe(1); + } + }); + + it('resolves tags by name in the target scope: reuses existing, creates missing, links both', async () => { + const bundle = validBundle([{ ...baseEntry, tags: ['printing', 'windows'] }]); + h.state.selectQueue.push( + [], // findExistingByName → none + [{ id: TAG_ID, name: 'printing' }] // ensureTagIds → 'printing' exists, 'windows' missing + ); + const result = await importBundle(makeAuth(), bundle, { mode: 'skip', availability: 'org' }); + expect('error' in result).toBe(false); + + const tagInsert = h.state.inserts.find((i) => i.table === scriptTags); + expect(tagInsert).toBeDefined(); + const createdTags = tagInsert!.values as Array>; + expect(createdTags).toHaveLength(1); + expect(createdTags[0]!.name).toBe('windows'); + expect(createdTags[0]!.orgId).toBe(ORG_ID); + + const linkInsert = h.state.inserts.find((i) => i.table === scriptToTags); + expect(linkInsert).toBeDefined(); + expect((linkInsert!.values as unknown[]).length).toBe(2); + }); + + it('rejects a system-scope import with no orgId instead of creating tenantless orphan rows', async () => { + const auth = makeAuth({ scope: 'system', orgId: null, partnerId: null, accessibleOrgIds: null }); + const result = await importBundle(auth, validBundle([baseEntry]), { + mode: 'skip', + availability: 'org' + }); + expect(result).toMatchObject({ status: 400 }); + expect(h.state.inserts).toHaveLength(0); + }); + + it('new-version mode with byte-identical content is a no-op skip (no version padding)', async () => { + const bundle = validBundle([baseEntry]); + h.state.selectQueue.push([ + { id: SCRIPT_ID, name: baseEntry.name, version: 4, content: baseEntry.content } + ]); + const result = await importBundle(makeAuth(), bundle, { + mode: 'new-version', + availability: 'org' + }); + expect('error' in result).toBe(false); + if ('skipped' in result) { + expect(result.skipped).toBe(1); + expect(result.versioned).toBe(0); + } + expect(h.state.inserts).toHaveLength(0); + expect(h.state.updates).toHaveLength(0); + }); + + it('conflict lookups exclude system-library rows, so a bundle can never update an is_system script', async () => { + h.state.selectQueue.push([]); + await importBundle(makeAuth(), validBundle([baseEntry]), { mode: 'new-version', availability: 'org' }); + // The first SELECT is findExistingByName; its WHERE must filter on + // is_system (= false) so a system script sharing the name is never + // matched — and therefore never rewritten by new-version mode. + expect(h.state.selectWheres.length).toBeGreaterThan(0); + expect(conditionMentionsColumn(h.state.selectWheres[0], 'is_system')).toBe(true); + expect(conditionMentionsColumn(h.state.selectWheres[0], 'deleted_at')).toBe(true); + }); + + it('records invalid entries per-entry and imports the valid ones', async () => { + const envelope = { + bundleVersion: 1 as const, + scripts: [ + { ...baseEntry, timeoutSeconds: 99999 }, // fails createScriptSchema parity bounds + { ...baseEntry, name: 'Valid script' } + ] + }; + h.state.selectQueue.push([]); // valid entry: no conflict + const result = await importBundle(makeAuth(), envelope, { mode: 'skip', availability: 'org' }); + expect('error' in result).toBe(false); + if ('errors' in result) { + expect(result.errors).toHaveLength(1); + expect(result.errors[0]!.error).toContain('timeoutSeconds'); + expect(result.imported).toBe(1); + } + }); +}); + +// --------------------------------------------------------------------------- +// previewBundle +// --------------------------------------------------------------------------- +describe('previewBundle', () => { + it('annotates entries new / name-conflict without writing', async () => { + const bundle = validBundle([baseEntry, { ...baseEntry, name: 'Second script' }]); + h.state.selectQueue.push( + [{ id: SCRIPT_ID, name: baseEntry.name, version: 2 }], + [] + ); + const result = await previewBundle(makeAuth(), bundle, { availability: 'org' }); + expect('error' in result).toBe(false); + if ('entries' in result) { + expect(result.entries[0]).toMatchObject({ + status: 'name-conflict', + existingScriptId: SCRIPT_ID, + existingVersion: 2 + }); + expect(result.entries[1]).toMatchObject({ status: 'new' }); + } + expect(h.state.inserts).toHaveLength(0); + expect(h.state.updates).toHaveLength(0); + }); + + it('propagates the partner-wide capability denial', async () => { + const auth = makeAuth({ scope: 'partner', orgId: null, partnerOrgAccess: 'selected' }); + const result = await previewBundle(auth, validBundle([baseEntry]), { availability: 'partner' }); + expect(result).toEqual({ error: PARTNER_WIDE_WRITE_DENIED_MESSAGE, status: 403 }); + }); +}); + +// --------------------------------------------------------------------------- +// exportBundle +// --------------------------------------------------------------------------- +describe('exportBundle', () => { + it('emits no tenancy identifiers and no isSystem flag, and skips unreadable scripts', async () => { + h.state.selectQueue.push( + [ + { + id: SCRIPT_ID, + orgId: ORG_ID, + partnerId: PARTNER_ID, + name: 'Mine', + description: 'desc', + category: 'Maintenance', + osTypes: ['windows'], + language: 'powershell', + content: 'Write-Host hi', + parameters: { foo: 'bar' }, + timeoutSeconds: 120, + runAs: 'system', + isSystem: false, + version: 2, + exitCodeSeverityMapping: { '1': 'high' }, + deletedAt: null + }, + { + id: 'not-readable', + orgId: OTHER_ORG_ID, + partnerId: OTHER_PARTNER_ID, + name: 'Theirs', + osTypes: ['linux'], + language: 'bash', + content: 'echo hi', + timeoutSeconds: 300, + runAs: 'system', + isSystem: false, + version: 1, + deletedAt: null + } + ], + [{ scriptId: SCRIPT_ID, name: 'printing' }] // tag join + ); + + const bundle = await exportBundle(makeAuth(), [SCRIPT_ID, 'not-readable'] as string[]); + expect(bundle.bundleVersion).toBe(1); + expect(bundle.scripts).toHaveLength(1); + const entry = bundle.scripts[0] as Record; + expect(entry.name).toBe('Mine'); + expect(entry.tags).toEqual(['printing']); + expect(entry).not.toHaveProperty('id'); + expect(entry).not.toHaveProperty('orgId'); + expect(entry).not.toHaveProperty('partnerId'); + expect(entry).not.toHaveProperty('isSystem'); + expect(entry).not.toHaveProperty('createdBy'); + }); + + it('round-trips: an exported bundle validates against the bundle schema', async () => { + h.state.selectQueue.push( + [ + { + id: SCRIPT_ID, + orgId: ORG_ID, + partnerId: PARTNER_ID, + name: 'Mine', + description: null, + category: null, + osTypes: ['windows'], + language: 'powershell', + content: 'Write-Host hi', + parameters: null, + timeoutSeconds: 300, + runAs: 'system', + isSystem: true, // even a system script exports clean + version: 1, + exitCodeSeverityMapping: null, + deletedAt: null + } + ], + [] + ); + const bundle = await exportBundle(makeAuth(), [SCRIPT_ID]); + const parsed = scriptBundleSchema.safeParse(bundle); + expect(parsed.success).toBe(true); + }); +}); diff --git a/apps/api/src/services/scriptBundle/index.ts b/apps/api/src/services/scriptBundle/index.ts new file mode 100644 index 000000000..0e7c970ba --- /dev/null +++ b/apps/api/src/services/scriptBundle/index.ts @@ -0,0 +1,463 @@ +/** + * Script bundle export / preview / import (#3245). + * + * A bundle is untrusted input regardless of who uploads it, and its contents + * run as SYSTEM on customer endpoints. The security posture: + * + * - Intake is bounded by `scriptBundleSchema` (see ./schema.ts) — callers must + * parse with it before handing a bundle to this service. + * - Ownership comes from the caller's auth context ONLY, via + * `resolveScriptCreateScope` (services/scriptWrite.ts — the shared + * chokepoint with POST /scripts, including the #3262 partner-wide + * capability gate). Tenancy identifiers inside a bundle are never read: + * the schema strips them. + * - `isSystem` is never honoured from a bundle, at ANY caller scope — + * `insertScriptRow` is called without `requestedIsSystem`, so the clamp + * yields `false` even for system-scope callers. Stricter than POST /scripts. + * - Import never executes anything, and a v1 bundle cannot carry automations, + * schedules, or triggers (the schema has no such fields). + * - Imported rows are ordinary `scripts` rows, so the existing + * abuse-signal sweep covers them by construction. That is detection after + * the fact — which is why the route audits every imported script with the + * bundle's identity. + */ +import { and, eq, inArray, isNull } from 'drizzle-orm'; +import { db } from '../../db'; +import { scripts, scriptTags, scriptToTags, scriptVersions } from '../../db/schema'; +import type { AuthContext } from '../../middleware/auth'; +import { + isScriptScopeError, + resolveScriptCreateScope, + insertScriptRow, + type ScriptCreateScope, + type ScriptScopeError, + type ScriptWriteAuth +} from '../scriptWrite'; +import { + SCRIPT_BUNDLE_VERSION, + bundleScriptEntrySchema, + formatEntryIssues, + type ScriptBundle, + type ScriptBundleEntry, + type ScriptBundleEnvelope +} from './schema'; + +export type BundleAuth = ScriptWriteAuth & Pick; +export type BundleImportMode = 'skip' | 'rename' | 'new-version'; +export type BundleAvailability = 'org' | 'partner'; + +export type BundleTargetOptions = { + /** Defaults to 'org'. 'partner' is capability-gated in resolveScriptCreateScope. */ + availability: BundleAvailability; + orgId?: string | null; +}; + +type ScriptRow = typeof scripts.$inferSelect; + +function canReadScript(auth: BundleAuth, script: ScriptRow): boolean { + if (auth.scope === 'system') return true; + if (script.isSystem) return true; + if (script.orgId && auth.canAccessOrg(script.orgId)) return true; + // Partner-wide (and partner-denormalized) rows are readable by the owning + // partner's users — same visibility the list route grants. + if (script.partnerId && auth.partnerId === script.partnerId) return true; + return false; +} + +/** + * Export the selected scripts as a v1 bundle, scoped to what the caller can + * already read. Emits NO tenancy identifiers and no `isSystem` flag — system + * scripts export like any other script, so a round-trip cannot launder them + * back in as system-library entries. + */ +export async function exportBundle(auth: BundleAuth, ids: string[]): Promise { + const unique = [...new Set(ids)]; + const rows = unique.length + ? await db + .select() + .from(scripts) + .where(and(inArray(scripts.id, unique), isNull(scripts.deletedAt))) + : []; + + const readable = rows.filter((s) => canReadScript(auth, s)); + + const tagsByScript = new Map(); + if (readable.length > 0) { + const tagRows = await db + .select({ scriptId: scriptToTags.scriptId, name: scriptTags.name }) + .from(scriptToTags) + .innerJoin(scriptTags, eq(scriptToTags.tagId, scriptTags.id)) + .where(inArray(scriptToTags.scriptId, readable.map((s) => s.id))); + for (const row of tagRows) { + const list = tagsByScript.get(row.scriptId) ?? []; + list.push(row.name); + tagsByScript.set(row.scriptId, list); + } + } + + return { + bundleVersion: SCRIPT_BUNDLE_VERSION, + exportedAt: new Date().toISOString(), + scripts: readable.map((s) => { + const tags = tagsByScript.get(s.id); + return { + name: s.name, + ...(s.description ? { description: s.description } : {}), + ...(s.category ? { category: s.category } : {}), + ...(tags && tags.length > 0 ? { tags: tags.sort() } : {}), + osTypes: s.osTypes as ScriptBundleEntry['osTypes'], + language: s.language, + content: s.content, + ...(s.parameters != null ? { parameters: s.parameters } : {}), + timeoutSeconds: s.timeoutSeconds, + runAs: s.runAs, + ...(s.exitCodeSeverityMapping != null + ? { exitCodeSeverityMapping: s.exitCodeSeverityMapping } + : {}) + }; + }) + }; +} + +/** + * Scope condition for conflict lookups. `is_system = false` is load-bearing: + * a system-library script can share a name with a tenant script, and matching + * it here would let a `new-version` import rewrite the body of an `is_system` + * row — the exact edit `PUT /scripts/:id` rejects with "System scripts are + * read-only". System rows are a different namespace; never conflict against + * them, never update them from a bundle. + */ +function scopeCondition(scope: ScriptCreateScope) { + if (scope.orgId) { + return and(eq(scripts.orgId, scope.orgId), eq(scripts.isSystem, false), isNull(scripts.deletedAt)); + } + // Partner-wide target: conflict against the partner's own partner-wide rows. + return and( + isNull(scripts.orgId), + eq(scripts.partnerId, scope.partnerId!), + eq(scripts.isSystem, false), + isNull(scripts.deletedAt) + ); +} + +function sameNameCondition(scope: ScriptCreateScope, name: string) { + return and(eq(scripts.name, name), scopeCondition(scope)); +} + +async function findExistingByName(scope: ScriptCreateScope, name: string) { + // Duplicate names are not prevented by any unique index; order by creation + // so a conflict deterministically resolves to the OLDEST matching row + // instead of whichever row the query plan happens to return first. + const [existing] = await db + .select() + .from(scripts) + .where(sameNameCondition(scope, name)) + .orderBy(scripts.createdAt) + .limit(1); + return existing; +} + +/** + * A bundle import creates ordinary (non-system) rows, which must belong to + * SOME tenant. A system-scope caller who supplies no orgId would otherwise + * resolve to `{ orgId: null, partnerId: null }` — rows invisible to every + * tenant and conflict lookups comparing `partner_id = NULL` (never true). + */ +function unownedScopeError(scope: ScriptCreateScope): ScriptScopeError | null { + if (scope.orgId === null && scope.partnerId === null) { + return { error: 'orgId is required when importing with a system-scope token', status: 400 }; + } + return null; +} + +export type BundlePreviewEntry = { + index: number; + name: string; + status: 'new' | 'name-conflict' | 'invalid'; + error?: string; + existingScriptId?: string; + existingVersion?: number; +}; + +export type BundlePreviewResult = { + target: ScriptCreateScope & { availability: BundleAvailability }; + entries: BundlePreviewEntry[]; +}; + +type ParsedEntry = + | { ok: true; entry: ScriptBundleEntry; name: string } + | { ok: false; error: string; name: string }; + +function parseEntry(raw: unknown): ParsedEntry { + const parsed = bundleScriptEntrySchema.safeParse(raw); + const rawName = + raw && typeof raw === 'object' && typeof (raw as { name?: unknown }).name === 'string' + ? ((raw as { name: string }).name) + : '(unnamed)'; + if (!parsed.success) { + return { ok: false, error: formatEntryIssues(parsed.error), name: rawName.slice(0, 255) }; + } + return { ok: true, entry: parsed.data, name: parsed.data.name }; +} + +/** + * Annotate each bundle entry as `new` / `name-conflict` / `invalid` against + * the resolved target scope. Performs no writes. Entry validation happens + * here (per entry), not at the route, so one bad entry doesn't reject the + * whole bundle. + */ +export async function previewBundle( + auth: BundleAuth, + bundle: ScriptBundleEnvelope, + options: BundleTargetOptions +): Promise { + const scope = resolveScriptCreateScope(auth, options.availability, options.orgId); + if (isScriptScopeError(scope)) return scope; + const unowned = unownedScopeError(scope); + if (unowned) return unowned; + + const entries: BundlePreviewEntry[] = []; + for (const [index, raw] of bundle.scripts.entries()) { + const parsed = parseEntry(raw); + if (!parsed.ok) { + entries.push({ index, name: parsed.name, status: 'invalid', error: parsed.error }); + continue; + } + const existing = await findExistingByName(scope, parsed.entry.name); + entries.push({ + index, + name: parsed.entry.name, + status: existing ? 'name-conflict' : 'new', + ...(existing ? { existingScriptId: existing.id, existingVersion: existing.version } : {}) + }); + } + + return { target: { ...scope, availability: options.availability }, entries }; +} + +/** Resolve tag names to ids within the target scope, creating what's missing. */ +async function ensureTagIds(scope: ScriptCreateScope, names: string[]): Promise { + if (names.length === 0) return []; + const unique = [...new Set(names)]; + + const scopeCondition = scope.orgId + ? eq(scriptTags.orgId, scope.orgId) + : and(isNull(scriptTags.orgId), eq(scriptTags.partnerId, scope.partnerId!)); + + const existing = await db + .select({ id: scriptTags.id, name: scriptTags.name }) + .from(scriptTags) + .where(and(inArray(scriptTags.name, unique), scopeCondition)); + + const byName = new Map(existing.map((t) => [t.name, t.id])); + const missing = unique.filter((n) => !byName.has(n)); + if (missing.length > 0) { + const created = await db + .insert(scriptTags) + .values(missing.map((name) => ({ name, orgId: scope.orgId, partnerId: scope.partnerId }))) + .returning({ id: scriptTags.id, name: scriptTags.name }); + for (const t of created) byName.set(t.name, t.id); + } + + return unique.map((n) => byName.get(n)).filter((id): id is string => typeof id === 'string'); +} + +async function linkTags(scriptId: string, tagIds: string[], isExistingScript: boolean) { + if (tagIds.length === 0) return; + let toLink = tagIds; + if (isExistingScript) { + const links = await db + .select({ tagId: scriptToTags.tagId }) + .from(scriptToTags) + .where(eq(scriptToTags.scriptId, scriptId)); + const already = new Set(links.map((l) => l.tagId)); + toLink = tagIds.filter((id) => !already.has(id)); + } + if (toLink.length > 0) { + await db.insert(scriptToTags).values(toLink.map((tagId) => ({ scriptId, tagId }))); + } +} + +const MAX_RENAME_ATTEMPTS = 100; + +async function findFreeName(scope: ScriptCreateScope, base: string): Promise { + // Generate all candidates up front and resolve them with ONE query per + // entry, not one per candidate — a fully-conflicting 200-entry bundle would + // otherwise issue up to 20,000 sequential SELECTs. + const candidates: string[] = []; + for (let i = 2; i < 2 + MAX_RENAME_ATTEMPTS; i++) { + // Respect the 255-char column limit when suffixing. + const suffix = ` (${i})`; + candidates.push(base.slice(0, 255 - suffix.length) + suffix); + } + const taken = await db + .select({ name: scripts.name }) + .from(scripts) + .where(and(inArray(scripts.name, candidates), scopeCondition(scope))); + const takenNames = new Set(taken.map((t) => t.name)); + return candidates.find((c) => !takenNames.has(c)) ?? null; +} + +export type BundleImportEntryResult = { + index: number; + name: string; + action: 'imported' | 'renamed' | 'versioned' | 'skipped'; + finalName?: string; + scriptId?: string; +}; + +export type BundleImportResult = { + target: ScriptCreateScope & { availability: BundleAvailability }; + imported: number; + skipped: number; + renamed: number; + versioned: number; + errors: Array<{ index: number; name: string; error: string }>; + scripts: BundleImportEntryResult[]; +}; + +/** + * Import a validated bundle into the caller's resolved scope. + * + * Per-entry failures are recorded and the remaining entries proceed. Never + * executes anything; never honours `isSystem` or tenancy from the bundle. + */ +export async function importBundle( + auth: BundleAuth, + bundle: ScriptBundleEnvelope, + options: BundleTargetOptions & { mode: BundleImportMode } +): Promise { + const scope = resolveScriptCreateScope(auth, options.availability, options.orgId); + if (isScriptScopeError(scope)) return scope; + const unowned = unownedScopeError(scope); + if (unowned) return unowned; + + const result: BundleImportResult = { + target: { ...scope, availability: options.availability }, + imported: 0, + skipped: 0, + renamed: 0, + versioned: 0, + errors: [], + scripts: [] + }; + + for (const [index, raw] of bundle.scripts.entries()) { + const parsed = parseEntry(raw); + if (!parsed.ok) { + result.errors.push({ index, name: parsed.name, error: parsed.error }); + continue; + } + const entry = parsed.entry; + try { + const existing = await findExistingByName(scope, entry.name); + + if (existing && options.mode === 'skip') { + result.skipped++; + result.scripts.push({ index, name: entry.name, action: 'skipped', scriptId: existing.id }); + continue; + } + + if (existing && options.mode === 'new-version' && existing.content === entry.content) { + // Idempotent re-import: identical content must not pad version + // history — re-running the same bundle N times would otherwise + // produce N no-op versions and N identical snapshots. + result.skipped++; + result.scripts.push({ index, name: entry.name, action: 'skipped', scriptId: existing.id }); + continue; + } + + if (existing && options.mode === 'new-version') { + // Snapshot the current content into scriptVersions FIRST, so the + // import appends to history rather than replacing it. + await db.insert(scriptVersions).values({ + scriptId: existing.id, + version: existing.version, + content: existing.content, + changelog: 'Superseded by bundle import', + createdBy: auth.user.id + }); + await db + .update(scripts) + .set({ + description: entry.description ?? existing.description, + category: entry.category ?? existing.category, + osTypes: entry.osTypes, + language: entry.language, + content: entry.content, + parameters: entry.parameters ?? existing.parameters, + timeoutSeconds: entry.timeoutSeconds, + runAs: entry.runAs, + exitCodeSeverityMapping: entry.exitCodeSeverityMapping ?? existing.exitCodeSeverityMapping, + version: existing.version + 1, + updatedAt: new Date() + }) + .where(eq(scripts.id, existing.id)); + + const tagIds = await ensureTagIds(scope, entry.tags ?? []); + await linkTags(existing.id, tagIds, true); + + result.versioned++; + result.scripts.push({ index, name: entry.name, action: 'versioned', scriptId: existing.id }); + continue; + } + + let finalName = entry.name; + let action: 'imported' | 'renamed' = 'imported'; + if (existing) { + // mode === 'rename' + const free = await findFreeName(scope, entry.name); + if (!free) { + result.errors.push({ + index, + name: entry.name, + error: 'Could not find a free name after 100 rename attempts' + }); + continue; + } + finalName = free; + action = 'renamed'; + } + + // NOTE: never pass requestedIsSystem here — a bundle can never create a + // system script, at any caller scope (see module docblock). + const created = await insertScriptRow(auth, scope, { + name: finalName, + description: entry.description, + category: entry.category, + osTypes: entry.osTypes, + language: entry.language, + content: entry.content, + parameters: entry.parameters, + timeoutSeconds: entry.timeoutSeconds, + runAs: entry.runAs, + exitCodeSeverityMapping: entry.exitCodeSeverityMapping ?? null + }); + if (!created) { + result.errors.push({ index, name: entry.name, error: 'Insert returned no row' }); + continue; + } + + const tagIds = await ensureTagIds(scope, entry.tags ?? []); + await linkTags(created.id, tagIds, false); + + if (action === 'renamed') result.renamed++; + else result.imported++; + result.scripts.push({ + index, + name: entry.name, + action, + ...(action === 'renamed' ? { finalName } : {}), + scriptId: created.id + }); + } catch (err) { + result.errors.push({ + index, + name: entry.name, + error: err instanceof Error ? err.message : 'Import failed' + }); + } + } + + return result; +} diff --git a/apps/api/src/services/scriptBundle/schema.ts b/apps/api/src/services/scriptBundle/schema.ts new file mode 100644 index 000000000..8b7eb84cc --- /dev/null +++ b/apps/api/src/services/scriptBundle/schema.ts @@ -0,0 +1,154 @@ +/** + * Script bundle format, v1 (#3245). + * + * A bundle is a portable JSON container for scripts moving between Breeze + * instances (or in from another RMM). It is a durable public contract AND + * untrusted input whose contents run as SYSTEM on customer endpoints, so this + * schema is deliberately stricter than `createScriptSchema`: + * + * - No tenancy or trust fields. `id`, `orgId`, `partnerId`, `createdBy` and + * `isSystem` are not part of the schema; Zod object parsing strips unknown + * keys, so their presence in an uploaded file cannot carry through to the + * import path (the #633 hole in a new costume — never read the field). + * - `parameters` is bounded at intake (size + depth). The route-level + * `createScriptSchema` types it `z.any()` and the 64KB cap at + * `routes/scripts.ts` is execute-time only; a bundle must not deliver an + * arbitrary attacker-authored jsonb blob into storage. + * - An `exitCodeSeverityMapping` that maps every exit code to `null` is + * rejected: it would ship a SYSTEM-level script pre-configured never to + * raise an alert, neutering the after-the-fact abuse detection. + * - An unknown `bundleVersion` is rejected with a clear error, never + * best-effort parsed. + * + * Categories and tags travel BY NAME, never by id — ids are meaningless + * across instances and would leak the source tenant's identifiers. + */ +import { z } from 'zod'; +import { exitCodeSeverityMappingSchema } from '@breeze/shared'; + +export const SCRIPT_BUNDLE_VERSION = 1; +/** Server-side cap on scripts per bundle. */ +export const MAX_BUNDLE_SCRIPTS = 200; +/** Server-side cap on a single script's content (characters). */ +export const MAX_BUNDLE_CONTENT_LENGTH = 256 * 1024; +/** Serialized-size cap on `parameters`, matching the execute-time cap. */ +export const MAX_BUNDLE_PARAMETERS_BYTES = 64 * 1024; +/** Nesting-depth cap on `parameters`. */ +export const MAX_BUNDLE_PARAMETERS_DEPTH = 8; +export const MAX_BUNDLE_TAGS_PER_SCRIPT = 20; + +function jsonDepth(value: unknown, depth = 1): number { + if (value === null || typeof value !== 'object') return depth; + const children = Array.isArray(value) ? value : Object.values(value as Record); + let max = depth; + for (const child of children) { + // Short-circuit: no point measuring past the cap. + if (max > MAX_BUNDLE_PARAMETERS_DEPTH) return max; + const d = jsonDepth(child, depth + 1); + if (d > max) max = d; + } + return max; +} + +/** + * Bounded replacement for the `z.any()` parameters field. Rejects payloads + * that are not JSON-serializable, exceed 64KB serialized, or nest deeper than + * MAX_BUNDLE_PARAMETERS_DEPTH levels — at intake, not at execute time. + */ +const boundedParametersSchema = z.unknown().superRefine((val, ctx) => { + if (val === undefined || val === null) return; + let serialized: string | undefined; + try { + serialized = JSON.stringify(val); + } catch { + serialized = undefined; + } + if (typeof serialized !== 'string') { + ctx.addIssue({ code: 'custom', message: 'parameters must be JSON-serializable' }); + return; + } + if (serialized.length > MAX_BUNDLE_PARAMETERS_BYTES) { + ctx.addIssue({ code: 'custom', message: 'parameters too large (max 64KB)' }); + } + if (jsonDepth(val) > MAX_BUNDLE_PARAMETERS_DEPTH) { + ctx.addIssue({ + code: 'custom', + message: `parameters nested too deeply (max depth ${MAX_BUNDLE_PARAMETERS_DEPTH})` + }); + } +}); + +/** + * `exitCodeSeverityMappingSchema` allows any value to be `null` ("no alert"). + * A mapping in which EVERY exit code is null is schema-valid but ships a + * script pre-configured never to alert on any outcome — reject it and make + * the importer re-add the mapping deliberately. + */ +const bundleSeverityMappingSchema = exitCodeSeverityMappingSchema + .nullable() + .optional() + .superRefine((mapping, ctx) => { + if (!mapping) return; + const values = Object.values(mapping); + if (values.length > 0 && values.every((v) => v === null)) { + ctx.addIssue({ + code: 'custom', + message: + 'exitCodeSeverityMapping maps every exit code to null (never alert); remove the mapping or assign at least one severity' + }); + } + }); + +export const bundleScriptEntrySchema = z.object({ + name: z.string().min(1).max(255), + description: z.string().max(10_000).optional(), + category: z.string().min(1).max(100).optional(), + tags: z.array(z.string().min(1).max(50)).max(MAX_BUNDLE_TAGS_PER_SCRIPT).optional(), + osTypes: z.array(z.enum(['windows', 'macos', 'linux'])).min(1), + language: z.enum(['powershell', 'bash', 'python', 'cmd']), + content: z.string().min(1).max(MAX_BUNDLE_CONTENT_LENGTH), + parameters: boundedParametersSchema.optional(), + // Same bounds as createScriptSchema (agent executor clamps at 1 hour). + timeoutSeconds: z.number().int().min(1).max(3600).default(300), + runAs: z.enum(['system', 'user', 'elevated']).default('system'), + exitCodeSeverityMapping: bundleSeverityMappingSchema +}); + +const bundleVersionSchema = z + .number() + .int() + .refine((v) => v === SCRIPT_BUNDLE_VERSION, { + message: `Unsupported bundleVersion — this server only understands version ${SCRIPT_BUNDLE_VERSION}` + }); + +/** + * The envelope the routes validate: version + a bounded array of UNVALIDATED + * entries. Entries are validated individually inside the service so one bad + * entry (e.g. a legitimately-authored script whose content exceeds the bundle + * cap) fails per-entry instead of rejecting the whole bundle wholesale — + * preview annotates it `invalid`, import records it in `errors` and proceeds. + */ +export const scriptBundleEnvelopeSchema = z.object({ + bundleVersion: bundleVersionSchema, + exportedAt: z.string().optional(), + scripts: z.array(z.unknown()).min(1).max(MAX_BUNDLE_SCRIPTS) +}); + +/** Fully-validated bundle shape — what export emits. */ +export const scriptBundleSchema = z.object({ + bundleVersion: bundleVersionSchema, + exportedAt: z.string().optional(), + scripts: z.array(bundleScriptEntrySchema).min(1).max(MAX_BUNDLE_SCRIPTS) +}); + +export type ScriptBundleEntry = z.infer; +export type ScriptBundle = z.infer; +export type ScriptBundleEnvelope = z.infer; + +/** Flatten a Zod failure into a compact per-entry error message. */ +export function formatEntryIssues(error: z.ZodError): string { + return error.issues + .slice(0, 5) + .map((i) => (i.path.length > 0 ? `${i.path.join('.')}: ${i.message}` : i.message)) + .join('; '); +} diff --git a/apps/api/src/services/scriptWrite.test.ts b/apps/api/src/services/scriptWrite.test.ts new file mode 100644 index 000000000..c8a71b4b9 --- /dev/null +++ b/apps/api/src/services/scriptWrite.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const ORG_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const OTHER_ORG_ID = '99999999-9999-4999-8999-999999999999'; +const PARTNER_ID = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; + +const h = vi.hoisted(() => ({ + inserts: [] as Array> +})); + +vi.mock('../db', () => ({ + db: { + insert: vi.fn(() => ({ + values: vi.fn((values: Record) => { + h.inserts.push(values); + return { returning: vi.fn(() => Promise.resolve([{ id: 'new-script', ...values }])) }; + }) + })) + } +})); + +import { + resolveScriptCreateScope, + insertScriptRow, + isScriptScopeError, + type ScriptWriteAuth +} from './scriptWrite'; +import { PARTNER_WIDE_WRITE_DENIED_MESSAGE } from './partnerWideAccess'; + +function makeAuth(overrides: Partial = {}): ScriptWriteAuth { + return { + scope: 'partner', + orgId: null, + partnerId: PARTNER_ID, + partnerOrgAccess: 'selected', + accessibleOrgIds: [ORG_ID], + canAccessOrg: (orgId: string) => orgId === ORG_ID, + ...overrides + } as ScriptWriteAuth; +} + +const input = { + name: 'Test', + osTypes: ['windows'], + language: 'powershell' as const, + content: 'Write-Host hi', + timeoutSeconds: 300, + runAs: 'system' as const +}; + +beforeEach(() => { + vi.clearAllMocks(); + h.inserts = []; +}); + +describe('resolveScriptCreateScope', () => { + it('org scope always lands in the caller org, ignoring a requested orgId', () => { + const scope = resolveScriptCreateScope( + makeAuth({ scope: 'organization', orgId: ORG_ID }), + undefined, + OTHER_ORG_ID + ); + expect(scope).toEqual({ orgId: ORG_ID, partnerId: PARTNER_ID }); + }); + + it("denies partner-wide creation to a partner user without the capability (#3262)", () => { + const scope = resolveScriptCreateScope(makeAuth({ partnerOrgAccess: 'selected' }), 'partner', undefined); + expect(scope).toEqual({ error: PARTNER_WIDE_WRITE_DENIED_MESSAGE, status: 403 }); + }); + + it("grants partner-wide creation to a full-partner admin", () => { + const scope = resolveScriptCreateScope(makeAuth({ partnerOrgAccess: 'all' }), 'partner', undefined); + expect(scope).toEqual({ orgId: null, partnerId: PARTNER_ID }); + }); + + it('denies an inaccessible org for partner scope', () => { + const scope = resolveScriptCreateScope(makeAuth(), 'org', OTHER_ORG_ID); + expect(scope).toEqual({ error: 'Access to this organization denied', status: 403 }); + }); + + it('requires orgId when the partner has multiple organizations', () => { + const scope = resolveScriptCreateScope( + makeAuth({ accessibleOrgIds: [ORG_ID, OTHER_ORG_ID] }), + undefined, + undefined + ); + expect(isScriptScopeError(scope) && scope.status === 400).toBe(true); + }); + + it('falls back to the single accessible org for partner scope', () => { + const scope = resolveScriptCreateScope(makeAuth(), undefined, undefined); + expect(scope).toEqual({ orgId: ORG_ID, partnerId: PARTNER_ID }); + }); + + it("system scope ignores availability and takes the requested org (never partner-wide)", () => { + const scope = resolveScriptCreateScope( + makeAuth({ scope: 'system', partnerId: null, partnerOrgAccess: undefined }), + 'partner', + ORG_ID + ); + expect(scope).toEqual({ orgId: ORG_ID, partnerId: null }); + }); +}); + +describe('insertScriptRow', () => { + it('clamps isSystem to false for non-system scopes even when requested', async () => { + await insertScriptRow( + { scope: 'partner', user: { id: 'u1' } } as Parameters[0], + { orgId: ORG_ID, partnerId: PARTNER_ID }, + input, + { requestedIsSystem: true } + ); + expect(h.inserts[0]!.isSystem).toBe(false); + }); + + it('honours requestedIsSystem only for system scope', async () => { + await insertScriptRow( + { scope: 'system', user: { id: 'u1' } } as Parameters[0], + { orgId: null, partnerId: null }, + input, + { requestedIsSystem: true } + ); + expect(h.inserts[0]!.isSystem).toBe(true); + }); + + it('defaults isSystem to false when the option is omitted — the bundle-import path', async () => { + await insertScriptRow( + { scope: 'system', user: { id: 'u1' } } as Parameters[0], + { orgId: ORG_ID, partnerId: null }, + input + ); + expect(h.inserts[0]!.isSystem).toBe(false); + expect(h.inserts[0]!.orgId).toBe(ORG_ID); + }); +}); diff --git a/apps/api/src/services/scriptWrite.ts b/apps/api/src/services/scriptWrite.ts new file mode 100644 index 000000000..683d9af64 --- /dev/null +++ b/apps/api/src/services/scriptWrite.ts @@ -0,0 +1,143 @@ +/** + * Single service-layer path for creating `scripts` rows (#3245, #3262 review). + * + * Both `POST /scripts` and the bundle importer (`services/scriptBundle`) write + * through these helpers so the tenancy resolution, the partner-wide capability + * gate, and the `isSystem` clamp cannot diverge between the two intakes. The + * #3263 review specifically flagged that the partner-wide gate previously + * lived only in route handlers with no service-layer chokepoint — this module + * is that chokepoint. Do not add a second script-insert path that bypasses it. + */ +import { db } from '../db'; +import { scripts } from '../db/schema'; +import type { AuthContext } from '../middleware/auth'; +import { + canManagePartnerWidePolicies, + PARTNER_WIDE_WRITE_DENIED_MESSAGE +} from './partnerWideAccess'; + +export type ScriptWriteAuth = Pick< + AuthContext, + 'scope' | 'orgId' | 'partnerId' | 'partnerOrgAccess' | 'accessibleOrgIds' | 'canAccessOrg' +>; + +export type ScriptCreateScope = { orgId: string | null; partnerId: string | null }; +export type ScriptScopeError = { error: string; status: 400 | 403 }; + +export function isScriptScopeError( + r: ScriptCreateScope | ScriptScopeError +): r is ScriptScopeError { + return 'error' in r; +} + +/** + * Resolve the `{ orgId, partnerId }` a new script should be created under. + * Mirrors (and is now the single source of) the `POST /scripts` tenancy rules: + * + * - Org scope: always the caller's own org (a requested orgId is ignored). + * - Partner scope + `availability: 'partner'`: partner-wide (org_id NULL). + * Gated on `canManagePartnerWidePolicies` — partner SCOPE is not the same + * as partner-wide CAPABILITY (#3262): a 'selected'-access user must not be + * able to create a script that runs as SYSTEM across every org under the + * partner, including orgs they hold no grant for and orgs created later. + * - Partner scope otherwise: the requested org (or the single accessible org), + * access-checked. + * - System scope: any requested org, or none. `availability` is ignored — + * system tokens carry no partnerId, so partner-wide creation is not + * expressible on this path (parity with the pre-existing route behavior). + */ +export function resolveScriptCreateScope( + auth: ScriptWriteAuth, + availability: 'org' | 'partner' | undefined, + requestedOrgId: string | null | undefined +): ScriptCreateScope | ScriptScopeError { + if (auth.scope === 'organization') { + if (!auth.orgId) { + return { error: 'Organization context required', status: 403 }; + } + return { orgId: auth.orgId, partnerId: auth.partnerId ?? null }; + } + + if (auth.scope === 'partner') { + if (availability === 'partner') { + if (!canManagePartnerWidePolicies(auth)) { + return { error: PARTNER_WIDE_WRITE_DENIED_MESSAGE, status: 403 }; + } + if (!auth.partnerId) { + return { error: 'Partner context required', status: 403 }; + } + return { orgId: null, partnerId: auth.partnerId }; + } + + let orgId = requestedOrgId ?? null; + if (!orgId) { + const singleOrg = auth.accessibleOrgIds?.[0]; + if (auth.accessibleOrgIds?.length === 1 && singleOrg) { + orgId = singleOrg; + } else { + return { error: 'orgId is required when partner has multiple organizations', status: 400 }; + } + } + if (!auth.canAccessOrg(orgId)) { + return { error: 'Access to this organization denied', status: 403 }; + } + return { orgId, partnerId: auth.partnerId ?? null }; + } + + // System scope. + return { orgId: requestedOrgId ?? null, partnerId: null }; +} + +export type ScriptInsertInput = { + name: string; + description?: string | null; + category?: string | null; + osTypes: string[]; + language: 'powershell' | 'bash' | 'python' | 'cmd'; + content: string; + parameters?: unknown; + timeoutSeconds: number; + runAs: 'system' | 'user' | 'elevated'; + exitCodeSeverityMapping?: Record | null; +}; + +/** + * Insert a script row under an already-resolved scope. + * + * `isSystem` is clamped here, not at call sites: only system scope may request + * it, and only via the explicit `requestedIsSystem` option (the Discussion + * #633 write hole). The bundle importer never passes the option, so a bundle + * can NEVER produce an `isSystem: true` row — at any caller scope, including + * system — which is deliberately stricter than `POST /scripts`. + */ +export async function insertScriptRow( + auth: Pick, + scope: ScriptCreateScope, + input: ScriptInsertInput, + opts: { requestedIsSystem?: boolean } = {} +) { + const isSystem = auth.scope === 'system' ? (opts.requestedIsSystem ?? false) : false; + + const [script] = await db + .insert(scripts) + .values({ + orgId: isSystem && !scope.orgId ? null : scope.orgId, + partnerId: scope.partnerId, + name: input.name, + description: input.description ?? undefined, + category: input.category ?? undefined, + osTypes: input.osTypes, + language: input.language, + content: input.content, + parameters: input.parameters, + timeoutSeconds: input.timeoutSeconds, + runAs: input.runAs, + isSystem, + version: 1, + exitCodeSeverityMapping: input.exitCodeSeverityMapping ?? null, + createdBy: auth.user.id + }) + .returning(); + + return script; +} diff --git a/apps/docs/src/content/docs/features/scripts.mdx b/apps/docs/src/content/docs/features/scripts.mdx index 00be3be96..646346e9a 100644 --- a/apps/docs/src/content/docs/features/scripts.mdx +++ b/apps/docs/src/content/docs/features/scripts.mdx @@ -45,6 +45,51 @@ Breeze provides a shared system library of pre-built scripts available to all or +### Importing and exporting script bundles + +A **script bundle** is a versioned JSON file that carries whole script libraries between Breeze instances — or in from another RMM — including the metadata that makes a library a library: parameters, category, tags, timeout, run-as level, and exit-code severity mappings. + +**Export:** on the Scripts page, click **Export**, select the scripts to include, and download the bundle as a `.json` file. Bundles are portable by design: they contain no organization, partner, or user identifiers, and system-library scripts export as ordinary scripts. + +**Import:** click **Import bundle** and upload either: + +- a `.json` bundle exported from Breeze, or +- loose script files (`.ps1`, `.sh`, `.py`, `.bat`) — the browser converts them into a bundle, inferring the language and OS types from each file extension and the script name from the filename. + +The preview shows each script as **New** or **Name conflict**; for conflicts choose to skip, import under a new name, or add the bundle's content as a new version of the existing script. Imports default to the current organization; partner users with full org access can instead import partner-wide ("all organizations"). + +The equivalent API endpoints are `GET /api/v1/scripts/bundle/export?ids=…`, `POST /api/v1/scripts/bundle/preview`, and `POST /api/v1/scripts/bundle/import` (`{ bundle, mode: "skip" | "rename" | "new-version", availability: "org" | "partner", orgId? }`). + +:::caution +Bundles are **unsigned**: a bundle is exactly as trustworthy as the person importing it, and imported scripts can run as SYSTEM on managed devices. Only import bundles from a source you trust. Imports never execute anything by themselves, cannot carry automations or schedules, and cannot mark scripts as system-library entries — but the script content itself is whatever the bundle author wrote. +::: + +Bundle format (v1): + +```json +{ + "bundleVersion": 1, + "exportedAt": "2026-08-08T00:00:00Z", + "scripts": [ + { + "name": "Clear print spooler", + "description": "Stops spooler, clears queue, restarts.", + "category": "Maintenance", + "tags": ["printing", "windows"], + "osTypes": ["windows"], + "language": "powershell", + "content": "…", + "parameters": {}, + "timeoutSeconds": 300, + "runAs": "system", + "exitCodeSeverityMapping": { "1": "high" } + } + ] +} +``` + +Categories and tags travel by name and are created in the importing tenant when missing. An unknown `bundleVersion` is rejected. Bundles are capped at 200 scripts and 20 MB, script content at 256 KB, and `parameters` at 64 KB. + ### Creating a script diff --git a/apps/web/src/components/scripts/ScriptBundleImport.tsx b/apps/web/src/components/scripts/ScriptBundleImport.tsx new file mode 100644 index 000000000..c6d32b3c2 --- /dev/null +++ b/apps/web/src/components/scripts/ScriptBundleImport.tsx @@ -0,0 +1,561 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertTriangle, Download, Loader2, Upload, X } from 'lucide-react'; +import { fetchWithAuth, useAuthStore } from '../../stores/auth'; +import { useOrgStore } from '../../stores/orgStore'; +import { getJwtClaims } from '@/lib/authScope'; +import { runAction, ActionError } from '@/lib/runAction'; +import { showToast } from '../shared/Toast'; +import { asList } from '@/lib/asList'; +import { + filesToBundle, + downloadBundle, + type ScriptBundle +} from '@/lib/scriptBundle'; +import { cn } from '@/lib/utils'; +// Initializes the shared i18next singleton (see ScriptsPage.tsx). +import '../../lib/i18n'; + +/** Server-side cap on scripts per bundle (mirrors MAX_BUNDLE_SCRIPTS). */ +const MAX_BUNDLE_SCRIPTS = 200; + +type ImportMode = 'skip' | 'rename' | 'new-version'; + +type PreviewEntry = { + index: number; + name: string; + status: 'new' | 'name-conflict' | 'invalid'; + error?: string; + existingVersion?: number; +}; + +type ImportResult = { + imported: number; + skipped: number; + renamed: number; + versioned: number; + errors: Array<{ index: number; name: string; error: string }>; +}; + +// --------------------------------------------------------------------------- +// Export modal: multi-select → download .json +// --------------------------------------------------------------------------- + +export type ExportableScript = { id: string; name: string; language?: string; category?: string }; + +export function ScriptBundleExportModal({ + isOpen, + onClose +}: { + isOpen: boolean; + onClose: () => void; +}) { + const { t } = useTranslation('scripts'); + const [selected, setSelected] = useState>(new Set()); + const [exporting, setExporting] = useState(false); + const [scripts, setScripts] = useState([]); + const [loadingList, setLoadingList] = useState(false); + + // The Scripts page keeps only the first page of GET /scripts (default limit + // 50) — fetch the full library here so every script is offered for export. + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + (async () => { + setLoadingList(true); + try { + const all: ExportableScript[] = []; + // API caps limit at 100/page; walk pages (bounded) until exhausted. + for (let page = 1; page <= 10; page++) { + const response = await fetchWithAuth(`/scripts?limit=100&page=${page}`); + if (!response.ok) break; + const data = await response.json(); + const rows = asList(data, 'scripts') as ExportableScript[]; + all.push(...rows.map(s => ({ id: s.id, name: s.name, language: s.language }))); + const total = (data as { pagination?: { total?: number } })?.pagination?.total; + if (rows.length === 0 || (typeof total === 'number' && all.length >= total)) break; + } + if (!cancelled) setScripts(all); + } catch { + // List failure leaves an empty modal; the empty-state copy covers it. + } finally { + if (!cancelled) setLoadingList(false); + } + })(); + return () => { + cancelled = true; + }; + }, [isOpen]); + + if (!isOpen) return null; + + const overLimit = selected.size > MAX_BUNDLE_SCRIPTS; + + const toggle = (id: string) => { + setSelected(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleExport = async () => { + if (selected.size === 0) return; + setExporting(true); + try { + const ids = [...selected].join(','); + const response = await fetchWithAuth(`/scripts/bundle/export?ids=${encodeURIComponent(ids)}`); + if (!response.ok) { + throw new Error(t('bundle.exportFailed')); + } + const bundle = (await response.json()) as ScriptBundle; + downloadBundle(bundle); + onClose(); + } catch (err) { + showToast({ + type: 'error', + message: err instanceof Error ? err.message : t('bundle.exportFailed') + }); + } finally { + setExporting(false); + } + }; + + return ( +
+
+
+
+

{t('bundle.exportTitle')}

+

{t('bundle.exportDescription')}

+
+ +
+ +
+ +
+ +
+ {loadingList ? ( +
+ +
+ ) : scripts.length === 0 ? ( +

+ {t('bundle.exportEmpty')} +

+ ) : ( +
    + {scripts.map(script => ( +
  • + +
  • + ))} +
+ )} +
+ +
+ {overLimit && ( +

+ {t('bundle.exportLimit', { max: MAX_BUNDLE_SCRIPTS })} +

+ )} + + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Import modal: pick files → preview → commit +// --------------------------------------------------------------------------- + +export function ScriptBundleImportModal({ + isOpen, + onClose, + onImported +}: { + isOpen: boolean; + onClose: () => void; + onImported: () => void; +}) { + const { t } = useTranslation('scripts'); + const fileInputRef = useRef(null); + const folderInputRef = useRef(null); + const [bundle, setBundle] = useState(null); + const [fileErrors, setFileErrors] = useState([]); + const [preview, setPreview] = useState(null); + const [mode, setMode] = useState('skip'); + const [partnerWide, setPartnerWide] = useState(false); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(null); + + // Same UX gate as ScriptForm (#3262): the option is only meaningful for + // partner-scope callers (the route rejects every other scope), and within + // partner scope only for users with the partner-wide capability (absent = + // capable; the server enforces regardless). + const canManagePartnerWide = useAuthStore(s => s.user?.canManagePartnerWide ?? true); + const { scope: jwtScope, partnerId: jwtPartnerId } = getJwtClaims(); + const showPartnerWide = jwtScope === 'partner' && !!jwtPartnerId && canManagePartnerWide; + const currentOrgId = useOrgStore(s => s.currentOrgId); + + if (!isOpen) return null; + + const reset = () => { + setBundle(null); + setFileErrors([]); + setPreview(null); + setResult(null); + setPartnerWide(false); + setMode('skip'); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const targetBody = (nextPartnerWide = partnerWide) => ({ + availability: nextPartnerWide ? ('partner' as const) : ('org' as const), + ...(nextPartnerWide || !currentOrgId ? {} : { orgId: currentOrgId }) + }); + + const runPreview = async (nextBundle: ScriptBundle, nextPartnerWide = partnerWide) => { + setBusy(true); + // Clear any previous preview FIRST: if this request fails, a stale + // conflict table must not leave the Import button armed for a bundle or + // target scope the table was never computed against. + setPreview(null); + try { + const data = await runAction<{ entries: PreviewEntry[] }>({ + request: () => + fetchWithAuth('/scripts/bundle/preview', { + method: 'POST', + body: JSON.stringify({ bundle: nextBundle, ...targetBody(nextPartnerWide) }) + }), + errorFallback: t('bundle.previewFailed') + }); + setPreview(data.entries); + } catch (err) { + if (!(err instanceof ActionError)) { + showToast({ type: 'error', message: t('bundle.previewFailed') }); + } + } finally { + setBusy(false); + } + }; + + const handleFiles = async (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) return; + // Invalidate the previous selection before parsing the new one. + setBundle(null); + setPreview(null); + const { bundle: parsed, errors } = await filesToBundle(Array.from(fileList)); + setFileErrors(errors); + if (!parsed) { + if (errors.length > 0) { + showToast({ type: 'error', message: t('bundle.parseErrors') }); + } + return; + } + setBundle(parsed); + await runPreview(parsed); + }; + + const handleTogglePartnerWide = async (checked: boolean) => { + setPartnerWide(checked); + // Conflict detection depends on the target scope — re-run the preview. + if (bundle) await runPreview(bundle, checked); + }; + + const handleImport = async () => { + if (!bundle) return; + setBusy(true); + try { + const data = await runAction({ + request: () => + fetchWithAuth('/scripts/bundle/import', { + method: 'POST', + body: JSON.stringify({ bundle, mode, ...targetBody() }) + }), + errorFallback: t('bundle.importFailed'), + successMessage: res => + t('bundle.resultSummary', { + imported: res.imported, + renamed: res.renamed, + versioned: res.versioned, + skipped: res.skipped, + failed: res.errors.length + }) + }); + setResult(data); + onImported(); + } catch (err) { + if (!(err instanceof ActionError)) { + showToast({ type: 'error', message: t('bundle.importFailed') }); + } + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+
+

{t('bundle.importTitle')}

+

{t('bundle.importDescription')}

+
+ +
+ +
+ {/* Scripts run as SYSTEM — say so plainly. */} +
+ + {t('bundle.systemWarning')} +
+ + {!result && ( +
+ { + void handleFiles(e.target.files); + e.target.value = ''; + }} + data-testid="bundle-import-file-input" + /> + {/* Folder picker: webkitdirectory hands over every file in a + folder; filesToBundle keeps only supported script files. */} + { + void handleFiles(e.target.files); + e.target.value = ''; + }} + data-testid="bundle-import-folder-input" + /> +
+ + +
+ {fileErrors.length > 0 && ( +

+ {t('bundle.parseErrors')}: {fileErrors.join(', ')} +

+ )} +
+ )} + + {bundle && !result && ( +

+ {t('bundle.scriptCount', { count: bundle.scripts.length })} +

+ )} + + {preview && !result && ( +
+
+ + + + + + + + + {preview.map(entry => ( + + + + + ))} + +
{t('bundle.nameColumn')}{t('bundle.previewTitle')}
{entry.name} + + {entry.status === 'new' + ? t('bundle.statusNew') + : entry.status === 'name-conflict' + ? t('bundle.statusConflict') + : t('bundle.statusInvalid')} + + {entry.status === 'invalid' && entry.error && ( +

{entry.error}

+ )} +
+
+ +
+ + +
+ + {showPartnerWide && ( + + )} +
+ )} + + {result && ( +
+

+ {t('bundle.resultSummary', { + imported: result.imported, + renamed: result.renamed, + versioned: result.versioned, + skipped: result.skipped, + failed: result.errors.length + })} +

+ {result.errors.length > 0 && ( +
    + {result.errors.map(err => ( +
  • + {err.name}: {err.error} +
  • + ))} +
+ )} +
+ )} +
+ +
+ + {!result && ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/scripts/ScriptsPage.tsx b/apps/web/src/components/scripts/ScriptsPage.tsx index 239786f3b..5c8206918 100644 --- a/apps/web/src/components/scripts/ScriptsPage.tsx +++ b/apps/web/src/components/scripts/ScriptsPage.tsx @@ -1,8 +1,9 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { extractApiError } from '@/lib/apiError'; -import { Plus, Download, Search, X, Loader2, Check, FileCode, ArrowRight } from 'lucide-react'; +import { Plus, Download, Search, Upload, X, Loader2, Check, FileCode, ArrowRight } from 'lucide-react'; import ScriptList, { type Script, type ScriptLanguage, type OSType } from './ScriptList'; +import { ScriptBundleExportModal, ScriptBundleImportModal } from './ScriptBundleImport'; import ScriptExecutionModal, { type Device, type Site } from './ScriptExecutionModal'; import ExecutionDetails from './ExecutionDetails'; import type { ScriptExecution } from './ExecutionHistory'; @@ -18,7 +19,14 @@ import { asList } from '@/lib/asList'; // would otherwise render raw keys (and mismatch the SSR markup). import '../../lib/i18n'; -type ModalMode = 'closed' | 'execute' | 'delete' | 'execution-details' | 'import-library'; +type ModalMode = + | 'closed' + | 'execute' + | 'delete' + | 'execution-details' + | 'import-library' + | 'bundle-export' + | 'bundle-import'; type ScriptWithDetails = Script & { parameters?: ScriptParameter[]; @@ -359,6 +367,25 @@ export default function ScriptsPage() {

{t('scriptsPage.description')}

+ +