diff --git a/apps/api/migrations/2026-08-13-ring-third-party-auto-approve-backfill.sql b/apps/api/migrations/2026-08-13-ring-third-party-auto-approve-backfill.sql new file mode 100644 index 0000000000..1cc50f2823 --- /dev/null +++ b/apps/api/migrations/2026-08-13-ring-third-party-auto-approve-backfill.sql @@ -0,0 +1,102 @@ +-- Spec 2026-08-04: third-party ring auto-approve. Backfills the explicit +-- autoApprove.thirdPartyApps shape and migrates legacy 'third_party_app' +-- category rules to the ring-level toggle. Idempotent; counts RAISEd so the +-- rollout numbers land in Postgres logs (expected prod: 0 / 1 / 0 rows). + +DO $$ +DECLARE + n integer; +BEGIN + -- 1) Enabled object-shaped rows lacking thirdPartyApps: derive it from + -- whether the row has >=1 recognized severity (mirrors + -- parseRingAutoApprove's compatibility rule / the old #2218 exemption). + UPDATE patch_policies + SET auto_approve = auto_approve + || jsonb_build_object( + 'thirdPartyApps', + EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text( + CASE WHEN jsonb_typeof(auto_approve->'severities') = 'array' + THEN auto_approve->'severities' + ELSE '[]'::jsonb END + ) AS sev(v) + WHERE sev.v IN ('critical','important','moderate','low') + ) + ) + || CASE WHEN auto_approve ? 'thirdPartyDeferralDays' + THEN '{}'::jsonb + ELSE jsonb_build_object('thirdPartyDeferralDays', NULL::int) END + WHERE kind = 'ring' + AND jsonb_typeof(auto_approve) = 'object' + AND auto_approve->>'enabled' = 'true' + AND NOT auto_approve ? 'thirdPartyApps'; + GET DIAGNOSTICS n = ROW_COUNT; + IF n > 0 THEN RAISE WARNING 'ring-3p backfill: stamped explicit thirdPartyApps on % enabled auto_approve rows', n; END IF; + + -- 2) Rings with an autoApprove:true third_party_app category rule: turn on + -- the ring-level toggle (carrying the rule's deferral override) and strip + -- the rule. Preserves intent: those rings wanted 3P auto-approved. + -- Accepted narrowing: an {enabled:true, severities:[]} ring with a 3P + -- rule loses its rule-based 3P behavior — statement 1 runs first and + -- stamps thirdPartyApps:false (no recognized severities), so this + -- statement's "lacks thirdPartyApps" guard then skips it, and statement + -- 3 strips the now-inert rule. Fail-closed; 0 known rows. + UPDATE patch_policies p + SET auto_approve = + (CASE WHEN jsonb_typeof(p.auto_approve) = 'object' THEN p.auto_approve ELSE '{}'::jsonb END) + || jsonb_build_object('enabled', true, 'thirdPartyApps', true) + -- The pre-image's OS auto-approve state must survive this ring-level + -- enable: if the ring wasn't already boolean-true enabled, its + -- severities (if any) belonged to a disabled state and must not + -- silently start applying now that 'enabled' flips to true. Compare + -- against the jsonb boolean (not ->>'enabled' = 'true' text, which a + -- malformed {"enabled":"true"} string row would also fail — and we + -- want that case cleared too). + || CASE WHEN p.auto_approve->'enabled' = 'true'::jsonb THEN '{}'::jsonb + ELSE jsonb_build_object('severities', '[]'::jsonb) END + || COALESCE( + (SELECT CASE WHEN (r.rule->>'deferralDaysOverride') ~ '^\d{1,3}$' + AND (r.rule->>'deferralDaysOverride')::int <= 365 + THEN jsonb_build_object('thirdPartyDeferralDays', (r.rule->>'deferralDaysOverride')::int) + ELSE '{}'::jsonb END + FROM jsonb_array_elements(p.category_rules) AS r(rule) + WHERE r.rule->>'category' = 'third_party_app' + AND r.rule->>'autoApprove' = 'true' + LIMIT 1), + '{}'::jsonb), + category_rules = COALESCE( + (SELECT jsonb_agg(r.rule) + FROM jsonb_array_elements(p.category_rules) AS r(rule) + WHERE r.rule->>'category' IS DISTINCT FROM 'third_party_app'), + '[]'::jsonb), + updated_at = now() + WHERE p.kind = 'ring' + AND jsonb_typeof(p.category_rules) = 'array' + AND NOT (p.auto_approve ? 'thirdPartyApps') + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(p.category_rules) AS r(rule) + WHERE r.rule->>'category' = 'third_party_app' + AND r.rule->>'autoApprove' = 'true' + ); + GET DIAGNOSTICS n = ROW_COUNT; + IF n > 0 THEN RAISE WARNING 'ring-3p backfill: converted third_party_app category rules to the ring toggle on % rings', n; END IF; + + -- 3) Strip any remaining (autoApprove:false) third_party_app rules — nothing + -- to preserve; the category no longer exists. + UPDATE patch_policies p + SET category_rules = COALESCE( + (SELECT jsonb_agg(r.rule) + FROM jsonb_array_elements(p.category_rules) AS r(rule) + WHERE r.rule->>'category' IS DISTINCT FROM 'third_party_app'), + '[]'::jsonb), + updated_at = now() + WHERE p.kind = 'ring' + AND jsonb_typeof(p.category_rules) = 'array' + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(p.category_rules) AS r(rule) + WHERE r.rule->>'category' = 'third_party_app' + ); + GET DIAGNOSTICS n = ROW_COUNT; + IF n > 0 THEN RAISE WARNING 'ring-3p backfill: stripped inert third_party_app rules from % rings', n; END IF; +END $$; diff --git a/apps/api/migrations/2026-08-14-drop-patch-policies-sources.sql b/apps/api/migrations/2026-08-14-drop-patch-policies-sources.sql new file mode 100644 index 0000000000..eab71008b4 --- /dev/null +++ b/apps/api/migrations/2026-08-14-drop-patch-policies-sources.sql @@ -0,0 +1,9 @@ +-- Contract phase of the third-party update ring auto-approve spec +-- (docs/superpowers/specs/vuln-patch/2026-08-04-third-party-update-ring-auto-approve-design.md). +-- The expand phase (#3150) removed every reader and writer of +-- patch_policies.sources; the column was never consumed by the approval path +-- (the evaluated sources live on config_policy_patch_settings.sources). +-- This migration must only ship one release AFTER #3150 so rolling deploys +-- never run an older API against the dropped column. See issue #3151. + +ALTER TABLE patch_policies DROP COLUMN IF EXISTS sources; diff --git a/apps/api/src/__tests__/integration/patchThirdPartyRingAutoApprove.integration.test.ts b/apps/api/src/__tests__/integration/patchThirdPartyRingAutoApprove.integration.test.ts index c561197a90..85314f459c 100644 --- a/apps/api/src/__tests__/integration/patchThirdPartyRingAutoApprove.integration.test.ts +++ b/apps/api/src/__tests__/integration/patchThirdPartyRingAutoApprove.integration.test.ts @@ -93,16 +93,38 @@ async function seedPendingPatch(opts: { return patch.id; } +/** + * Shape of the ring's raw `autoApprove` JSONB as accepted by + * parseRingAutoApprove — deliberately loose (deferralDays/thirdPartyApps/ + * thirdPartyDeferralDays all optional) so tests can exercise legacy/compat + * shapes (e.g. omitting thirdPartyApps entirely) the same way a real stored + * row could. + */ +interface RingConfigAutoApprove { + enabled: boolean; + severities: string[]; + deferralDays?: number; + thirdPartyApps?: boolean; + thirdPartyDeferralDays?: number | null; +} + function ringConfig( partnerId: string, - sources: string[], + sources: string[] | undefined, deferralDays = 0, + autoApprove: RingConfigAutoApprove = { + enabled: true, + severities: ['critical'], + deferralDays: deferralDays ?? 0, + thirdPartyApps: true, + thirdPartyDeferralDays: null, + }, ): ApprovalEvaluationConfig { return { ringId: randomUUID(), ringPartnerId: partnerId, categoryRules: [], - autoApprove: { enabled: true, severities: ['critical', 'important'], deferralDays }, + autoApprove, deferralDays: 0, sources, }; @@ -226,4 +248,143 @@ describe('third-party ring auto-approve (#2218) — end-to-end against Postgres' expect(approved[0]!.patchId).toBe(patchId); expect(approved[0]!.approvalReason).toBe('ring_auto_approve'); }); + + it('does NOT approve third-party when the ring toggle is off, even with sources third_party', async () => { + const deviceId = await seedDevice(orgId, siteId, '3p-device-f'); + await seedPendingPatch({ + orgId, + deviceId, + source: 'third_party', + severity: 'unknown', + packageId: 'Mozilla.Firefox', + }); + + const approved = await evaluate( + deviceId, + ringConfig(partnerId, ['os', 'third_party'], 0, { + enabled: true, + severities: ['critical'], + thirdPartyApps: false, + }), + ); + + expect(approved).toEqual([]); + }); + + it('does NOT approve third-party for a legacy snapshot with absent sources, even with the toggle on', async () => { + const deviceId = await seedDevice(orgId, siteId, '3p-device-g'); + await seedPendingPatch({ + orgId, + deviceId, + source: 'third_party', + severity: 'unknown', + packageId: 'Mozilla.Firefox', + }); + + const approved = await evaluate( + deviceId, + ringConfig(partnerId, undefined, 0, { + enabled: true, + severities: ['critical'], + thirdPartyApps: true, + }), + ); + + expect(approved).toEqual([]); + }); + + it('approves third-party on a third-party-only ring (empty severities)', async () => { + const deviceId = await seedDevice(orgId, siteId, '3p-device-h'); + const thirdPartyPatchId = await seedPendingPatch({ + orgId, + deviceId, + source: 'third_party', + severity: 'unknown', + packageId: 'Mozilla.Firefox', + }); + const osPatchId = await seedPendingPatch({ + orgId, + deviceId, + source: 'microsoft', + severity: 'critical', + }); + + const approved = await evaluate( + deviceId, + ringConfig(partnerId, ['third_party'], 0, { + enabled: true, + severities: [], + thirdPartyApps: true, + }), + ); + + expect(approved.map((p) => p.patchId)).toEqual([thirdPartyPatchId]); + expect(approved.map((p) => p.patchId)).not.toContain(osPatchId); + }); + + it('legacy autoApprove without thirdPartyApps still approves 3P when severities were set (compat rule)', async () => { + const deviceId = await seedDevice(orgId, siteId, '3p-device-i'); + const patchId = await seedPendingPatch({ + orgId, + deviceId, + source: 'third_party', + severity: 'unknown', + packageId: 'Mozilla.Firefox', + }); + + const approved = await evaluate( + deviceId, + // No thirdPartyApps key at all — parseRingAutoApprove must derive it + // from severities.length > 0 (the pre-2026-08 compat rule). + ringConfig(partnerId, ['os', 'third_party'], 0, { + enabled: true, + severities: ['critical'], + }), + ); + + expect(approved).toHaveLength(1); + expect(approved[0]!.patchId).toBe(patchId); + expect(approved[0]!.approvalReason).toBe('ring_auto_approve'); + }); + + it('applies thirdPartyDeferralDays over deferralDays using the first-seen anchor', async () => { + // autoApprove.deferralDays is deliberately set to a DIFFERENT value (1) + // than thirdPartyDeferralDays (7) so a bug that fell back to deferralDays + // instead of the third-party override would flip the "3 days ago" case. + const config = ringConfig(partnerId, ['third_party'], 0, { + enabled: true, + severities: ['critical'], + deferralDays: 1, + thirdPartyApps: true, + thirdPartyDeferralDays: 7, + }); + + const heldDeviceId = await seedDevice(orgId, siteId, '3p-device-j'); + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 3600 * 1000); + await seedPendingPatch({ + orgId, + deviceId: heldDeviceId, + source: 'third_party', + severity: 'unknown', + packageId: 'Mozilla.Firefox', + firstSeenAt: threeDaysAgo, + }); + const heldApproved = await evaluate(heldDeviceId, config); + expect(heldApproved).toEqual([]); + + const approvedDeviceId = await seedDevice(orgId, siteId, '3p-device-k'); + const eightDaysAgo = new Date(Date.now() - 8 * 24 * 3600 * 1000); + const patchId = await seedPendingPatch({ + orgId, + deviceId: approvedDeviceId, + source: 'third_party', + severity: 'unknown', + packageId: 'Mozilla.Firefox', + firstSeenAt: eightDaysAgo, + }); + const approved = await evaluate(approvedDeviceId, config); + expect(approved).toHaveLength(1); + expect(approved[0]!.patchId).toBe(patchId); + expect(approved[0]!.approvalReason).toBe('ring_auto_approve'); + }); }); diff --git a/apps/api/src/db/schema/patches.ts b/apps/api/src/db/schema/patches.ts index 32d149f2f8..a08c07153f 100644 --- a/apps/api/src/db/schema/patches.ts +++ b/apps/api/src/db/schema/patches.ts @@ -148,7 +148,6 @@ export const patchPolicies = pgTable('patch_policies', { description: text('description'), enabled: boolean('enabled').notNull().default(true), targets: jsonb('targets').notNull().default({}), - sources: patchSourceEnum('sources').array(), autoApprove: jsonb('auto_approve').notNull().default({}), schedule: jsonb('schedule').notNull().default({}), rebootPolicy: jsonb('reboot_policy').notNull().default({}), diff --git a/apps/api/src/routes/updateRings.ts b/apps/api/src/routes/updateRings.ts index 8360a1f1f4..0c376b85fd 100644 --- a/apps/api/src/routes/updateRings.ts +++ b/apps/api/src/routes/updateRings.ts @@ -19,7 +19,9 @@ import { ringAutoApproveSchema } from '@breeze/shared/validators'; // Typed default for a ring's autoApprove JSONB (#1317). A freshly created or // auto-provisioned ring auto-approves nothing until an operator opts in. -const DEFAULT_RING_AUTO_APPROVE = { enabled: false, severities: [], deferralDays: 0 } as const; +const DEFAULT_RING_AUTO_APPROVE = { + enabled: false, severities: [], deferralDays: 0, thirdPartyApps: false, thirdPartyDeferralDays: null, +} as const; export const updateRingRoutes = new Hono(); const requireUpdateRingRead = requirePermission(PERMISSIONS.DEVICES_READ.resource, PERMISSIONS.DEVICES_READ.action); @@ -80,7 +82,9 @@ const listRingsSchema = z.object({ }); const categoryRuleSchema = z.object({ - category: z.string().max(100), + category: z.string().max(100).refine((c) => c.trim().toLowerCase() !== 'third_party_app', { + message: "The 'third_party_app' category rule was replaced by autoApprove.thirdPartyApps on the ring.", + }), autoApprove: z.boolean(), autoApproveSeverities: z.array(z.enum(['critical', 'important', 'moderate', 'low'])).optional(), deferralDaysOverride: z.number().int().min(0).max(365).nullable().optional(), @@ -98,7 +102,6 @@ const createRingSchema = z.object({ categories: z.array(z.string().max(100)).optional(), excludeCategories: z.array(z.string().max(100)).optional(), categoryRules: z.array(categoryRuleSchema).optional(), - sources: z.array(z.enum(['microsoft', 'apple', 'linux', 'third_party', 'custom'])).optional(), // Ring-level auto-approval gate (#1317). Typed shape replaces the old // free-form record so the ring owns approval rules with validated severities. autoApprove: ringAutoApproveSchema.optional(), @@ -116,7 +119,6 @@ const updateRingSchema = z.object({ categories: z.array(z.string().max(100)).optional(), excludeCategories: z.array(z.string().max(100)).optional(), categoryRules: z.array(categoryRuleSchema).optional(), - sources: z.array(z.enum(['microsoft', 'apple', 'linux', 'third_party', 'custom'])).optional(), // Ring-level auto-approval gate (#1317). See createRingSchema. autoApprove: ringAutoApproveSchema.optional(), targets: z.record(z.string(), z.unknown()).optional(), @@ -179,7 +181,6 @@ updateRingRoutes.get( gracePeriodHours: patchPolicies.gracePeriodHours, categories: patchPolicies.categories, excludeCategories: patchPolicies.excludeCategories, - sources: patchPolicies.sources, autoApprove: patchPolicies.autoApprove, categoryRules: patchPolicies.categoryRules, targets: patchPolicies.targets, @@ -230,7 +231,6 @@ updateRingRoutes.post( gracePeriodHours: data.gracePeriodHours ?? 4, categories: data.categories ?? [], excludeCategories: data.excludeCategories ?? [], - sources: data.sources ?? null, autoApprove: data.autoApprove ?? DEFAULT_RING_AUTO_APPROVE, categoryRules: data.categoryRules ?? [], targets: data.targets ?? {}, @@ -345,7 +345,6 @@ updateRingRoutes.patch( if (data.gracePeriodHours !== undefined) updateFields.gracePeriodHours = data.gracePeriodHours; if (data.categories !== undefined) updateFields.categories = data.categories; if (data.excludeCategories !== undefined) updateFields.excludeCategories = data.excludeCategories; - if (data.sources !== undefined) updateFields.sources = data.sources; if (data.autoApprove !== undefined) updateFields.autoApprove = data.autoApprove; if (data.categoryRules !== undefined) updateFields.categoryRules = data.categoryRules; if (data.targets !== undefined) updateFields.targets = data.targets; diff --git a/apps/api/src/routes/updateRings_detail_update_delete.test.ts b/apps/api/src/routes/updateRings_detail_update_delete.test.ts index 1a410a9da2..5ffd0f3448 100644 --- a/apps/api/src/routes/updateRings_detail_update_delete.test.ts +++ b/apps/api/src/routes/updateRings_detail_update_delete.test.ts @@ -43,7 +43,6 @@ vi.mock('../db/schema', () => ({ gracePeriodHours: 'gracePeriodHours', categories: 'categories', excludeCategories: 'excludeCategories', - sources: 'sources', autoApprove: 'autoApprove', categoryRules: 'categoryRules', targets: 'targets', @@ -126,7 +125,6 @@ function makeRing(overrides: Record = {}) { gracePeriodHours: 4, categories: [], excludeCategories: [], - sources: null, autoApprove: {}, categoryRules: [], targets: {}, @@ -248,6 +246,46 @@ describe('updateRings routes', () => { expect(res.status).toBe(400); }); + + it('no longer returns sources in ring detail responses', async () => { + vi.mocked(db.select) + // ring lookup — the sources column was dropped (#3151), so a real + // full-row select never returns it and neither must the response. + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([makeRing()]) + }) + }) + } as any) + // approval counts + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + groupBy: vi.fn().mockResolvedValue([]) + }) + }) + } as any) + // recent jobs + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]) + }) + }) + }) + } as any); + + const res = await app.request(`/update-rings/${RING_ID}`, { + method: 'GET', + headers: { Authorization: 'Bearer token' } + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).not.toHaveProperty('sources'); + }); }); // ---------------------------------------------------------------- @@ -355,6 +393,73 @@ describe('updateRings routes', () => { expect(res.status).toBe(400); }); + + it('PATCH persists thirdPartyApps and thirdPartyDeferralDays', async () => { + const autoApprove = { + enabled: true, + severities: [] as string[], + deferralDays: 0, + thirdPartyApps: true, + thirdPartyDeferralDays: 21 + }; + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ id: RING_ID, partnerId: PARTNER_ID }]) + }) + }) + } as any); + const setMock = vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([makeRing({ autoApprove })]) + }) + }); + vi.mocked(db.update).mockReturnValueOnce({ set: setMock } as any); + + const res = await app.request(`/update-rings/${RING_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token' }, + body: JSON.stringify({ autoApprove }) + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.autoApprove).toEqual(autoApprove); + + const updateFields = setMock.mock.calls[0]![0] as Record; + expect(updateFields.autoApprove).toMatchObject({ + thirdPartyApps: true, + thirdPartyDeferralDays: 21 + }); + }); + + it('PATCH rejects a sources payload as an unknown field no-op', async () => { + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ id: RING_ID, partnerId: PARTNER_ID }]) + }) + }) + } as any); + const setMock = vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([makeRing()]) + }) + }); + vi.mocked(db.update).mockReturnValueOnce({ set: setMock } as any); + + const res = await app.request(`/update-rings/${RING_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token' }, + // Zod strips unknown keys by default, so `sources` is silently dropped + // rather than rejected — assert the DB update call never sees it. + body: JSON.stringify({ sources: ['third_party'] }) + }); + + expect(res.status).toBe(200); + const updateFields = setMock.mock.calls[0]![0] as Record; + expect(updateFields).not.toHaveProperty('sources'); + }); }); // ---------------------------------------------------------------- diff --git a/apps/api/src/routes/updateRings_list_create.test.ts b/apps/api/src/routes/updateRings_list_create.test.ts index 584e1dbcb6..d1665b7494 100644 --- a/apps/api/src/routes/updateRings_list_create.test.ts +++ b/apps/api/src/routes/updateRings_list_create.test.ts @@ -44,7 +44,6 @@ vi.mock('../db/schema', () => ({ gracePeriodHours: 'gracePeriodHours', categories: 'categories', excludeCategories: 'excludeCategories', - sources: 'sources', autoApprove: 'autoApprove', categoryRules: 'categoryRules', targets: 'targets', @@ -127,7 +126,6 @@ function makeRing(overrides: Record = {}) { gracePeriodHours: 4, categories: [], excludeCategories: [], - sources: null, autoApprove: {}, categoryRules: [], targets: {}, @@ -240,6 +238,35 @@ describe('updateRings routes', () => { expect(res.status).toBe(403); }); + + it('no longer returns sources in ring list responses', async () => { + // The sources column was dropped (#3151), so a real DB response + // (and thus this mock) has no `sources` key at all. + const rings = [makeRing({ name: 'Default', ringOrder: 0 })]; + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue(rings) + }) + }) + } as any); + + const res = await app.request('/update-rings', { + method: 'GET', + headers: { Authorization: 'Bearer token' } + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.length).toBeGreaterThan(0); + for (const ring of body.data) { + expect(ring).not.toHaveProperty('sources'); + } + + // The select projection itself must not request the sources column. + const projection = vi.mocked(db.select).mock.calls[0]![0] as Record; + expect(projection).not.toHaveProperty('sources'); + }); }); // ---------------------------------------------------------------- @@ -402,6 +429,52 @@ describe('updateRings routes', () => { expect(res.status).toBe(400); }); + + it('creates a third-party-only ring (empty severities + thirdPartyApps)', async () => { + const autoApprove = { + enabled: true, + severities: [] as string[], + deferralDays: 0, + thirdPartyApps: true, + thirdPartyDeferralDays: 14 + }; + const created = makeRing({ autoApprove }); + const valuesMock = vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([created]) + }); + vi.mocked(db.insert).mockReturnValueOnce({ values: valuesMock } as any); + + const res = await app.request('/update-rings', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token' }, + body: JSON.stringify({ name: 'Third Party Ring', autoApprove }) + }); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.autoApprove).toEqual(autoApprove); + + const insertValues = valuesMock.mock.calls[0]![0] as Record; + expect(insertValues.autoApprove).toMatchObject({ + thirdPartyApps: true, + thirdPartyDeferralDays: 14 + }); + }); + + it('rejects a third_party_app category rule with a helpful message', async () => { + const res = await app.request('/update-rings', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token' }, + body: JSON.stringify({ + name: 'Ring', + categoryRules: [{ category: 'third_party_app', autoApprove: true }] + }) + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(JSON.stringify(body)).toContain('thirdPartyApps'); + }); }); }); diff --git a/apps/api/src/scripts/migrateToConfigPolicies.ts b/apps/api/src/scripts/migrateToConfigPolicies.ts index 8bf608a9ab..140f6a7cda 100644 --- a/apps/api/src/scripts/migrateToConfigPolicies.ts +++ b/apps/api/src/scripts/migrateToConfigPolicies.ts @@ -488,8 +488,13 @@ async function migratePatchPoliciesLive( if (!featureLink) throw new Error('Failed to create patch feature link'); summary.featureLinksCreated++; - // sources is a patchSourceEnum[] on the legacy table, text[] on the target. - const sources: string[] = (primary.sources as string[] | null) ?? ['os']; + // The legacy patch_policies.sources column was dropped (spec 2026-08-04, + // never consumed by the approval path) — default to ['os']. + // Acceptable only because this script is retained as a one-shot, not a + // repeatable sync: re-running it for a legacy partner whose ring already + // opted in to third-party auto-approve would silently drop that opt-in + // by re-writing sources to ['os'] here. + const sources: string[] = ['os']; await tx.insert(configPolicyPatchSettings).values({ featureLinkId: featureLink.id, diff --git a/apps/api/src/services/aiToolSchemas.ts b/apps/api/src/services/aiToolSchemas.ts index c93b4a02f4..a40bc7bdac 100644 --- a/apps/api/src/services/aiToolSchemas.ts +++ b/apps/api/src/services/aiToolSchemas.ts @@ -1393,7 +1393,6 @@ export const toolInputSchemas: Record = { gracePeriodHours: z.number().int().min(0).optional(), categories: z.array(z.string()).max(50).optional(), excludeCategories: z.array(z.string()).max(50).optional(), - sources: z.array(z.enum(['microsoft', 'apple', 'linux', 'third_party', 'custom'])).optional(), autoApprove: ringAutoApproveSchema.optional(), enabled: z.boolean().optional(), limit: z.number().int().min(1).max(100).optional(), diff --git a/apps/api/src/services/aiToolsPolicyPrereqs.test.ts b/apps/api/src/services/aiToolsPolicyPrereqs.test.ts index 506cb843a2..b34f1a735a 100644 --- a/apps/api/src/services/aiToolsPolicyPrereqs.test.ts +++ b/apps/api/src/services/aiToolsPolicyPrereqs.test.ts @@ -32,7 +32,6 @@ vi.mock('../db/schema/patches', () => ({ gracePeriodHours: 'patchPolicies.gracePeriodHours', categories: 'patchPolicies.categories', excludeCategories: 'patchPolicies.excludeCategories', - sources: 'patchPolicies.sources', }, })); vi.mock('../db/schema/softwarePolicies', () => ({ softwarePolicies: {} })); @@ -286,6 +285,85 @@ describe('manage_update_rings autoApprove fail-closed write boundary (#1317)', ( expect(written.partnerId).toBe(PARTNER_ID); expect(written.orgId).toBeUndefined(); }); + + it('manage_update_rings create accepts a third-party-only autoApprove', async () => { + mockInsertReturns({ id: RING_ID, name: 'Ring A' }); + const tool = getTool(); + const output = await tool.handler( + { + action: 'create', + name: 'Ring A', + autoApprove: { enabled: true, severities: [], thirdPartyApps: true }, + }, + makeAuth() + ); + + const parsed = JSON.parse(output); + expect(parsed.error).toBeUndefined(); + expect(parsed.success).toBe(true); + expect(insertMock).toHaveBeenCalledTimes(1); + const written = insertMock.mock.results[0]!.value.values.mock.calls[0][0]; + expect(written.autoApprove).toMatchObject({ + enabled: true, + severities: [], + thirdPartyApps: true, + }); + }); + + it('manage_update_rings create/update ignore a sources input and never write the column', async () => { + mockInsertReturns({ id: RING_ID, name: 'Ring A' }); + const tool = getTool(); + const createOutput = await tool.handler( + { action: 'create', name: 'Ring A', sources: ['os'] }, + makeAuth() + ); + expect(JSON.parse(createOutput).success).toBe(true); + const createdValues = insertMock.mock.results[0]!.value.values.mock.calls[0][0]; + expect(createdValues).not.toHaveProperty('sources'); + + vi.clearAllMocks(); + mockSelectReturns({ id: RING_ID, partnerId: PARTNER_ID, name: 'Ring A', kind: 'ring' }); + mockUpdate(); + const updateOutput = await tool.handler( + { action: 'update', ringId: RING_ID, sources: ['os'] }, + makeAuth() + ); + expect(JSON.parse(updateOutput).success).toBe(true); + const updatedValues = updateMock.mock.results[0]!.value.set.mock.calls[0][0]; + expect(updatedValues).not.toHaveProperty('sources'); + }); + + it('manage_update_rings still rejects enabled with no severities and no thirdPartyApps', async () => { + const tool = getTool(); + const output = await tool.handler( + { + action: 'create', + name: 'Ring A', + autoApprove: { enabled: true, severities: [] }, + }, + makeAuth() + ); + + const parsed = JSON.parse(output); + expect(parsed.error).toMatch(/severity|third-party/i); + expect(insertMock).not.toHaveBeenCalled(); + }); + + it('manage_update_rings get returns ring rows without a sources key (column dropped, #3151)', async () => { + mockSelectReturns({ + id: RING_ID, + partnerId: PARTNER_ID, + name: 'Ring A', + kind: 'ring', + }); + const tool = getTool(); + const output = await tool.handler({ action: 'get', ringId: RING_ID }, makeAuth()); + + const parsed = JSON.parse(output); + expect(parsed.ring).toBeDefined(); + expect(parsed.ring).not.toHaveProperty('sources'); + expect(parsed.ring.id).toBe(RING_ID); + }); }); // manage_backup_configs used to write `providerConfig` straight to the DB, diff --git a/apps/api/src/services/aiToolsPolicyPrereqs.ts b/apps/api/src/services/aiToolsPolicyPrereqs.ts index 147c4329b4..1cb27c3a66 100644 --- a/apps/api/src/services/aiToolsPolicyPrereqs.ts +++ b/apps/api/src/services/aiToolsPolicyPrereqs.ts @@ -180,8 +180,7 @@ export function registerPolicyPrereqTools(aiTools: Map): void { gracePeriodHours: { type: 'number', description: 'Hours after deadline before reboot is forced (default: 4)' }, categories: { type: 'array', items: { type: 'string' }, description: 'Patch categories to include (e.g. ["critical","important","security"])' }, excludeCategories: { type: 'array', items: { type: 'string' }, description: 'Patch categories to exclude' }, - sources: { type: 'array', items: { type: 'string' }, description: 'Patch sources: ["microsoft","apple","linux","third_party","custom"]' }, - autoApprove: { type: 'object', description: 'Auto-approval rules (e.g. { enabled: true, severities: ["critical","important"], deferralDays: 0 }). severities must be a subset of ["critical","important","moderate","low"]. If enabled is true you MUST list at least one severity — an enabled rule with an empty severity set is rejected (it would auto-approve nothing).' }, + autoApprove: { type: 'object', description: 'Auto-approval rules, e.g. { enabled: true, severities: ["critical","important"], deferralDays: 0, thirdPartyApps: false, thirdPartyDeferralDays: null }. severities gate OS patches only and must be a subset of ["critical","important","moderate","low"]. thirdPartyApps auto-approves third-party app updates (winget/Chocolatey/Homebrew/custom) — it also requires the linked configuration policy to include third-party patch sources. If enabled is true you MUST set at least one severity OR thirdPartyApps: true.' }, enabled: { type: 'boolean', description: 'Whether ring is active (for update)' }, limit: { type: 'number', description: 'Max results for list (default 25)' }, }, @@ -213,7 +212,6 @@ export function registerPolicyPrereqTools(aiTools: Map): void { deadlineDays: patchPolicies.deadlineDays, gracePeriodHours: patchPolicies.gracePeriodHours, categories: patchPolicies.categories, - sources: patchPolicies.sources, autoApprove: patchPolicies.autoApprove, ringOrder: patchPolicies.ringOrder, createdAt: patchPolicies.createdAt, @@ -264,7 +262,6 @@ export function registerPolicyPrereqTools(aiTools: Map): void { gracePeriodHours: Number(input.gracePeriodHours) || 4, categories: (input.categories as string[]) ?? [], excludeCategories: (input.excludeCategories as string[]) ?? [], - sources: (input.sources as any[]) ?? undefined, autoApprove, createdBy: auth.user.id, }).returning(); @@ -296,7 +293,6 @@ export function registerPolicyPrereqTools(aiTools: Map): void { if (input.gracePeriodHours != null) updates.gracePeriodHours = Number(input.gracePeriodHours); if (input.categories) updates.categories = input.categories; if (input.excludeCategories) updates.excludeCategories = input.excludeCategories; - if (input.sources) updates.sources = input.sources; if (input.autoApprove != null) { // Fail-closed autoApprove (#1317): reject enabled-without-severity at // the write boundary, mirroring the route's ringAutoApproveSchema. diff --git a/apps/api/src/services/patchApprovalEvaluator.test.ts b/apps/api/src/services/patchApprovalEvaluator.test.ts index 657af65408..7119dd96b1 100644 --- a/apps/api/src/services/patchApprovalEvaluator.test.ts +++ b/apps/api/src/services/patchApprovalEvaluator.test.ts @@ -24,6 +24,7 @@ import { comparePatchVersions, evaluateAppRule, isCategoryAllowed, + parseRingAutoApprove, resolveApprovedPatchesForDevice, THIRD_PARTY_PATCH_SOURCES, type ApprovalEvaluationConfig, @@ -263,7 +264,13 @@ describe('resolveApprovedPatchesForDevice source filtering', () => { expect(approved.map((p) => p.patchId)).toEqual(['aaaaaaaa-0000-0000-0000-000000000003']); }); - it('applies no source filtering when sources is absent (legacy jobs)', async () => { + it('applies no source filtering to OS patches when sources is absent (legacy jobs), but still refuses third-party (dual consent, #spec 2026-08-04)', async () => { + // buildAllowedPatchSources itself does not gate on absent sources (legacy + // "no filtering" jobs), but the ring-level dual-consent check in + // evaluatePatchApproval requires the literal 'third_party' selection + // regardless — an absent policy sources array can never satisfy that, so + // the third-party patch stays unapproved even though its severity matches + // the ring's OS severity list. mockPendingAndApprovals( [ pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-000000000001', source: 'microsoft' }), @@ -274,7 +281,7 @@ describe('resolveApprovedPatchesForDevice source filtering', () => { const approved = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, baseRing); - expect(approved).toHaveLength(2); + expect(approved.map((p) => p.patchId)).toEqual(['aaaaaaaa-0000-0000-0000-000000000001']); }); it('source filter also gates manually approved patches', async () => { @@ -386,8 +393,8 @@ describe('app rules in resolveApprovedPatchesForDevice', () => { it('applies an app block rule under a linked ring too', async () => { mockPendingAndApprovals( [ - pendingRow({ patchId: P1, devicePatchId: 'dp-1', source: 'third_party', category: 'homebrew', packageId: 'Mozilla.Firefox', version: '121.0' }), - pendingRow({ patchId: P2, devicePatchId: 'dp-2', source: 'third_party', category: 'homebrew', packageId: 'VideoLAN.VLC', version: '3.0.20' }), + pendingRow({ patchId: P1, devicePatchId: 'dp-1', source: 'third_party', category: 'third_party_app', packageId: 'Mozilla.Firefox', version: '121.0' }), + pendingRow({ patchId: P2, devicePatchId: 'dp-2', source: 'third_party', category: 'third_party_app', packageId: 'VideoLAN.VLC', version: '3.0.20' }), ], [] ); @@ -519,7 +526,7 @@ describe('ring-less path: only manual approvals apply', () => { }); }); -describe('third_party_app category rule', () => { +describe('"third_party_app" as a literal category (virtual source-matching removed, #spec 2026-08-04)', () => { beforeEach(() => { vi.mocked(db.select).mockReset(); }); @@ -531,7 +538,7 @@ describe('third_party_app category rule', () => { deferralDays: 0, }; - it('auto-approves a third_party-source patch regardless of its category string', async () => { + it('no longer matches a third_party-source patch whose category is not literally "third_party_app"', async () => { mockPendingAndApprovals( [pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-000000000010', source: 'third_party', category: 'homebrew-cask' })], [] @@ -539,11 +546,10 @@ describe('third_party_app category rule', () => { const approved = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, ringWithThirdPartyRule); - expect(approved).toHaveLength(1); - expect(approved[0]?.approvalReason).toBe('category_rule'); + expect(approved).toHaveLength(0); }); - it('does not apply the third_party_app rule to OS-source patches', async () => { + it('does not match an OS-source patch whose category is not literally "third_party_app"', async () => { mockPendingAndApprovals( [pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-000000000011', source: 'microsoft', category: 'application' })], [] @@ -554,7 +560,7 @@ describe('third_party_app category rule', () => { expect(approved).toHaveLength(0); }); - it('prefers an exact category rule over the third_party_app fallback', async () => { + it('an exact category rule governs; an unrelated third_party_app rule never applies to it (no virtual fallback)', async () => { mockPendingAndApprovals( [pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-000000000012', source: 'third_party', category: 'homebrew', severity: 'low' })], [] @@ -571,9 +577,9 @@ describe('third_party_app category rule', () => { expect(approved).toHaveLength(0); }); - it('applies the severity filter on the third_party_app rule', async () => { + it('applies the severity filter on a literal "third_party_app" category rule', async () => { mockPendingAndApprovals( - [pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-000000000013', source: 'third_party', category: 'homebrew', severity: 'low' })], + [pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-000000000013', source: 'third_party', category: 'third_party_app', severity: 'low' })], [] ); @@ -585,11 +591,11 @@ describe('third_party_app category rule', () => { expect(approved).toHaveLength(0); }); - it('does NOT auto-approve a null-severity patch under a category severity filter', async () => { + it('does NOT auto-approve a null-severity patch under a literal "third_party_app" category severity filter', async () => { // Mirrors the ring/policy fail-closed posture: a null-severity patch must // not slip past a non-empty category severityFilter. mockPendingAndApprovals( - [pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-00000000001a', source: 'third_party', category: 'homebrew', severity: null })], + [pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-00000000001a', source: 'third_party', category: 'third_party_app', severity: null })], [] ); @@ -601,10 +607,10 @@ describe('third_party_app category rule', () => { expect(approved).toHaveLength(0); }); - it('applies the deferral window on the third_party_app rule', async () => { + it('applies the deferral window on a literal "third_party_app" category rule', async () => { const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); mockPendingAndApprovals( - [pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-000000000014', source: 'third_party', category: 'homebrew', releaseDate: yesterday })], + [pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-000000000014', source: 'third_party', category: 'third_party_app', releaseDate: yesterday })], [] ); @@ -620,7 +626,7 @@ describe('third_party_app category rule', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { mockPendingAndApprovals( - [pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-000000000019', source: 'third_party', category: 'homebrew', releaseDate: null })], + [pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-000000000019', source: 'third_party', category: 'third_party_app', releaseDate: null })], [] ); @@ -638,7 +644,7 @@ describe('third_party_app category rule', () => { } }); - it('matches the third_party_app rule when the patch category is null', async () => { + it('does not match the third_party_app rule when the patch category is null (no virtual source-fallback)', async () => { mockPendingAndApprovals( [pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-000000000015', source: 'third_party', category: null })], [] @@ -646,11 +652,10 @@ describe('third_party_app category rule', () => { const approved = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, ringWithThirdPartyRule); - expect(approved).toHaveLength(1); - expect(approved[0]?.approvalReason).toBe('category_rule'); + expect(approved).toHaveLength(0); }); - it('an exact category rule with autoApprove false suppresses the third_party_app fallback', async () => { + it('a matching autoApprove:false category rule blocks approval regardless of an unrelated third_party_app rule', async () => { mockPendingAndApprovals( [pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-000000000016', source: 'third_party', category: 'homebrew' })], [] @@ -667,7 +672,7 @@ describe('third_party_app category rule', () => { expect(approved).toHaveLength(0); }); - it('combines source filtering with the third_party_app rule (headline flow)', async () => { + it('source filtering still applies; a same-source patch with a different category no longer matches a third_party_app rule', async () => { mockPendingAndApprovals( [ pendingRow({ patchId: 'aaaaaaaa-0000-0000-0000-000000000017', source: 'microsoft', category: 'security' }), @@ -681,7 +686,93 @@ describe('third_party_app category rule', () => { sources: ['third_party'], }); - expect(approved.map((p) => p.patchId)).toEqual(['aaaaaaaa-0000-0000-0000-000000000018']); + expect(approved).toEqual([]); + }); +}); + +describe('category rules — repaired semantics (#spec 2026-08-04)', () => { + beforeEach(() => { + vi.mocked(db.select).mockReset(); + }); + + it('enforces autoApproveSeverities written by the route/UI (was silently ignored)', async () => { + mockPendingAndApprovals( + [pendingRow({ patchId: P1, category: 'security', severity: 'moderate' })], + [] + ); + + const result = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, { + ringId: RING_ID, + categoryRules: [{ category: 'security', autoApprove: true, autoApproveSeverities: ['critical'] }], + autoApprove: {}, + deferralDays: 0, + }); + + expect(result).toEqual([]); + }); + + it('still honors legacy stored severityFilter as a read alias', async () => { + mockPendingAndApprovals( + [pendingRow({ patchId: P1, category: 'security', severity: 'critical' })], + [] + ); + + const approved = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, { + ringId: RING_ID, + categoryRules: [{ category: 'security', autoApprove: true, severityFilter: ['critical'] }], + autoApprove: {}, + deferralDays: 0, + }); + + expect(approved.map((r) => r.patchId)).toEqual([P1]); + expect(approved[0]?.approvalReason).toBe('category_rule'); + + mockPendingAndApprovals( + [pendingRow({ patchId: P1, category: 'security', severity: 'low' })], + [] + ); + + const notApproved = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, { + ringId: RING_ID, + categoryRules: [{ category: 'security', autoApprove: true, severityFilter: ['critical'] }], + autoApprove: {}, + deferralDays: 0, + }); + + expect(notApproved).toEqual([]); + }); + + it('treats a matching autoApprove:false rule as terminal — no fall-through to ring auto-approve', async () => { + mockPendingAndApprovals( + [pendingRow({ patchId: P1, category: 'security', severity: 'critical' })], + [] + ); + + const result = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, { + ringId: RING_ID, + categoryRules: [{ category: 'security', autoApprove: false }], + autoApprove: { enabled: true, severities: ['critical'] }, + deferralDays: 0, + }); + + expect(result).toEqual([]); + }); + + it('no longer matches third-party patches to a third_party_app rule', async () => { + mockPendingAndApprovals( + [pendingRow({ patchId: P1, source: 'third_party', category: 'application', severity: 'critical' })], + [] + ); + + const result = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, { + ringId: RING_ID, + categoryRules: [{ category: 'third_party_app', autoApprove: true }], + autoApprove: { enabled: false, severities: [] }, + deferralDays: 0, + sources: ['os', 'third_party'], + }); + + expect(result).toEqual([]); }); }); @@ -1410,10 +1501,10 @@ describe('deferral first-seen fallback for third-party patches (#2218)', () => { } }); - it('applies the first-seen fallback on a third_party_app category deferral too', async () => { + it('applies the first-seen fallback on a literal "third_party_app" category deferral too', async () => { const yesterday = new Date(Date.now() - 24 * 3600 * 1000); mockPendingAndApprovals( - [pendingRow({ patchId: P1, source: 'third_party', severity: 'unknown', releaseDate: null, firstSeenAt: yesterday, category: 'homebrew' })], + [pendingRow({ patchId: P1, source: 'third_party', severity: 'unknown', releaseDate: null, firstSeenAt: yesterday, category: 'third_party_app' })], [] ); @@ -1428,7 +1519,7 @@ describe('deferral first-seen fallback for third-party patches (#2218)', () => { const tenDaysAgo = new Date(Date.now() - 10 * 24 * 3600 * 1000); mockPendingAndApprovals( - [pendingRow({ patchId: P1, source: 'third_party', severity: 'unknown', releaseDate: null, firstSeenAt: tenDaysAgo, category: 'homebrew' })], + [pendingRow({ patchId: P1, source: 'third_party', severity: 'unknown', releaseDate: null, firstSeenAt: tenDaysAgo, category: 'third_party_app' })], [] ); @@ -1443,3 +1534,189 @@ describe('deferral first-seen fallback for third-party patches (#2218)', () => { expect(approved[0]?.approvalReason).toBe('category_rule'); }); }); + +// ---- Ring auto-approve: third-party dual consent (#spec 2026-08-04) ---- +describe('ring auto-approve — third-party dual consent (#spec 2026-08-04)', () => { + beforeEach(() => { + vi.mocked(db.select).mockReset(); + }); + + it('approves a third-party patch only with BOTH policy sources third_party AND ring thirdPartyApps', async () => { + mockPendingAndApprovals( + [pendingRow({ patchId: P1, source: 'third_party', severity: 'unknown', releaseDate: null })], + [] + ); + + const result = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, { + ringId: RING_ID, + categoryRules: [], + autoApprove: { enabled: true, severities: [], thirdPartyApps: true, deferralDays: 0 }, + deferralDays: 0, + sources: ['os', 'third_party'], + }); + + expect(result).toHaveLength(1); + expect(result[0]?.approvalReason).toBe('ring_auto_approve'); + }); + + it('does not approve third-party when the ring toggle is off, even with policy consent', async () => { + mockPendingAndApprovals( + [pendingRow({ patchId: P1, source: 'third_party', severity: 'unknown', releaseDate: null })], + [] + ); + + const result = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, { + ringId: RING_ID, + categoryRules: [], + autoApprove: { enabled: true, severities: ['critical'], thirdPartyApps: false, deferralDays: 0 }, + deferralDays: 0, + sources: ['os', 'third_party'], + }); + + expect(result).toEqual([]); + }); + + it('does not approve third-party when policy sources are absent (legacy snapshot) even with the toggle on', async () => { + mockPendingAndApprovals( + [pendingRow({ patchId: P1, source: 'third_party', severity: 'unknown', releaseDate: null })], + [] + ); + + const result = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, { + ringId: RING_ID, + categoryRules: [], + autoApprove: { enabled: true, severities: [], thirdPartyApps: true, deferralDays: 0 }, + deferralDays: 0, + sources: undefined, + }); + + expect(result).toEqual([]); + }); + + it('supports a third-party-only ring: empty severities approves 3P and no OS patches', async () => { + mockPendingAndApprovals( + [ + pendingRow({ patchId: P1, devicePatchId: 'dp-1', source: 'third_party', severity: 'unknown', releaseDate: null }), + pendingRow({ patchId: P2, devicePatchId: 'dp-2', source: 'microsoft', severity: 'critical' }), + ], + [] + ); + + const result = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, { + ringId: RING_ID, + categoryRules: [], + autoApprove: { enabled: true, severities: [], thirdPartyApps: true, deferralDays: 0 }, + deferralDays: 0, + sources: ['os', 'third_party'], + }); + + expect(result.map((r) => r.patchId)).toEqual([P1]); + expect(result[0]?.approvalReason).toBe('ring_auto_approve'); + }); + + it('treats custom-source patches as third-party for the toggle', async () => { + mockPendingAndApprovals( + [pendingRow({ patchId: P1, source: 'custom', severity: 'unknown', releaseDate: null })], + [] + ); + + const result = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, { + ringId: RING_ID, + categoryRules: [], + autoApprove: { enabled: true, severities: [], thirdPartyApps: true, deferralDays: 0 }, + deferralDays: 0, + sources: ['os', 'third_party'], + }); + + expect(result).toHaveLength(1); + expect(result[0]?.approvalReason).toBe('ring_auto_approve'); + }); + + it('applies thirdPartyDeferralDays over deferralDays for 3P, anchored on firstSeenAt', async () => { + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 3600 * 1000); + mockPendingAndApprovals( + [pendingRow({ patchId: P1, source: 'third_party', severity: 'unknown', releaseDate: null, firstSeenAt: threeDaysAgo })], + [] + ); + + const held = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, { + ringId: RING_ID, + categoryRules: [], + autoApprove: { enabled: true, severities: [], thirdPartyApps: true, deferralDays: 0, thirdPartyDeferralDays: 7 }, + deferralDays: 0, + sources: ['os', 'third_party'], + }); + expect(held).toEqual([]); + + const eightDaysAgo = new Date(Date.now() - 8 * 24 * 3600 * 1000); + mockPendingAndApprovals( + [pendingRow({ patchId: P1, source: 'third_party', severity: 'unknown', releaseDate: null, firstSeenAt: eightDaysAgo })], + [] + ); + + const approved = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, { + ringId: RING_ID, + categoryRules: [], + autoApprove: { enabled: true, severities: [], thirdPartyApps: true, deferralDays: 0, thirdPartyDeferralDays: 7 }, + deferralDays: 0, + sources: ['os', 'third_party'], + }); + expect(approved).toHaveLength(1); + expect(approved[0]?.approvalReason).toBe('ring_auto_approve'); + + // null thirdPartyDeferralDays inherits deferralDays: with deferralDays:7 the + // same 3-day-old first-seen timestamp is still held. + const threeDaysAgo2 = new Date(Date.now() - 3 * 24 * 3600 * 1000); + mockPendingAndApprovals( + [pendingRow({ patchId: P1, source: 'third_party', severity: 'unknown', releaseDate: null, firstSeenAt: threeDaysAgo2 })], + [] + ); + + const heldViaInherit = await resolveApprovedPatchesForDevice(DEVICE_ID, ORG_ID, { + ringId: RING_ID, + categoryRules: [], + autoApprove: { enabled: true, severities: [], thirdPartyApps: true, deferralDays: 7, thirdPartyDeferralDays: null }, + deferralDays: 0, + sources: ['os', 'third_party'], + }); + expect(heldViaInherit).toEqual([]); + }); +}); + +describe('parseRingAutoApprove — thirdPartyApps compatibility (#spec 2026-08-04)', () => { + it('derives thirdPartyApps=true for a legacy enabled row with recognized severities', () => { + const cfg = parseRingAutoApprove({ enabled: true, severities: ['critical'], deferralDays: 3 }); + expect(cfg).toEqual({ enabled: true, severities: ['critical'], deferralDays: 3, thirdPartyApps: true, thirdPartyDeferralDays: null }); + }); + + it('derives thirdPartyApps=false for legacy enabled rows with no recognized severities', () => { + expect(parseRingAutoApprove({ enabled: true, severities: [] }).thirdPartyApps).toBe(false); + expect(parseRingAutoApprove({ enabled: true, severities: ['bogus'] }).thirdPartyApps).toBe(false); + expect(parseRingAutoApprove(true).thirdPartyApps).toBe(false); + }); + + it('honors an explicit thirdPartyApps boolean and treats malformed as false', () => { + expect(parseRingAutoApprove({ enabled: true, severities: [], thirdPartyApps: true }).thirdPartyApps).toBe(true); + expect(parseRingAutoApprove({ enabled: true, severities: ['critical'], thirdPartyApps: false }).thirdPartyApps).toBe(false); + expect(parseRingAutoApprove({ enabled: true, severities: ['critical'], thirdPartyApps: 'yes' }).thirdPartyApps).toBe(false); + }); + + it('drops unrecognized severity strings', () => { + expect(parseRingAutoApprove({ enabled: true, severities: ['critical', 'bogus', 7] }).severities).toEqual(['critical']); + }); + + it('disables the row on a present-but-invalid deferralDays instead of coercing to 0', () => { + expect(parseRingAutoApprove({ enabled: true, severities: ['critical'], deferralDays: 'soon' }).enabled).toBe(false); + expect(parseRingAutoApprove({ enabled: true, severities: ['critical'], deferralDays: -1 }).enabled).toBe(false); + expect(parseRingAutoApprove({ enabled: true, severities: ['critical'], deferralDays: 1.5 }).enabled).toBe(false); + // absent stays fine + expect(parseRingAutoApprove({ enabled: true, severities: ['critical'] })).toMatchObject({ enabled: true, deferralDays: 0 }); + }); + + it('parses thirdPartyDeferralDays: valid int kept, malformed/absent/null → null', () => { + expect(parseRingAutoApprove({ enabled: true, severities: [], thirdPartyApps: true, thirdPartyDeferralDays: 14 }).thirdPartyDeferralDays).toBe(14); + expect(parseRingAutoApprove({ enabled: true, severities: [], thirdPartyApps: true, thirdPartyDeferralDays: null }).thirdPartyDeferralDays).toBeNull(); + expect(parseRingAutoApprove({ enabled: true, severities: [], thirdPartyApps: true, thirdPartyDeferralDays: 999 }).thirdPartyDeferralDays).toBeNull(); + expect(parseRingAutoApprove({ enabled: true, severities: [], thirdPartyApps: true }).thirdPartyDeferralDays).toBeNull(); + }); +}); diff --git a/apps/api/src/services/patchApprovalEvaluator.ts b/apps/api/src/services/patchApprovalEvaluator.ts index 240aa0324f..a1b883e8f9 100644 --- a/apps/api/src/services/patchApprovalEvaluator.ts +++ b/apps/api/src/services/patchApprovalEvaluator.ts @@ -4,7 +4,7 @@ * Single approval/filtering gate for patch job execution. For each device it * resolves the set of pending patches a job is allowed to install, covering: * - manual approvals (partner-wide or ring-scoped) - * - ring category rules (including the virtual 'third_party_app' category) + * - ring category rules (exact OS category match; terminal on match) * - ring-level auto-approve (enabled + severities + deferral window) — #1317 * - ring-less policy-level auto-approve (severity list + deferral window) * - policy source filtering ('os' vs 'third_party', ...) @@ -24,8 +24,11 @@ import { and, eq, inArray } from 'drizzle-orm'; export interface CategoryRule { category: string; autoApprove: boolean; + /** Severity allowlist — the canonical field name the route/UI write (updateRings.ts categoryRuleSchema). */ + autoApproveSeverities?: string[]; + /** @deprecated Legacy stored alias for autoApproveSeverities (rows/snapshots written before 2026-08). Read-only. */ severityFilter?: string[]; - deferralDaysOverride?: number; + deferralDaysOverride?: number | null; } /** @@ -527,76 +530,72 @@ function evaluatePatchApproval( return null; } - // Priority 2: Category rule. - // 'third_party_app' is a virtual category — agents report inconsistent - // category strings for app updates (application/homebrew/homebrew-cask/...), - // so it matches by patch source instead. An exact category rule wins. - let rule = patch.category ? categoryRuleMap.get(canonicalizePatchCategory(patch.category)) : undefined; - if (!rule && isThirdPartyPatchSource(patch.source)) { - rule = categoryRuleMap.get('third_party_app'); - } - if (rule && rule.autoApprove) { - // Check severity filter. When a non-empty filter is set, a patch whose - // severity is null cannot satisfy it and must NOT auto-approve — same - // fail-closed posture as the policy and ring paths. (Previously a - // null-severity patch short-circuited the filter and fell through.) - if (rule.severityFilter && rule.severityFilter.length > 0) { - if (!patch.severity || !rule.severityFilter.includes(patch.severity)) { - return null; // Severity null, or not in allowed list + // Priority 2: Category rule (OS categories only). The virtual + // 'third_party_app' category was removed — third-party auto-approval is the + // ring-level thirdPartyApps toggle (Priority 3); stored third_party_app rules + // were migrated to it by the 2026-08-13 backfill. A matching rule is + // TERMINAL either way: autoApprove:false means "needs manual approval" (the + // UI's words) and must not fall through to ring-level auto-approve. + const rule = patch.category + ? categoryRuleMap.get(canonicalizePatchCategory(patch.category)) + : undefined; + if (rule) { + if (!rule.autoApprove) { + return null; + } + // Severity allowlist. Canonical name is autoApproveSeverities (what the + // route/UI write); severityFilter is honored as a legacy stored alias — + // before 2026-08 the evaluator ONLY read severityFilter, which the writers + // never produced, so chips were silently unenforced (fail-open). + const severityAllowlist = rule.autoApproveSeverities ?? rule.severityFilter; + if (severityAllowlist && severityAllowlist.length > 0) { + if (!patch.severity || !severityAllowlist.includes(patch.severity)) { + return null; } } - - // Check deferral period const deferralDays = rule.deferralDaysOverride ?? ringConfig.deferralDays; if (isHeldByDeferral(patch, deferralDays, now, 'category')) { return null; } - return 'category_rule'; } - // Priority 3: Ring-level auto-approve (#1317). The ring now owns approval, so - // this honors the configured severities AND a deferral window (held, not - // approved, until the patch ages past it) — consistent with the policy-level - // and category deferral semantics. - // - // FAIL-CLOSED at the read boundary (mirrors the write-side Zod refinement in - // ringAutoApproveSchema): auto-approval requires an explicit, non-empty - // severity set AND a patch severity that is in it. We must NOT trust that the - // stored row went through the route schema — the manage_update_rings AI tool - // and legacy boolean `true` rows can both produce `enabled` with empty - // severities, which previously fell through and auto-approved EVERY pending - // patch (auto-approve-all). A null-severity patch likewise never auto-approves - // under a restricted list, matching the policy path above. + // Priority 3: Ring-level auto-approve (#1317). Severity gates OS candidates; + // third-party candidates are gated by the explicit thirdPartyApps toggle + // (#2218 exemption replaced by spec 2026-08-04) under DUAL CONSENT: + // - the POLICY must have opted into third-party sources ('third_party' in + // the snapshotted sources; the default is ['os']). This stays even though + // buildAllowedPatchSources filters upstream, because ABSENT sources mean + // "no filtering" for legacy job snapshots — without this literal check, a + // legacy snapshot plus a permissive ring would silently widen to 3P. + // - the RING's autoApprove.thirdPartyApps must be true. + // NOTE: the literal 'third_party' selection vs the expanded patch-source + // bucket ('third_party'|'custom') stay in lockstep because + // buildAllowedPatchSources only admits 'custom' rows via the 'third_party' + // selection (or an explicit 'custom' entry) — if that expansion table ever + // changes, revisit this check too. if (ringAutoApprove.enabled) { + if (isThirdPartyPatchSource(patch.source)) { + if (!(ringConfig.sources ?? []).includes('third_party')) { + return null; + } + if (!ringAutoApprove.thirdPartyApps) { + return null; + } + const hold = ringAutoApprove.thirdPartyDeferralDays ?? ringAutoApprove.deferralDays; + if (isHeldByDeferral(patch, hold, now, 'ring')) { + return null; + } + return 'ring_auto_approve'; + } + + // OS path: unchanged fail-closed severity gating. Enabled with an empty + // severity set approves no OS patches (legacy boolean `true` and malformed + // `{enabled:true}` rows stay inert here). if (ringAutoApprove.severities.length === 0) { - // Enabled but no severities selected = approve nothing (fail-closed). return null; } - // Third-party severity exemption (#2218): winget/chocolatey/homebrew - // updates have no vendor severity concept — the agent/API ingest them with - // severity='unknown' — so requiring membership in the ring's severity set - // made third-party auto-approval dead configuration. When the policy has - // EXPLICITLY opted into third-party sources ('third_party' in sources; the - // default is ['os']) and this candidate is a third-party patch, skip the - // severity MEMBERSHIP check only. Everything else stays fail-closed: the - // empty-severities kill-switch above still approves nothing (a malformed - // `{ enabled: true }` row stays inert), OS patches keep full severity - // gating, and the source, category, app-rule, and deferral gates all still - // apply to third-party candidates. - // NOTE: this reads the RAW policy sources array for the literal - // 'third_party' selection, while isThirdPartyPatchSource matches the - // expanded patch-source bucket ('third_party' | 'custom'). The two stay in - // lockstep because buildAllowedPatchSources only admits 'custom' rows via - // the 'third_party' selection (or an explicit 'custom' entry) — if that - // expansion table ever changes, revisit this line too. - const severityExempt = - isThirdPartyPatchSource(patch.source) && - (ringConfig.sources ?? []).includes('third_party'); - if ( - !severityExempt && - (!patch.severity || !ringAutoApprove.severities.includes(patch.severity)) - ) { + if (!patch.severity || !ringAutoApprove.severities.includes(patch.severity)) { return null; } if (isHeldByDeferral(patch, ringAutoApprove.deferralDays, now, 'ring')) { @@ -659,54 +658,76 @@ function isHeldByDeferral( interface RingAutoApproveConfig { enabled: boolean; severities: string[]; - /** - * Deferral window in days for the ring auto-approve gate (#1317). 0 = no - * deferral. A patch whose release date is within this window is held, not - * approved, mirroring the policy-level / category deferral semantics. - */ + /** Deferral window (days) for OS ring auto-approve. 0 = no deferral. */ deferralDays: number; + /** Third-party source-level auto-approve toggle (dual consent with policy sources). */ + thirdPartyApps: boolean; + /** Third-party hold override; null = inherit deferralDays. First-seen anchored (#2218). */ + thirdPartyDeferralDays: number | null; } +const RECOGNIZED_RING_SEVERITIES = new Set(['critical', 'important', 'moderate', 'low']); + +const DISABLED_RING_AUTO_APPROVE: RingAutoApproveConfig = { + enabled: false, + severities: [], + deferralDays: 0, + thirdPartyApps: false, + thirdPartyDeferralDays: null, +}; + /** * Parse a ring's `autoApprove` JSONB into a typed config. Tolerant of every - * historical shape so already-stored rings keep working after #1317: - * - boolean `true` → enabled, EMPTY severity set, no deferral - * - `{ enabled: true, severities: [...] }` (no deferralDays) → deferral 0 - * - `{ enabled: true, severities: [...], deferralDays: N }` → typed shape - * Anything else (missing, `{}`, malformed) fails closed to disabled. - * - * NOTE: this parser is deliberately permissive about SHAPE but the approval - * decision is fail-closed about MEANING. `enabled` with an empty severity set - * (the legacy boolean `true`, an AI-tool-written `{ enabled: true }`, etc.) - * auto-approves NOTHING — evaluatePatchApproval requires a non-empty severity - * set before it will return 'ring_auto_approve'. This matches the write-side - * Zod refinement (ringAutoApproveSchema) so the read path cannot become more - * permissive than the writer, regardless of who wrote the row. + * historical shape, but FAIL-CLOSED about meaning: + * - boolean `true` → enabled, no severities, no third-party → approves nothing + * - missing/`{}`/malformed → disabled + * - unrecognized severity strings are dropped (never matched anyway; dropping + * them keeps the thirdPartyApps compatibility rule honest) + * - a PRESENT but invalid `deferralDays` disables the row entirely — the old + * coerce-to-0 turned a malformed hold into "no hold", which is fail-open + * - `thirdPartyApps` absent (pre-2026-08 rows and job snapshots frozen before + * the backfill): derived as `severities.length > 0`, which reproduces the + * old #2218 severity-exemption behavior for rows the write schema accepted, + * while keeping malformed `{enabled:true}` rows inert. Present non-boolean + * (AI-tool or hand-written rows) → false. */ -function parseRingAutoApprove(autoApprove: unknown): RingAutoApproveConfig { - // Boolean `true` shorthand: enabled but no explicit severities. Because the - // read boundary fails closed on an empty severity set, this approves nothing. +export function parseRingAutoApprove(autoApprove: unknown): RingAutoApproveConfig { if (autoApprove === true) { - return { enabled: true, severities: [], deferralDays: 0 }; + return { ...DISABLED_RING_AUTO_APPROVE, enabled: true }; } if (!autoApprove || typeof autoApprove !== 'object') { - return { enabled: false, severities: [], deferralDays: 0 }; + return DISABLED_RING_AUTO_APPROVE; } const config = autoApprove as Record; + if (config.enabled !== true) { + return DISABLED_RING_AUTO_APPROVE; + } - if (config.enabled === true) { - const severities = Array.isArray(config.severities) - ? config.severities.filter((s): s is string => typeof s === 'string') - : []; - const rawDeferral = config.deferralDays; - const deferralDays = - typeof rawDeferral === 'number' && Number.isInteger(rawDeferral) && rawDeferral > 0 - ? rawDeferral - : 0; - return { enabled: true, severities, deferralDays }; + const severities = Array.isArray(config.severities) + ? config.severities.filter( + (s): s is string => typeof s === 'string' && RECOGNIZED_RING_SEVERITIES.has(s) + ) + : []; + + let deferralDays = 0; + if (config.deferralDays !== undefined) { + const raw = config.deferralDays; + if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 0) { + return DISABLED_RING_AUTO_APPROVE; + } + deferralDays = raw; } - return { enabled: false, severities: [], deferralDays: 0 }; + const thirdPartyApps = + 'thirdPartyApps' in config ? config.thirdPartyApps === true : severities.length > 0; + + const rawTp = config.thirdPartyDeferralDays; + const thirdPartyDeferralDays = + typeof rawTp === 'number' && Number.isInteger(rawTp) && rawTp >= 0 && rawTp <= 365 + ? rawTp + : null; + + return { enabled: true, severities, deferralDays, thirdPartyApps, thirdPartyDeferralDays }; } diff --git a/apps/web/src/components/configurationPolicies/featureTabs/PatchTab.tsx b/apps/web/src/components/configurationPolicies/featureTabs/PatchTab.tsx index e735a3596a..e1fef0c45a 100644 --- a/apps/web/src/components/configurationPolicies/featureTabs/PatchTab.tsx +++ b/apps/web/src/components/configurationPolicies/featureTabs/PatchTab.tsx @@ -560,6 +560,9 @@ export default function PatchTab({ /> +

+ {i18n.t("policies:configurationPolicies.featureTabs.patchTab.thirdPartyRingHint")} +

update('apps', apps)} /> diff --git a/apps/web/src/components/patches/UpdateRingForm.test.tsx b/apps/web/src/components/patches/UpdateRingForm.test.tsx index 1e9dabf153..d7c7ae7e6c 100644 --- a/apps/web/src/components/patches/UpdateRingForm.test.tsx +++ b/apps/web/src/components/patches/UpdateRingForm.test.tsx @@ -46,6 +46,8 @@ describe('UpdateRingForm — ring auto-approve (#1317)', () => { enabled: true, severities: ['critical', 'important'], deferralDays: 7, + thirdPartyApps: false, + thirdPartyDeferralDays: 0, }); }); @@ -59,7 +61,9 @@ describe('UpdateRingForm — ring auto-approve (#1317)', () => { fireEvent.click(screen.getByTestId('ring-auto-approve-enabled')); fireEvent.click(screen.getByRole('button', { name: /save ring/i })); - await screen.findByText('Select at least one severity for auto-approval.'); + await screen.findByText( + 'Select at least one severity or enable third-party app auto-approval.' + ); expect(onSubmit).not.toHaveBeenCalled(); }); @@ -169,3 +173,133 @@ describe('UpdateRingForm — ring auto-approve (#1317)', () => { expect((onSubmit.mock.calls[0][0] as UpdateRingFormValues).deadlineDays).toBeNull(); }); }); + +// Third-party app updates are no longer a patch *category* rule — they are a +// ring-level gate with its own hold, because vendors publish no severity for +// them (winget/Chocolatey/Homebrew). +describe('UpdateRingForm — third-party app auto-approve', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the third-party toggle inside the enabled auto-approve section', () => { + render( + + ); + + expect(screen.getByTestId('ring-third-party-section')).toBeInTheDocument(); + expect(screen.getByTestId('ring-third-party-enabled')).not.toBeChecked(); + // The hold + policy note only appear once the gate is on. + expect(screen.queryByTestId('ring-third-party-deferral')).not.toBeInTheDocument(); + expect(screen.queryByTestId('ring-third-party-policy-note')).not.toBeInTheDocument(); + }); + + it('hides the third-party subsection while the default rule is manual', () => { + render(); + + expect(screen.queryByTestId('ring-third-party-section')).not.toBeInTheDocument(); + }); + + it('shows the policy-consent note when third-party is on', () => { + render(); + + fireEvent.click(screen.getByTestId('ring-auto-approve-enabled')); + fireEvent.click(screen.getByTestId('ring-third-party-enabled')); + + expect(screen.getByTestId('ring-third-party-policy-note')).toBeInTheDocument(); + expect(screen.getByTestId('ring-third-party-deferral')).toBeInTheDocument(); + }); + + it('submits a third-party-only ring without a severity validation error', async () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText('e.g. Pilot, Broad'), { target: { value: 'Apps' } }); + fireEvent.click(screen.getByTestId('ring-auto-approve-enabled')); + fireEvent.click(screen.getByTestId('ring-third-party-enabled')); + fireEvent.change(screen.getByTestId('ring-third-party-deferral'), { target: { value: '3' } }); + fireEvent.click(screen.getByRole('button', { name: /save ring/i })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + const values = onSubmit.mock.calls[0][0] as UpdateRingFormValues; + expect(values.autoApprove.thirdPartyApps).toBe(true); + expect(values.autoApprove.severities).toEqual([]); + expect(values.autoApprove.thirdPartyDeferralDays).toBe(3); + }); + + it('still blocks enabled + no severities + third-party off', async () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText('e.g. Pilot, Broad'), { target: { value: 'Pilot' } }); + fireEvent.click(screen.getByTestId('ring-auto-approve-enabled')); + expect(screen.getByTestId('ring-third-party-enabled')).not.toBeChecked(); + fireEvent.click(screen.getByRole('button', { name: /save ring/i })); + + await screen.findByText( + 'Select at least one severity or enable third-party app auto-approval.' + ); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('no longer offers third_party_app as a category override option', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /add override/i })); + + const select = screen.getByRole('combobox', { name: 'Category' }) as HTMLSelectElement; + const values = Array.from(select.options).map((o) => o.value); + expect(values).not.toContain('third_party_app'); + expect(values).toContain('security'); + }); + + // The API sends `thirdPartyDeferralDays: null` for "inherit the ring hold"; + // the form always shows (and submits) a concrete number. + it('resolves a null third-party hold to the inherited ring hold', () => { + render( + + ); + + expect(screen.getByTestId('ring-third-party-enabled')).toBeChecked(); + expect(screen.getByTestId('ring-third-party-deferral')).toHaveValue(6); + }); + + // Rings saved before the gate existed have no third-party fields at all. + it('defaults a legacy ring without third-party fields to off', () => { + render( + + ); + + expect(screen.getByTestId('ring-third-party-enabled')).not.toBeChecked(); + }); +}); diff --git a/apps/web/src/components/patches/UpdateRingForm.tsx b/apps/web/src/components/patches/UpdateRingForm.tsx index eea45fa582..4fbfacf690 100644 --- a/apps/web/src/components/patches/UpdateRingForm.tsx +++ b/apps/web/src/components/patches/UpdateRingForm.tsx @@ -19,12 +19,16 @@ function makeRingSchema(t: TFunction<'patches'>) { enabled: z.boolean(), severities: z.array(z.enum(['critical', 'important', 'moderate', 'low'])), deferralDays: z.coerce.number().int().min(0).max(365), + thirdPartyApps: z.boolean(), + // Always a concrete number in the form (pre-filled from the ring hold); + // null "inherit" is an API-writer concept, mirroring deferralDaysOverride. + thirdPartyDeferralDays: z.coerce.number().int().min(0).max(365), }).superRefine((data, ctx) => { - if (data.enabled && data.severities.length === 0) { + if (data.enabled && data.severities.length === 0 && !data.thirdPartyApps) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['severities'], - message: t('updateRingForm.validation.selectSeverity'), + message: t('updateRingForm.validation.selectSeverityOrThirdParty'), }); } }); @@ -48,10 +52,19 @@ function makeRingSchema(t: TFunction<'patches'>) { type RingSchema = ReturnType; export type UpdateRingFormValues = z.infer; +/** Defaults arrive from the API, which may omit the third-party fields (older + * rings) or send `thirdPartyDeferralDays: null` meaning "inherit the ring + * hold". Both are normalized to concrete form values in `initialValues`. */ +export type UpdateRingFormDefaults = Partial> & { + autoApprove?: Partial> & { + thirdPartyDeferralDays?: number | null; + }; +}; + type UpdateRingFormProps = { onSubmit?: (values: UpdateRingFormValues) => void | Promise; onCancel?: () => void; - defaultValues?: Partial; + defaultValues?: UpdateRingFormDefaults; submitLabel?: string; loading?: boolean; /** When editing, surfaces the blast radius of a change. */ @@ -64,12 +77,13 @@ type Severity = 'critical' | 'important' | 'moderate' | 'low'; // classifyWindowsUpdateCategory) so the approval evaluator's category rules // actually match. Note 'definitions' is plural to match the agent; the // evaluator also canonicalizes legacy singular 'definition' rules. +// 'third_party_app' is deliberately absent: third-party app updates are governed +// by the ring-level toggle below (the API rejects a category rule for them). const categoryOptions = [ { value: 'security', labelKey: 'updateRingForm.categories.security' }, { value: 'feature', labelKey: 'updateRingForm.categories.feature' }, { value: 'firmware', labelKey: 'updateRingForm.categories.firmware' }, { value: 'driver', labelKey: 'updateRingForm.categories.driver' }, - { value: 'third_party_app', labelKey: 'updateRingForm.categories.thirdPartyApp' }, { value: 'definitions', labelKey: 'updateRingForm.categories.definitions' }, ]; @@ -209,13 +223,26 @@ export default function UpdateRingForm({ deferralDays: 0, deadlineDays: null, gracePeriodHours: 4, - autoApprove: { enabled: false, severities: [], deferralDays: 0 }, + autoApprove: { enabled: false, severities: [], deferralDays: 0, thirdPartyApps: false, thirdPartyDeferralDays: 0 }, categoryRules: [], ...defaultValues, - } satisfies Partial; + } satisfies UpdateRingFormDefaults; const inheritedHold = merged.autoApprove?.deferralDays ?? merged.deferralDays ?? 0; + // A ring saved before the third-party gate existed has no `thirdPartyApps`, + // and a null third-party hold means "inherit" — resolve both to concrete + // values so the controls are never blank/uncontrolled. + const aa = merged.autoApprove ?? {}; + const mergedAutoApprove = { + enabled: false, + severities: [], + deferralDays: inheritedHold, + thirdPartyApps: false, + ...aa, + thirdPartyDeferralDays: aa.thirdPartyDeferralDays ?? inheritedHold, + }; return { ...merged, + autoApprove: mergedAutoApprove, categoryRules: (merged.categoryRules ?? []).map((r) => ({ ...r, autoApproveSeverities: r.autoApproveSeverities ?? [], @@ -402,6 +429,35 @@ export default function UpdateRingForm({

