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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 $$;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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');
});
});
1 change: 0 additions & 1 deletion apps/api/src/db/schema/patches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}),
Expand Down
13 changes: 6 additions & 7 deletions apps/api/src/routes/updateRings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ?? {},
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading