@opengeni/db 4.3.3-canary.2 → 4.4.0-canary.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.
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { RotateSessionMcpCredentialsReceipt } from "@opengeni/contracts";
|
|
2
|
+
import { type Database } from "./database.js";
|
|
3
|
+
export declare class SessionMcpCredentialRotationError extends Error {
|
|
4
|
+
readonly code: "invalid_request" | "not_found" | "not_quiescent" | "version_conflict" | "destination_conflict" | "brokered_server" | "operation_reuse" | "receipt_key_unavailable" | "authority_revoked";
|
|
5
|
+
constructor(code: "invalid_request" | "not_found" | "not_quiescent" | "version_conflict" | "destination_conflict" | "brokered_server" | "operation_reuse" | "receipt_key_unavailable" | "authority_revoked");
|
|
6
|
+
}
|
|
7
|
+
export type AtomicSessionMcpCredentialRotationInput = {
|
|
8
|
+
accountId: string;
|
|
9
|
+
workspaceId: string;
|
|
10
|
+
sessionId: string;
|
|
11
|
+
subjectId: string;
|
|
12
|
+
actorType: "human" | "service";
|
|
13
|
+
operationKey: string;
|
|
14
|
+
requestDigest: string;
|
|
15
|
+
digestKeyTag: string;
|
|
16
|
+
updates: Array<{
|
|
17
|
+
id: string;
|
|
18
|
+
expectedCredentialVersion: number;
|
|
19
|
+
expectedServerUrl: string;
|
|
20
|
+
headersEncrypted: Record<string, string>;
|
|
21
|
+
}>;
|
|
22
|
+
/** Trusted request authorizer, mandatory even for a committed receipt replay.
|
|
23
|
+
* Revalidate the original authenticated subject and permissions on this tx. */
|
|
24
|
+
authorize: (tx: Database) => Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
/** Existing command receipts own durable idempotency; this is not a new queue
|
|
27
|
+
* command. No session/event/history/attempt/control/wake rows are mutated. */
|
|
28
|
+
export declare function rotateSessionMcpCredentialsAtomically(db: Database, input: AtomicSessionMcpCredentialRotationInput): Promise<RotateSessionMcpCredentialsReceipt>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lockSessionEventWriteRows
|
|
3
|
+
} from "./chunk-EYF6MH6C.js";
|
|
4
|
+
import "./chunk-QLA3UQNX.js";
|
|
5
|
+
import {
|
|
6
|
+
rawRows,
|
|
7
|
+
setSubjectRlsContext,
|
|
8
|
+
withRlsContext
|
|
9
|
+
} from "./chunk-GVZJ7XSR.js";
|
|
10
|
+
import {
|
|
11
|
+
apiKeys,
|
|
12
|
+
sessionCommandReceipts,
|
|
13
|
+
sessionMcpServers,
|
|
14
|
+
sessions
|
|
15
|
+
} from "./chunk-6TKL3GQO.js";
|
|
16
|
+
import "./chunk-PZ5AY32C.js";
|
|
17
|
+
|
|
18
|
+
// src/session-mcp-credential-rotation.ts
|
|
19
|
+
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
20
|
+
import { RotateSessionMcpCredentialsReceipt, SessionTurnStatus } from "@opengeni/contracts";
|
|
21
|
+
var action = "session.mcp.credentials.rotate";
|
|
22
|
+
var liveTurnStatuses = SessionTurnStatus.exclude([
|
|
23
|
+
"completed",
|
|
24
|
+
"failed",
|
|
25
|
+
"cancelled",
|
|
26
|
+
"superseded",
|
|
27
|
+
"withdrawn_for_edit"
|
|
28
|
+
]).options;
|
|
29
|
+
var SessionMcpCredentialRotationError = class extends Error {
|
|
30
|
+
constructor(code) {
|
|
31
|
+
super(code);
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.name = "SessionMcpCredentialRotationError";
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
async function rotateSessionMcpCredentialsAtomically(db, input) {
|
|
37
|
+
if (!input.updates.length || input.updates.length > 64 || new Set(input.updates.map((update) => update.id)).size !== input.updates.length || !/^[a-f0-9]{64}$/.test(input.requestDigest) || !/^[a-f0-9]{64}$/.test(input.digestKeyTag)) {
|
|
38
|
+
throw new SessionMcpCredentialRotationError("invalid_request");
|
|
39
|
+
}
|
|
40
|
+
return withRlsContext(
|
|
41
|
+
db,
|
|
42
|
+
input,
|
|
43
|
+
async (tx) => {
|
|
44
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(
|
|
45
|
+
${`organization-membership:${input.accountId}`}, 0))`);
|
|
46
|
+
await tx.execute(sql`select pg_advisory_xact_lock_shared(hashtextextended(
|
|
47
|
+
${`session-tenancy:${input.workspaceId}`}, 0))`);
|
|
48
|
+
await setSubjectRlsContext(tx, input.subjectId);
|
|
49
|
+
const [target] = await tx.select({ id: sessions.id }).from(sessions).where(
|
|
50
|
+
and(
|
|
51
|
+
eq(sessions.accountId, input.accountId),
|
|
52
|
+
eq(sessions.workspaceId, input.workspaceId),
|
|
53
|
+
eq(sessions.id, input.sessionId)
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
if (!target) throw new SessionMcpCredentialRotationError("not_found");
|
|
57
|
+
const locks = await lockSessionEventWriteRows(tx, {
|
|
58
|
+
workspaceId: input.workspaceId,
|
|
59
|
+
controlLock: "share",
|
|
60
|
+
sessionIds: [input.sessionId]
|
|
61
|
+
});
|
|
62
|
+
const session = locks.sessions[0];
|
|
63
|
+
if (!session || session.accountId !== input.accountId) {
|
|
64
|
+
throw new SessionMcpCredentialRotationError("not_found");
|
|
65
|
+
}
|
|
66
|
+
if (input.subjectId.startsWith("api_key:")) {
|
|
67
|
+
const keyId = input.subjectId.slice("api_key:".length);
|
|
68
|
+
const [key] = await tx.select({ id: apiKeys.id, permissions: apiKeys.permissions }).from(apiKeys).where(
|
|
69
|
+
and(
|
|
70
|
+
eq(apiKeys.id, keyId),
|
|
71
|
+
eq(apiKeys.accountId, input.accountId),
|
|
72
|
+
sql`(${apiKeys.workspaceId} is null or ${apiKeys.workspaceId} = ${input.workspaceId}::uuid)`,
|
|
73
|
+
sql`${apiKeys.revokedAt} is null`,
|
|
74
|
+
sql`(${apiKeys.expiresAt} is null or ${apiKeys.expiresAt} > clock_timestamp())`
|
|
75
|
+
)
|
|
76
|
+
).for("update");
|
|
77
|
+
if (!key || !key.permissions.includes("workspace:admin") && (!key.permissions.includes("sessions:control") || !key.permissions.includes("mcp_servers:attach")))
|
|
78
|
+
throw new SessionMcpCredentialRotationError("authority_revoked");
|
|
79
|
+
}
|
|
80
|
+
await input.authorize(tx);
|
|
81
|
+
const [prior] = await tx.select().from(sessionCommandReceipts).where(
|
|
82
|
+
and(
|
|
83
|
+
eq(sessionCommandReceipts.accountId, input.accountId),
|
|
84
|
+
eq(sessionCommandReceipts.workspaceId, input.workspaceId),
|
|
85
|
+
eq(sessionCommandReceipts.targetSessionId, input.sessionId),
|
|
86
|
+
eq(sessionCommandReceipts.actorType, input.actorType),
|
|
87
|
+
eq(sessionCommandReceipts.actorSubjectId, input.subjectId),
|
|
88
|
+
eq(sessionCommandReceipts.action, action),
|
|
89
|
+
eq(sessionCommandReceipts.operationKey, input.operationKey)
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
if (prior) {
|
|
93
|
+
if (prior.result.digestKeyTag !== input.digestKeyTag)
|
|
94
|
+
throw new SessionMcpCredentialRotationError("receipt_key_unavailable");
|
|
95
|
+
if (prior.canonicalRequestHash !== input.requestDigest)
|
|
96
|
+
throw new SessionMcpCredentialRotationError("operation_reuse");
|
|
97
|
+
return RotateSessionMcpCredentialsReceipt.parse(prior.result.receipt);
|
|
98
|
+
}
|
|
99
|
+
const [busy] = await rawRows(
|
|
100
|
+
tx,
|
|
101
|
+
sql`select (
|
|
102
|
+
exists(select 1 from session_turns where workspace_id = ${input.workspaceId}::uuid
|
|
103
|
+
and session_id = ${input.sessionId}::uuid and status in (${sql.join(
|
|
104
|
+
liveTurnStatuses.map((status) => sql`${status}`),
|
|
105
|
+
sql`, `
|
|
106
|
+
)}))
|
|
107
|
+
or exists(select 1 from session_turn_attempts a where a.workspace_id = ${input.workspaceId}::uuid
|
|
108
|
+
and a.session_id = ${input.sessionId}::uuid and (a.state <> 'closed' or
|
|
109
|
+
(a.quiesced_at is null and exists(select 1 from session_attempt_interruptions i
|
|
110
|
+
where i.workspace_id = a.workspace_id and i.attempt_id = a.id))))
|
|
111
|
+
or exists(select 1 from session_system_updates where workspace_id = ${input.workspaceId}::uuid
|
|
112
|
+
and session_id = ${input.sessionId}::uuid and state = 'pending')
|
|
113
|
+
or exists(select 1 from sandbox_workspace_mutation_admissions where workspace_id = ${input.workspaceId}::uuid
|
|
114
|
+
and session_id = ${input.sessionId}::uuid and attempt_id is not null and settled_at is null)
|
|
115
|
+
or exists(select 1 from session_realtime_modes where workspace_id = ${input.workspaceId}::uuid
|
|
116
|
+
and session_id = ${input.sessionId}::uuid and state = 'active')
|
|
117
|
+
or exists(select 1 from session_realtime_connections where workspace_id = ${input.workspaceId}::uuid
|
|
118
|
+
and session_id = ${input.sessionId}::uuid and state not in ('failed', 'closed'))
|
|
119
|
+
) as present`
|
|
120
|
+
);
|
|
121
|
+
if (!busy || busy.present) throw new SessionMcpCredentialRotationError("not_quiescent");
|
|
122
|
+
const rows = await tx.select().from(sessionMcpServers).where(
|
|
123
|
+
and(
|
|
124
|
+
eq(sessionMcpServers.workspaceId, input.workspaceId),
|
|
125
|
+
eq(sessionMcpServers.sessionId, input.sessionId),
|
|
126
|
+
inArray(
|
|
127
|
+
sessionMcpServers.serverId,
|
|
128
|
+
input.updates.map((update) => update.id)
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
).orderBy(sessionMcpServers.serverId).for("update");
|
|
132
|
+
for (const update of input.updates) {
|
|
133
|
+
const row = rows.find((server) => server.serverId === update.id);
|
|
134
|
+
if (!row || row.accountId !== input.accountId)
|
|
135
|
+
throw new SessionMcpCredentialRotationError("not_found");
|
|
136
|
+
if (row.connectionRef) throw new SessionMcpCredentialRotationError("brokered_server");
|
|
137
|
+
if (row.url !== update.expectedServerUrl)
|
|
138
|
+
throw new SessionMcpCredentialRotationError("destination_conflict");
|
|
139
|
+
if (!Number.isInteger(update.expectedCredentialVersion) || update.expectedCredentialVersion < 1 || update.expectedCredentialVersion >= 2147483647 || row.credentialVersion !== update.expectedCredentialVersion)
|
|
140
|
+
throw new SessionMcpCredentialRotationError("version_conflict");
|
|
141
|
+
}
|
|
142
|
+
const receipt = {
|
|
143
|
+
operationKey: input.operationKey,
|
|
144
|
+
sessionId: input.sessionId,
|
|
145
|
+
appliedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
146
|
+
servers: input.updates.map((update) => ({
|
|
147
|
+
id: update.id,
|
|
148
|
+
credentialVersion: update.expectedCredentialVersion + 1
|
|
149
|
+
}))
|
|
150
|
+
};
|
|
151
|
+
for (const update of input.updates) {
|
|
152
|
+
await tx.update(sessionMcpServers).set({
|
|
153
|
+
headersEncrypted: update.headersEncrypted,
|
|
154
|
+
credentialVersion: update.expectedCredentialVersion + 1,
|
|
155
|
+
updatedAt: new Date(receipt.appliedAt)
|
|
156
|
+
}).where(
|
|
157
|
+
and(
|
|
158
|
+
eq(sessionMcpServers.workspaceId, input.workspaceId),
|
|
159
|
+
eq(sessionMcpServers.sessionId, input.sessionId),
|
|
160
|
+
eq(sessionMcpServers.serverId, update.id)
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
await tx.insert(sessionCommandReceipts).values({
|
|
165
|
+
accountId: input.accountId,
|
|
166
|
+
workspaceId: input.workspaceId,
|
|
167
|
+
actorType: input.actorType,
|
|
168
|
+
actorSubjectId: input.subjectId,
|
|
169
|
+
action,
|
|
170
|
+
targetSessionId: input.sessionId,
|
|
171
|
+
operationKey: input.operationKey,
|
|
172
|
+
canonicalRequestHash: input.requestDigest,
|
|
173
|
+
result: { digestKeyTag: input.digestKeyTag, receipt }
|
|
174
|
+
});
|
|
175
|
+
return receipt;
|
|
176
|
+
},
|
|
177
|
+
void 0,
|
|
178
|
+
"none"
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
export {
|
|
182
|
+
SessionMcpCredentialRotationError,
|
|
183
|
+
rotateSessionMcpCredentialsAtomically
|
|
184
|
+
};
|
|
185
|
+
//# sourceMappingURL=session-mcp-credential-rotation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/session-mcp-credential-rotation.ts"],"sourcesContent":["import { and, eq, inArray, sql } from \"drizzle-orm\";\nimport { RotateSessionMcpCredentialsReceipt, SessionTurnStatus } from \"@opengeni/contracts\";\nimport { rawRows, setSubjectRlsContext, withRlsContext, type Database } from \"./database\";\nimport { lockSessionEventWriteRows } from \"./session-control\";\nimport * as schema from \"./schema\";\n\nconst action = \"session.mcp.credentials.rotate\";\nconst liveTurnStatuses = SessionTurnStatus.exclude([\n \"completed\",\n \"failed\",\n \"cancelled\",\n \"superseded\",\n \"withdrawn_for_edit\",\n]).options;\n\nexport class SessionMcpCredentialRotationError extends Error {\n constructor(\n readonly code:\n | \"invalid_request\"\n | \"not_found\"\n | \"not_quiescent\"\n | \"version_conflict\"\n | \"destination_conflict\"\n | \"brokered_server\"\n | \"operation_reuse\"\n | \"receipt_key_unavailable\"\n | \"authority_revoked\",\n ) {\n super(code);\n this.name = \"SessionMcpCredentialRotationError\";\n }\n}\n\nexport type AtomicSessionMcpCredentialRotationInput = {\n accountId: string;\n workspaceId: string;\n sessionId: string;\n subjectId: string;\n actorType: \"human\" | \"service\";\n operationKey: string;\n requestDigest: string;\n digestKeyTag: string;\n updates: Array<{\n id: string;\n expectedCredentialVersion: number;\n expectedServerUrl: string;\n headersEncrypted: Record<string, string>;\n }>;\n /** Trusted request authorizer, mandatory even for a committed receipt replay.\n * Revalidate the original authenticated subject and permissions on this tx. */\n authorize: (tx: Database) => Promise<void>;\n};\n\n/** Existing command receipts own durable idempotency; this is not a new queue\n * command. No session/event/history/attempt/control/wake rows are mutated. */\nexport async function rotateSessionMcpCredentialsAtomically(\n db: Database,\n input: AtomicSessionMcpCredentialRotationInput,\n): Promise<RotateSessionMcpCredentialsReceipt> {\n if (\n !input.updates.length ||\n input.updates.length > 64 ||\n new Set(input.updates.map((update) => update.id)).size !== input.updates.length ||\n !/^[a-f0-9]{64}$/.test(input.requestDigest) ||\n !/^[a-f0-9]{64}$/.test(input.digestKeyTag)\n ) {\n throw new SessionMcpCredentialRotationError(\"invalid_request\");\n }\n return withRlsContext(\n db,\n input,\n async (tx) => {\n // Membership before tenancy/control/session is the canonical claim/removal\n // order. External continuation authorization reacquires this exclusive\n // membership fence, so taking shared first would introduce an upgrade.\n // Do not upgrade a previously acquired shared tenancy lock.\n await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(\n ${`organization-membership:${input.accountId}`}, 0))`);\n await tx.execute(sql`select pg_advisory_xact_lock_shared(hashtextextended(\n ${`session-tenancy:${input.workspaceId}`}, 0))`);\n await setSubjectRlsContext(tx, input.subjectId);\n const [target] = await tx\n .select({ id: schema.sessions.id })\n .from(schema.sessions)\n .where(\n and(\n eq(schema.sessions.accountId, input.accountId),\n eq(schema.sessions.workspaceId, input.workspaceId),\n eq(schema.sessions.id, input.sessionId),\n ),\n );\n if (!target) throw new SessionMcpCredentialRotationError(\"not_found\");\n const locks = await lockSessionEventWriteRows(tx, {\n workspaceId: input.workspaceId,\n controlLock: \"share\",\n sessionIds: [input.sessionId],\n });\n const session = locks.sessions[0];\n if (!session || session.accountId !== input.accountId) {\n throw new SessionMcpCredentialRotationError(\"not_found\");\n }\n // Hold a current API key through commit. The ordinary access resolver's\n // last-used write alone does not recheck a revocation racing its first read.\n // Take UPDATE directly because fresh resolution writes lastUsedAt.\n if (input.subjectId.startsWith(\"api_key:\")) {\n const keyId = input.subjectId.slice(\"api_key:\".length);\n const [key] = await tx\n .select({ id: schema.apiKeys.id, permissions: schema.apiKeys.permissions })\n .from(schema.apiKeys)\n .where(\n and(\n eq(schema.apiKeys.id, keyId),\n eq(schema.apiKeys.accountId, input.accountId),\n sql`(${schema.apiKeys.workspaceId} is null or ${schema.apiKeys.workspaceId} = ${input.workspaceId}::uuid)`,\n sql`${schema.apiKeys.revokedAt} is null`,\n sql`(${schema.apiKeys.expiresAt} is null or ${schema.apiKeys.expiresAt} > clock_timestamp())`,\n ),\n )\n .for(\"update\");\n if (\n !key ||\n (!key.permissions.includes(\"workspace:admin\") &&\n (!key.permissions.includes(\"sessions:control\") ||\n !key.permissions.includes(\"mcp_servers:attach\")))\n )\n throw new SessionMcpCredentialRotationError(\"authority_revoked\");\n }\n await input.authorize(tx);\n const [prior] = await tx\n .select()\n .from(schema.sessionCommandReceipts)\n .where(\n and(\n eq(schema.sessionCommandReceipts.accountId, input.accountId),\n eq(schema.sessionCommandReceipts.workspaceId, input.workspaceId),\n eq(schema.sessionCommandReceipts.targetSessionId, input.sessionId),\n eq(schema.sessionCommandReceipts.actorType, input.actorType),\n eq(schema.sessionCommandReceipts.actorSubjectId, input.subjectId),\n eq(schema.sessionCommandReceipts.action, action),\n eq(schema.sessionCommandReceipts.operationKey, input.operationKey),\n ),\n );\n if (prior) {\n if (prior.result.digestKeyTag !== input.digestKeyTag)\n throw new SessionMcpCredentialRotationError(\"receipt_key_unavailable\");\n if (prior.canonicalRequestHash !== input.requestDigest)\n throw new SessionMcpCredentialRotationError(\"operation_reuse\");\n return RotateSessionMcpCredentialsReceipt.parse(prior.result.receipt);\n }\n // Session lock serializes admission, claim, realtime start, interruption\n // settlement, and attempt-owned provider admission. Dormant goals/schedules\n // and unrelated viewers are deliberately not credential consumers.\n // Retained run snapshots and tool receipts need a live turn/attempt to\n // consume credentials; their historical presence alone is not activity.\n const [busy] = await rawRows<{ present: boolean }>(\n tx,\n sql`select (\n exists(select 1 from session_turns where workspace_id = ${input.workspaceId}::uuid\n and session_id = ${input.sessionId}::uuid and status in (${sql.join(\n liveTurnStatuses.map((status) => sql`${status}`),\n sql`, `,\n )}))\n or exists(select 1 from session_turn_attempts a where a.workspace_id = ${input.workspaceId}::uuid\n and a.session_id = ${input.sessionId}::uuid and (a.state <> 'closed' or\n (a.quiesced_at is null and exists(select 1 from session_attempt_interruptions i\n where i.workspace_id = a.workspace_id and i.attempt_id = a.id))))\n or exists(select 1 from session_system_updates where workspace_id = ${input.workspaceId}::uuid\n and session_id = ${input.sessionId}::uuid and state = 'pending')\n or exists(select 1 from sandbox_workspace_mutation_admissions where workspace_id = ${input.workspaceId}::uuid\n and session_id = ${input.sessionId}::uuid and attempt_id is not null and settled_at is null)\n or exists(select 1 from session_realtime_modes where workspace_id = ${input.workspaceId}::uuid\n and session_id = ${input.sessionId}::uuid and state = 'active')\n or exists(select 1 from session_realtime_connections where workspace_id = ${input.workspaceId}::uuid\n and session_id = ${input.sessionId}::uuid and state not in ('failed', 'closed'))\n ) as present`,\n );\n if (!busy || busy.present) throw new SessionMcpCredentialRotationError(\"not_quiescent\");\n const rows = await tx\n .select()\n .from(schema.sessionMcpServers)\n .where(\n and(\n eq(schema.sessionMcpServers.workspaceId, input.workspaceId),\n eq(schema.sessionMcpServers.sessionId, input.sessionId),\n inArray(\n schema.sessionMcpServers.serverId,\n input.updates.map((update) => update.id),\n ),\n ),\n )\n .orderBy(schema.sessionMcpServers.serverId)\n .for(\"update\");\n for (const update of input.updates) {\n const row = rows.find((server) => server.serverId === update.id);\n if (!row || row.accountId !== input.accountId)\n throw new SessionMcpCredentialRotationError(\"not_found\");\n if (row.connectionRef) throw new SessionMcpCredentialRotationError(\"brokered_server\");\n if (row.url !== update.expectedServerUrl)\n throw new SessionMcpCredentialRotationError(\"destination_conflict\");\n if (\n !Number.isInteger(update.expectedCredentialVersion) ||\n update.expectedCredentialVersion < 1 ||\n update.expectedCredentialVersion >= 2_147_483_647 ||\n row.credentialVersion !== update.expectedCredentialVersion\n )\n throw new SessionMcpCredentialRotationError(\"version_conflict\");\n }\n const receipt: RotateSessionMcpCredentialsReceipt = {\n operationKey: input.operationKey,\n sessionId: input.sessionId,\n appliedAt: new Date().toISOString(),\n servers: input.updates.map((update) => ({\n id: update.id,\n credentialVersion: update.expectedCredentialVersion + 1,\n })),\n };\n for (const update of input.updates) {\n await tx\n .update(schema.sessionMcpServers)\n .set({\n headersEncrypted: update.headersEncrypted,\n credentialVersion: update.expectedCredentialVersion + 1,\n updatedAt: new Date(receipt.appliedAt),\n })\n .where(\n and(\n eq(schema.sessionMcpServers.workspaceId, input.workspaceId),\n eq(schema.sessionMcpServers.sessionId, input.sessionId),\n eq(schema.sessionMcpServers.serverId, update.id),\n ),\n );\n }\n await tx.insert(schema.sessionCommandReceipts).values({\n accountId: input.accountId,\n workspaceId: input.workspaceId,\n actorType: input.actorType,\n actorSubjectId: input.subjectId,\n action,\n targetSessionId: input.sessionId,\n operationKey: input.operationKey,\n canonicalRequestHash: input.requestDigest,\n result: { digestKeyTag: input.digestKeyTag, receipt },\n });\n return receipt;\n },\n undefined,\n \"none\",\n );\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/db",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0-canary.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": {
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
"module": "./dist/index.js",
|
|
20
20
|
"types": "./dist/index.d.ts",
|
|
21
21
|
"exports": {
|
|
22
|
+
"./session-mcp-credential-rotation": {
|
|
23
|
+
"types": "./dist/session-mcp-credential-rotation.d.ts",
|
|
24
|
+
"import": "./dist/session-mcp-credential-rotation.js"
|
|
25
|
+
},
|
|
22
26
|
"./mcp-operations": {
|
|
23
27
|
"types": "./dist/mcp-operations.d.ts",
|
|
24
28
|
"import": "./dist/mcp-operations.js"
|
|
@@ -98,11 +102,11 @@
|
|
|
98
102
|
"prepublishOnly": "bash ../../scripts/prepublish-guard"
|
|
99
103
|
},
|
|
100
104
|
"dependencies": {
|
|
101
|
-
"@opengeni/codemode": "^0.5.
|
|
102
|
-
"@opengeni/codex": "^0.2.22-canary.
|
|
103
|
-
"@opengeni/config": "^1.
|
|
104
|
-
"@opengeni/contracts": "^3.0
|
|
105
|
-
"@opengeni/network": "^0.3.1-canary.
|
|
105
|
+
"@opengeni/codemode": "^0.5.7-canary.0",
|
|
106
|
+
"@opengeni/codex": "^0.2.22-canary.12",
|
|
107
|
+
"@opengeni/config": "^1.2.0-canary.0",
|
|
108
|
+
"@opengeni/contracts": "^3.1.0-canary.0",
|
|
109
|
+
"@opengeni/network": "^0.3.1-canary.12",
|
|
106
110
|
"drizzle-orm": "^0.45.2",
|
|
107
111
|
"postgres": "^3.4.7"
|
|
108
112
|
},
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
2
|
+
import { RotateSessionMcpCredentialsReceipt, SessionTurnStatus } from "@opengeni/contracts";
|
|
3
|
+
import { rawRows, setSubjectRlsContext, withRlsContext, type Database } from "./database";
|
|
4
|
+
import { lockSessionEventWriteRows } from "./session-control";
|
|
5
|
+
import * as schema from "./schema";
|
|
6
|
+
|
|
7
|
+
const action = "session.mcp.credentials.rotate";
|
|
8
|
+
const liveTurnStatuses = SessionTurnStatus.exclude([
|
|
9
|
+
"completed",
|
|
10
|
+
"failed",
|
|
11
|
+
"cancelled",
|
|
12
|
+
"superseded",
|
|
13
|
+
"withdrawn_for_edit",
|
|
14
|
+
]).options;
|
|
15
|
+
|
|
16
|
+
export class SessionMcpCredentialRotationError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
readonly code:
|
|
19
|
+
| "invalid_request"
|
|
20
|
+
| "not_found"
|
|
21
|
+
| "not_quiescent"
|
|
22
|
+
| "version_conflict"
|
|
23
|
+
| "destination_conflict"
|
|
24
|
+
| "brokered_server"
|
|
25
|
+
| "operation_reuse"
|
|
26
|
+
| "receipt_key_unavailable"
|
|
27
|
+
| "authority_revoked",
|
|
28
|
+
) {
|
|
29
|
+
super(code);
|
|
30
|
+
this.name = "SessionMcpCredentialRotationError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type AtomicSessionMcpCredentialRotationInput = {
|
|
35
|
+
accountId: string;
|
|
36
|
+
workspaceId: string;
|
|
37
|
+
sessionId: string;
|
|
38
|
+
subjectId: string;
|
|
39
|
+
actorType: "human" | "service";
|
|
40
|
+
operationKey: string;
|
|
41
|
+
requestDigest: string;
|
|
42
|
+
digestKeyTag: string;
|
|
43
|
+
updates: Array<{
|
|
44
|
+
id: string;
|
|
45
|
+
expectedCredentialVersion: number;
|
|
46
|
+
expectedServerUrl: string;
|
|
47
|
+
headersEncrypted: Record<string, string>;
|
|
48
|
+
}>;
|
|
49
|
+
/** Trusted request authorizer, mandatory even for a committed receipt replay.
|
|
50
|
+
* Revalidate the original authenticated subject and permissions on this tx. */
|
|
51
|
+
authorize: (tx: Database) => Promise<void>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Existing command receipts own durable idempotency; this is not a new queue
|
|
55
|
+
* command. No session/event/history/attempt/control/wake rows are mutated. */
|
|
56
|
+
export async function rotateSessionMcpCredentialsAtomically(
|
|
57
|
+
db: Database,
|
|
58
|
+
input: AtomicSessionMcpCredentialRotationInput,
|
|
59
|
+
): Promise<RotateSessionMcpCredentialsReceipt> {
|
|
60
|
+
if (
|
|
61
|
+
!input.updates.length ||
|
|
62
|
+
input.updates.length > 64 ||
|
|
63
|
+
new Set(input.updates.map((update) => update.id)).size !== input.updates.length ||
|
|
64
|
+
!/^[a-f0-9]{64}$/.test(input.requestDigest) ||
|
|
65
|
+
!/^[a-f0-9]{64}$/.test(input.digestKeyTag)
|
|
66
|
+
) {
|
|
67
|
+
throw new SessionMcpCredentialRotationError("invalid_request");
|
|
68
|
+
}
|
|
69
|
+
return withRlsContext(
|
|
70
|
+
db,
|
|
71
|
+
input,
|
|
72
|
+
async (tx) => {
|
|
73
|
+
// Membership before tenancy/control/session is the canonical claim/removal
|
|
74
|
+
// order. External continuation authorization reacquires this exclusive
|
|
75
|
+
// membership fence, so taking shared first would introduce an upgrade.
|
|
76
|
+
// Do not upgrade a previously acquired shared tenancy lock.
|
|
77
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(
|
|
78
|
+
${`organization-membership:${input.accountId}`}, 0))`);
|
|
79
|
+
await tx.execute(sql`select pg_advisory_xact_lock_shared(hashtextextended(
|
|
80
|
+
${`session-tenancy:${input.workspaceId}`}, 0))`);
|
|
81
|
+
await setSubjectRlsContext(tx, input.subjectId);
|
|
82
|
+
const [target] = await tx
|
|
83
|
+
.select({ id: schema.sessions.id })
|
|
84
|
+
.from(schema.sessions)
|
|
85
|
+
.where(
|
|
86
|
+
and(
|
|
87
|
+
eq(schema.sessions.accountId, input.accountId),
|
|
88
|
+
eq(schema.sessions.workspaceId, input.workspaceId),
|
|
89
|
+
eq(schema.sessions.id, input.sessionId),
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
if (!target) throw new SessionMcpCredentialRotationError("not_found");
|
|
93
|
+
const locks = await lockSessionEventWriteRows(tx, {
|
|
94
|
+
workspaceId: input.workspaceId,
|
|
95
|
+
controlLock: "share",
|
|
96
|
+
sessionIds: [input.sessionId],
|
|
97
|
+
});
|
|
98
|
+
const session = locks.sessions[0];
|
|
99
|
+
if (!session || session.accountId !== input.accountId) {
|
|
100
|
+
throw new SessionMcpCredentialRotationError("not_found");
|
|
101
|
+
}
|
|
102
|
+
// Hold a current API key through commit. The ordinary access resolver's
|
|
103
|
+
// last-used write alone does not recheck a revocation racing its first read.
|
|
104
|
+
// Take UPDATE directly because fresh resolution writes lastUsedAt.
|
|
105
|
+
if (input.subjectId.startsWith("api_key:")) {
|
|
106
|
+
const keyId = input.subjectId.slice("api_key:".length);
|
|
107
|
+
const [key] = await tx
|
|
108
|
+
.select({ id: schema.apiKeys.id, permissions: schema.apiKeys.permissions })
|
|
109
|
+
.from(schema.apiKeys)
|
|
110
|
+
.where(
|
|
111
|
+
and(
|
|
112
|
+
eq(schema.apiKeys.id, keyId),
|
|
113
|
+
eq(schema.apiKeys.accountId, input.accountId),
|
|
114
|
+
sql`(${schema.apiKeys.workspaceId} is null or ${schema.apiKeys.workspaceId} = ${input.workspaceId}::uuid)`,
|
|
115
|
+
sql`${schema.apiKeys.revokedAt} is null`,
|
|
116
|
+
sql`(${schema.apiKeys.expiresAt} is null or ${schema.apiKeys.expiresAt} > clock_timestamp())`,
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
.for("update");
|
|
120
|
+
if (
|
|
121
|
+
!key ||
|
|
122
|
+
(!key.permissions.includes("workspace:admin") &&
|
|
123
|
+
(!key.permissions.includes("sessions:control") ||
|
|
124
|
+
!key.permissions.includes("mcp_servers:attach")))
|
|
125
|
+
)
|
|
126
|
+
throw new SessionMcpCredentialRotationError("authority_revoked");
|
|
127
|
+
}
|
|
128
|
+
await input.authorize(tx);
|
|
129
|
+
const [prior] = await tx
|
|
130
|
+
.select()
|
|
131
|
+
.from(schema.sessionCommandReceipts)
|
|
132
|
+
.where(
|
|
133
|
+
and(
|
|
134
|
+
eq(schema.sessionCommandReceipts.accountId, input.accountId),
|
|
135
|
+
eq(schema.sessionCommandReceipts.workspaceId, input.workspaceId),
|
|
136
|
+
eq(schema.sessionCommandReceipts.targetSessionId, input.sessionId),
|
|
137
|
+
eq(schema.sessionCommandReceipts.actorType, input.actorType),
|
|
138
|
+
eq(schema.sessionCommandReceipts.actorSubjectId, input.subjectId),
|
|
139
|
+
eq(schema.sessionCommandReceipts.action, action),
|
|
140
|
+
eq(schema.sessionCommandReceipts.operationKey, input.operationKey),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
if (prior) {
|
|
144
|
+
if (prior.result.digestKeyTag !== input.digestKeyTag)
|
|
145
|
+
throw new SessionMcpCredentialRotationError("receipt_key_unavailable");
|
|
146
|
+
if (prior.canonicalRequestHash !== input.requestDigest)
|
|
147
|
+
throw new SessionMcpCredentialRotationError("operation_reuse");
|
|
148
|
+
return RotateSessionMcpCredentialsReceipt.parse(prior.result.receipt);
|
|
149
|
+
}
|
|
150
|
+
// Session lock serializes admission, claim, realtime start, interruption
|
|
151
|
+
// settlement, and attempt-owned provider admission. Dormant goals/schedules
|
|
152
|
+
// and unrelated viewers are deliberately not credential consumers.
|
|
153
|
+
// Retained run snapshots and tool receipts need a live turn/attempt to
|
|
154
|
+
// consume credentials; their historical presence alone is not activity.
|
|
155
|
+
const [busy] = await rawRows<{ present: boolean }>(
|
|
156
|
+
tx,
|
|
157
|
+
sql`select (
|
|
158
|
+
exists(select 1 from session_turns where workspace_id = ${input.workspaceId}::uuid
|
|
159
|
+
and session_id = ${input.sessionId}::uuid and status in (${sql.join(
|
|
160
|
+
liveTurnStatuses.map((status) => sql`${status}`),
|
|
161
|
+
sql`, `,
|
|
162
|
+
)}))
|
|
163
|
+
or exists(select 1 from session_turn_attempts a where a.workspace_id = ${input.workspaceId}::uuid
|
|
164
|
+
and a.session_id = ${input.sessionId}::uuid and (a.state <> 'closed' or
|
|
165
|
+
(a.quiesced_at is null and exists(select 1 from session_attempt_interruptions i
|
|
166
|
+
where i.workspace_id = a.workspace_id and i.attempt_id = a.id))))
|
|
167
|
+
or exists(select 1 from session_system_updates where workspace_id = ${input.workspaceId}::uuid
|
|
168
|
+
and session_id = ${input.sessionId}::uuid and state = 'pending')
|
|
169
|
+
or exists(select 1 from sandbox_workspace_mutation_admissions where workspace_id = ${input.workspaceId}::uuid
|
|
170
|
+
and session_id = ${input.sessionId}::uuid and attempt_id is not null and settled_at is null)
|
|
171
|
+
or exists(select 1 from session_realtime_modes where workspace_id = ${input.workspaceId}::uuid
|
|
172
|
+
and session_id = ${input.sessionId}::uuid and state = 'active')
|
|
173
|
+
or exists(select 1 from session_realtime_connections where workspace_id = ${input.workspaceId}::uuid
|
|
174
|
+
and session_id = ${input.sessionId}::uuid and state not in ('failed', 'closed'))
|
|
175
|
+
) as present`,
|
|
176
|
+
);
|
|
177
|
+
if (!busy || busy.present) throw new SessionMcpCredentialRotationError("not_quiescent");
|
|
178
|
+
const rows = await tx
|
|
179
|
+
.select()
|
|
180
|
+
.from(schema.sessionMcpServers)
|
|
181
|
+
.where(
|
|
182
|
+
and(
|
|
183
|
+
eq(schema.sessionMcpServers.workspaceId, input.workspaceId),
|
|
184
|
+
eq(schema.sessionMcpServers.sessionId, input.sessionId),
|
|
185
|
+
inArray(
|
|
186
|
+
schema.sessionMcpServers.serverId,
|
|
187
|
+
input.updates.map((update) => update.id),
|
|
188
|
+
),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
.orderBy(schema.sessionMcpServers.serverId)
|
|
192
|
+
.for("update");
|
|
193
|
+
for (const update of input.updates) {
|
|
194
|
+
const row = rows.find((server) => server.serverId === update.id);
|
|
195
|
+
if (!row || row.accountId !== input.accountId)
|
|
196
|
+
throw new SessionMcpCredentialRotationError("not_found");
|
|
197
|
+
if (row.connectionRef) throw new SessionMcpCredentialRotationError("brokered_server");
|
|
198
|
+
if (row.url !== update.expectedServerUrl)
|
|
199
|
+
throw new SessionMcpCredentialRotationError("destination_conflict");
|
|
200
|
+
if (
|
|
201
|
+
!Number.isInteger(update.expectedCredentialVersion) ||
|
|
202
|
+
update.expectedCredentialVersion < 1 ||
|
|
203
|
+
update.expectedCredentialVersion >= 2_147_483_647 ||
|
|
204
|
+
row.credentialVersion !== update.expectedCredentialVersion
|
|
205
|
+
)
|
|
206
|
+
throw new SessionMcpCredentialRotationError("version_conflict");
|
|
207
|
+
}
|
|
208
|
+
const receipt: RotateSessionMcpCredentialsReceipt = {
|
|
209
|
+
operationKey: input.operationKey,
|
|
210
|
+
sessionId: input.sessionId,
|
|
211
|
+
appliedAt: new Date().toISOString(),
|
|
212
|
+
servers: input.updates.map((update) => ({
|
|
213
|
+
id: update.id,
|
|
214
|
+
credentialVersion: update.expectedCredentialVersion + 1,
|
|
215
|
+
})),
|
|
216
|
+
};
|
|
217
|
+
for (const update of input.updates) {
|
|
218
|
+
await tx
|
|
219
|
+
.update(schema.sessionMcpServers)
|
|
220
|
+
.set({
|
|
221
|
+
headersEncrypted: update.headersEncrypted,
|
|
222
|
+
credentialVersion: update.expectedCredentialVersion + 1,
|
|
223
|
+
updatedAt: new Date(receipt.appliedAt),
|
|
224
|
+
})
|
|
225
|
+
.where(
|
|
226
|
+
and(
|
|
227
|
+
eq(schema.sessionMcpServers.workspaceId, input.workspaceId),
|
|
228
|
+
eq(schema.sessionMcpServers.sessionId, input.sessionId),
|
|
229
|
+
eq(schema.sessionMcpServers.serverId, update.id),
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
await tx.insert(schema.sessionCommandReceipts).values({
|
|
234
|
+
accountId: input.accountId,
|
|
235
|
+
workspaceId: input.workspaceId,
|
|
236
|
+
actorType: input.actorType,
|
|
237
|
+
actorSubjectId: input.subjectId,
|
|
238
|
+
action,
|
|
239
|
+
targetSessionId: input.sessionId,
|
|
240
|
+
operationKey: input.operationKey,
|
|
241
|
+
canonicalRequestHash: input.requestDigest,
|
|
242
|
+
result: { digestKeyTag: input.digestKeyTag, receipt },
|
|
243
|
+
});
|
|
244
|
+
return receipt;
|
|
245
|
+
},
|
|
246
|
+
undefined,
|
|
247
|
+
"none",
|
|
248
|
+
);
|
|
249
|
+
}
|