@opengeni/db 0.23.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-3UCHDMKG.js → chunk-7WTDI7Y3.js} +109 -2
- package/dist/chunk-7WTDI7Y3.js.map +1 -0
- package/dist/{chunk-L6ADMZHE.js → chunk-KW6U54V2.js} +3 -1
- package/dist/chunk-KW6U54V2.js.map +1 -0
- package/dist/connection-token-resolver.d.ts +24 -2
- package/dist/index.d.ts +68 -2
- package/dist/index.js +1168 -336
- package/dist/index.js.map +1 -1
- package/dist/preference-registry.d.ts +14 -0
- package/dist/provision-roles.js +1 -1
- package/dist/runtime-posture.d.ts +2 -2
- package/dist/schema.d.ts +262 -4
- package/dist/schema.js +3 -1
- package/dist/session-realtime-mirror.d.ts +22 -1
- package/dist/workspace-instruction-policies-schema.d.ts +68 -0
- package/dist/workspace-instruction-policies.d.ts +53 -7
- package/drizzle/0165_document_authority_foundation.sql +259 -0
- package/drizzle/0166_connection_disconnect_idempotency.sql +49 -0
- package/drizzle/0167_document_index_replay_authority.sql +61 -0
- package/drizzle/0168_workspace_instruction_policy_operation_receipts.sql +44 -0
- package/package.json +3 -3
- package/src/connection-token-resolver.ts +79 -16
- package/src/index.ts +419 -23
- package/src/preference-registry.ts +103 -0
- package/src/runtime-posture.ts +2 -0
- package/src/schema.ts +86 -0
- package/src/session-control.ts +48 -1
- package/src/session-queue-commands.ts +45 -11
- package/src/session-realtime-mirror.ts +117 -1
- package/src/session-realtime.ts +49 -1
- package/src/workspace-instruction-policies-schema.ts +30 -0
- package/src/workspace-instruction-policies.ts +438 -24
- package/dist/chunk-3UCHDMKG.js.map +0 -1
- package/dist/chunk-L6ADMZHE.js.map +0 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
-- deployment-mode: maintenance
|
|
2
|
+
-- Durable organization/workspace/personal authority for Documents and chunks.
|
|
3
|
+
-- Existing workspace-visible rows remain workspace authority. Existing private
|
|
4
|
+
-- rows remain personal authority, anchored to their original workspace and
|
|
5
|
+
-- immutable creating subject. Collections/bases are not authority boundaries.
|
|
6
|
+
|
|
7
|
+
SET lock_timeout = '5s';
|
|
8
|
+
SET statement_timeout = '10min';
|
|
9
|
+
|
|
10
|
+
-- Reject a mixed-version cutover before taking table locks. Repeat after the
|
|
11
|
+
-- locks to close the connect-before-lock race. Deployment still owns the
|
|
12
|
+
-- external stop/no-restart protocol after this migration commits.
|
|
13
|
+
DO $document_writer_drain_before_lock$
|
|
14
|
+
BEGIN
|
|
15
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app')
|
|
16
|
+
AND EXISTS (
|
|
17
|
+
SELECT 1
|
|
18
|
+
FROM pg_stat_activity
|
|
19
|
+
WHERE datname = current_database()
|
|
20
|
+
AND usename = 'opengeni_app'
|
|
21
|
+
AND pid <> pg_backend_pid()
|
|
22
|
+
)
|
|
23
|
+
THEN
|
|
24
|
+
RAISE EXCEPTION 'document authority activation requires all opengeni_app sessions to be stopped'
|
|
25
|
+
USING ERRCODE = '55000';
|
|
26
|
+
END IF;
|
|
27
|
+
END
|
|
28
|
+
$document_writer_drain_before_lock$;
|
|
29
|
+
|
|
30
|
+
-- Both tables already use FORCE RLS. The migration role owns them but may not
|
|
31
|
+
-- be a superuser, so temporarily restore the ordinary owner bypass inside this
|
|
32
|
+
-- transaction. Runtime roles remain subject to RLS throughout the cutover.
|
|
33
|
+
-- The final policy block restores FORCE before this migration can commit.
|
|
34
|
+
ALTER TABLE "documents" NO FORCE ROW LEVEL SECURITY;
|
|
35
|
+
ALTER TABLE "document_chunks" NO FORCE ROW LEVEL SECURITY;
|
|
36
|
+
|
|
37
|
+
DO $document_writer_drain_after_lock$
|
|
38
|
+
BEGIN
|
|
39
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app')
|
|
40
|
+
AND EXISTS (
|
|
41
|
+
SELECT 1
|
|
42
|
+
FROM pg_stat_activity
|
|
43
|
+
WHERE datname = current_database()
|
|
44
|
+
AND usename = 'opengeni_app'
|
|
45
|
+
AND pid <> pg_backend_pid()
|
|
46
|
+
)
|
|
47
|
+
THEN
|
|
48
|
+
RAISE EXCEPTION 'document authority activation requires all opengeni_app sessions to be stopped'
|
|
49
|
+
USING ERRCODE = '55000';
|
|
50
|
+
END IF;
|
|
51
|
+
END
|
|
52
|
+
$document_writer_drain_after_lock$;
|
|
53
|
+
|
|
54
|
+
-- This is a protocol cutover, not a replay adapter. Stop the API and every
|
|
55
|
+
-- worker, wait for all document-index Temporal workflows to close, and settle
|
|
56
|
+
-- every queued/indexing document before applying the migration. The row check
|
|
57
|
+
-- is the database-verifiable half of that drain and prevents a legacy
|
|
58
|
+
-- three-field activity payload from being resumed under the new authority
|
|
59
|
+
-- contract.
|
|
60
|
+
DO $document_index_drain$
|
|
61
|
+
BEGIN
|
|
62
|
+
IF EXISTS (
|
|
63
|
+
SELECT 1 FROM "documents" WHERE "status" IN ('queued', 'indexing')
|
|
64
|
+
) THEN
|
|
65
|
+
RAISE EXCEPTION 'migration 0165 requires every queued/indexing document to settle before cutover'
|
|
66
|
+
USING ERRCODE = '55000',
|
|
67
|
+
HINT = 'Stop API/workers, close document-index workflows, and retry after documents are ready or failed.';
|
|
68
|
+
END IF;
|
|
69
|
+
END
|
|
70
|
+
$document_index_drain$;
|
|
71
|
+
|
|
72
|
+
ALTER TABLE "documents" ADD COLUMN "authority_kind" text;
|
|
73
|
+
ALTER TABLE "documents" ADD COLUMN "authority_workspace_id" uuid;
|
|
74
|
+
ALTER TABLE "documents" ADD COLUMN "authority_subject_id" text;
|
|
75
|
+
|
|
76
|
+
ALTER TABLE "document_chunks" ADD COLUMN "authority_kind" text;
|
|
77
|
+
ALTER TABLE "document_chunks" ADD COLUMN "authority_workspace_id" uuid;
|
|
78
|
+
ALTER TABLE "document_chunks" ADD COLUMN "authority_subject_id" text;
|
|
79
|
+
|
|
80
|
+
-- Deterministic legacy backfill. Migration 0126 already guarantees every
|
|
81
|
+
-- private row has a non-empty creator, so this never widens an ambiguous row.
|
|
82
|
+
UPDATE "documents"
|
|
83
|
+
SET "authority_kind" = CASE WHEN "visibility" = 'private' THEN 'personal' ELSE 'workspace' END,
|
|
84
|
+
"authority_workspace_id" = "workspace_id",
|
|
85
|
+
"authority_subject_id" = CASE WHEN "visibility" = 'private' THEN "created_by" ELSE NULL END;
|
|
86
|
+
|
|
87
|
+
-- The document is the canonical parent for every chunk identity field. Repair
|
|
88
|
+
-- any legacy drift before freezing future writes with the trigger below.
|
|
89
|
+
UPDATE "document_chunks" AS chunk
|
|
90
|
+
SET "account_id" = document."account_id",
|
|
91
|
+
"workspace_id" = document."workspace_id",
|
|
92
|
+
"base_id" = document."base_id",
|
|
93
|
+
"file_id" = document."file_id"
|
|
94
|
+
FROM "documents" AS document
|
|
95
|
+
WHERE document."id" = chunk."document_id"
|
|
96
|
+
AND (
|
|
97
|
+
chunk."account_id" IS DISTINCT FROM document."account_id"
|
|
98
|
+
OR chunk."workspace_id" IS DISTINCT FROM document."workspace_id"
|
|
99
|
+
OR chunk."base_id" IS DISTINCT FROM document."base_id"
|
|
100
|
+
OR chunk."file_id" IS DISTINCT FROM document."file_id"
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
UPDATE "document_chunks" AS chunk
|
|
104
|
+
SET "authority_kind" = document."authority_kind",
|
|
105
|
+
"authority_workspace_id" = document."authority_workspace_id",
|
|
106
|
+
"authority_subject_id" = document."authority_subject_id"
|
|
107
|
+
FROM "documents" AS document
|
|
108
|
+
WHERE document."id" = chunk."document_id";
|
|
109
|
+
|
|
110
|
+
ALTER TABLE "documents" ALTER COLUMN "authority_kind" SET NOT NULL;
|
|
111
|
+
ALTER TABLE "documents" ALTER COLUMN "authority_kind" SET DEFAULT 'workspace';
|
|
112
|
+
ALTER TABLE "document_chunks" ALTER COLUMN "authority_kind" SET NOT NULL;
|
|
113
|
+
ALTER TABLE "document_chunks" ALTER COLUMN "authority_kind" SET DEFAULT 'workspace';
|
|
114
|
+
|
|
115
|
+
ALTER TABLE "documents"
|
|
116
|
+
ADD CONSTRAINT "documents_authority_workspace_fk"
|
|
117
|
+
FOREIGN KEY ("authority_workspace_id", "account_id")
|
|
118
|
+
REFERENCES "workspaces"("id", "account_id") ON DELETE RESTRICT;
|
|
119
|
+
ALTER TABLE "document_chunks"
|
|
120
|
+
ADD CONSTRAINT "document_chunks_authority_workspace_fk"
|
|
121
|
+
FOREIGN KEY ("authority_workspace_id", "account_id")
|
|
122
|
+
REFERENCES "workspaces"("id", "account_id") ON DELETE RESTRICT;
|
|
123
|
+
|
|
124
|
+
ALTER TABLE "documents" ADD CONSTRAINT "documents_authority_chk" CHECK (
|
|
125
|
+
("authority_kind" = 'organization' AND "authority_workspace_id" IS NULL AND "authority_subject_id" IS NULL)
|
|
126
|
+
OR ("authority_kind" = 'workspace' AND "authority_workspace_id" = "workspace_id" AND "authority_subject_id" IS NULL)
|
|
127
|
+
OR (
|
|
128
|
+
"authority_kind" = 'personal'
|
|
129
|
+
AND "authority_workspace_id" = "workspace_id"
|
|
130
|
+
AND NULLIF(btrim("authority_subject_id"), '') IS NOT NULL
|
|
131
|
+
AND octet_length(convert_to("authority_subject_id", 'UTF8')) <= 1024
|
|
132
|
+
AND "authority_subject_id" = "created_by"
|
|
133
|
+
)
|
|
134
|
+
);
|
|
135
|
+
ALTER TABLE "documents" ADD CONSTRAINT "documents_authority_visibility_chk" CHECK (
|
|
136
|
+
("authority_kind" = 'personal') = ("visibility" = 'private')
|
|
137
|
+
);
|
|
138
|
+
ALTER TABLE "document_chunks" ADD CONSTRAINT "document_chunks_authority_chk" CHECK (
|
|
139
|
+
("authority_kind" = 'organization' AND "authority_workspace_id" IS NULL AND "authority_subject_id" IS NULL)
|
|
140
|
+
OR ("authority_kind" = 'workspace' AND "authority_workspace_id" = "workspace_id" AND "authority_subject_id" IS NULL)
|
|
141
|
+
OR (
|
|
142
|
+
"authority_kind" = 'personal'
|
|
143
|
+
AND "authority_workspace_id" = "workspace_id"
|
|
144
|
+
AND NULLIF(btrim("authority_subject_id"), '') IS NOT NULL
|
|
145
|
+
AND octet_length(convert_to("authority_subject_id", 'UTF8')) <= 1024
|
|
146
|
+
)
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
CREATE INDEX "documents_authority_idx" ON "documents" (
|
|
150
|
+
"account_id", "authority_kind", "authority_workspace_id", "authority_subject_id", "status"
|
|
151
|
+
);
|
|
152
|
+
CREATE INDEX "document_chunks_authority_idx" ON "document_chunks" (
|
|
153
|
+
"account_id", "authority_kind", "authority_workspace_id", "authority_subject_id"
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
CREATE OR REPLACE FUNCTION opengeni_private.apply_document_authority()
|
|
157
|
+
RETURNS trigger
|
|
158
|
+
LANGUAGE plpgsql
|
|
159
|
+
AS $$
|
|
160
|
+
BEGIN
|
|
161
|
+
IF TG_OP = 'UPDATE' AND (
|
|
162
|
+
NEW.authority_kind IS DISTINCT FROM OLD.authority_kind
|
|
163
|
+
OR NEW.authority_workspace_id IS DISTINCT FROM OLD.authority_workspace_id
|
|
164
|
+
OR NEW.authority_subject_id IS DISTINCT FROM OLD.authority_subject_id
|
|
165
|
+
OR NEW.created_by IS DISTINCT FROM OLD.created_by
|
|
166
|
+
OR NEW.visibility IS DISTINCT FROM OLD.visibility
|
|
167
|
+
) THEN
|
|
168
|
+
RAISE EXCEPTION 'document authority is immutable';
|
|
169
|
+
END IF;
|
|
170
|
+
|
|
171
|
+
IF NEW.authority_kind IS NULL OR (
|
|
172
|
+
NEW.authority_kind = 'workspace'
|
|
173
|
+
AND NEW.authority_workspace_id IS NULL
|
|
174
|
+
AND NEW.visibility = 'private'
|
|
175
|
+
) THEN
|
|
176
|
+
NEW.authority_kind := CASE WHEN NEW.visibility = 'private' THEN 'personal' ELSE 'workspace' END;
|
|
177
|
+
END IF;
|
|
178
|
+
CASE NEW.authority_kind
|
|
179
|
+
WHEN 'organization' THEN
|
|
180
|
+
NEW.authority_workspace_id := NULL;
|
|
181
|
+
NEW.authority_subject_id := NULL;
|
|
182
|
+
NEW.visibility := 'workspace';
|
|
183
|
+
WHEN 'workspace' THEN
|
|
184
|
+
NEW.authority_workspace_id := NEW.workspace_id;
|
|
185
|
+
NEW.authority_subject_id := NULL;
|
|
186
|
+
NEW.visibility := 'workspace';
|
|
187
|
+
WHEN 'personal' THEN
|
|
188
|
+
NEW.authority_workspace_id := NEW.workspace_id;
|
|
189
|
+
NEW.authority_subject_id := coalesce(NULLIF(btrim(NEW.authority_subject_id), ''), NULLIF(btrim(NEW.created_by), ''));
|
|
190
|
+
NEW.visibility := 'private';
|
|
191
|
+
ELSE
|
|
192
|
+
RAISE EXCEPTION 'invalid document authority kind: %', NEW.authority_kind;
|
|
193
|
+
END CASE;
|
|
194
|
+
RETURN NEW;
|
|
195
|
+
END
|
|
196
|
+
$$;
|
|
197
|
+
|
|
198
|
+
CREATE TRIGGER documents_authority_guard
|
|
199
|
+
BEFORE INSERT OR UPDATE ON "documents"
|
|
200
|
+
FOR EACH ROW EXECUTE FUNCTION opengeni_private.apply_document_authority();
|
|
201
|
+
|
|
202
|
+
CREATE OR REPLACE FUNCTION opengeni_private.apply_document_chunk_authority()
|
|
203
|
+
RETURNS trigger
|
|
204
|
+
LANGUAGE plpgsql
|
|
205
|
+
AS $$
|
|
206
|
+
DECLARE parent "documents"%ROWTYPE;
|
|
207
|
+
BEGIN
|
|
208
|
+
IF TG_OP = 'UPDATE' AND (
|
|
209
|
+
NEW.authority_kind IS DISTINCT FROM OLD.authority_kind
|
|
210
|
+
OR NEW.authority_workspace_id IS DISTINCT FROM OLD.authority_workspace_id
|
|
211
|
+
OR NEW.authority_subject_id IS DISTINCT FROM OLD.authority_subject_id
|
|
212
|
+
OR NEW.document_id IS DISTINCT FROM OLD.document_id
|
|
213
|
+
) THEN
|
|
214
|
+
RAISE EXCEPTION 'document chunk authority is immutable';
|
|
215
|
+
END IF;
|
|
216
|
+
SELECT * INTO parent FROM "documents" WHERE "id" = NEW.document_id;
|
|
217
|
+
IF NOT FOUND
|
|
218
|
+
OR parent.account_id IS DISTINCT FROM NEW.account_id
|
|
219
|
+
OR parent.workspace_id IS DISTINCT FROM NEW.workspace_id
|
|
220
|
+
OR parent.base_id IS DISTINCT FROM NEW.base_id
|
|
221
|
+
OR parent.file_id IS DISTINCT FROM NEW.file_id
|
|
222
|
+
THEN
|
|
223
|
+
RAISE EXCEPTION 'document chunk parent identity mismatch';
|
|
224
|
+
END IF;
|
|
225
|
+
NEW.authority_kind := parent.authority_kind;
|
|
226
|
+
NEW.authority_workspace_id := parent.authority_workspace_id;
|
|
227
|
+
NEW.authority_subject_id := parent.authority_subject_id;
|
|
228
|
+
RETURN NEW;
|
|
229
|
+
END
|
|
230
|
+
$$;
|
|
231
|
+
|
|
232
|
+
CREATE TRIGGER document_chunks_authority_guard
|
|
233
|
+
BEFORE INSERT OR UPDATE ON "document_chunks"
|
|
234
|
+
FOR EACH ROW EXECUTE FUNCTION opengeni_private.apply_document_chunk_authority();
|
|
235
|
+
|
|
236
|
+
DO $$
|
|
237
|
+
DECLARE table_name text;
|
|
238
|
+
BEGIN
|
|
239
|
+
FOREACH table_name IN ARRAY ARRAY['documents', 'document_chunks'] LOOP
|
|
240
|
+
IF EXISTS (
|
|
241
|
+
SELECT 1 FROM pg_policies
|
|
242
|
+
WHERE schemaname = current_schema() AND tablename = table_name
|
|
243
|
+
AND policyname = 'workspace_isolation'
|
|
244
|
+
) THEN
|
|
245
|
+
EXECUTE format('DROP POLICY workspace_isolation ON %I', table_name);
|
|
246
|
+
END IF;
|
|
247
|
+
EXECUTE format(
|
|
248
|
+
'CREATE POLICY document_authority_isolation ON %I '
|
|
249
|
+
|| 'USING (opengeni_private.scoped_knowledge_scope_visible('
|
|
250
|
+
|| 'account_id, authority_kind, authority_workspace_id, authority_subject_id)) '
|
|
251
|
+
|| 'WITH CHECK (opengeni_private.scoped_knowledge_scope_visible('
|
|
252
|
+
|| 'account_id, authority_kind, authority_workspace_id, authority_subject_id))',
|
|
253
|
+
table_name
|
|
254
|
+
);
|
|
255
|
+
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name);
|
|
256
|
+
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
|
|
257
|
+
END LOOP;
|
|
258
|
+
END
|
|
259
|
+
$$;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
-- deployment-mode: rolling
|
|
2
|
+
-- Bind a subject-owned connection disconnect to one caller-frozen generation
|
|
3
|
+
-- and operation key. Exact retries converge only while the produced generation
|
|
4
|
+
-- remains current; reconnecting permanently fences delayed old requests.
|
|
5
|
+
|
|
6
|
+
CREATE TABLE "connection_disconnect_operations" (
|
|
7
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
8
|
+
"account_id" uuid NOT NULL REFERENCES "managed_accounts"("id") ON DELETE CASCADE,
|
|
9
|
+
"workspace_id" uuid NOT NULL REFERENCES "workspaces"("id") ON DELETE CASCADE,
|
|
10
|
+
"connection_id" uuid NOT NULL REFERENCES "connections"("id") ON DELETE CASCADE,
|
|
11
|
+
"subject_id" text NOT NULL,
|
|
12
|
+
"idempotency_key" text NOT NULL,
|
|
13
|
+
"expected_version" integer NOT NULL,
|
|
14
|
+
"result_version" integer NOT NULL,
|
|
15
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
16
|
+
CONSTRAINT "connection_disconnect_operations_subject_key_uq"
|
|
17
|
+
UNIQUE ("workspace_id", "subject_id", "idempotency_key"),
|
|
18
|
+
CONSTRAINT "connection_disconnect_operations_connection_generation_uq"
|
|
19
|
+
UNIQUE ("workspace_id", "connection_id", "expected_version"),
|
|
20
|
+
CONSTRAINT "connection_disconnect_operations_identity_check"
|
|
21
|
+
CHECK (
|
|
22
|
+
length("subject_id") BETWEEN 1 AND 512
|
|
23
|
+
AND length("idempotency_key") BETWEEN 1 AND 200
|
|
24
|
+
AND "idempotency_key" = btrim("idempotency_key")
|
|
25
|
+
AND "expected_version" > 0
|
|
26
|
+
AND "result_version" = "expected_version" + 1
|
|
27
|
+
)
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
ALTER TABLE "connection_disconnect_operations" ENABLE ROW LEVEL SECURITY;
|
|
31
|
+
ALTER TABLE "connection_disconnect_operations" FORCE ROW LEVEL SECURITY;
|
|
32
|
+
CREATE POLICY workspace_subject_isolation ON "connection_disconnect_operations"
|
|
33
|
+
USING (
|
|
34
|
+
opengeni_private.workspace_rls_visible("account_id", "workspace_id")
|
|
35
|
+
AND "subject_id" = nullif(current_setting('opengeni.subject_id', true), '')
|
|
36
|
+
)
|
|
37
|
+
WITH CHECK (
|
|
38
|
+
opengeni_private.workspace_rls_visible("account_id", "workspace_id")
|
|
39
|
+
AND "subject_id" = nullif(current_setting('opengeni.subject_id', true), '')
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
DO $$
|
|
43
|
+
BEGIN
|
|
44
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
|
|
45
|
+
REVOKE ALL PRIVILEGES ON TABLE "connection_disconnect_operations" FROM opengeni_app;
|
|
46
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE "connection_disconnect_operations" TO opengeni_app;
|
|
47
|
+
END IF;
|
|
48
|
+
END
|
|
49
|
+
$$;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
-- deployment-mode: rolling
|
|
2
|
+
-- Resolve only the immutable document authority tuple for historical
|
|
3
|
+
-- three-field document-index Temporal payloads. The ordinary documents policy
|
|
4
|
+
-- remains unchanged: personal content is still invisible without its exact
|
|
5
|
+
-- subject. This SECURITY DEFINER capability returns no content and manually
|
|
6
|
+
-- enforces the caller's already-applied account/workspace RLS context before
|
|
7
|
+
-- reading the exact document identity.
|
|
8
|
+
|
|
9
|
+
DO $document_index_authority_resolver$
|
|
10
|
+
DECLARE data_schema text := current_schema();
|
|
11
|
+
BEGIN
|
|
12
|
+
EXECUTE format($ddl$
|
|
13
|
+
CREATE OR REPLACE FUNCTION opengeni_private.resolve_document_index_authority(
|
|
14
|
+
p_account_id uuid,
|
|
15
|
+
p_workspace_id uuid,
|
|
16
|
+
p_document_id uuid
|
|
17
|
+
)
|
|
18
|
+
RETURNS TABLE (
|
|
19
|
+
authority_kind text,
|
|
20
|
+
authority_workspace_id uuid,
|
|
21
|
+
authority_subject_id text
|
|
22
|
+
)
|
|
23
|
+
LANGUAGE plpgsql
|
|
24
|
+
SECURITY DEFINER
|
|
25
|
+
SET search_path = pg_catalog
|
|
26
|
+
AS $body$
|
|
27
|
+
BEGIN
|
|
28
|
+
IF p_account_id IS DISTINCT FROM opengeni_private.current_account_id()
|
|
29
|
+
OR p_workspace_id IS DISTINCT FROM opengeni_private.current_workspace_id()
|
|
30
|
+
THEN
|
|
31
|
+
RAISE EXCEPTION 'document index authority lookup requires exact account/workspace RLS context'
|
|
32
|
+
USING ERRCODE = '42501';
|
|
33
|
+
END IF;
|
|
34
|
+
|
|
35
|
+
RETURN QUERY
|
|
36
|
+
SELECT
|
|
37
|
+
document.authority_kind,
|
|
38
|
+
document.authority_workspace_id,
|
|
39
|
+
document.authority_subject_id
|
|
40
|
+
FROM %1$I.documents document
|
|
41
|
+
WHERE document.account_id = p_account_id
|
|
42
|
+
AND document.workspace_id = p_workspace_id
|
|
43
|
+
AND document.id = p_document_id
|
|
44
|
+
LIMIT 1;
|
|
45
|
+
END;
|
|
46
|
+
$body$;
|
|
47
|
+
$ddl$, data_schema);
|
|
48
|
+
END
|
|
49
|
+
$document_index_authority_resolver$;
|
|
50
|
+
|
|
51
|
+
REVOKE ALL ON FUNCTION opengeni_private.resolve_document_index_authority(uuid, uuid, uuid)
|
|
52
|
+
FROM PUBLIC;
|
|
53
|
+
|
|
54
|
+
DO $runtime_grant$
|
|
55
|
+
BEGIN
|
|
56
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
|
|
57
|
+
GRANT EXECUTE ON FUNCTION opengeni_private.resolve_document_index_authority(uuid, uuid, uuid)
|
|
58
|
+
TO opengeni_app;
|
|
59
|
+
END IF;
|
|
60
|
+
END
|
|
61
|
+
$runtime_grant$;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- deployment-mode: rolling
|
|
2
|
+
|
|
3
|
+
-- Immutable revision and activation rows are also the durable receipts for
|
|
4
|
+
-- policy-administration mutations. Legacy rows remain untouched and project
|
|
5
|
+
-- their immutable row id as a compatibility operation identity; new writers
|
|
6
|
+
-- persist stable UUIDs plus a secret-safe fingerprint of the complete canonical
|
|
7
|
+
-- request so only byte-equivalent semantics can replay the original result.
|
|
8
|
+
ALTER TABLE "workspace_instruction_policy_revisions"
|
|
9
|
+
ADD COLUMN IF NOT EXISTS "operation_id" uuid,
|
|
10
|
+
ADD COLUMN IF NOT EXISTS "request_fingerprint" text;
|
|
11
|
+
|
|
12
|
+
ALTER TABLE "workspace_instruction_policy_revisions"
|
|
13
|
+
ADD CONSTRAINT "workspace_instruction_policy_revisions_operation_receipt_chk"
|
|
14
|
+
CHECK (
|
|
15
|
+
("operation_id" IS NULL AND "request_fingerprint" IS NULL)
|
|
16
|
+
OR (
|
|
17
|
+
"operation_id" IS NOT NULL
|
|
18
|
+
AND "request_fingerprint" ~ '^[0-9a-f]{64}$'
|
|
19
|
+
)
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
CREATE UNIQUE INDEX IF NOT EXISTS
|
|
23
|
+
"workspace_instruction_policy_revisions_workspace_operation_uq"
|
|
24
|
+
ON "workspace_instruction_policy_revisions" ("workspace_id", "operation_id")
|
|
25
|
+
WHERE "operation_id" IS NOT NULL;
|
|
26
|
+
|
|
27
|
+
ALTER TABLE "workspace_instruction_policy_activation_events"
|
|
28
|
+
ADD COLUMN IF NOT EXISTS "operation_id" uuid,
|
|
29
|
+
ADD COLUMN IF NOT EXISTS "request_fingerprint" text;
|
|
30
|
+
|
|
31
|
+
ALTER TABLE "workspace_instruction_policy_activation_events"
|
|
32
|
+
ADD CONSTRAINT "workspace_instruction_policy_events_operation_receipt_chk"
|
|
33
|
+
CHECK (
|
|
34
|
+
("operation_id" IS NULL AND "request_fingerprint" IS NULL)
|
|
35
|
+
OR (
|
|
36
|
+
"operation_id" IS NOT NULL
|
|
37
|
+
AND "request_fingerprint" ~ '^[0-9a-f]{64}$'
|
|
38
|
+
)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE UNIQUE INDEX IF NOT EXISTS
|
|
42
|
+
"workspace_instruction_policy_events_workspace_operation_uq"
|
|
43
|
+
ON "workspace_instruction_policy_activation_events" ("workspace_id", "operation_id")
|
|
44
|
+
WHERE "operation_id" IS NOT NULL;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "OpenGeni persistence: Drizzle schema, RLS-scoped query layer, the SQL migration runner, and role provisioning.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@opengeni/codex": "^0.2.10",
|
|
54
|
-
"@opengeni/config": "^0.10.
|
|
55
|
-
"@opengeni/contracts": "^0.
|
|
54
|
+
"@opengeni/config": "^0.10.6",
|
|
55
|
+
"@opengeni/contracts": "^0.35.0",
|
|
56
56
|
"@opengeni/network": "^0.1.1",
|
|
57
57
|
"drizzle-orm": "^0.45.2",
|
|
58
58
|
"postgres": "^3.4.7"
|
|
@@ -34,7 +34,14 @@ import {
|
|
|
34
34
|
} from "./index";
|
|
35
35
|
|
|
36
36
|
export type ResolveConnectionCredentialResult =
|
|
37
|
-
| {
|
|
37
|
+
| {
|
|
38
|
+
status: "ok";
|
|
39
|
+
headers: Record<string, string>;
|
|
40
|
+
connectionId: string;
|
|
41
|
+
/** Exact durable version when the credential came from the local connection store. */
|
|
42
|
+
connectionVersion?: number;
|
|
43
|
+
expiresAt?: Date | null;
|
|
44
|
+
}
|
|
38
45
|
| {
|
|
39
46
|
status: "auth_needed";
|
|
40
47
|
reason: McpCredentialAuthNeededReason;
|
|
@@ -337,6 +344,29 @@ export type ConnectionBrokerDeps = {
|
|
|
337
344
|
now: () => Date;
|
|
338
345
|
};
|
|
339
346
|
|
|
347
|
+
export type PermanentConnectionRefreshFailure = {
|
|
348
|
+
workspaceId: string;
|
|
349
|
+
connectionId: string;
|
|
350
|
+
connectionVersion: number;
|
|
351
|
+
subjectId: string | null;
|
|
352
|
+
providerDomain: string;
|
|
353
|
+
httpStatus: number;
|
|
354
|
+
oauthErrorCode: string | null;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
export type ConnectionTokenResolverOptions = {
|
|
358
|
+
/** Provider-aware transport override used by local/integration adapters. */
|
|
359
|
+
refreshTransport?: RefreshTransportOptions;
|
|
360
|
+
/**
|
|
361
|
+
* Optional provider adapter for an atomic, metadata-aware permanent refresh
|
|
362
|
+
* transition. Returning true means the adapter owned the transition (even if
|
|
363
|
+
* its CAS lost to newer truth); false falls back to generic needs_reauth.
|
|
364
|
+
*/
|
|
365
|
+
transitionPermanentRefreshFailure?: (
|
|
366
|
+
failure: PermanentConnectionRefreshFailure,
|
|
367
|
+
) => Promise<boolean>;
|
|
368
|
+
};
|
|
369
|
+
|
|
340
370
|
export type RefreshTransportOptions = {
|
|
341
371
|
fetchImpl?: FetchLike;
|
|
342
372
|
dnsLookup?: DnsLookup;
|
|
@@ -361,6 +391,7 @@ export function buildConnectionTokenResolver(
|
|
|
361
391
|
db: Database,
|
|
362
392
|
settings: Settings,
|
|
363
393
|
deps: ConnectionBrokerDeps = defaultDeps,
|
|
394
|
+
options: ConnectionTokenResolverOptions = {},
|
|
364
395
|
): (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult> {
|
|
365
396
|
type CredentialLookupInput = Pick<
|
|
366
397
|
ResolveConnectionCredentialInput,
|
|
@@ -439,6 +470,7 @@ export function buildConnectionTokenResolver(
|
|
|
439
470
|
status: "ok",
|
|
440
471
|
headers,
|
|
441
472
|
connectionId: cred.id,
|
|
473
|
+
connectionVersion: cred.version,
|
|
442
474
|
expiresAt: cred.expiresAt,
|
|
443
475
|
};
|
|
444
476
|
};
|
|
@@ -451,7 +483,7 @@ export function buildConnectionTokenResolver(
|
|
|
451
483
|
if (!key) {
|
|
452
484
|
throw new Error("OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is not configured");
|
|
453
485
|
}
|
|
454
|
-
const refreshed = await deps.refresh(cred, ref, settings);
|
|
486
|
+
const refreshed = await deps.refresh(cred, ref, settings, options.refreshTransport);
|
|
455
487
|
const refreshRecord: Parameters<typeof recordConnectionTokenRefresh>[1] = {
|
|
456
488
|
id: cred.id,
|
|
457
489
|
version: cred.version,
|
|
@@ -541,19 +573,31 @@ export function buildConnectionTokenResolver(
|
|
|
541
573
|
// Only a rejected grant may poison the connection; transient failures
|
|
542
574
|
// (network errors, AS 5xx) leave it active so the next resolve retries.
|
|
543
575
|
if (isPermanentRefreshError(error)) {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
576
|
+
let handled = false;
|
|
577
|
+
if (options.transitionPermanentRefreshFailure) {
|
|
578
|
+
try {
|
|
579
|
+
handled = await options.transitionPermanentRefreshFailure({
|
|
580
|
+
workspaceId: cred.workspaceId,
|
|
581
|
+
connectionId: cred.id,
|
|
582
|
+
connectionVersion: cred.version,
|
|
583
|
+
subjectId: cred.subjectId,
|
|
584
|
+
providerDomain: cred.providerDomain,
|
|
585
|
+
httpStatus: error.httpStatus,
|
|
586
|
+
oauthErrorCode: error.oauthErrorCode,
|
|
587
|
+
});
|
|
588
|
+
} catch {
|
|
589
|
+
handled = false;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (!handled) {
|
|
593
|
+
await deps
|
|
594
|
+
.setStatus(db, input.workspaceId, "needs_reauth", error.message, {
|
|
551
595
|
id: cred.id,
|
|
552
596
|
version: cred.version,
|
|
553
597
|
subjectId: cred.subjectId,
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
|
|
598
|
+
})
|
|
599
|
+
.catch(() => undefined);
|
|
600
|
+
}
|
|
557
601
|
}
|
|
558
602
|
return authNeeded(ref, "refresh_failed", cred.id);
|
|
559
603
|
}
|
|
@@ -629,18 +673,20 @@ function canonicalResource(value: string): string {
|
|
|
629
673
|
|
|
630
674
|
export class ConnectionRefreshHttpError extends Error {
|
|
631
675
|
readonly httpStatus: number;
|
|
676
|
+
readonly oauthErrorCode: string | null;
|
|
632
677
|
|
|
633
|
-
constructor(httpStatus: number) {
|
|
678
|
+
constructor(httpStatus: number, oauthErrorCode: string | null = null) {
|
|
634
679
|
super(`connection refresh failed with HTTP ${httpStatus}`);
|
|
635
680
|
this.name = "ConnectionRefreshHttpError";
|
|
636
681
|
this.httpStatus = httpStatus;
|
|
682
|
+
this.oauthErrorCode = oauthErrorCode;
|
|
637
683
|
}
|
|
638
684
|
}
|
|
639
685
|
|
|
640
686
|
// The token endpoint rejecting the grant itself means re-auth is the only way
|
|
641
687
|
// forward. 429 (throttling) and 408 are transient despite being 4xx; network
|
|
642
688
|
// failures and AS 5xx are likewise retryable.
|
|
643
|
-
function isPermanentRefreshError(error: unknown):
|
|
689
|
+
function isPermanentRefreshError(error: unknown): error is ConnectionRefreshHttpError {
|
|
644
690
|
return (
|
|
645
691
|
error instanceof ConnectionRefreshHttpError &&
|
|
646
692
|
error.httpStatus >= 400 &&
|
|
@@ -817,8 +863,10 @@ export async function refreshOAuthConnectionCredential(
|
|
|
817
863
|
throw new ConnectionRefreshHttpError(response.status);
|
|
818
864
|
}
|
|
819
865
|
if (!response.ok) {
|
|
820
|
-
|
|
821
|
-
|
|
866
|
+
throw new ConnectionRefreshHttpError(
|
|
867
|
+
response.status,
|
|
868
|
+
await readOAuthRefreshErrorCode(response),
|
|
869
|
+
);
|
|
822
870
|
}
|
|
823
871
|
const payload = await readResponseJsonBounded<Record<string, unknown>>(
|
|
824
872
|
response,
|
|
@@ -853,6 +901,21 @@ export async function refreshOAuthConnectionCredential(
|
|
|
853
901
|
};
|
|
854
902
|
}
|
|
855
903
|
|
|
904
|
+
async function readOAuthRefreshErrorCode(response: Response): Promise<string | null> {
|
|
905
|
+
try {
|
|
906
|
+
const payload = await readResponseJsonBounded<Record<string, unknown>>(
|
|
907
|
+
response,
|
|
908
|
+
OAUTH_MAX_RESPONSE_BYTES,
|
|
909
|
+
"OAuth refresh error response",
|
|
910
|
+
);
|
|
911
|
+
const code = stringValue(payload.error);
|
|
912
|
+
return code && /^[a-z0-9_.-]{1,64}$/i.test(code) ? code : null;
|
|
913
|
+
} catch {
|
|
914
|
+
await cancelResponseBody(response);
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
856
919
|
function expiresAtFromTokenResponse(
|
|
857
920
|
payload: Record<string, unknown>,
|
|
858
921
|
fallback: Date | null,
|