Files
simstudioai--sim/packages/db/migrations/0222_kb_document_storage_key.sql
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

193 lines
7.4 KiB
PL/PgSQL

-- ============================================================================
-- Knowledge-base file-access authorization: document.storage_key + backfill
-- ============================================================================
-- Adds document.storage_key (the canonical kb/ key KB file authorization matches
-- on at read time), then two data steps: (1) populate storage_key from file_url
-- for existing rows; (2) backfill trusted ownership bindings into workspace_files
-- (context 'knowledge-base') for pre-existing kb/ objects, derived from the
-- populated storage_key.
--
-- Lock profile (mirrors 0220_early_hellion.sql): the ADD COLUMN runs in the
-- migration transaction, then COMMIT releases the ACCESS EXCLUSIVE lock so the
-- row-by-row backfill runs in bounded batches (committing every 5000 rows) and
-- the index is built CONCURRENTLY — no full read/write lockout on `document`.
--
-- Ownership is derived conservatively (anti-poisoning):
-- * Only active documents (not excluded/archived/deleted, in a live KB) count.
-- * A key referenced by documents in more than one workspace is ambiguous and
-- skipped (deny-by-default).
-- * Within a single workspace the earliest uploader wins.
-- * Keys with an existing active binding are skipped (idempotent; ON CONFLICT
-- DO NOTHING guards the active-key unique index against a concurrent insert).
--
-- IMPORTANT — run these read-only pre-flight checks on the DB console and
-- remediate (exclude/delete) any rows BEFORE applying this migration. The
-- multi-workspace guard catches contested keys, but a pre-existing plant that is
-- the sole referencer of an orphan key can only be caught by check (2).
--
-- (1) Multi-workspace keys — same kb/ object referenced by documents in >1 workspace:
-- WITH kb_docs AS (
-- SELECT d.id AS document_id, kb.workspace_id,
-- regexp_replace(replace(replace(
-- substring(split_part(d.file_url, '?', 1) FROM '/api/files/serve/(.*)$'),
-- '%2F','/'),'%2f','/'),'^(s3|blob)/','') AS storage_key
-- FROM document d JOIN knowledge_base kb ON kb.id = d.knowledge_base_id
-- WHERE d.user_excluded = false AND d.archived_at IS NULL
-- AND d.deleted_at IS NULL AND kb.deleted_at IS NULL
-- )
-- SELECT * FROM kb_docs WHERE storage_key LIKE 'kb/%' AND storage_key IN (
-- SELECT storage_key FROM kb_docs
-- WHERE storage_key LIKE 'kb/%' AND workspace_id IS NOT NULL
-- GROUP BY storage_key HAVING COUNT(DISTINCT workspace_id) > 1)
-- ORDER BY storage_key;
--
-- (2) Cross-origin serve URLs — foreign host mimicking the serve path. Replace
-- 'your-app-host' with your deployment host(s):
-- SELECT d.id, kb.workspace_id, d.file_url,
-- substring(split_part(d.file_url, '?', 1) FROM '^https?://([^/]+)') AS url_host
-- FROM document d JOIN knowledge_base kb ON kb.id = d.knowledge_base_id
-- WHERE d.user_excluded = false AND d.archived_at IS NULL
-- AND d.deleted_at IS NULL AND kb.deleted_at IS NULL
-- AND split_part(d.file_url, '?', 1) ~ '/api/files/serve/(s3/|blob/)?kb(/|%2F)'
-- AND substring(split_part(d.file_url, '?', 1) FROM '^https?://([^/]+)')
-- NOT IN ('your-app-host')
-- ORDER BY url_host;
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "storage_key" text;--> statement-breakpoint
COMMIT;--> statement-breakpoint
-- (1) Populate storage_key from file_url for existing documents, in bounded
-- batches. Only internal serve URLs are touched; external/data: URLs keep NULL.
CREATE OR REPLACE PROCEDURE backfill_document_storage_key_0222() LANGUAGE plpgsql AS $$
DECLARE
updated integer;
BEGIN
LOOP
WITH candidates AS (
SELECT id FROM document
WHERE storage_key IS NULL
AND file_url LIKE '%/api/files/serve/%'
LIMIT 5000
)
UPDATE document d
SET storage_key = regexp_replace(
replace(
replace(
substring(split_part(d.file_url, '?', 1) FROM '/api/files/serve/(.*)$'),
'%2F', '/'
),
'%2f', '/'
),
'^(s3|blob)/',
''
)
FROM candidates c
WHERE d.id = c.id;
GET DIAGNOSTICS updated = ROW_COUNT;
EXIT WHEN updated = 0;
COMMIT;
END LOOP;
END;
$$;--> statement-breakpoint
CALL backfill_document_storage_key_0222();--> statement-breakpoint
DROP PROCEDURE backfill_document_storage_key_0222();--> statement-breakpoint
-- (1b) Fully URL-decode keys that carry encodings beyond the separator (e.g.
-- '%2C' -> ',', '%26' -> '&', multi-byte UTF-8 like '%C3%ADa' -> 'ía'). The app
-- derives the canonical key with decodeURIComponent (extractStorageKey), so a
-- storage_key that is only %2F-decoded would never match the runtime key for
-- these documents — breaking their liveness lookups and stranding their
-- ownership bindings. The bulk pass above already handled '%2F'; only the
-- remaining residual-'%' rows are re-derived here (a small subset), so the heavy
-- decode never runs over the whole table. This single UPDATE evaluates its target
-- set once, so a key that legitimately contains a literal '%' is not reprocessed.
CREATE OR REPLACE FUNCTION url_decode_0222(input text) RETURNS text LANGUAGE sql IMMUTABLE AS $$
SELECT CASE
WHEN input IS NULL THEN NULL
WHEN position('%' IN input) = 0 THEN input
ELSE convert_from(
(
SELECT string_agg(
CASE
WHEN r.m[1] ~ '^%[0-9A-Fa-f]{2}$' THEN decode(substring(r.m[1] FROM 2), 'hex')
ELSE convert_to(r.m[1], 'UTF8')
END,
''::bytea ORDER BY r.ord
)
FROM regexp_matches(input, '%[0-9A-Fa-f]{2}|.', 'g') WITH ORDINALITY AS r(m, ord)
),
'UTF8'
)
END
$$;--> statement-breakpoint
UPDATE "document"
SET "storage_key" = regexp_replace(
url_decode_0222(substring(split_part("file_url", '?', 1) FROM '/api/files/serve/(.*)$')),
'^(s3|blob)/',
''
)
WHERE "storage_key" LIKE 'kb/%'
AND position('%' IN "storage_key") > 0;--> statement-breakpoint
DROP FUNCTION url_decode_0222(text);--> statement-breakpoint
CREATE INDEX CONCURRENTLY IF NOT EXISTS "doc_storage_key_idx" ON "document" USING btree ("storage_key") WHERE "storage_key" IS NOT NULL;--> statement-breakpoint
-- (2) Backfill workspace_files ownership bindings from the populated storage_key.
WITH kb_docs AS (
SELECT
d."uploaded_at" AS uploaded_at,
kb."workspace_id" AS workspace_id,
kb."user_id" AS user_id,
d."storage_key" AS storage_key
FROM "document" d
INNER JOIN "knowledge_base" kb ON kb."id" = d."knowledge_base_id"
WHERE d."user_excluded" = false
AND d."archived_at" IS NULL
AND d."deleted_at" IS NULL
AND kb."deleted_at" IS NULL
AND kb."workspace_id" IS NOT NULL
AND d."storage_key" LIKE 'kb/%'
),
kb_keys AS (
SELECT storage_key
FROM kb_docs
GROUP BY storage_key
HAVING COUNT(DISTINCT workspace_id) = 1
),
owners AS (
SELECT DISTINCT ON (kb_docs.storage_key)
kb_docs.storage_key AS storage_key,
kb_docs.workspace_id AS workspace_id,
kb_docs.user_id AS user_id
FROM kb_docs
INNER JOIN kb_keys ON kb_keys.storage_key = kb_docs.storage_key
ORDER BY kb_docs.storage_key, kb_docs.uploaded_at ASC
)
INSERT INTO "workspace_files" (
"id",
"key",
"user_id",
"workspace_id",
"context",
"original_name",
"content_type",
"size"
)
SELECT
gen_random_uuid()::text,
owners.storage_key,
owners.user_id,
owners.workspace_id,
'knowledge-base',
regexp_replace(owners.storage_key, '^.*/', ''),
'application/octet-stream',
0
FROM owners
WHERE NOT EXISTS (
SELECT 1
FROM "workspace_files" wf
WHERE wf."key" = owners.storage_key
AND wf."deleted_at" IS NULL
)
ON CONFLICT DO NOTHING;