Skip to content

Commit 4b7ed91

Browse files
committed
fix(files): require workspace owners for workspace-scoped files
1 parent 23b0ad5 commit 4b7ed91

7 files changed

Lines changed: 30990 additions & 1 deletion

File tree

‎packages/db/file-entity-ownership.integration.ts‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { readFileSync } from 'node:fs'
2+
import { validateFileWorkspaceBindingMigration } from '@sim/db/script-migrations/0034_validate_file_workspace_binding'
3+
import { runScriptMigrations } from '@sim/db/script-migrations/index'
24
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
35
import { createDeferred } from '@sim/testing/helpers/deferred'
46
import { sleep } from '@sim/utils/helpers'
@@ -12,13 +14,20 @@ const migration = readFileSync(
1214
'utf8'
1315
)
1416

17+
const workspaceBindingMigration = readFileSync(
18+
new URL('./migrations/0408_file_workspace_binding.sql', import.meta.url),
19+
'utf8'
20+
)
21+
1522
describe('file entity ownership migration in PostgreSQL', () => {
1623
const schemaName = `file_entity_${generateId().replaceAll('-', '')}`
1724
let sql: Sql
1825
let admin: Sql
1926

2027
async function applyMigration() {
21-
for (const statement of migration.split('--> statement-breakpoint')) {
28+
for (const statement of `${migration}\n--> statement-breakpoint\n${workspaceBindingMigration}`.split(
29+
'--> statement-breakpoint'
30+
)) {
2231
if (statement.trim()) await sql.unsafe(statement)
2332
}
2433
}
@@ -168,6 +177,85 @@ describe('file entity ownership migration in PostgreSQL', () => {
168177
})
169178
})
170179

180+
it.each(['workspace', 'chat', 'mothership', 'execution', 'workspace-logos'])(
181+
'requires a workspace owner on insert and update for %s files, including archived files',
182+
async (context) => {
183+
for (const deletedAt of [null, new Date()]) {
184+
await expect(sql`INSERT INTO workspace_files (id, context, user_id, deleted_at)
185+
VALUES ('unowned', ${context}, 'user-a', ${deletedAt})`).rejects.toMatchObject({
186+
code: '23514',
187+
})
188+
}
189+
await expect(sql`INSERT INTO workspace_files (id, context, user_id, organization_id)
190+
VALUES ('wrong-owner', ${context}, 'user-a', 'organization-a')`).rejects.toMatchObject({
191+
code: '23514',
192+
})
193+
await sql`INSERT INTO workspace_files (id, context, user_id, workspace_id)
194+
VALUES ('owned', ${context}, 'user-a', 'workspace-a')`
195+
await expect(
196+
sql`UPDATE workspace_files SET workspace_id = NULL WHERE id = 'owned'`
197+
).rejects.toMatchObject({ code: '23514' })
198+
await sql`INSERT INTO workspace_files (id, context, user_id)
199+
VALUES ('personal', 'copilot', 'user-a')`
200+
await expect(
201+
sql`UPDATE workspace_files SET context = ${context} WHERE id = 'personal'`
202+
).rejects.toMatchObject({ code: '23514' })
203+
expect(await sql`SELECT workspace_id FROM workspace_files WHERE id = 'owned'`).toEqual([
204+
{ workspace_id: 'workspace-a' },
205+
])
206+
}
207+
)
208+
209+
it('keeps personal and both knowledge-base owner types valid', async () => {
210+
await sql`INSERT INTO workspace_files (id, context, user_id, workspace_id, organization_id)
211+
VALUES ('avatar', 'profile-pictures', 'user-a', NULL, NULL),
212+
('personal', 'copilot', 'user-a', NULL, NULL),
213+
('workspace-kb', 'knowledge-base', 'user-a', 'workspace-a', NULL),
214+
('organization-kb', 'knowledge-base', 'user-a', NULL, 'organization-a')`
215+
expect(
216+
await sql`SELECT file.id, owner.entity_type, owner.entity_id
217+
FROM workspace_files file CROSS JOIN LATERAL workspace_file_owner(
218+
file.context, file.workspace_id, file.project_id, file.organization_id, file.user_id
219+
) owner ORDER BY file.id`
220+
).toEqual([
221+
{ id: 'avatar', entity_type: 'user', entity_id: 'user-a' },
222+
{ id: 'organization-kb', entity_type: 'organization', entity_id: 'organization-a' },
223+
{ id: 'personal', entity_type: 'user', entity_id: 'user-a' },
224+
{ id: 'workspace-kb', entity_type: 'workspace', entity_id: 'workspace-a' },
225+
])
226+
})
227+
228+
it('reports retained ownerless files without guessing, then validates after explicit repair and replays safely', async () => {
229+
await sql`ALTER TABLE workspace_files DROP CONSTRAINT workspace_files_workspace_binding_check`
230+
await sql`INSERT INTO workspace_files (id, context, user_id, deleted_at)
231+
VALUES ('legacy-unowned', 'workspace', 'user-a', now())`
232+
await applyMigration()
233+
await applyMigration()
234+
await expect(runScriptMigrations(sql, [validateFileWorkspaceBindingMigration])).rejects.toThrow(
235+
'Workspace-scoped files are missing workspace_id'
236+
)
237+
expect(await sql`SELECT name FROM script_migrations`).toEqual([])
238+
expect(await sql`SELECT workspace_id, user_id, key FROM workspace_files`).toEqual([
239+
{ workspace_id: null, user_id: 'user-a', key: 'unchanged-object-key' },
240+
])
241+
expect(
242+
await sql`SELECT convalidated FROM pg_constraint
243+
WHERE conrelid = 'workspace_files'::regclass
244+
AND conname = 'workspace_files_workspace_binding_check'`
245+
).toEqual([{ convalidated: false }])
246+
await sql`UPDATE workspace_files SET workspace_id = 'workspace-b' WHERE id = 'legacy-unowned'`
247+
await runScriptMigrations(sql, [validateFileWorkspaceBindingMigration])
248+
await runScriptMigrations(sql, [validateFileWorkspaceBindingMigration])
249+
expect(await sql`SELECT name FROM script_migrations`).toEqual([
250+
{ name: '0034_validate_file_workspace_binding' },
251+
])
252+
expect(
253+
await sql`SELECT convalidated FROM pg_constraint
254+
WHERE conrelid = 'workspace_files'::regclass
255+
AND conname = 'workspace_files_workspace_binding_check'`
256+
).toEqual([{ convalidated: true }])
257+
})
258+
171259
it('allows independent concurrent file inserts into the same Project', async () => {
172260
const inserted = createDeferred<void>()
173261
const release = createDeferred<void>()
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
DO $$ BEGIN
2+
IF NOT EXISTS (
3+
SELECT 1 FROM pg_constraint
4+
WHERE conname = 'workspace_files_workspace_binding_check'
5+
AND conrelid = 'workspace_files'::regclass
6+
) THEN
7+
ALTER TABLE workspace_files ADD CONSTRAINT workspace_files_workspace_binding_check
8+
CHECK (context NOT IN ('workspace', 'chat', 'mothership', 'execution', 'workspace-logos')
9+
OR workspace_id IS NOT NULL) NOT VALID;
10+
END IF;
11+
END $$;

0 commit comments

Comments
 (0)