Files
C-SSH/creation-cloud/migrations/0045_unified_encrypted_sync.sql

354 lines
15 KiB
SQL

-- 迁移不得解析、改写或删除既有地址;活动 hostname 以固定脱敏错误阻断整次迁移。
DO $creation_sync_ip_precheck$
DECLARE
candidate RECORD;
parsed INET;
BEGIN
FOR candidate IN
SELECT address FROM cloud_hosts WHERE NOT is_deleted
LOOP
BEGIN
IF candidate.address <> btrim(candidate.address)
OR position('/' IN candidate.address) > 0
OR (
position(':' IN candidate.address) = 0
AND candidate.address !~ '^[0-9]{1,3}([.][0-9]{1,3}){3}$'
)
OR (
position(':' IN candidate.address) > 0
AND candidate.address !~ '^[0-9A-Fa-f:.]+$'
)
THEN
RAISE EXCEPTION 'invalid active host address';
END IF;
parsed := candidate.address::inet;
IF family(parsed) NOT IN (4, 6)
OR (family(parsed) = 4 AND masklen(parsed) <> 32)
OR (family(parsed) = 6 AND masklen(parsed) <> 128)
THEN
RAISE EXCEPTION 'invalid active host address';
END IF;
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = 'unified encrypted sync migration blocked: active host address must be numeric IP';
END;
END LOOP;
END;
$creation_sync_ip_precheck$;
-- 将主机和 AI provider 配置纳入同一账号 revision 流,并删除专用主机冲突持久层。
CREATE TABLE cloud_ai_provider_configs (
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
id UUID NOT NULL,
ciphertext BYTEA,
nonce BYTEA,
envelope_metadata JSONB,
source_device_id UUID NOT NULL,
revision BIGINT NOT NULL CHECK (revision > 0),
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, id),
FOREIGN KEY (account_id, source_device_id)
REFERENCES devices(account_id, id) ON DELETE RESTRICT,
CHECK (ciphertext IS NULL OR octet_length(ciphertext) <= 262144),
CHECK (nonce IS NULL OR octet_length(nonce) BETWEEN 1 AND 4096),
CHECK (
envelope_metadata IS NULL
OR (
jsonb_typeof(envelope_metadata) = 'object'
AND pg_column_size(envelope_metadata) <= 16384
)
),
CHECK (
(is_deleted AND ciphertext IS NULL AND nonce IS NULL AND envelope_metadata IS NULL)
OR
(NOT is_deleted AND ciphertext IS NOT NULL AND nonce IS NOT NULL
AND envelope_metadata IS NOT NULL)
)
);
CREATE INDEX cloud_ai_provider_configs_account_list_idx
ON cloud_ai_provider_configs(account_id, is_deleted, updated_at DESC, id);
CREATE INDEX cloud_ai_provider_configs_revision_idx
ON cloud_ai_provider_configs(account_id, revision, id);
CREATE TABLE cloud_ai_provider_config_versions (
account_id UUID NOT NULL,
resource_id UUID NOT NULL,
revision BIGINT NOT NULL CHECK (revision > 0),
ciphertext BYTEA,
nonce BYTEA,
envelope_metadata JSONB,
source_device_id UUID NOT NULL,
is_deleted BOOLEAN NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, revision),
UNIQUE (account_id, resource_id, revision),
FOREIGN KEY (account_id, resource_id)
REFERENCES cloud_ai_provider_configs(account_id, id) ON DELETE CASCADE,
FOREIGN KEY (account_id, source_device_id)
REFERENCES devices(account_id, id) ON DELETE RESTRICT,
CHECK (ciphertext IS NULL OR octet_length(ciphertext) <= 262144),
CHECK (nonce IS NULL OR octet_length(nonce) BETWEEN 1 AND 4096),
CHECK (
envelope_metadata IS NULL
OR (
jsonb_typeof(envelope_metadata) = 'object'
AND pg_column_size(envelope_metadata) <= 16384
)
),
CHECK (
(is_deleted AND ciphertext IS NULL AND nonce IS NULL AND envelope_metadata IS NULL)
OR
(NOT is_deleted AND ciphertext IS NOT NULL AND nonce IS NOT NULL
AND envelope_metadata IS NOT NULL)
)
);
CREATE INDEX cloud_ai_provider_versions_pull_idx
ON cloud_ai_provider_config_versions(account_id, resource_id, revision DESC);
CREATE INDEX cloud_ai_provider_versions_retention_idx
ON cloud_ai_provider_config_versions(recorded_at, account_id, revision);
CREATE INDEX cloud_ai_provider_tombstone_retention_idx
ON cloud_ai_provider_configs(updated_at, account_id, revision, id)
WHERE is_deleted;
CREATE INDEX cloud_host_versions_retention_idx
ON cloud_host_versions(recorded_at, account_id, revision);
CREATE INDEX cloud_hosts_tombstone_retention_idx
ON cloud_hosts(updated_at, account_id, revision, id)
WHERE is_deleted;
CREATE TABLE cloud_sync_push_mutations_v2 (
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
client_mutation_id UUID NOT NULL,
source_device_id UUID NOT NULL,
request_generation BIGINT NOT NULL CHECK (request_generation > 0),
base_revision BIGINT NOT NULL CHECK (base_revision >= 0),
request_hash BYTEA NOT NULL CHECK (octet_length(request_hash) = 32),
outcome TEXT NOT NULL CHECK (outcome IN ('applied', 'unchanged')),
result_revision BIGINT NOT NULL CHECK (result_revision >= 0),
changed_count INTEGER NOT NULL DEFAULT 0 CHECK (changed_count >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
admin_deleted_at TIMESTAMPTZ,
PRIMARY KEY (account_id, client_mutation_id),
FOREIGN KEY (account_id, source_device_id)
REFERENCES devices(account_id, id) ON DELETE RESTRICT,
CHECK (
(outcome = 'applied' AND changed_count > 0)
OR (outcome = 'unchanged' AND changed_count = 0)
)
);
-- 旧请求正文与 v2 DTO 不同,只保留成功历史供后台展示,不提供兼容回放。
INSERT INTO cloud_sync_push_mutations_v2
(account_id, client_mutation_id, source_device_id, request_generation,
base_revision, request_hash, outcome, result_revision, changed_count,
created_at, admin_deleted_at)
SELECT mutations.account_id, mutations.client_mutation_id,
mutations.source_device_id, states.sync_generation, 0,
mutations.request_hash, mutations.outcome, mutations.result_revision,
mutations.changed_count, mutations.created_at, mutations.admin_deleted_at
FROM cloud_host_mutations AS mutations
JOIN cloud_host_sync_states AS states ON states.account_id = mutations.account_id
WHERE mutations.outcome IN ('applied', 'unchanged');
DROP TABLE cloud_host_mutations;
DROP TABLE cloud_host_conflicts;
ALTER TABLE cloud_sync_push_mutations_v2 RENAME TO cloud_sync_push_mutations;
CREATE INDEX cloud_sync_push_mutations_admin_visible_idx
ON cloud_sync_push_mutations(account_id, created_at DESC, client_mutation_id DESC)
WHERE admin_deleted_at IS NULL;
CREATE INDEX cloud_sync_push_mutations_retention_idx
ON cloud_sync_push_mutations(account_id, created_at, client_mutation_id);
CREATE TABLE cloud_sync_push_results (
account_id UUID NOT NULL,
client_mutation_id UUID NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('host', 'ai_provider_account')),
resource_id UUID NOT NULL,
result_revision BIGINT NOT NULL CHECK (result_revision > 0),
PRIMARY KEY (account_id, client_mutation_id, resource_kind, resource_id),
FOREIGN KEY (account_id, client_mutation_id)
REFERENCES cloud_sync_push_mutations(account_id, client_mutation_id)
ON DELETE CASCADE
);
CREATE TABLE cloud_sync_resource_deliveries (
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
device_id UUID NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('host', 'ai_provider_account')),
resource_id UUID NOT NULL,
delivered_revision BIGINT NOT NULL CHECK (delivered_revision > 0),
snapshot_revision BIGINT NOT NULL CHECK (snapshot_revision >= delivered_revision),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (
account_id, device_id, resource_kind, resource_id,
delivered_revision, snapshot_revision
),
FOREIGN KEY (account_id, device_id)
REFERENCES devices(account_id, id) ON DELETE CASCADE
);
INSERT INTO cloud_sync_resource_deliveries
(account_id, device_id, resource_kind, resource_id,
delivered_revision, snapshot_revision, updated_at)
SELECT account_id, device_id, 'host', host_id,
delivered_revision, delivered_revision, updated_at
FROM cloud_host_device_deliveries
WHERE delivered_revision > 0;
DROP TABLE cloud_host_device_deliveries;
CREATE TABLE cloud_sync_pull_decisions (
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
device_id UUID NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('host', 'ai_provider_account')),
resource_id UUID NOT NULL,
revision BIGINT NOT NULL CHECK (revision > 0),
action TEXT NOT NULL CHECK (action IN ('replace_local', 'keep_local')),
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, device_id, resource_kind, resource_id, revision),
FOREIGN KEY (account_id, device_id)
REFERENCES devices(account_id, id) ON DELETE CASCADE
);
INSERT INTO cloud_sync_pull_decisions
(account_id, device_id, resource_kind, resource_id, revision, action, recorded_at)
SELECT account_id, device_id, 'host', host_id, cloud_revision, action, recorded_at
FROM cloud_host_pull_decisions;
DROP TABLE cloud_host_pull_decisions;
ALTER TABLE cloud_host_pull_watermarks RENAME TO cloud_sync_pull_watermarks;
ALTER TABLE cloud_host_device_checkpoints RENAME TO cloud_sync_device_checkpoints;
ALTER INDEX cloud_host_device_checkpoints_admin_visible_idx
RENAME TO cloud_sync_device_checkpoints_admin_visible_idx;
-- 旧 checkpoint 可能已越过“已预览但未显式选择”的主机;迁移时回退到首个未决 revision 之前。
WITH latest_resources AS (
SELECT checkpoint.account_id, checkpoint.device_id,
latest.host_id AS resource_id, latest.revision
FROM cloud_sync_device_checkpoints AS checkpoint
JOIN LATERAL (
SELECT DISTINCT ON (versions.host_id)
versions.host_id, versions.revision
FROM cloud_host_versions AS versions
WHERE versions.account_id = checkpoint.account_id
AND versions.revision <= checkpoint.acknowledged_revision
ORDER BY versions.host_id, versions.revision DESC
) AS latest ON TRUE
), first_unresolved AS (
SELECT resource.account_id, resource.device_id, MIN(resource.revision) AS revision
FROM latest_resources AS resource
WHERE NOT EXISTS (
SELECT 1 FROM cloud_sync_pull_decisions AS decision
WHERE decision.account_id = resource.account_id
AND decision.device_id = resource.device_id
AND decision.resource_kind = 'host'
AND decision.resource_id = resource.resource_id
AND decision.revision = resource.revision
)
GROUP BY resource.account_id, resource.device_id
)
UPDATE cloud_sync_device_checkpoints AS checkpoint
SET acknowledged_revision = LEAST(
checkpoint.acknowledged_revision,
GREATEST(first_unresolved.revision - 1, 0)
),
updated_at = now()
FROM first_unresolved
WHERE checkpoint.account_id = first_unresolved.account_id
AND checkpoint.device_id = first_unresolved.device_id;
CREATE TABLE cloud_sync_rekey_resource_results (
account_id UUID NOT NULL,
mutation_id UUID NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('host', 'ai_provider_account')),
resource_id UUID NOT NULL,
previous_revision BIGINT NOT NULL CHECK (previous_revision > 0),
result_revision BIGINT NOT NULL CHECK (result_revision > previous_revision),
PRIMARY KEY (account_id, mutation_id, resource_kind, resource_id),
FOREIGN KEY (account_id, mutation_id)
REFERENCES cloud_sync_rekey_mutations(account_id, mutation_id)
ON DELETE CASCADE
);
INSERT INTO cloud_sync_rekey_resource_results
(account_id, mutation_id, resource_kind, resource_id,
previous_revision, result_revision)
SELECT account_id, mutation_id, 'host', host_id, previous_revision, result_revision
FROM cloud_sync_rekey_results;
DROP TABLE cloud_sync_rekey_results;
-- v2 使用新 action,旧 action、旧约束和历史审计正文保持逐字不变。
ALTER TABLE audit_events
ADD CONSTRAINT audit_events_sync_reset_v2_semantic_contract CHECK (
action <> 'sync.encrypted_data_reset_v2'
OR (
actor_account_id IS NOT NULL
AND resource_kind = 'sync_account'
AND resource_id = actor_account_id::TEXT
AND outcome = 'success'
AND details ?& ARRAY[
'mutation_id', 'device_id', 'removed_hosts', 'removed_versions',
'removed_ai_providers', 'removed_ai_versions',
'removed_deliveries', 'removed_ack_records', 'removed_tombstones',
'previous_sync_generation', 'sync_generation'
]
AND details - ARRAY[
'mutation_id', 'device_id', 'removed_hosts', 'removed_versions',
'removed_ai_providers', 'removed_ai_versions',
'removed_deliveries', 'removed_ack_records', 'removed_tombstones',
'previous_sync_generation', 'sync_generation'
] = '{}'::jsonb
AND jsonb_typeof(details->'mutation_id') = 'string'
AND jsonb_typeof(details->'device_id') = 'string'
AND jsonb_typeof(details->'removed_hosts') = 'number'
AND jsonb_typeof(details->'removed_versions') = 'number'
AND jsonb_typeof(details->'removed_ai_providers') = 'number'
AND jsonb_typeof(details->'removed_ai_versions') = 'number'
AND jsonb_typeof(details->'removed_deliveries') = 'number'
AND jsonb_typeof(details->'removed_ack_records') = 'number'
AND jsonb_typeof(details->'removed_tombstones') = 'number'
AND jsonb_typeof(details->'previous_sync_generation') = 'number'
AND jsonb_typeof(details->'sync_generation') = 'number'
AND (details->>'previous_sync_generation')::BIGINT > 0
AND (details->>'sync_generation')::BIGINT
= (details->>'previous_sync_generation')::BIGINT + 1
)
);
ALTER TABLE audit_events
ADD CONSTRAINT audit_events_sync_rekey_v2_semantic_contract CHECK (
action <> 'sync.encrypted_data_rekey_v2'
OR (
actor_account_id IS NOT NULL
AND resource_kind = 'sync_account'
AND resource_id = actor_account_id::TEXT
AND outcome = 'success'
AND details ?& ARRAY[
'mutation_id', 'device_id', 'changed_hosts',
'changed_ai_providers', 'result_revision',
'previous_sync_generation', 'sync_generation'
]
AND details - ARRAY[
'mutation_id', 'device_id', 'changed_hosts',
'changed_ai_providers', 'result_revision',
'previous_sync_generation', 'sync_generation'
] = '{}'::jsonb
AND jsonb_typeof(details->'mutation_id') = 'string'
AND jsonb_typeof(details->'device_id') = 'string'
AND jsonb_typeof(details->'changed_hosts') = 'number'
AND jsonb_typeof(details->'changed_ai_providers') = 'number'
AND jsonb_typeof(details->'result_revision') = 'number'
AND jsonb_typeof(details->'previous_sync_generation') = 'number'
AND jsonb_typeof(details->'sync_generation') = 'number'
AND (details->>'previous_sync_generation')::BIGINT > 0
AND (details->>'sync_generation')::BIGINT
= (details->>'previous_sync_generation')::BIGINT + 1
)
);