+ +
+
+
+ {t('updateRingForm.thirdParty.title')} +

+ {t('updateRingForm.thirdParty.description')} +

+
+ +
+ {autoApprove?.thirdPartyApps && ( +
+

+ {t('updateRingForm.thirdParty.policyNote')} +

+ +
+ )} +
) : (

diff --git a/apps/web/src/components/patches/UpdateRingList.test.tsx b/apps/web/src/components/patches/UpdateRingList.test.tsx new file mode 100644 index 0000000000..91f403e93c --- /dev/null +++ b/apps/web/src/components/patches/UpdateRingList.test.tsx @@ -0,0 +1,85 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import '@/lib/i18n'; + +import UpdateRingList, { type UpdateRingItem } from './UpdateRingList'; + +function makeRing(overrides: Partial = {}): UpdateRingItem { + return { + id: '11111111-1111-1111-1111-111111111111', + name: 'Ring A', + enabled: true, + ringOrder: 1, + deferralDays: 0, + deadlineDays: null, + gracePeriodHours: 4, + ...overrides, + }; +} + +describe('UpdateRingList auto-approve column', () => { + it('summarizes auto-approve as badges: OS severities, third-party, or Manual', () => { + const ringA = makeRing({ + id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + name: 'Ring A', + autoApprove: { + enabled: true, + severities: ['critical', 'important'], + deferralDays: 0, + thirdPartyApps: true, + thirdPartyDeferralDays: null, + }, + }); + const ringB = makeRing({ + id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + name: 'Ring B', + ringOrder: 2, + autoApprove: { + enabled: false, + severities: ['critical'], + deferralDays: 0, + thirdPartyApps: true, + thirdPartyDeferralDays: null, + }, + }); + const ringC = makeRing({ + id: 'cccccccc-cccc-cccc-cccc-cccccccccccc', + name: 'Ring C', + ringOrder: 3, + autoApprove: { + enabled: true, + severities: [], + deferralDays: 0, + thirdPartyApps: true, + thirdPartyDeferralDays: 3, + }, + }); + + render(); + + // Ring A: OS severities badge + third-party badge. + const osBadge = screen.getByTestId(`ring-badge-os-${ringA.id}`); + expect(osBadge.textContent).toContain('Critical'); + expect(osBadge.textContent).toContain('Important'); + expect(screen.getByTestId(`ring-badge-third-party-${ringA.id}`)).toBeTruthy(); + + // Ring B: auto-approve disabled → no badges, Manual label instead. + expect(screen.queryByTestId(`ring-badge-os-${ringB.id}`)).toBeNull(); + expect(screen.queryByTestId(`ring-badge-third-party-${ringB.id}`)).toBeNull(); + const rowB = screen.getByText('Ring B').closest('tr'); + expect(rowB).not.toBeNull(); + expect(within(rowB as HTMLElement).getByText('Manual')).toBeTruthy(); + + // Ring C: third-party only — an empty severity list means no OS badge. + expect(screen.queryByTestId(`ring-badge-os-${ringC.id}`)).toBeNull(); + expect(screen.getByTestId(`ring-badge-third-party-${ringC.id}`)).toBeTruthy(); + }); + + it('renders the auto-approve header column', () => { + render(); + + expect(screen.getByRole('columnheader', { name: 'Auto-approve' })).toBeTruthy(); + // Empty state must span every column, including the new one. + expect(screen.getByText('No update rings found.').getAttribute('colspan')).toBe('9'); + }); +}); diff --git a/apps/web/src/components/patches/UpdateRingList.tsx b/apps/web/src/components/patches/UpdateRingList.tsx index 09aa320997..36dc185f97 100644 --- a/apps/web/src/components/patches/UpdateRingList.tsx +++ b/apps/web/src/components/patches/UpdateRingList.tsx @@ -26,6 +26,11 @@ export type RingAutoApprove = { enabled: boolean; severities: Array<'critical' | 'important' | 'moderate' | 'low'>; deferralDays: number; + /** Third-party app updates auto-approve independently of severity. Absent on + * rings saved before the gate existed. */ + thirdPartyApps?: boolean; + /** null = inherit the ring's hold. */ + thirdPartyDeferralDays?: number | null; }; export type UpdateRingItem = { @@ -81,6 +86,39 @@ function ComplianceBadge({ percent, t }: { percent?: number; t: TFunction<'patch ); } +// Summarizes the ring's auto-approval gate: OS severities and third-party apps +// are independent switches, so a ring can auto-approve one, both, or neither. +function AutoApproveBadges({ ring, t }: { ring: UpdateRingItem; t: TFunction<'patches'> }) { + const aa = ring.autoApprove; + const osOn = !!aa?.enabled && aa.severities.length > 0; + const tpOn = !!aa?.enabled && !!aa.thirdPartyApps; + if (!osOn && !tpOn) { + return {t('updateRingList.badges.manual')}; + } + return ( +

+ {osOn && ( + + {t('updateRingList.badges.os', { + severities: aa!.severities.map((s) => t(/* i18n-dynamic */ `updateRingForm.severities.${s}`)).join(', '), + })} + + )} + {tpOn && ( + + {t('updateRingList.badges.thirdParty')} + + )} +
+ ); +} + export default function UpdateRingList({ rings, onEdit, @@ -142,6 +180,7 @@ export default function UpdateRingList({ {t('updateRingList.table.ring')} {t('updateRingList.table.deferral')} {t('updateRingList.table.deadline')} + {t('updateRingList.table.autoApprove')} {t('updateRingList.table.devices')} {t('updateRingList.table.compliance')} {t('updateRingList.table.updated')} @@ -152,7 +191,7 @@ export default function UpdateRingList({ {paginatedRings.length === 0 ? ( {t('updateRingList.empty')} @@ -192,6 +231,9 @@ export default function UpdateRingList({ ? t('updateRingList.none') : t('updateRingList.days', { count: ring.deadlineDays })} + + + {ring.deviceCount ?? t('updateRingList.emptyValue')} diff --git a/apps/web/src/components/patches/patchHelpers.test.ts b/apps/web/src/components/patches/patchHelpers.test.ts index da4cb3f710..181c65d6b9 100644 --- a/apps/web/src/components/patches/patchHelpers.test.ts +++ b/apps/web/src/components/patches/patchHelpers.test.ts @@ -38,36 +38,168 @@ describe('normalizePatch — os resolution (#2215)', () => { describe('normalizeRing — autoApprove normalization (#1317)', () => { it('defaults a missing autoApprove to disabled', () => { const ring = normalizeRing({ id: 'r1', name: 'Default' }); - expect(ring.autoApprove).toEqual({ enabled: false, severities: [], deferralDays: 0 }); + expect(ring.autoApprove).toEqual({ + enabled: false, + severities: [], + deferralDays: 0, + thirdPartyApps: false, + thirdPartyDeferralDays: null, + }); }); it('coerces a legacy {} autoApprove to disabled', () => { const ring = normalizeRing({ id: 'r1', name: 'Default', autoApprove: {} }); - expect(ring.autoApprove).toEqual({ enabled: false, severities: [], deferralDays: 0 }); + expect(ring.autoApprove).toEqual({ + enabled: false, + severities: [], + deferralDays: 0, + thirdPartyApps: false, + thirdPartyDeferralDays: null, + }); }); it('coerces a legacy boolean true to enabled with no severity filter', () => { const ring = normalizeRing({ id: 'r1', name: 'Default', autoApprove: true }); - expect(ring.autoApprove).toEqual({ enabled: true, severities: [], deferralDays: 0 }); + expect(ring.autoApprove).toEqual({ + enabled: true, + severities: [], + deferralDays: 0, + thirdPartyApps: false, + thirdPartyDeferralDays: null, + }); }); it('passes through a typed autoApprove gate and drops unknown severities', () => { const ring = normalizeRing({ id: 'r1', name: 'Broad', - autoApprove: { enabled: true, severities: ['critical', 'bogus', 'low'], deferralDays: 5 }, + autoApprove: { + enabled: true, + severities: ['critical', 'bogus', 'low'], + deferralDays: 5, + thirdPartyApps: false, + thirdPartyDeferralDays: null, + }, + }); + expect(ring.autoApprove).toEqual({ + enabled: true, + severities: ['critical', 'low'], + deferralDays: 5, + thirdPartyApps: false, + thirdPartyDeferralDays: null, }); - expect(ring.autoApprove).toEqual({ enabled: true, severities: ['critical', 'low'], deferralDays: 5 }); }); it('clamps a non-positive or non-integer deferralDays to 0', () => { expect( - normalizeRing({ id: 'r1', name: 'x', autoApprove: { enabled: true, severities: ['low'], deferralDays: -3 } }) + normalizeRing({ id: 'r1', name: 'x', autoApprove: { enabled: true, severities: ['low'], deferralDays: -3, thirdPartyApps: false } }) .autoApprove - ).toEqual({ enabled: true, severities: ['low'], deferralDays: 0 }); + ).toMatchObject({ enabled: true, severities: ['low'], deferralDays: 0 }); expect( - normalizeRing({ id: 'r1', name: 'x', autoApprove: { enabled: true, severities: ['low'], deferralDays: 1.5 } }) + normalizeRing({ id: 'r1', name: 'x', autoApprove: { enabled: true, severities: ['low'], deferralDays: 1.5, thirdPartyApps: false } }) .autoApprove - ).toEqual({ enabled: true, severities: ['low'], deferralDays: 0 }); + ).toMatchObject({ enabled: true, severities: ['low'], deferralDays: 0 }); + }); + + // The editor round-trips this object straight back into the ring PATCH body, + // so a dropped field is a silently reverted policy (#spec 2026-08-04). + it('round-trips an explicit third-party gate in both states', () => { + expect( + normalizeRing({ + id: 'r1', + name: 'Apps', + autoApprove: { + enabled: true, + severities: [], + deferralDays: 0, + thirdPartyApps: true, + thirdPartyDeferralDays: 3, + }, + }).autoApprove + ).toEqual({ + enabled: true, + severities: [], + deferralDays: 0, + thirdPartyApps: true, + thirdPartyDeferralDays: 3, + }); + + expect( + normalizeRing({ + id: 'r1', + name: 'OS only', + autoApprove: { + enabled: true, + severities: ['critical'], + deferralDays: 2, + thirdPartyApps: false, + thirdPartyDeferralDays: 0, + }, + }).autoApprove + ).toEqual({ + enabled: true, + severities: ['critical'], + deferralDays: 2, + thirdPartyApps: false, + thirdPartyDeferralDays: 0, + }); + }); + + // Mirrors parseRingAutoApprove: a pre-gate ring auto-approved third-party + // updates whenever it auto-approved anything, so an absent key derives from + // the (filtered) severity list rather than defaulting to off. + it('derives thirdPartyApps from severities when the key is absent (legacy rows)', () => { + expect( + normalizeRing({ + id: 'r1', + name: 'Legacy on', + autoApprove: { enabled: true, severities: ['critical'], deferralDays: 0 }, + }).autoApprove + ).toMatchObject({ thirdPartyApps: true, thirdPartyDeferralDays: null }); + + expect( + normalizeRing({ + id: 'r1', + name: 'Legacy off', + autoApprove: { enabled: true, severities: [], deferralDays: 0 }, + }).autoApprove + ).toMatchObject({ thirdPartyApps: false }); + + // Only recognized severities count — an all-bogus list derives off. + expect( + normalizeRing({ + id: 'r1', + name: 'Legacy bogus', + autoApprove: { enabled: true, severities: ['bogus'], deferralDays: 0 }, + }).autoApprove + ).toMatchObject({ thirdPartyApps: false }); + }); + + it('coerces malformed third-party values', () => { + // A non-boolean thirdPartyApps is present-but-invalid → false (never derived). + expect( + normalizeRing({ + id: 'r1', + name: 'x', + autoApprove: { enabled: true, severities: ['critical'], deferralDays: 0, thirdPartyApps: 'yes' }, + }).autoApprove + ).toMatchObject({ thirdPartyApps: false }); + + // Out-of-range / non-integer / non-numeric holds fall back to inherit (null). + for (const bad of [-1, 366, 1.5, '3', null]) { + expect( + normalizeRing({ + id: 'r1', + name: 'x', + autoApprove: { + enabled: true, + severities: [], + deferralDays: 0, + thirdPartyApps: true, + thirdPartyDeferralDays: bad, + }, + }).autoApprove + ).toMatchObject({ thirdPartyApps: true, thirdPartyDeferralDays: null }); + } }); }); diff --git a/apps/web/src/components/patches/patchHelpers.ts b/apps/web/src/components/patches/patchHelpers.ts index 2121ba1891..72f539888d 100644 --- a/apps/web/src/components/patches/patchHelpers.ts +++ b/apps/web/src/components/patches/patchHelpers.ts @@ -98,12 +98,25 @@ type RingSeverity = (typeof RING_SEVERITIES)[number]; * Normalize a ring's stored `autoApprove` JSONB (#1317) into the typed form * the editor expects. Tolerant of every historical shape the API may have * stored: `{}` / missing → disabled; boolean `true` → enabled with no severity - * filter; the typed `{ enabled, severities, deferralDays }` object passes - * through. Mirrors the API-side `parseRingAutoApprove`. + * filter; the typed `{ enabled, severities, deferralDays, thirdPartyApps, + * thirdPartyDeferralDays }` object passes through. Mirrors the API-side + * `parseRingAutoApprove`, including its legacy-compat rule for the third-party + * gate: an absent `thirdPartyApps` key derives from whether any severity is + * selected (pre-gate rings auto-approved third-party updates whenever the ring + * auto-approved anything), and a `thirdPartyDeferralDays` outside 0-365 — + * or absent — means "inherit the ring hold" (null). + * + * Dropping a field here is a silent policy loss: the editor round-trips this + * object straight back into the PATCH body, so anything not carried is written + * back as its default. */ function normalizeRingAutoApprove(raw: unknown): UpdateRingItem['autoApprove'] { - if (raw === true) return { enabled: true, severities: [], deferralDays: 0 }; - if (!raw || typeof raw !== 'object') return { enabled: false, severities: [], deferralDays: 0 }; + if (raw === true) { + return { enabled: true, severities: [], deferralDays: 0, thirdPartyApps: false, thirdPartyDeferralDays: null }; + } + if (!raw || typeof raw !== 'object') { + return { enabled: false, severities: [], deferralDays: 0, thirdPartyApps: false, thirdPartyDeferralDays: null }; + } const obj = raw as Record; const severities = Array.isArray(obj.severities) ? obj.severities.filter((s): s is RingSeverity => RING_SEVERITIES.includes(s as RingSeverity)) @@ -112,7 +125,17 @@ function normalizeRingAutoApprove(raw: unknown): UpdateRingItem['autoApprove'] { typeof obj.deferralDays === 'number' && Number.isInteger(obj.deferralDays) && obj.deferralDays > 0 ? obj.deferralDays : 0; - return { enabled: obj.enabled === true, severities, deferralDays }; + const thirdPartyApps = + 'thirdPartyApps' in obj ? obj.thirdPartyApps === true : severities.length > 0; + const rawThirdPartyHold = obj.thirdPartyDeferralDays; + const thirdPartyDeferralDays = + typeof rawThirdPartyHold === 'number' + && Number.isInteger(rawThirdPartyHold) + && rawThirdPartyHold >= 0 + && rawThirdPartyHold <= 365 + ? rawThirdPartyHold + : null; + return { enabled: obj.enabled === true, severities, deferralDays, thirdPartyApps, thirdPartyDeferralDays }; } export function normalizeRing(raw: Record): UpdateRingItem { diff --git a/apps/web/src/lib/i18n/translationCoverage.test.ts b/apps/web/src/lib/i18n/translationCoverage.test.ts index 9b72ba76f5..20d5faa05b 100644 --- a/apps/web/src/lib/i18n/translationCoverage.test.ts +++ b/apps/web/src/lib/i18n/translationCoverage.test.ts @@ -34,7 +34,9 @@ const namespaceDuplicateBaselines = { 'devices.json': 159, 'discovery.json': 17, 'integrations.json': 23, - 'patches.json': 22, + // +1: updateRingList.badges.manual — "Manual" is spelled identically in + // pt-BR. + 'patches.json': 23, 'peripherals.json': 4, 'policies.json': 357, 'portal.json': 3, @@ -67,7 +69,9 @@ const namespaceDuplicateBaselines = { 'devices.json': 115, 'discovery.json': 17, 'integrations.json': 31, - 'patches.json': 15, + // +1: updateRingList.badges.manual — "Manual" is spelled identically in + // es-419. + 'patches.json': 16, 'peripherals.json': 4, 'policies.json': 241, 'portal.json': 4, @@ -168,7 +172,9 @@ const namespaceDuplicateBaselines = { 'devices.json': 146, 'discovery.json': 26, 'integrations.json': 43, - 'patches.json': 22, + // +1: updateRingList.badges.os — "OS: {{severities}}" is an acronym plus an + // interpolation; German uses the same "OS" acronym. + 'patches.json': 23, 'peripherals.json': 4, 'policies.json': 205, 'portal.json': 4, diff --git a/apps/web/src/locales/de-DE/patches.json b/apps/web/src/locales/de-DE/patches.json index 5223ca392e..2fb624cad0 100644 --- a/apps/web/src/locales/de-DE/patches.json +++ b/apps/web/src/locales/de-DE/patches.json @@ -372,6 +372,7 @@ "validation": { "selectCategory": "Kategorie auswählen", "selectSeverity": "Mindestens einen Schweregrad für die automatische Freigabe auswählen.", + "selectSeverityOrThirdParty": "Mindestens einen Schweregrad auswählen oder die automatische Genehmigung für Drittanbieter-Apps aktivieren.", "nameRequired": "Ringname ist erforderlich" }, "categories": { @@ -379,7 +380,6 @@ "feature": "Funktionsupdates", "firmware": "Firmware", "driver": "Treiber", - "thirdPartyApp": "Drittanbieter-Apps", "definitions": "Definitionsupdates" }, "severities": { @@ -421,11 +421,16 @@ "description": "Legt fest, welche Patches in diesem Ring automatisch freigegeben und wie lange sie nach der Veröffentlichung durch den Anbieter zurückgehalten werden. Der Standard gilt für jede Kategorie; mit einer Überschreibung kann eine Kategorie abweichend behandelt werden.", "allCategories": "Alle Kategorien", "default": "Standard", - "severityNote": "Schweregrade gelten für Betriebssystemupdates. Updates von Drittanbieter-Apps (winget, Homebrew) haben keinen vom Anbieter festgelegten Schweregrad – wenn eine Richtlinie andere Softwarequellen aktiviert, werden sie unabhängig von den hier ausgewählten Schweregraden nach dem Zeitplan dieses Rings automatisch freigegeben.", + "severityNote": "Schweregrade gelten nur für Betriebssystem-Updates. Drittanbieter-App-Updates werden über den Schalter unten gesteuert — sie sind nicht schweregradbasiert.", "manualDefault": "Jeder Patch in diesem Ring muss manuell freigegeben werden.", "manualCategory": "Patches in dieser Kategorie müssen manuell freigegeben werden.", "noOverrides": "Alle Kategorien verwenden den Standard. Mit einer Überschreibung kann eine Kategorie abweichend behandelt werden." }, + "thirdParty": { + "title": "Drittanbieter-Anwendungen", + "description": "App-Updates aus winget, Chocolatey und Homebrew automatisch genehmigen. App-Sperr-/Pin-Regeln gelten weiterhin; die Wartezeit zählt ab der ersten Meldung des Updates durch das Gerät.", + "policyNote": "Gilt nur für Geräte, deren Konfigurationsrichtlinie Software-Updates von Drittanbietern einschließt (Patch-Einstellungen der Richtlinie)." + }, "usage": { "messageOne": "Dieser Ring gilt für {{count}} Gerät. Änderungen werden bei dessen nächster Rückmeldung wirksam.", "messageMany": "Dieser Ring gilt für {{count}} Geräte. Änderungen werden bei deren nächster Rückmeldung wirksam." @@ -457,7 +462,13 @@ "devices": "Geräte", "compliance": "Compliance", "updated": "Aktualisiert", - "actions": "Aktionen" + "actions": "Aktionen", + "autoApprove": "Automatisch genehmigen" + }, + "badges": { + "manual": "Manuell", + "os": "OS: {{severities}}", + "thirdParty": "Drittanbieter-Apps" } } } diff --git a/apps/web/src/locales/de-DE/policies.json b/apps/web/src/locales/de-DE/policies.json index d3ad1b66b3..d229f962dc 100644 --- a/apps/web/src/locales/de-DE/policies.json +++ b/apps/web/src/locales/de-DE/policies.json @@ -910,7 +910,8 @@ "createUpdateRing": "Update-Ring erstellen", "text2xl": "2xl", "saveChanges": "Änderungen speichern", - "createRing": "Ring erstellen" + "createRing": "Ring erstellen", + "thirdPartyRingHint": "Regeln zur automatischen Genehmigung von Drittanbieter-Updates werden im verknüpften Update-Ring konfiguriert." }, "peripheralControlTab": { "linkPeripheralPolicy": "Link-Peripherierichtlinie", diff --git a/apps/web/src/locales/en/patches.json b/apps/web/src/locales/en/patches.json index 0a1568cdfd..87e5b9c500 100644 --- a/apps/web/src/locales/en/patches.json +++ b/apps/web/src/locales/en/patches.json @@ -372,6 +372,7 @@ "validation": { "selectCategory": "Select a category", "selectSeverity": "Select at least one severity for auto-approval.", + "selectSeverityOrThirdParty": "Select at least one severity or enable third-party app auto-approval.", "nameRequired": "Ring name is required" }, "categories": { @@ -379,7 +380,6 @@ "feature": "Feature Updates", "firmware": "Firmware", "driver": "Drivers", - "thirdPartyApp": "Third-Party Apps", "definitions": "Definition Updates" }, "severities": { @@ -421,11 +421,16 @@ "description": "What auto-approves in this ring, and how long to hold a patch after its vendor release. The default applies to every category; add an override to treat one differently.", "allCategories": "All categories", "default": "Default", - "severityNote": "Severities apply to OS updates. Third-party app updates (winget, Homebrew) have no vendor severity — when a policy enables other-software sources, they auto-approve on this ring's cadence regardless of the severities selected here.", + "severityNote": "Severities apply to OS updates only. Third-party app updates are controlled by the toggle below — they are not severity-controlled.", "manualDefault": "Every patch in this ring needs manual approval.", "manualCategory": "Patches in this category need manual approval.", "noOverrides": "All categories follow the default. Add an override to treat one differently." }, + "thirdParty": { + "title": "Third-party applications", + "description": "Auto-approve app updates from winget, Chocolatey, and Homebrew. App block/pin rules still apply; the hold is measured from when each device first reports the update.", + "policyNote": "Applies only to devices whose configuration policy includes third-party software updates (policy Patch settings)." + }, "usage": { "messageOne": "This ring applies to {{count}} device. Changes take effect on its next check-in.", "messageMany": "This ring applies to {{count}} devices. Changes take effect on their next check-in." @@ -457,7 +462,13 @@ "devices": "Devices", "compliance": "Compliance", "updated": "Updated", - "actions": "Actions" + "actions": "Actions", + "autoApprove": "Auto-approve" + }, + "badges": { + "manual": "Manual", + "os": "OS: {{severities}}", + "thirdParty": "3rd-party apps" } } } diff --git a/apps/web/src/locales/en/policies.json b/apps/web/src/locales/en/policies.json index 614b2e35ce..d140448c4c 100644 --- a/apps/web/src/locales/en/policies.json +++ b/apps/web/src/locales/en/policies.json @@ -910,7 +910,8 @@ "createUpdateRing": "Create update ring", "text2xl": "2xl", "saveChanges": "Save Changes", - "createRing": "Create Ring" + "createRing": "Create Ring", + "thirdPartyRingHint": "Auto-approval rules for third-party updates are configured on the linked Update Ring." }, "peripheralControlTab": { "linkPeripheralPolicy": "Link Peripheral Policy", diff --git a/apps/web/src/locales/es-419/patches.json b/apps/web/src/locales/es-419/patches.json index 9da80576c0..36f0a32ba7 100644 --- a/apps/web/src/locales/es-419/patches.json +++ b/apps/web/src/locales/es-419/patches.json @@ -372,6 +372,7 @@ "validation": { "selectCategory": "Seleccione una categoría", "selectSeverity": "Seleccione al menos una gravedad para la aprobación automática.", + "selectSeverityOrThirdParty": "Selecciona al menos una severidad o habilita la aprobación automática de apps de terceros.", "nameRequired": "El nombre del anillo es obligatorio." }, "categories": { @@ -379,7 +380,6 @@ "feature": "Actualizaciones de funciones", "firmware": "firmware", "driver": "Controladores", - "thirdPartyApp": "Aplicaciones de terceros", "definitions": "Actualizaciones de definiciones" }, "severities": { @@ -421,11 +421,16 @@ "description": "Qué se aprueba automáticamente en este anillo y durante cuánto tiempo se debe retener un parche después del lanzamiento del proveedor. El valor predeterminado se aplica a todas las categorías; agregue una anulación para tratar uno de manera diferente.", "allCategories": "Todas las categorias", "default": "Por defecto", - "severityNote": "Se aplican gravedades a las actualizaciones de OS. Las actualizaciones de aplicaciones de terceros (winget, Homebrew) no tienen gravedad de proveedor: cuando una política habilita otras fuentes de software, se aprueban automáticamente según la cadencia de este anillo, independientemente de las gravedades seleccionadas aquí.", + "severityNote": "Las severidades aplican solo a las actualizaciones del sistema operativo. Las actualizaciones de apps de terceros se controlan con el interruptor de abajo — no se controlan por severidad.", "manualDefault": "Cada parche de este anillo necesita aprobación manual.", "manualCategory": "Los parches de esta categoría necesitan aprobación manual.", "noOverrides": "Todas las categorías siguen el valor predeterminado. Agregue una anulación para tratar uno de manera diferente." }, + "thirdParty": { + "title": "Aplicaciones de terceros", + "description": "Aprueba automáticamente actualizaciones de apps de winget, Chocolatey y Homebrew. Las reglas de bloqueo/fijado de apps siguen aplicando; la espera se mide desde que cada dispositivo reporta la actualización por primera vez.", + "policyNote": "Aplica solo a dispositivos cuya política de configuración incluye actualizaciones de software de terceros (ajustes de parches de la política)." + }, "usage": { "messageOne": "Este anillo se aplica al dispositivo {{count}}. Los cambios entran en vigor en su próximo check-in.", "messageMany": "Este anillo se aplica a los dispositivos {{count}}. Los cambios entran en vigor en su próximo check-in." @@ -457,7 +462,13 @@ "devices": "Dispositivos", "compliance": "Cumplimiento", "updated": "Actualizado", - "actions": "Acciones" + "actions": "Acciones", + "autoApprove": "Aprobación automática" + }, + "badges": { + "manual": "Manual", + "os": "SO: {{severities}}", + "thirdParty": "Apps de terceros" } } } diff --git a/apps/web/src/locales/es-419/policies.json b/apps/web/src/locales/es-419/policies.json index b9c0dc3a00..4a1b2cbbb3 100644 --- a/apps/web/src/locales/es-419/policies.json +++ b/apps/web/src/locales/es-419/policies.json @@ -910,7 +910,8 @@ "createUpdateRing": "Crear anillo de actualización", "text2xl": "2xl", "saveChanges": "Guardar cambios", - "createRing": "Crear anillo" + "createRing": "Crear anillo", + "thirdPartyRingHint": "Las reglas de aprobación automática para actualizaciones de terceros se configuran en el anillo de actualización vinculado." }, "peripheralControlTab": { "linkPeripheralPolicy": "Política de enlace periférico", diff --git a/apps/web/src/locales/fr-CA/patches.json b/apps/web/src/locales/fr-CA/patches.json index 39b3651cdc..6035ac6071 100644 --- a/apps/web/src/locales/fr-CA/patches.json +++ b/apps/web/src/locales/fr-CA/patches.json @@ -372,6 +372,7 @@ "validation": { "selectCategory": "Sélectionnez une catégorie", "selectSeverity": "Sélectionnez au moins une sévérité pour l’approbation automatique.", + "selectSeverityOrThirdParty": "Sélectionnez au moins une sévérité ou activez l'approbation automatique des applications tierces.", "nameRequired": "Un nom d’anneau est requis" }, "categories": { @@ -379,7 +380,6 @@ "feature": "Mises à jour des fonctionnalités", "firmware": "Micrologiciel", "driver": "Pilotes", - "thirdPartyApp": "Applications tierces", "definitions": "Mises à jour des définitions" }, "severities": { @@ -421,11 +421,16 @@ "description": "Ce qui est approuvé automatiquement dans cet anneau, et combien de temps il faut conserver un patch après sa sortie chez le fabricant. Le principe par défaut s’applique à toutes les catégories ; Ajoutez une dérogation pour traiter un autre type différemment.", "allCategories": "Toutes les catégories", "default": "Par défaut", - "severityNote": "Les sévérités s’appliquent aux mises à jour du système d’exploitation. Les mises à jour d’applications tierces (winget, Homebrew) n’ont aucune sévérité du fournisseur — lorsqu’une politique permet à d’autres sources logicielles, elles sont approuvées automatiquement selon le rythme de cet anneau, quel que soit le niveau de sévérité sélectionné ici.", + "severityNote": "Les sévérités ne s'appliquent qu'aux mises à jour du système d'exploitation. Les mises à jour d'applications tierces sont contrôlées par l'interrupteur ci-dessous — elles ne sont pas contrôlées par sévérité.", "manualDefault": "Chaque correctif de cet anneau nécessite une approbation manuelle.", "manualCategory": "Les correctifs de cette catégorie nécessitent une approbation manuelle.", "noOverrides": "Toutes les catégories suivent la norme par défaut. Ajoutez une dérogation pour traiter un autre type différemment." }, + "thirdParty": { + "title": "Applications tierces", + "description": "Approuver automatiquement les mises à jour d'applications winget, Chocolatey et Homebrew. Les règles de blocage/épinglage s'appliquent toujours ; le délai est mesuré à partir du premier signalement de la mise à jour par chaque appareil.", + "policyNote": "S'applique uniquement aux appareils dont la politique de configuration inclut les mises à jour logicielles tierces (paramètres de correctifs de la politique)." + }, "usage": { "messageOne": "Cet anneau s’applique à {{count}} appareil. Les modifications prendront effet lors de son prochain check-in.", "messageMany": "Cet anneau s’applique à {{count}} appareils. Les modifications prendront effet lors de leur prochain check-in." @@ -457,7 +462,13 @@ "devices": "Dispositifs", "compliance": "Conformité", "updated": "Mise à jour", - "actions": "Actions" + "actions": "Actions", + "autoApprove": "Approbation auto" + }, + "badges": { + "manual": "Manuelle", + "os": "SE : {{severities}}", + "thirdParty": "Applications tierces" } } } diff --git a/apps/web/src/locales/fr-CA/policies.json b/apps/web/src/locales/fr-CA/policies.json index bfb37ece8c..a750f1eb01 100644 --- a/apps/web/src/locales/fr-CA/policies.json +++ b/apps/web/src/locales/fr-CA/policies.json @@ -910,7 +910,8 @@ "createUpdateRing": "Créer un anneau de mise à jour", "text2xl": "2XL", "saveChanges": "Enregistrer les modifications", - "createRing": "Créer un anneau" + "createRing": "Créer un anneau", + "thirdPartyRingHint": "Les règles d'approbation automatique des mises à jour tierces se configurent dans l'anneau de mise à jour lié." }, "peripheralControlTab": { "linkPeripheralPolicy": "Politique de périphériques de lien", diff --git a/apps/web/src/locales/fr-FR/patches.json b/apps/web/src/locales/fr-FR/patches.json index ad67d08441..2456af2e6e 100644 --- a/apps/web/src/locales/fr-FR/patches.json +++ b/apps/web/src/locales/fr-FR/patches.json @@ -372,6 +372,7 @@ "validation": { "selectCategory": "Sélectionnez une catégorie", "selectSeverity": "Sélectionnez au moins une sévérité pour l’approbation automatique.", + "selectSeverityOrThirdParty": "Sélectionnez au moins une sévérité ou activez l'approbation automatique des applications tierces.", "nameRequired": "Un nom d’anneau est requis" }, "categories": { @@ -379,7 +380,6 @@ "feature": "Mises à jour des fonctionnalités", "firmware": "Micrologiciel", "driver": "Pilotes", - "thirdPartyApp": "Applications tierces", "definitions": "Mises à jour des définitions" }, "severities": { @@ -421,11 +421,16 @@ "description": "Ce qui est approuvé automatiquement dans cet anneau, et combien de temps il faut conserver un patch après sa sortie chez le fabricant. Le principe par défaut s’applique à toutes les catégories ; Ajoutez une dérogation pour traiter un autre type différemment.", "allCategories": "Toutes les catégories", "default": "Par défaut", - "severityNote": "Les sévérités s’appliquent aux mises à jour du système d’exploitation. Les mises à jour d’applications tierces (winget, Homebrew) n’ont aucune sévérité du fournisseur — lorsqu’une politique permet à d’autres sources logicielles, elles sont approuvées automatiquement selon le rythme de cet anneau, quel que soit le niveau de sévérité sélectionné ici.", + "severityNote": "Les sévérités ne s'appliquent qu'aux mises à jour du système d'exploitation. Les mises à jour d'applications tierces sont contrôlées par l'interrupteur ci-dessous — elles ne sont pas contrôlées par sévérité.", "manualDefault": "Chaque correctif de cet anneau nécessite une approbation manuelle.", "manualCategory": "Les correctifs de cette catégorie nécessitent une approbation manuelle.", "noOverrides": "Toutes les catégories suivent la norme par défaut. Ajoutez une dérogation pour traiter un autre type différemment." }, + "thirdParty": { + "title": "Applications tierces", + "description": "Approuver automatiquement les mises à jour d'applications winget, Chocolatey et Homebrew. Les règles de blocage/épinglage s'appliquent toujours ; le délai est mesuré à partir du premier signalement de la mise à jour par chaque appareil.", + "policyNote": "S'applique uniquement aux appareils dont la politique de configuration inclut les mises à jour logicielles tierces (paramètres de correctifs de la politique)." + }, "usage": { "messageOne": "Cet anneau s’applique à {{count}} appareil. Les modifications prendront effet lors de son prochain check-in.", "messageMany": "Cet anneau s’applique à {{count}} appareils. Les modifications prendront effet lors de leur prochain check-in." @@ -457,7 +462,13 @@ "devices": "Dispositifs", "compliance": "Conformité", "updated": "Mise à jour", - "actions": "Actions" + "actions": "Actions", + "autoApprove": "Approbation auto" + }, + "badges": { + "manual": "Manuelle", + "os": "SE : {{severities}}", + "thirdParty": "Applications tierces" } } } diff --git a/apps/web/src/locales/fr-FR/policies.json b/apps/web/src/locales/fr-FR/policies.json index c36af626ca..fe60c13711 100644 --- a/apps/web/src/locales/fr-FR/policies.json +++ b/apps/web/src/locales/fr-FR/policies.json @@ -910,7 +910,8 @@ "createUpdateRing": "Créer un anneau de mise à jour", "text2xl": "2XL", "saveChanges": "Enregistrer les modifications", - "createRing": "Créer un anneau" + "createRing": "Créer un anneau", + "thirdPartyRingHint": "Les règles d'approbation automatique des mises à jour tierces se configurent dans l'anneau de mise à jour lié." }, "peripheralControlTab": { "linkPeripheralPolicy": "Politique de périphériques de lien", diff --git a/apps/web/src/locales/it-IT/patches.json b/apps/web/src/locales/it-IT/patches.json index fcd7b677ea..db23420f13 100644 --- a/apps/web/src/locales/it-IT/patches.json +++ b/apps/web/src/locales/it-IT/patches.json @@ -372,6 +372,7 @@ "validation": { "selectCategory": "Seleziona una categoria", "selectSeverity": "Seleziona almeno una gravità per l'approvazione automatica.", + "selectSeverityOrThirdParty": "Seleziona almeno una gravità o abilita l'approvazione automatica delle app di terze parti.", "nameRequired": "Il nome dell'anello è obbligatorio" }, "categories": { @@ -379,7 +380,6 @@ "feature": "Aggiornamenti funzionalità", "firmware": "Firmware", "driver": "Driver", - "thirdPartyApp": "App di terze parti", "definitions": "Aggiornamenti definizioni" }, "severities": { @@ -421,11 +421,16 @@ "description": "Cosa viene approvato automaticamente in questo anello e per quanto tempo trattenere una patch dopo il rilascio del vendor. L'impostazione predefinita si applica a ogni categoria; aggiungi un override per gestirne una diversamente.", "allCategories": "Tutte le categorie", "default": "Predefinito", - "severityNote": "Le gravità si applicano agli aggiornamenti OS. Gli aggiornamenti delle app di terze parti (winget, Homebrew) non hanno una gravità del vendor — quando un criterio abilita fonti di altro software, vengono approvati automaticamente secondo la cadenza di questo anello, indipendentemente dalle gravità selezionate qui.", + "severityNote": "Le gravità si applicano solo agli aggiornamenti del sistema operativo. Gli aggiornamenti delle app di terze parti sono controllati dall'interruttore qui sotto — non sono controllati per gravità.", "manualDefault": "Ogni patch in questo anello richiede l'approvazione manuale.", "manualCategory": "Le patch in questa categoria richiedono l'approvazione manuale.", "noOverrides": "Tutte le categorie seguono l'impostazione predefinita. Aggiungi un override per gestirne una diversamente." }, + "thirdParty": { + "title": "Applicazioni di terze parti", + "description": "Approva automaticamente gli aggiornamenti delle app da winget, Chocolatey e Homebrew. Le regole di blocco/fissaggio delle app restano valide; l'attesa è misurata da quando ogni dispositivo segnala per la prima volta l'aggiornamento.", + "policyNote": "Si applica solo ai dispositivi la cui politica di configurazione include gli aggiornamenti software di terze parti (impostazioni patch della politica)." + }, "usage": { "messageOne": "Questo anello si applica a {{count}} dispositivo. Le modifiche avranno effetto al prossimo check-in.", "messageMany": "Questo anello si applica a {{count}} dispositivi. Le modifiche avranno effetto al prossimo check-in." @@ -457,7 +462,13 @@ "devices": "Dispositivi", "compliance": "Conformità", "updated": "Aggiornato", - "actions": "Azioni" + "actions": "Azioni", + "autoApprove": "Approvazione automatica" + }, + "badges": { + "manual": "Manuale", + "os": "SO: {{severities}}", + "thirdParty": "App di terze parti" } } } diff --git a/apps/web/src/locales/it-IT/policies.json b/apps/web/src/locales/it-IT/policies.json index 7553a1d727..37e37514a8 100644 --- a/apps/web/src/locales/it-IT/policies.json +++ b/apps/web/src/locales/it-IT/policies.json @@ -910,7 +910,8 @@ "createUpdateRing": "Crea anello di aggiornamento", "text2xl": "2xl", "saveChanges": "Salva modifiche", - "createRing": "Crea anello" + "createRing": "Crea anello", + "thirdPartyRingHint": "Le regole di approvazione automatica per gli aggiornamenti di terze parti si configurano nell'anello di aggiornamento collegato." }, "peripheralControlTab": { "linkPeripheralPolicy": "Collega criterio periferiche", diff --git a/apps/web/src/locales/pt-BR/patches.json b/apps/web/src/locales/pt-BR/patches.json index a662bbb6c5..58fd2209ed 100644 --- a/apps/web/src/locales/pt-BR/patches.json +++ b/apps/web/src/locales/pt-BR/patches.json @@ -372,6 +372,7 @@ "validation": { "selectCategory": "Selecione uma categoria", "selectSeverity": "Selecione pelo menos uma severidade para aprovação automática.", + "selectSeverityOrThirdParty": "Selecione pelo menos uma severidade ou habilite a aprovação automática de apps de terceiros.", "nameRequired": "O nome do anel é obrigatório" }, "categories": { @@ -379,7 +380,6 @@ "feature": "Atualizações de recursos", "firmware": "Firmware", "driver": "Drivers", - "thirdPartyApp": "Apps de terceiros", "definitions": "Atualizações de definições" }, "severities": { @@ -421,11 +421,16 @@ "description": "O que é aprovado automaticamente neste anel e por quanto tempo reter um patch após o lançamento pelo fornecedor. O padrão se aplica a todas as categorias; adicione uma substituição para tratar uma delas de forma diferente.", "allCategories": "Todas as categorias", "default": "Padrão", - "severityNote": "As severidades se aplicam a atualizações do SO. Atualizações de apps de terceiros (winget, Homebrew) não têm severidade do fornecedor — quando uma política habilita fontes de outros softwares, elas são aprovadas automaticamente no ritmo deste anel, independentemente das severidades selecionadas aqui.", + "severityNote": "As severidades se aplicam apenas às atualizações do sistema operacional. Atualizações de apps de terceiros são controladas pelo botão abaixo — não são controladas por severidade.", "manualDefault": "Todos os patches neste anel precisam de aprovação manual.", "manualCategory": "Patches nesta categoria precisam de aprovação manual.", "noOverrides": "Todas as categorias seguem o padrão. Adicione uma substituição para tratar uma delas de forma diferente." }, + "thirdParty": { + "title": "Aplicativos de terceiros", + "description": "Aprovar automaticamente atualizações de apps do winget, Chocolatey e Homebrew. As regras de bloqueio/fixação de apps continuam valendo; a espera é medida a partir do primeiro relato da atualização por cada dispositivo.", + "policyNote": "Aplica-se apenas a dispositivos cuja política de configuração inclui atualizações de software de terceiros (configurações de patch da política)." + }, "usage": { "messageOne": "Este anel se aplica a {{count}} dispositivo. As alterações entram em vigor no próximo check-in dele.", "messageMany": "Este anel se aplica a {{count}} dispositivos. As alterações entram em vigor no próximo check-in deles." @@ -457,7 +462,13 @@ "devices": "Dispositivos", "compliance": "Conformidade", "updated": "Atualizado", - "actions": "Ações" + "actions": "Ações", + "autoApprove": "Aprovação automática" + }, + "badges": { + "manual": "Manual", + "os": "SO: {{severities}}", + "thirdParty": "Apps de terceiros" } } } diff --git a/apps/web/src/locales/pt-BR/policies.json b/apps/web/src/locales/pt-BR/policies.json index 5495d002a5..255d5f6067 100644 --- a/apps/web/src/locales/pt-BR/policies.json +++ b/apps/web/src/locales/pt-BR/policies.json @@ -910,7 +910,8 @@ "createUpdateRing": "Criar anel de atualização", "text2xl": "2xl", "saveChanges": "Salvar alterações", - "createRing": "Criar anel" + "createRing": "Criar anel", + "thirdPartyRingHint": "As regras de aprovação automática para atualizações de terceiros são configuradas no anel de atualização vinculado." }, "peripheralControlTab": { "linkPeripheralPolicy": "Vincular política de periféricos", diff --git a/docs/superpowers/plans/vuln-patch/2026-08-04-third-party-update-ring-auto-approve.md b/docs/superpowers/plans/vuln-patch/2026-08-04-third-party-update-ring-auto-approve.md new file mode 100644 index 0000000000..3f07929743 --- /dev/null +++ b/docs/superpowers/plans/vuln-patch/2026-08-04-third-party-update-ring-auto-approve.md @@ -0,0 +1,1149 @@ +# Third-Party Update Ring Auto-Approval Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make third-party (winget/Chocolatey/Homebrew) patching a first-class, visible citizen of Update Ring auto-approval via an explicit `thirdPartyApps` toggle, replacing the invisible severity exemption — and fix the category-rule bugs found during design. + +**Architecture:** The ring's `auto_approve` JSONB gains `thirdPartyApps: boolean` and `thirdPartyDeferralDays: number|null`. The evaluator requires **dual consent** for third-party auto-approval: the config policy's `sources` must include `'third_party'` (existing gate, kept) AND the ring toggle must be on. The `third_party_app` virtual category is removed (one inert stored rule fleet-wide, migrated by SQL). The dead `patch_policies.sources` column is deprecated this release (writers removed), dropped next release. + +**Tech Stack:** TypeScript, Hono, Drizzle, Zod 4, Vitest, React + react-hook-form + i18next, hand-written SQL migrations. + +**Spec:** `docs/superpowers/specs/vuln-patch/2026-08-04-third-party-update-ring-auto-approve-design.md` — read it before starting. + +## Global Constraints + +- Node is pinned to 22.23.2; use `pnpm`. +- Never edit a shipped migration; new migration filename must sort lexicographically AFTER the current last file in `apps/api/migrations/` (currently `2026-08-12-device-identity-collision-alert-template.sql` — verify with `ls apps/api/migrations | tail -1` and use a later date prefix, e.g. `2026-08-13-`). +- Migrations must be idempotent and must NOT contain inner `BEGIN;`/`COMMIT;`. Data-cleanup statements report row counts via `GET DIAGNOSTICS` + `RAISE WARNING`. +- i18n key parity: any key added/removed in `apps/web/src/locales/en/` MUST be added/removed in ALL locales: `de-DE`, `en`, `es-419`, `fr-CA`, `fr-FR`, `it-IT`, `pt-BR`. Missing parity reds main. +- `pnpm test` does NOT run the integration suites. Task 10 needs a real Postgres (`DATABASE_URL=postgresql://breeze:breeze@localhost:5432/breeze`); locally the integration suite wants the fsync=off tmpfs DB or it looks hung. +- Evaluator changes are fail-closed: when in doubt, approve nothing. Never widen approval for malformed stored data. +- No changes to `patchJobExecutor.ts` or `patchJobSnapshot.ts` are needed: `autoApprove` JSONB passes through the snapshot opaquely and is parsed at read time by `parseRingAutoApprove`; the policy `sources` array is already snapshotted and threaded as `ApprovalEvaluationConfig.sources`. +- This branch (`ToddHebebrand/3rd-party-patch-update-rings`) is the working branch; commit after every task. + +--- + +### Task 1: Shared validator — `ringAutoApproveSchema` gains third-party fields + +**Files:** +- Modify: `packages/shared/src/validators/index.ts:612-645` +- Test: `packages/shared/src/validators/index_inline_settings.test.ts` + +**Interfaces:** +- Consumes: nothing (root task). +- Produces: `ringAutoApproveSchema` / type `RingAutoApprove` now `{ enabled: boolean; severities: ('critical'|'important'|'moderate'|'low')[]; deferralDays: number; thirdPartyApps: boolean; thirdPartyDeferralDays: number | null }`. Refinement: `enabled` requires (`severities.length > 0` OR `thirdPartyApps`). Tasks 2, 5, 7, 8 rely on these exact names. + +- [ ] **Step 1: Write the failing tests** + +Add to the existing `ringAutoApproveSchema` describe block in `packages/shared/src/validators/index_inline_settings.test.ts` (the block containing the test at line ~44): + +```ts + it('accepts a third-party-only ring: enabled with empty severities but thirdPartyApps', () => { + const result = ringAutoApproveSchema.safeParse({ + enabled: true, severities: [], deferralDays: 0, thirdPartyApps: true, thirdPartyDeferralDays: null, + }); + expect(result.success).toBe(true); + }); + + it('still rejects enabled with empty severities and thirdPartyApps false', () => { + const result = ringAutoApproveSchema.safeParse({ + enabled: true, severities: [], deferralDays: 0, thirdPartyApps: false, thirdPartyDeferralDays: null, + }); + expect(result.success).toBe(false); + }); + + it('defaults thirdPartyApps=false and thirdPartyDeferralDays=null when omitted', () => { + const result = ringAutoApproveSchema.parse({ enabled: true, severities: ['critical'], deferralDays: 0 }); + expect(result.thirdPartyApps).toBe(false); + expect(result.thirdPartyDeferralDays).toBeNull(); + }); + + it('rejects out-of-range thirdPartyDeferralDays', () => { + expect(ringAutoApproveSchema.safeParse({ enabled: true, severities: ['low'], deferralDays: 0, thirdPartyApps: true, thirdPartyDeferralDays: 366 }).success).toBe(false); + expect(ringAutoApproveSchema.safeParse({ enabled: true, severities: ['low'], deferralDays: 0, thirdPartyApps: true, thirdPartyDeferralDays: -1 }).success).toBe(false); + }); +``` + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `pnpm --filter @breeze/shared test -- index_inline_settings` +Expected: the 4 new tests FAIL (`thirdPartyApps` unknown / third-party-only shape rejected by current refinement). + +- [ ] **Step 3: Implement the schema change** + +In `packages/shared/src/validators/index.ts`, replace the `ringAutoApproveSchema` definition (lines 631-643) with: + +```ts +export const ringAutoApproveSchema = z.object({ + enabled: z.boolean().default(false), + severities: z.array(z.enum(['critical', 'important', 'moderate', 'low'])).default([]), + deferralDays: z.number().int().min(0).max(365).default(0), + // Third-party (winget/Chocolatey/Homebrew + 'custom') auto-approval. Severity + // is not the control axis for these (they mostly ingest severity='unknown'), + // so this is a source-level toggle. Dual consent applies at evaluation: the + // config policy's `sources` must ALSO include 'third_party'. + thirdPartyApps: z.boolean().default(false), + // Hold for third-party candidates, anchored on first-seen (#2218). null = + // inherit deferralDays. + thirdPartyDeferralDays: z.number().int().min(0).max(365).nullable().default(null), +}).superRefine((data, ctx) => { + if (data.enabled && data.severities.length === 0 && !data.thirdPartyApps) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['severities'], + message: 'Select at least one severity or enable third-party app auto-approval.', + }); + } +}); +``` + +Also update the doc comment above it (lines 612-630): change "Empty `severities` while `enabled` means nothing auto-approves" to "Empty `severities` while `enabled` approves no OS patches; `thirdPartyApps` independently opts third-party candidates in (dual consent with the policy's `sources`)." + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @breeze/shared test -- index_inline_settings` +Expected: PASS (all, including pre-existing). + +- [ ] **Step 5: Commit** + +```bash +git add packages/shared/src/validators/index.ts packages/shared/src/validators/index_inline_settings.test.ts +git commit -m "feat(shared): ringAutoApproveSchema gains thirdPartyApps + thirdPartyDeferralDays" +``` + +--- + +### Task 2: Evaluator parse layer — `parseRingAutoApprove` compatibility + fail-closed tightening + +**Files:** +- Modify: `apps/api/src/services/patchApprovalEvaluator.ts:659-712` (interface `RingAutoApproveConfig` + `parseRingAutoApprove`) +- Test: `apps/api/src/services/patchApprovalEvaluator.test.ts` + +**Interfaces:** +- Consumes: nothing from Task 1 (the evaluator parses raw JSONB itself; it must not import the Zod schema — stored rows predate it). +- Produces: `RingAutoApproveConfig` = `{ enabled: boolean; severities: string[]; deferralDays: number; thirdPartyApps: boolean; thirdPartyDeferralDays: number | null }`. `parseRingAutoApprove(autoApprove: unknown): RingAutoApproveConfig` with the compatibility rules below. Tasks 3-4 rely on these exact field names. + +Compatibility rules (from the spec, §Evaluator changes item 4): +- `thirdPartyApps` **absent** → `severities.length > 0` after sanitization (preserves the old #2218 exemption for valid pre-migration rows; keeps `{enabled:true, severities:[]}` and boolean `true` inert). +- `thirdPartyApps` **present non-boolean** → `false`. +- Unrecognized severity strings are dropped (previously any string was kept). +- A **present but invalid** `deferralDays` (non-number, non-integer, negative) now disables the whole row (previously coerced to 0 = no hold, which is fail-open). Absent `deferralDays` still → 0. +- `thirdPartyDeferralDays`: integer in [0, 365] → value; anything else (absent, null, malformed) → `null`. + +- [ ] **Step 1: Write the failing tests** + +Add a new describe block to `apps/api/src/services/patchApprovalEvaluator.test.ts`. The file already exports/tests via `resolveApprovedPatchesForDevice`; for parse-level behavior, export `parseRingAutoApprove` from the evaluator (add `export` keyword) and test it directly: + +```ts +import { parseRingAutoApprove } from './patchApprovalEvaluator'; + +describe('parseRingAutoApprove — thirdPartyApps compatibility (#spec 2026-08-04)', () => { + it('derives thirdPartyApps=true for a legacy enabled row with recognized severities', () => { + const cfg = parseRingAutoApprove({ enabled: true, severities: ['critical'], deferralDays: 3 }); + expect(cfg).toEqual({ enabled: true, severities: ['critical'], deferralDays: 3, thirdPartyApps: true, thirdPartyDeferralDays: null }); + }); + + it('derives thirdPartyApps=false for legacy enabled rows with no recognized severities', () => { + expect(parseRingAutoApprove({ enabled: true, severities: [] }).thirdPartyApps).toBe(false); + expect(parseRingAutoApprove({ enabled: true, severities: ['bogus'] }).thirdPartyApps).toBe(false); + expect(parseRingAutoApprove(true).thirdPartyApps).toBe(false); + }); + + it('honors an explicit thirdPartyApps boolean and treats malformed as false', () => { + expect(parseRingAutoApprove({ enabled: true, severities: [], thirdPartyApps: true }).thirdPartyApps).toBe(true); + expect(parseRingAutoApprove({ enabled: true, severities: ['critical'], thirdPartyApps: false }).thirdPartyApps).toBe(false); + expect(parseRingAutoApprove({ enabled: true, severities: ['critical'], thirdPartyApps: 'yes' }).thirdPartyApps).toBe(false); + }); + + it('drops unrecognized severity strings', () => { + expect(parseRingAutoApprove({ enabled: true, severities: ['critical', 'bogus', 7] }).severities).toEqual(['critical']); + }); + + it('disables the row on a present-but-invalid deferralDays instead of coercing to 0', () => { + expect(parseRingAutoApprove({ enabled: true, severities: ['critical'], deferralDays: 'soon' }).enabled).toBe(false); + expect(parseRingAutoApprove({ enabled: true, severities: ['critical'], deferralDays: -1 }).enabled).toBe(false); + expect(parseRingAutoApprove({ enabled: true, severities: ['critical'], deferralDays: 1.5 }).enabled).toBe(false); + // absent stays fine + expect(parseRingAutoApprove({ enabled: true, severities: ['critical'] })).toMatchObject({ enabled: true, deferralDays: 0 }); + }); + + it('parses thirdPartyDeferralDays: valid int kept, malformed/absent/null → null', () => { + expect(parseRingAutoApprove({ enabled: true, severities: [], thirdPartyApps: true, thirdPartyDeferralDays: 14 }).thirdPartyDeferralDays).toBe(14); + expect(parseRingAutoApprove({ enabled: true, severities: [], thirdPartyApps: true, thirdPartyDeferralDays: null }).thirdPartyDeferralDays).toBeNull(); + expect(parseRingAutoApprove({ enabled: true, severities: [], thirdPartyApps: true, thirdPartyDeferralDays: 999 }).thirdPartyDeferralDays).toBeNull(); + expect(parseRingAutoApprove({ enabled: true, severities: [], thirdPartyApps: true }).thirdPartyDeferralDays).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @breeze/api test -- patchApprovalEvaluator` +Expected: FAIL — `parseRingAutoApprove` is not exported and lacks the new fields. + +- [ ] **Step 3: Implement** + +Replace `RingAutoApproveConfig` (lines 659-668) and `parseRingAutoApprove` (lines 670-712) with: + +```ts +interface RingAutoApproveConfig { + enabled: boolean; + severities: string[]; + /** Deferral window (days) for OS ring auto-approve. 0 = no deferral. */ + deferralDays: number; + /** Third-party source-level auto-approve toggle (dual consent with policy sources). */ + thirdPartyApps: boolean; + /** Third-party hold override; null = inherit deferralDays. First-seen anchored (#2218). */ + thirdPartyDeferralDays: number | null; +} + +const RECOGNIZED_RING_SEVERITIES = new Set(['critical', 'important', 'moderate', 'low']); + +const DISABLED_RING_AUTO_APPROVE: RingAutoApproveConfig = { + enabled: false, + severities: [], + deferralDays: 0, + thirdPartyApps: false, + thirdPartyDeferralDays: null, +}; + +/** + * Parse a ring's `autoApprove` JSONB into a typed config. Tolerant of every + * historical shape, but FAIL-CLOSED about meaning: + * - boolean `true` → enabled, no severities, no third-party → approves nothing + * - missing/`{}`/malformed → disabled + * - unrecognized severity strings are dropped (never matched anyway; dropping + * them keeps the thirdPartyApps compatibility rule honest) + * - a PRESENT but invalid `deferralDays` disables the row entirely — the old + * coerce-to-0 turned a malformed hold into "no hold", which is fail-open + * - `thirdPartyApps` absent (pre-2026-08 rows and job snapshots frozen before + * the backfill): derived as `severities.length > 0`, which reproduces the + * old #2218 severity-exemption behavior for rows the write schema accepted, + * while keeping malformed `{enabled:true}` rows inert. Present non-boolean + * (AI-tool or hand-written rows) → false. + */ +export function parseRingAutoApprove(autoApprove: unknown): RingAutoApproveConfig { + if (autoApprove === true) { + return { ...DISABLED_RING_AUTO_APPROVE, enabled: true }; + } + + if (!autoApprove || typeof autoApprove !== 'object') { + return DISABLED_RING_AUTO_APPROVE; + } + + const config = autoApprove as Record; + if (config.enabled !== true) { + return DISABLED_RING_AUTO_APPROVE; + } + + const severities = Array.isArray(config.severities) + ? config.severities.filter( + (s): s is string => typeof s === 'string' && RECOGNIZED_RING_SEVERITIES.has(s) + ) + : []; + + let deferralDays = 0; + if (config.deferralDays !== undefined) { + const raw = config.deferralDays; + if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 0) { + return DISABLED_RING_AUTO_APPROVE; + } + deferralDays = raw; + } + + const thirdPartyApps = + 'thirdPartyApps' in config ? config.thirdPartyApps === true : severities.length > 0; + + const rawTp = config.thirdPartyDeferralDays; + const thirdPartyDeferralDays = + typeof rawTp === 'number' && Number.isInteger(rawTp) && rawTp >= 0 && rawTp <= 365 + ? rawTp + : null; + + return { enabled: true, severities, deferralDays, thirdPartyApps, thirdPartyDeferralDays }; +} +``` + +- [ ] **Step 4: Run the full evaluator test file** + +Run: `pnpm --filter @breeze/api test -- patchApprovalEvaluator` +Expected: new tests PASS. Pre-existing tests may fail ONLY if they asserted the old coerce-to-0 deferral behavior — update any such test to expect the disabled row instead (the new behavior is the spec'd one). + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/services/patchApprovalEvaluator.ts apps/api/src/services/patchApprovalEvaluator.test.ts +git commit -m "feat(api): parseRingAutoApprove thirdPartyApps compatibility + fail-closed deferral/severity parsing" +``` + +--- + +### Task 3: Evaluator — category-rule repairs (inert severity chips, non-terminal manual rules, remove virtual category) + +**Files:** +- Modify: `apps/api/src/services/patchApprovalEvaluator.ts:24-29` (CategoryRule), `:530-556` (Priority 2 block) +- Test: `apps/api/src/services/patchApprovalEvaluator.test.ts` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `CategoryRule` = `{ category: string; autoApprove: boolean; autoApproveSeverities?: string[]; severityFilter?: string[]; deferralDaysOverride?: number | null }` (`severityFilter` kept as deprecated read alias). Priority-2 semantics: exact-category match only; a matching `autoApprove:false` rule is TERMINAL (returns null). + +Three defects being fixed (all confirmed against current code): +1. Routes/UI write `autoApproveSeverities` (`updateRings.ts:85`) but the evaluator reads `severityFilter` (`:27`, `:543`) → severity chips on category rules never enforce (fail-open). +2. A matching rule with `autoApprove:false` falls through to ring auto-approve, contradicting the UI's "needs manual approval" copy. +3. The virtual `third_party_app` source-match (`:535-537`) is superseded by the ring-level toggle (Task 4); stored rules are migrated by Task 6. + +- [ ] **Step 1: Write the failing tests** + +The existing test file drives `resolveApprovedPatchesForDevice` with mocked DB rows. Follow its established mock pattern (look at how existing category-rule tests in the file seed `device_patches`/`patches` and build the config). Add: + +```ts +describe('category rules — repaired semantics (#spec 2026-08-04)', () => { + it('enforces autoApproveSeverities written by the route/UI (was silently ignored)', async () => { + // patch: category 'security', severity 'moderate' + // rule: { category: 'security', autoApprove: true, autoApproveSeverities: ['critical'] } + // Expect: NOT approved (previously approved because the evaluator read `severityFilter`). + }); + + it('still honors legacy stored severityFilter as a read alias', async () => { + // rule: { category: 'security', autoApprove: true, severityFilter: ['critical'] } + // patch severity 'critical' → approved via 'category_rule'; patch severity 'low' → not approved. + }); + + it('treats a matching autoApprove:false rule as terminal — no fall-through to ring auto-approve', async () => { + // ring autoApprove: { enabled: true, severities: ['critical'] } + // rule: { category: 'security', autoApprove: false } + // patch: category 'security', severity 'critical' + // Expect: NOT approved (previously fell through and ring-auto-approved). + }); + + it('no longer matches third-party patches to a third_party_app rule', async () => { + // ring autoApprove disabled; rule: { category: 'third_party_app', autoApprove: true } + // patch: source 'third_party', category 'application', policy sources ['os','third_party'] + // Expect: NOT approved (virtual category removed; Task 4's toggle is the path). + }); +}); +``` + +Flesh these out with the file's existing helpers/mocks — each test body must build real inputs, run `resolveApprovedPatchesForDevice`, and assert on the returned array. Also UPDATE the existing tests that assert the old behavior (there are existing specs covering `severityFilter` naming, third_party_app virtual matching, and fall-through — flip their expectations to the new semantics rather than deleting them; keep their descriptions accurate). + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `pnpm --filter @breeze/api test -- patchApprovalEvaluator` +Expected: the 4 new tests FAIL against current logic. + +- [ ] **Step 3: Implement** + +Update `CategoryRule` (lines 24-29): + +```ts +export interface CategoryRule { + category: string; + autoApprove: boolean; + /** Severity allowlist — the canonical field name the route/UI write (updateRings.ts categoryRuleSchema). */ + autoApproveSeverities?: string[]; + /** @deprecated Legacy stored alias for autoApproveSeverities (rows/snapshots written before 2026-08). Read-only. */ + severityFilter?: string[]; + deferralDaysOverride?: number | null; +} +``` + +Replace the Priority 2 block (lines 530-556) with: + +```ts + // Priority 2: Category rule (OS categories only). The virtual + // 'third_party_app' category was removed — third-party auto-approval is the + // ring-level thirdPartyApps toggle (Priority 3); stored third_party_app rules + // were migrated to it by the 2026-08-13 backfill. A matching rule is + // TERMINAL either way: autoApprove:false means "needs manual approval" (the + // UI's words) and must not fall through to ring-level auto-approve. + const rule = patch.category + ? categoryRuleMap.get(canonicalizePatchCategory(patch.category)) + : undefined; + if (rule) { + if (!rule.autoApprove) { + return null; + } + // Severity allowlist. Canonical name is autoApproveSeverities (what the + // route/UI write); severityFilter is honored as a legacy stored alias — + // before 2026-08 the evaluator ONLY read severityFilter, which the writers + // never produced, so chips were silently unenforced (fail-open). + const severityAllowlist = rule.autoApproveSeverities ?? rule.severityFilter; + if (severityAllowlist && severityAllowlist.length > 0) { + if (!patch.severity || !severityAllowlist.includes(patch.severity)) { + return null; + } + } + const deferralDays = rule.deferralDaysOverride ?? ringConfig.deferralDays; + if (isHeldByDeferral(patch, deferralDays, now, 'category')) { + return null; + } + return 'category_rule'; + } +``` + +Note: `deferralDaysOverride` may be stored as `null` ("inherit") by the route schema — `?? ringConfig.deferralDays` already handles that; keep it. + +- [ ] **Step 4: Run tests** + +Run: `pnpm --filter @breeze/api test -- patchApprovalEvaluator` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/services/patchApprovalEvaluator.ts apps/api/src/services/patchApprovalEvaluator.test.ts +git commit -m "fix(api): category rules enforce written severities, are terminal, and drop the third_party_app virtual category" +``` + +--- + +### Task 4: Evaluator — dual-consent third-party ring auto-approve + +**Files:** +- Modify: `apps/api/src/services/patchApprovalEvaluator.ts:558-609` (Priority 3 block) +- Test: `apps/api/src/services/patchApprovalEvaluator.test.ts` + +**Interfaces:** +- Consumes: `RingAutoApproveConfig.thirdPartyApps` / `.thirdPartyDeferralDays` from Task 2. +- Produces: final ring-approval semantics — OS: enabled + non-empty severities + membership + deferral; third-party (`third_party`|`custom` source): enabled + policy `sources` contains `'third_party'` + `thirdPartyApps` + deferral(`thirdPartyDeferralDays ?? deferralDays`, first-seen anchored). Task 10's integration suite asserts these. + +- [ ] **Step 1: Write the failing tests** + +Add to `patchApprovalEvaluator.test.ts`, following the file's existing mock pattern: + +```ts +describe('ring auto-approve — third-party dual consent (#spec 2026-08-04)', () => { + it('approves a third-party patch only with BOTH policy sources third_party AND ring thirdPartyApps', async () => { + // enabled ring { severities: [], thirdPartyApps: true }, policy sources ['os','third_party'] + // 3P patch (severity 'unknown', no releaseDate) → approved, reason 'ring_auto_approve' + }); + + it('does not approve third-party when the ring toggle is off, even with policy consent', async () => { + // enabled ring { severities: ['critical'], thirdPartyApps: false }, sources ['os','third_party'] → 3P not approved + }); + + it('does not approve third-party when policy sources are absent (legacy snapshot) even with the toggle on', async () => { + // enabled ring { severities: [], thirdPartyApps: true }, config.sources UNDEFINED → 3P not approved + // (absent sources = "no filtering" upstream; the dual-consent check must still refuse) + }); + + it('supports a third-party-only ring: empty severities approves 3P and no OS patches', async () => { + // enabled ring { severities: [], thirdPartyApps: true }, sources ['os','third_party'] + // 3P patch approved; OS patch severity 'critical' NOT approved (empty OS severity set stays fail-closed) + }); + + it('treats custom-source patches as third-party for the toggle', async () => { + // patch source 'custom' behaves exactly like 'third_party' under the toggle + }); + + it('applies thirdPartyDeferralDays over deferralDays for 3P, anchored on firstSeenAt', async () => { + // thirdPartyDeferralDays: 7, firstSeenAt 3 days ago → held; firstSeenAt 8 days ago → approved + // null thirdPartyDeferralDays inherits deferralDays + }); +}); +``` + +Flesh out with real mock rows per the file's pattern. Also update the existing #2218 exemption tests in this file — the exemption predicate is replaced, so tests asserting "3P approves because sources contains third_party while thirdPartyApps is untouched/absent" must be revisited: with the Task 2 compatibility rule, a legacy row `{enabled:true, severities:['critical']}` derives `thirdPartyApps=true`, so most existing exemption tests keep passing unchanged — verify rather than assume. + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `pnpm --filter @breeze/api test -- patchApprovalEvaluator` +Expected: new tests FAIL (third-party-only ring hits the empty-severities kill-switch; toggle ignored). + +- [ ] **Step 3: Implement** + +Replace the Priority 3 block (current lines 558-606) with: + +```ts + // Priority 3: Ring-level auto-approve (#1317). Severity gates OS candidates; + // third-party candidates are gated by the explicit thirdPartyApps toggle + // (#2218 exemption replaced by spec 2026-08-04) under DUAL CONSENT: + // - the POLICY must have opted into third-party sources ('third_party' in + // the snapshotted sources; the default is ['os']). This stays even though + // buildAllowedPatchSources filters upstream, because ABSENT sources mean + // "no filtering" for legacy job snapshots — without this literal check, a + // legacy snapshot plus a permissive ring would silently widen to 3P. + // - the RING's autoApprove.thirdPartyApps must be true. + // NOTE: the literal 'third_party' selection vs the expanded patch-source + // bucket ('third_party'|'custom') stay in lockstep because + // buildAllowedPatchSources only admits 'custom' rows via the 'third_party' + // selection (or an explicit 'custom' entry) — if that expansion table ever + // changes, revisit this check too. + if (ringAutoApprove.enabled) { + if (isThirdPartyPatchSource(patch.source)) { + if (!(ringConfig.sources ?? []).includes('third_party')) { + return null; + } + if (!ringAutoApprove.thirdPartyApps) { + return null; + } + const hold = ringAutoApprove.thirdPartyDeferralDays ?? ringAutoApprove.deferralDays; + if (isHeldByDeferral(patch, hold, now, 'ring')) { + return null; + } + return 'ring_auto_approve'; + } + + // OS path: unchanged fail-closed severity gating. Enabled with an empty + // severity set approves no OS patches (legacy boolean `true` and malformed + // `{enabled:true}` rows stay inert here). + if (ringAutoApprove.severities.length === 0) { + return null; + } + if (!patch.severity || !ringAutoApprove.severities.includes(patch.severity)) { + return null; + } + if (isHeldByDeferral(patch, ringAutoApprove.deferralDays, now, 'ring')) { + return null; + } + return 'ring_auto_approve'; + } + + return null; +``` + +`isHeldByDeferral` (lines 611-657) needs no change — the first-seen fallback for third-party is already in place. + +- [ ] **Step 4: Run the whole API unit suite for the touched area** + +Run: `pnpm --filter @breeze/api test -- patchApprovalEvaluator` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/services/patchApprovalEvaluator.ts apps/api/src/services/patchApprovalEvaluator.test.ts +git commit -m "feat(api): explicit dual-consent third-party ring auto-approve replaces the severity exemption" +``` + +--- + +### Task 5: Ring routes — new autoApprove fields flow; deprecate `sources`; reject `third_party_app` rules + +**Files:** +- Modify: `apps/api/src/routes/updateRings.ts` (`:22` default, `:82-87` categoryRuleSchema, `:101`/`:119` sources lines, `:182` list select, `:233` insert, `:348` update, and the `GET /:id` select — grep `sources: patchPolicies.sources` for every occurrence) +- Modify: `apps/api/src/db/schema/patches.ts:151` (deprecation comment only) +- Test: `apps/api/src/routes/updateRings_list_create.test.ts`, `apps/api/src/routes/updateRings_detail_update_delete.test.ts` + +**Interfaces:** +- Consumes: `ringAutoApproveSchema` from Task 1 (already imported at `updateRings.ts:18` — new fields validate automatically). +- Produces: create/update API accepts `autoApprove.thirdPartyApps`/`thirdPartyDeferralDays`, rejects `sources` (unknown key) and `third_party_app` category rules. Ring responses no longer include `sources`. Task 8's UI posts against this contract. + +- [ ] **Step 1: Write the failing tests** + +In `updateRings_list_create.test.ts` (follow its existing route-test mock pattern): + +```ts + it('creates a third-party-only ring (empty severities + thirdPartyApps)', async () => { + // POST { name, autoApprove: { enabled: true, severities: [], deferralDays: 0, thirdPartyApps: true, thirdPartyDeferralDays: 14 } } + // Expect 200/201 and the inserted values to include the two new fields. + }); + + it('rejects a third_party_app category rule with a helpful message', async () => { + // POST { name, categoryRules: [{ category: 'third_party_app', autoApprove: true }] } → 400 + }); + + it('no longer returns sources in ring list responses', async () => { + // GET / → each ring object lacks a `sources` key + }); +``` + +In `updateRings_detail_update_delete.test.ts`: + +```ts + it('PATCH persists thirdPartyApps and thirdPartyDeferralDays', async () => { /* PATCH autoApprove with new fields → updateFields.autoApprove carries them */ }); + it('PATCH rejects a sources payload as an unknown field no-op', async () => { /* PATCH { sources: ['os'] } → sources not in updateFields (Zod strips unknown keys; assert the update call received no sources) */ }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @breeze/api test -- updateRings_list_create updateRings_detail_update_delete` +Expected: new tests FAIL (sources still selected/written; third_party_app accepted; note `createRingSchema` currently accepts `sources` so the strip-assertion fails). + +- [ ] **Step 3: Implement** + +1. `DEFAULT_RING_AUTO_APPROVE` (line 22): +```ts +const DEFAULT_RING_AUTO_APPROVE = { + enabled: false, severities: [], deferralDays: 0, thirdPartyApps: false, thirdPartyDeferralDays: null, +} as const; +``` +2. `categoryRuleSchema` (lines 82-87) — reject the retired virtual category: +```ts +const categoryRuleSchema = z.object({ + category: z.string().max(100).refine((c) => c.trim().toLowerCase() !== 'third_party_app', { + message: "The 'third_party_app' category rule was replaced by autoApprove.thirdPartyApps on the ring.", + }), + autoApprove: z.boolean(), + autoApproveSeverities: z.array(z.enum(['critical', 'important', 'moderate', 'low'])).optional(), + deferralDaysOverride: z.number().int().min(0).max(365).nullable().optional(), +}); +``` +3. Delete the `sources:` line from `createRingSchema` (:101) and `updateRingSchema` (:119). +4. Delete `sources: patchPolicies.sources,` from the list select (:182) and from the `GET /:id` select (grep for the second occurrence). +5. Delete `sources: data.sources ?? null,` from the insert (:233) and `if (data.sources !== undefined) updateFields.sources = data.sources;` from the update (:348). +6. In `apps/api/src/db/schema/patches.ts`, above the `sources` column on `patchPolicies` (~line 151), add: +```ts + // DEPRECATED (spec 2026-08-04): never consumed by the approval path — the + // evaluated sources are config_policy_patch_settings.sources. Writers removed + // in the same release; DROP COLUMN ships one release later (expand/contract). +``` +7. `pnpm --filter @breeze/api exec tsc --noEmit` will surface any other reader of `patchPolicies.sources` — the known one is `aiToolsPolicyPrereqs.ts` (Task 7); if others appear, remove their `sources` usage the same way and note it in the commit message. + +- [ ] **Step 4: Run tests** + +Run: `pnpm --filter @breeze/api test -- updateRings` +Expected: PASS (update any pre-existing test fixtures that posted `sources` or asserted it in responses). + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/routes/updateRings.ts apps/api/src/db/schema/patches.ts apps/api/src/routes/updateRings_list_create.test.ts apps/api/src/routes/updateRings_detail_update_delete.test.ts +git commit -m "feat(api): ring routes accept thirdPartyApps, drop dead sources plumbing, reject third_party_app rules" +``` + +--- + +### Task 6: Backfill migration + +**Files:** +- Create: `apps/api/migrations/2026-08-13-ring-third-party-auto-approve-backfill.sql` (confirm the prefix sorts after `ls apps/api/migrations | tail -1` first) + +**Interfaces:** +- Consumes: the parse compatibility rule from Task 2 (the SQL must implement the SAME rule: thirdPartyApps=true iff enabled with ≥1 recognized severity). +- Produces: all `patch_policies.auto_approve` rows carry explicit `thirdPartyApps`; no `third_party_app` category rules remain. Prod expectation (surveyed 2026-08-04): statement 1 touches 0 rows, statements 2-3 touch 1 row (EU). + +- [ ] **Step 1: Write the migration** + +```sql +-- Spec 2026-08-04: third-party ring auto-approve. Backfills the explicit +-- autoApprove.thirdPartyApps shape and migrates legacy 'third_party_app' +-- category rules to the ring-level toggle. Idempotent; counts RAISEd so the +-- rollout numbers land in Postgres logs (expected prod: 0 / 1 / 0 rows). + +DO $$ +DECLARE + n integer; +BEGIN + -- 1) Enabled object-shaped rows lacking thirdPartyApps: derive it from + -- whether the row has >=1 recognized severity (mirrors + -- parseRingAutoApprove's compatibility rule / the old #2218 exemption). + UPDATE patch_policies + SET auto_approve = auto_approve + || jsonb_build_object( + 'thirdPartyApps', + EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text( + CASE WHEN jsonb_typeof(auto_approve->'severities') = 'array' + THEN auto_approve->'severities' + ELSE '[]'::jsonb END + ) AS sev(v) + WHERE sev.v IN ('critical','important','moderate','low') + ) + ) + || jsonb_build_object('thirdPartyDeferralDays', NULL::int) + WHERE kind = 'ring' + AND jsonb_typeof(auto_approve) = 'object' + AND auto_approve->>'enabled' = 'true' + AND NOT auto_approve ? 'thirdPartyApps'; + GET DIAGNOSTICS n = ROW_COUNT; + IF n > 0 THEN RAISE WARNING 'ring-3p backfill: stamped explicit thirdPartyApps on % enabled auto_approve rows', n; END IF; + + -- 2) Rings with an autoApprove:true third_party_app category rule: turn on + -- the ring-level toggle (carrying the rule's deferral override) and strip + -- the rule. Preserves intent: those rings wanted 3P auto-approved. + UPDATE patch_policies p + SET auto_approve = + (CASE WHEN jsonb_typeof(p.auto_approve) = 'object' THEN p.auto_approve ELSE '{}'::jsonb END) + || jsonb_build_object('enabled', true, 'thirdPartyApps', true) + || COALESCE( + (SELECT CASE WHEN (r.rule->>'deferralDaysOverride') ~ '^\d+$' + THEN jsonb_build_object('thirdPartyDeferralDays', (r.rule->>'deferralDaysOverride')::int) + ELSE '{}'::jsonb END + FROM jsonb_array_elements(p.category_rules) AS r(rule) + WHERE r.rule->>'category' = 'third_party_app' + AND r.rule->>'autoApprove' = 'true' + LIMIT 1), + '{}'::jsonb), + category_rules = COALESCE( + (SELECT jsonb_agg(r.rule) + FROM jsonb_array_elements(p.category_rules) AS r(rule) + WHERE r.rule->>'category' IS DISTINCT FROM 'third_party_app'), + '[]'::jsonb), + updated_at = now() + WHERE p.kind = 'ring' + AND jsonb_typeof(p.category_rules) = 'array' + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(p.category_rules) AS r(rule) + WHERE r.rule->>'category' = 'third_party_app' + AND r.rule->>'autoApprove' = 'true' + ); + GET DIAGNOSTICS n = ROW_COUNT; + IF n > 0 THEN RAISE WARNING 'ring-3p backfill: converted third_party_app category rules to the ring toggle on % rings', n; END IF; + + -- 3) Strip any remaining (autoApprove:false) third_party_app rules — nothing + -- to preserve; the category no longer exists. + UPDATE patch_policies p + SET category_rules = COALESCE( + (SELECT jsonb_agg(r.rule) + FROM jsonb_array_elements(p.category_rules) AS r(rule) + WHERE r.rule->>'category' IS DISTINCT FROM 'third_party_app'), + '[]'::jsonb), + updated_at = now() + WHERE p.kind = 'ring' + AND jsonb_typeof(p.category_rules) = 'array' + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(p.category_rules) AS r(rule) + WHERE r.rule->>'category' = 'third_party_app' + ); + GET DIAGNOSTICS n = ROW_COUNT; + IF n > 0 THEN RAISE WARNING 'ring-3p backfill: stripped inert third_party_app rules from % rings', n; END IF; +END $$; +``` + +- [ ] **Step 2: Apply and verify idempotency locally** + +```bash +export DATABASE_URL="postgresql://breeze:breeze@localhost:5432/breeze" +pnpm db:migrate +``` +Expected: applies cleanly. Then seed a probe row and re-run to prove idempotency + correctness: + +```bash +psql "$DATABASE_URL" -c "UPDATE patch_policies SET auto_approve='{\"enabled\":true,\"severities\":[\"critical\"]}'::jsonb, category_rules='[{\"category\":\"third_party_app\",\"autoApprove\":true,\"deferralDaysOverride\":5}]'::jsonb WHERE kind='ring' AND id=(SELECT id FROM patch_policies WHERE kind='ring' LIMIT 1) RETURNING id;" +psql "$DATABASE_URL" -c "DELETE FROM breeze_migrations WHERE filename LIKE '2026-08-13-ring-third-party%';" +pnpm db:migrate +psql "$DATABASE_URL" -c "SELECT auto_approve, category_rules FROM patch_policies WHERE kind='ring' AND auto_approve ? 'thirdPartyApps' LIMIT 3;" +``` +Expected: the probe row shows `thirdPartyApps: true`, `thirdPartyDeferralDays: 5`, and an empty/3p-free `category_rules`. Re-running `pnpm db:migrate` again (after another `DELETE FROM breeze_migrations ...`) is a no-op (all three WARNING counts absent). Reset the probe row afterwards if it was a seeded dev ring. + +- [ ] **Step 3: Run `pnpm db:check-drift`** + +Expected: no drift (data-only migration). + +- [ ] **Step 4: Commit** + +```bash +git add apps/api/migrations/2026-08-13-ring-third-party-auto-approve-backfill.sql +git commit -m "feat(api): backfill explicit thirdPartyApps and migrate third_party_app category rules" +``` + +--- + +### Task 7: AI tool surface sweep + +**Files:** +- Modify: `apps/api/src/services/aiToolsPolicyPrereqs.ts` (`:35` validateRingAutoApprove doc, `:183-184` input_schema, `:216` list select, `:267` create values, `:299` update) +- Modify: `apps/api/src/services/aiToolSchemas.ts:1396` (manage_update_rings Zod: delete the `sources` line; `:1397` autoApprove uses the shared schema — updates automatically) +- Check (grep, update only if they mention ring `sources`/severity-only auto-approve): `apps/api/src/services/mcpGuidance.ts`, `aiAgentSystemPrompt.ts`, `aiGuardrails.ts`, `aiAgentSdkTools.ts` +- Test: `apps/api/src/services/aiToolsPolicyPrereqs.test.ts` + +**Interfaces:** +- Consumes: `ringAutoApproveSchema` (Task 1) via `validateRingAutoApprove`. +- Produces: `manage_update_rings` accepts the new autoApprove fields, no longer accepts/returns `sources`. + +- [ ] **Step 1: Write the failing tests** + +In `aiToolsPolicyPrereqs.test.ts`, following its existing handler-invocation pattern: + +```ts + it('manage_update_rings create accepts a third-party-only autoApprove', async () => { + // action create, autoApprove { enabled: true, severities: [], thirdPartyApps: true } → success (no "must list at least one severity" error) + }); + + it('manage_update_rings create/update ignore a sources input and never write the column', async () => { + // action create with sources: ['os'] → inserted values contain no `sources` key + }); + + it('manage_update_rings still rejects enabled with no severities and no thirdPartyApps', async () => { + // autoApprove { enabled: true, severities: [] } → error mentioning severity or third-party + }); +``` + +- [ ] **Step 2: Run to verify failures** + +Run: `pnpm --filter @breeze/api test -- aiToolsPolicyPrereqs` +Expected: FAIL (third-party-only rejected by the old refinement path through `validateRingAutoApprove`; sources still written). Note: if `validateRingAutoApprove` (line 35) wraps the shared `ringAutoApproveSchema`, the first/third tests may already pass after Task 1 — verify, and keep the tests either way as regression cover. + +- [ ] **Step 3: Implement** + +1. Delete the `sources:` property from the `manage_update_rings` `input_schema` (line 183), from the list select (line 216), the create values (line 267), and the update branch (line 299). +2. Update the `autoApprove` description (line 184) to: +```ts +autoApprove: { type: 'object', description: 'Auto-approval rules, e.g. { enabled: true, severities: ["critical","important"], deferralDays: 0, thirdPartyApps: false, thirdPartyDeferralDays: null }. severities gate OS patches only and must be a subset of ["critical","important","moderate","low"]. thirdPartyApps auto-approves third-party app updates (winget/Chocolatey/Homebrew/custom) — it also requires the linked configuration policy to include third-party patch sources. If enabled is true you MUST set at least one severity OR thirdPartyApps: true.' }, +``` +3. Delete the `sources:` line from the `manage_update_rings` Zod schema in `aiToolSchemas.ts` (line 1396). +4. `grep -n "sources" apps/api/src/services/mcpGuidance.ts apps/api/src/services/aiAgentSystemPrompt.ts apps/api/src/services/aiGuardrails.ts apps/api/src/services/aiAgentSdkTools.ts` — update any prose describing ring `sources` or "severities required" auto-approve rules to match the new contract; leave unrelated hits alone. +5. `pnpm --filter @breeze/api exec tsc --noEmit` — must be clean (this is also the check that no other `patchPolicies.sources` reader survived Tasks 5/7; `apps/api/src/scripts/migrateToConfigPolicies.ts:491` may still reference it — that script is a retained one-shot, remove its `sources` mapping too). + +- [ ] **Step 4: Run tests** + +Run: `pnpm --filter @breeze/api test -- aiToolsPolicyPrereqs aiToolSchemas` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/services/aiToolsPolicyPrereqs.ts apps/api/src/services/aiToolSchemas.ts apps/api/src/services/aiToolsPolicyPrereqs.test.ts apps/api/src/scripts/migrateToConfigPolicies.ts +git commit -m "feat(api): manage_update_rings supports thirdPartyApps, drops ring sources" +``` + +--- + +### Task 8: Web — UpdateRingForm third-party subsection + +**Files:** +- Modify: `apps/web/src/components/patches/UpdateRingForm.tsx` +- Modify: `apps/web/src/locales/{de-DE,en,es-419,fr-CA,fr-FR,it-IT,pt-BR}/patches.json` +- Test: `apps/web/src/components/patches/UpdateRingForm.test.tsx` + +**Interfaces:** +- Consumes: API contract from Task 5 (`autoApprove.thirdPartyApps: boolean`, `autoApprove.thirdPartyDeferralDays: number` — the form always submits a concrete number, mirroring the existing category-override "explicit, never blank" pattern at `UpdateRingForm.tsx:266-276`; `null` inherit is for API writers only). +- Produces: form values type `UpdateRingFormValues['autoApprove']` gains the two fields; `PatchesPage.tsx` posts form values as-is, so no wiring change there (verify in Step 4). + +- [ ] **Step 1: Write the failing tests** + +Add to `UpdateRingForm.test.tsx`, following its existing render/interaction pattern: + +```tsx + it('renders the third-party toggle inside the enabled auto-approve section', async () => { + // render with defaultValues { autoApprove: { enabled: true, severities: ['critical'], deferralDays: 0, thirdPartyApps: false, thirdPartyDeferralDays: 0 } } + // expect screen.getByTestId('ring-third-party-enabled') to be in the document and unchecked + }); + + it('submits a third-party-only ring without a severity validation error', async () => { + // enable auto-approve, leave severities empty, check ring-third-party-enabled, submit + // expect onSubmit called with autoApprove.thirdPartyApps === true and severities [] + }); + + it('still blocks enabled + no severities + third-party off', async () => { + // enable auto-approve, submit → validation message, onSubmit not called + }); + + it('no longer offers third_party_app as a category override option', () => { + // add an override; assert the category