@opengeni/core 2.9.4-canary.3 → 2.10.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.
- package/dist/application/session-mcp-credential-rotation.d.ts +29 -0
- package/dist/domain/sessions.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8043 -7922
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/src/application/session-mcp-credential-rotation.ts +174 -0
- package/src/domain/sessions.ts +1 -1
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0-canary.0",
|
|
4
4
|
"description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -58,17 +58,17 @@
|
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
61
|
-
"@opengeni/capabilities": "^0.3.3-canary.
|
|
62
|
-
"@opengeni/codex": "^0.2.22-canary.
|
|
63
|
-
"@opengeni/config": "^1.
|
|
64
|
-
"@opengeni/contracts": "^3.0
|
|
65
|
-
"@opengeni/db": "^4.
|
|
66
|
-
"@opengeni/documents": "^0.8.
|
|
67
|
-
"@opengeni/events": "^0.4.
|
|
68
|
-
"@opengeni/network": "^0.3.1-canary.
|
|
69
|
-
"@opengeni/observability": "^0.8.
|
|
70
|
-
"@opengeni/runtime": "^2.
|
|
71
|
-
"@opengeni/storage": "^0.2.
|
|
61
|
+
"@opengeni/capabilities": "^0.3.3-canary.12",
|
|
62
|
+
"@opengeni/codex": "^0.2.22-canary.12",
|
|
63
|
+
"@opengeni/config": "^1.2.0-canary.0",
|
|
64
|
+
"@opengeni/contracts": "^3.1.0-canary.0",
|
|
65
|
+
"@opengeni/db": "^4.4.0-canary.0",
|
|
66
|
+
"@opengeni/documents": "^0.8.28-canary.0",
|
|
67
|
+
"@opengeni/events": "^0.4.26-canary.0",
|
|
68
|
+
"@opengeni/network": "^0.3.1-canary.12",
|
|
69
|
+
"@opengeni/observability": "^0.8.27-canary.0",
|
|
70
|
+
"@opengeni/runtime": "^2.6.0-canary.0",
|
|
71
|
+
"@opengeni/storage": "^0.2.128-canary.0",
|
|
72
72
|
"hono": "^4.12.18",
|
|
73
73
|
"zod": "^4.2.1"
|
|
74
74
|
},
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { RotateSessionMcpCredentialsRequest, type AccessGrant } from "@opengeni/contracts";
|
|
3
|
+
import {
|
|
4
|
+
encryptVariableSetValue,
|
|
5
|
+
getWorkspaceGrant,
|
|
6
|
+
subjectHasLiveWorkspaceAuthorityInScope,
|
|
7
|
+
type Database,
|
|
8
|
+
} from "@opengeni/db";
|
|
9
|
+
import {
|
|
10
|
+
rotateSessionMcpCredentialsAtomically,
|
|
11
|
+
SessionMcpCredentialRotationError,
|
|
12
|
+
} from "@opengeni/db/session-mcp-credential-rotation";
|
|
13
|
+
import { HTTPException } from "hono/http-exception";
|
|
14
|
+
import type { Settings } from "@opengeni/config";
|
|
15
|
+
import {
|
|
16
|
+
requirePermission,
|
|
17
|
+
requireResolvedAccessGrantAuthorization,
|
|
18
|
+
type AccessGrantAuthorization,
|
|
19
|
+
} from "../access";
|
|
20
|
+
import {
|
|
21
|
+
grantHasAgentAttemptAuthority,
|
|
22
|
+
requireSessionAuthorization,
|
|
23
|
+
SessionAuthorizationDeniedError,
|
|
24
|
+
SessionAuthorizationUnavailableError,
|
|
25
|
+
type SessionAuthorizationDependencies,
|
|
26
|
+
} from "../session-authorization";
|
|
27
|
+
import { requireVariableSetEncryption } from "../domain/environments";
|
|
28
|
+
import { normalizedSessionMcpCredentialHeaders } from "../domain/sessions";
|
|
29
|
+
import { externalContinuationCommitAuthorizer } from "./external-continuation";
|
|
30
|
+
|
|
31
|
+
/** HMAC domains separate request identity from key availability. The key tag
|
|
32
|
+
* carries no request material and neither fingerprint is returned publicly. */
|
|
33
|
+
export function sessionMcpRotationFingerprint(
|
|
34
|
+
key: Uint8Array,
|
|
35
|
+
scope: { accountId: string; workspaceId: string; sessionId: string; subjectId: string },
|
|
36
|
+
request: RotateSessionMcpCredentialsRequest,
|
|
37
|
+
) {
|
|
38
|
+
return {
|
|
39
|
+
digestKeyTag: createHmac("sha256", key)
|
|
40
|
+
.update("opengeni:session-mcp-rotation:key:v1")
|
|
41
|
+
.digest("hex"),
|
|
42
|
+
requestDigest: createHmac("sha256", key)
|
|
43
|
+
.update("opengeni:session-mcp-rotation:request:v1\0")
|
|
44
|
+
.update(JSON.stringify({ ...scope, request }))
|
|
45
|
+
.digest("hex"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requireRotationGrant(grant: AccessGrant) {
|
|
50
|
+
if (grantHasAgentAttemptAuthority(grant))
|
|
51
|
+
throw new HTTPException(403, { message: "host authorization required" });
|
|
52
|
+
requirePermission(grant, "sessions:control");
|
|
53
|
+
requirePermission(grant, "mcp_servers:attach");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** HTTP/SDK host boundary only. The mandatory callback re-resolves request
|
|
57
|
+
* authentication with the transaction handle, not a cached route-time grant. */
|
|
58
|
+
export async function rotateSessionMcpCredentialsForRequest(
|
|
59
|
+
deps: SessionAuthorizationDependencies & { settings: Settings },
|
|
60
|
+
authorization: AccessGrantAuthorization,
|
|
61
|
+
sessionId: string,
|
|
62
|
+
raw: unknown,
|
|
63
|
+
reauthorize: (tx: Database) => Promise<AccessGrant>,
|
|
64
|
+
) {
|
|
65
|
+
const grant = requireResolvedAccessGrantAuthorization(
|
|
66
|
+
authorization,
|
|
67
|
+
authorization.grant.workspaceId,
|
|
68
|
+
);
|
|
69
|
+
requireRotationGrant(grant);
|
|
70
|
+
const parsed = RotateSessionMcpCredentialsRequest.safeParse(raw);
|
|
71
|
+
if (!parsed.success)
|
|
72
|
+
throw new HTTPException(422, { message: "invalid credential rotation request" });
|
|
73
|
+
const encryptionKey = requireVariableSetEncryption(deps.settings);
|
|
74
|
+
let request: RotateSessionMcpCredentialsRequest;
|
|
75
|
+
try {
|
|
76
|
+
request = {
|
|
77
|
+
operationKey: parsed.data.operationKey,
|
|
78
|
+
updates: parsed.data.updates
|
|
79
|
+
.map((update) => ({
|
|
80
|
+
id: update.id,
|
|
81
|
+
expectedCredentialVersion: update.expectedCredentialVersion,
|
|
82
|
+
expectedServerUrl: update.expectedServerUrl,
|
|
83
|
+
headers: Object.fromEntries(
|
|
84
|
+
Object.entries(normalizedSessionMcpCredentialHeaders(update.headers))
|
|
85
|
+
.map(([name, value]) => [name.toLowerCase(), value] as const)
|
|
86
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
|
|
87
|
+
),
|
|
88
|
+
}))
|
|
89
|
+
.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)),
|
|
90
|
+
};
|
|
91
|
+
if (new Set(request.updates.map((update) => update.id)).size !== request.updates.length)
|
|
92
|
+
throw new Error();
|
|
93
|
+
} catch {
|
|
94
|
+
// Header names and validation paths are caller-controlled secret channels.
|
|
95
|
+
throw new HTTPException(422, { message: "invalid credential rotation headers or server IDs" });
|
|
96
|
+
}
|
|
97
|
+
const scope = {
|
|
98
|
+
accountId: grant.accountId,
|
|
99
|
+
workspaceId: grant.workspaceId,
|
|
100
|
+
sessionId,
|
|
101
|
+
subjectId: grant.subjectId,
|
|
102
|
+
actorType: grant.principalKind === "service" ? ("service" as const) : ("human" as const),
|
|
103
|
+
};
|
|
104
|
+
const externalAuthorize = externalContinuationCommitAuthorizer(authorization);
|
|
105
|
+
try {
|
|
106
|
+
return await rotateSessionMcpCredentialsAtomically(deps.db, {
|
|
107
|
+
...scope,
|
|
108
|
+
operationKey: request.operationKey,
|
|
109
|
+
...sessionMcpRotationFingerprint(encryptionKey, scope, request),
|
|
110
|
+
updates: request.updates.map(({ headers, ...update }) => ({
|
|
111
|
+
...update,
|
|
112
|
+
headersEncrypted: Object.fromEntries(
|
|
113
|
+
Object.entries(headers).map(([name, value]) => [
|
|
114
|
+
name,
|
|
115
|
+
encryptVariableSetValue(encryptionKey, value),
|
|
116
|
+
]),
|
|
117
|
+
),
|
|
118
|
+
})),
|
|
119
|
+
authorize: async (tx) => {
|
|
120
|
+
await externalAuthorize?.(tx);
|
|
121
|
+
const fresh = await reauthorize(tx);
|
|
122
|
+
if (
|
|
123
|
+
fresh.accountId !== grant.accountId ||
|
|
124
|
+
fresh.workspaceId !== grant.workspaceId ||
|
|
125
|
+
fresh.subjectId !== grant.subjectId ||
|
|
126
|
+
fresh.principalKind !== grant.principalKind
|
|
127
|
+
)
|
|
128
|
+
throw new HTTPException(403, { message: "credential rotation authority changed" });
|
|
129
|
+
requireRotationGrant(fresh);
|
|
130
|
+
if (fresh.principalKind === "human_session" && !externalAuthorize) {
|
|
131
|
+
// Signed host grants may narrow a named human's permissions, never
|
|
132
|
+
// outlive removal or expand that human's current workspace grant.
|
|
133
|
+
const live = await getWorkspaceGrant(tx, fresh.subjectId, fresh.workspaceId);
|
|
134
|
+
if (live) {
|
|
135
|
+
requireRotationGrant(live);
|
|
136
|
+
} else if (!authorization.canonicalManagedHumanSession) {
|
|
137
|
+
throw new HTTPException(403, { message: "credential rotation authority changed" });
|
|
138
|
+
}
|
|
139
|
+
if (!(await subjectHasLiveWorkspaceAuthorityInScope(tx, scope))) {
|
|
140
|
+
throw new HTTPException(403, { message: "credential rotation authority changed" });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
await requireSessionAuthorization({ ...deps, db: tx }, fresh, {
|
|
144
|
+
sessionId,
|
|
145
|
+
operation: "session.mcp.credentials.rotate",
|
|
146
|
+
surface: "http",
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error instanceof HTTPException)
|
|
152
|
+
throw new HTTPException(error.status, { message: "credential rotation access denied" });
|
|
153
|
+
if (error instanceof SessionAuthorizationDeniedError)
|
|
154
|
+
throw new HTTPException(403, { message: "credential rotation access denied" });
|
|
155
|
+
if (error instanceof SessionAuthorizationUnavailableError)
|
|
156
|
+
throw new HTTPException(503, { message: "credential rotation authorization unavailable" });
|
|
157
|
+
if (error instanceof SessionMcpCredentialRotationError) {
|
|
158
|
+
const status =
|
|
159
|
+
error.code === "receipt_key_unavailable"
|
|
160
|
+
? 503
|
|
161
|
+
: error.code === "authority_revoked"
|
|
162
|
+
? 403
|
|
163
|
+
: error.code === "not_found"
|
|
164
|
+
? 404
|
|
165
|
+
: error.code === "invalid_request" || error.code === "brokered_server"
|
|
166
|
+
? 422
|
|
167
|
+
: 409;
|
|
168
|
+
throw new HTTPException(status, { message: `credential_rotation_${error.code}` });
|
|
169
|
+
}
|
|
170
|
+
// Drizzle failures can include query parameters. Never attach their cause,
|
|
171
|
+
// ciphertext, fingerprints, header names, or raw input to API telemetry.
|
|
172
|
+
throw new HTTPException(503, { message: "credential rotation unavailable" });
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/domain/sessions.ts
CHANGED
|
@@ -434,7 +434,7 @@ export function creationInitiatorForGrant(grant: AccessGrant): FrozenCreationIni
|
|
|
434
434
|
};
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
-
function normalizedSessionMcpCredentialHeaders(
|
|
437
|
+
export function normalizedSessionMcpCredentialHeaders(
|
|
438
438
|
headers: Record<string, string> | undefined,
|
|
439
439
|
): Record<string, string> {
|
|
440
440
|
if (!headers) {
|
package/src/index.ts
CHANGED
|
@@ -64,6 +64,7 @@ export * from "./access";
|
|
|
64
64
|
export * from "./application/external-workspace-members";
|
|
65
65
|
export * from "./application/external-identity-lifecycle";
|
|
66
66
|
export * from "./application/external-continuation";
|
|
67
|
+
export * from "./application/session-mcp-credential-rotation";
|
|
67
68
|
export * from "./application/external-link-work-admission";
|
|
68
69
|
export * from "./application/connect-authority";
|
|
69
70
|
export * from "./application/host-mcp-owner";
|