Skip to content

Commit 0307c39

Browse files
committed
fix(migrations): sweep already-parked subblock values; align picker 403
- an earlier version of this migration renamed retired fields into _removed_* keys instead of deleting them, so deployed workflows still hold those values; they match no oldId, so a dedicated sweep clears them for every block type - the picker now treats a Snowflake 403 like a 401: it means a network policy or a disabled SQL API, which the credential validator already reports as a credential problem rather than a bad request
1 parent 21c15f3 commit 0307c39

3 files changed

Lines changed: 83 additions & 8 deletions

File tree

apps/sim/app/api/tools/snowflake/objects/route.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -141,21 +141,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
141141
signal: AbortSignal.any([request.signal, AbortSignal.timeout(SELECTOR_FETCH_TIMEOUT_MS)]),
142142
})
143143

144-
// A rejected or expired token must tell the picker to reconnect rather than
145-
// read as a Snowflake outage.
146-
if (response.status === 401) {
144+
// A rejected credential must tell the picker to reconnect rather than read
145+
// as an outage or a bad request. 403 belongs here alongside 401: Snowflake
146+
// uses it for a network-policy rejection and for a disabled SQL API, which
147+
// is why the credential validator also treats it as a credential problem.
148+
if (response.status === 401 || response.status === 403) {
147149
logger.warn('Snowflake rejected the stored credential', { credentialId: credential, kind })
148150
return NextResponse.json(
149151
{
150-
error: 'Snowflake rejected this credential. Reconnect it and try again.',
152+
error:
153+
'Snowflake rejected this credential. Check that it has not expired and that a network policy allows Sim to reach the account, then reconnect it.',
151154
authRequired: true,
152155
},
153156
{ status: 401 }
154157
)
155158
}
156159

157-
// A 4xx names something wrong with the request (missing object, privilege
158-
// gap); only a 5xx or a malformed body is a gateway failure.
160+
// A remaining 4xx names something wrong with the request itself (unknown
161+
// object, malformed statement); only a 5xx or an unreadable body is a
162+
// gateway failure.
159163
upstreamStatus = response.status
160164
const result = await readSnowflakeResult(response)
161165
// A metadata-only statement completes synchronously; a 202 means Snowflake

apps/sim/lib/workflows/migrations/subblock-migrations.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,46 @@ describe('migrateSubblockIds', () => {
113113
})
114114
})
115115

116+
/**
117+
* An earlier version of this migration renamed retired fields into a
118+
* `_removed_*` key instead of deleting them, so deployed workflows still hold
119+
* those values. They match no `oldId`, so only a dedicated sweep clears them.
120+
*/
121+
it('drops values parked by an earlier run of the migration', () => {
122+
const input: Record<string, BlockState> = {
123+
b1: makeBlock({
124+
type: 'rippling',
125+
subBlocks: {
126+
_removed_email: { id: '_removed_email', type: 'short-input', value: 'ada@example.com' },
127+
_removed_firstName: { id: '_removed_firstName', type: 'short-input', value: 'Ada' },
128+
// Not in rippling's rename map, so it must survive the sweep untouched.
129+
credential: { id: 'credential', type: 'oauth-input', value: 'cred-1' },
130+
},
131+
}),
132+
// A block type with no rename map at all must still be swept.
133+
b2: makeBlock({
134+
type: 'snowflake',
135+
subBlocks: {
136+
_removed_apiKey: {
137+
id: '_removed_apiKey',
138+
type: 'short-input',
139+
value: 'super-secret-pat',
140+
},
141+
},
142+
}),
143+
}
144+
145+
const { blocks, migrated } = migrateSubblockIds(input)
146+
147+
expect(migrated).toBe(true)
148+
expect(blocks.b1.subBlocks._removed_email).toBeUndefined()
149+
expect(blocks.b1.subBlocks._removed_firstName).toBeUndefined()
150+
expect(blocks.b1.subBlocks.credential?.value).toBe('cred-1')
151+
expect(blocks.b2.subBlocks._removed_apiKey).toBeUndefined()
152+
expect(JSON.stringify(blocks)).not.toContain('super-secret-pat')
153+
expect(JSON.stringify(blocks)).not.toContain('ada@example.com')
154+
})
155+
116156
describe('knowledge block', () => {
117157
it('should rename knowledgeBaseId to knowledgeBaseSelector', () => {
118158
const input: Record<string, BlockState> = {

apps/sim/lib/workflows/migrations/subblock-migrations.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,29 @@ function migrateBlockSubblockIds(
212212
return { subBlocks: result, migrated: true }
213213
}
214214

215+
/**
216+
* Drops any `_removed_*` subblock left behind by an earlier version of this
217+
* migration, which renamed retired fields into a dead key instead of deleting
218+
* them. Those keys are unreachable from the block config, so secret scrubbing —
219+
* which walks the config — can never clear them, and a parked token or PII
220+
* would otherwise survive in state, exports, and templates indefinitely.
221+
*
222+
* Runs for every block, not just those with a rename map: the parked keys no
223+
* longer appear in any `SUBBLOCK_ID_MIGRATIONS` entry as an `oldId`, so nothing
224+
* else would ever look at them.
225+
*/
226+
function dropParkedSubblocks(subBlocks: Record<string, BlockState['subBlocks'][string]>): {
227+
subBlocks: Record<string, BlockState['subBlocks'][string]>
228+
dropped: boolean
229+
} {
230+
const parked = Object.keys(subBlocks).filter((id) => id.startsWith(REMOVED_SUBBLOCK_ID_PREFIX))
231+
if (parked.length === 0) return { subBlocks, dropped: false }
232+
233+
const result = { ...subBlocks }
234+
for (const id of parked) delete result[id]
235+
return { subBlocks: result, dropped: true }
236+
}
237+
215238
/**
216239
* Applies subblock-ID migrations to every block in a workflow.
217240
* Returns a new blocks record with migrated subBlocks where needed.
@@ -233,11 +256,19 @@ export function migrateSubblockIds(blocks: Record<string, BlockState>): {
233256
const renamed = renames
234257
? migrateBlockSubblockIds(block.type, block.subBlocks, renames)
235258
: { subBlocks: block.subBlocks, migrated: false }
236-
const renamedBlock = renamed.migrated ? { ...block, subBlocks: renamed.subBlocks } : block
259+
const purged = dropParkedSubblocks(renamed.subBlocks)
260+
const changedSubBlocks = renamed.migrated || purged.dropped
261+
const renamedBlock = changedSubBlocks ? { ...block, subBlocks: purged.subBlocks } : block
237262
const sanitized = sanitizeMalformedSubBlocks(renamedBlock)
238-
const blockMigrated = renamed.migrated || sanitized.changed
263+
const blockMigrated = changedSubBlocks || sanitized.changed
239264

240265
if (blockMigrated) {
266+
if (purged.dropped) {
267+
logger.info('Dropped parked subblock values left by an earlier migration', {
268+
blockId: block.id,
269+
blockType: block.type,
270+
})
271+
}
241272
if (renamed.migrated) {
242273
logger.info('Migrated legacy subblock IDs', {
243274
blockId: block.id,

0 commit comments

Comments
 (0)