feat: enforce Mind workspace authorization

This commit is contained in:
YBF
2026-09-14 21:03:56 +08:00
parent 854e9c46eb
commit c9417f4469
19 changed files with 447 additions and 38 deletions
+1 -1
View File
@@ -38,7 +38,7 @@
| [日志规范](./logging-guidelines.md) | 日志能力的当前边界 | 已建立基线 |
| [服务基础设施](./service-foundation.md) | Fastify、WebSocket 与 ORM 基础契约 | 已建立 |
| [后台纪要内部网络读取](./summary-authorization.md) | 内部 7777 listener、Docker 网络边界、固定窗口与发布约束 | Center 独立契约 |
| [Mind HTTP 授权](./mind-authorization.md) | 两个 Mind 授权 HTTP 接口、同域 Cookie、CORS/Origin 与 fail-closed 边界 | 已建立适配器与本地 mock |
| [Mind HTTP 授权](./mind-authorization.md) | 两个 Mind 授权 HTTP 接口、同域 Cookie、workspace 声明、CORS/Origin 与 fail-closed 边界 | Center 已实现;真实 Mind/浏览器联调待验 |
| [OneTalk 单会话历史重建](./mind-history-rebuild.md) | rebuild 独立授权、scoped clear、generation/rebuild correlation、原子 reset 与 completion anchor | 已实现;真实 PostgreSQL/浏览器联调另行验证 |
| [OneTalk 联系人资料 Bright 持久化](./mind-contact-profile.md) | profile composite key、严格时间前进 upsert、future-skew 拒绝、transaction/ACK fence 和 read-model 内存组合 | 已实现并有 focused tests;真实 PostgreSQL 另行验证 |
| [OneTalk 买家事实 Bright 持久化与读取](./mind-buyer-fact.md) | buyer source replace、transaction/ACK fence、无 JOIN read projection | 已实现并有 PostgreSQL integration tests |
@@ -1,6 +1,6 @@
# Mind HTTP 授权目标契约
# Mind HTTP 授权契约
> 本文是 `08-26-mind-integration-cutover` 的目标契约,记录已确认的集成边界;在真实 Mind 联调完成,不代表生产适配器已经实现
> 本文记录已确认且已在 Center 实现的集成边界真实 Mind 服务与浏览器联调尚未完成,不能把本地测试描述为生产授权链路已验证
## 1. Scope / Trigger
@@ -17,7 +17,9 @@
Body: { channelAccountId: string; binding: string }
POST /internal/bright/onetalk/authorize-session
Headers: Cookie: <original Mind login cookie>
Headers:
Cookie: <original Mind login cookie>
Sinan-Pilot-Workspace-Id: <Mind page workspace declaration>
Body: { channelAccountId: string }
### Bright adapter
@@ -53,14 +55,15 @@
## 3. Contracts
- binding endpoint 的请求只含 channelAccountId + binding,不含 deviceId、Cookie、Mind user 或 workspace。
- Session endpoint 的请求由 Bright 转发原始 Cookie,并只含目标 channelAccountIdMind 从 Session 推导 user/workspace/权限/active binding
- 两个 endpoint 的成功响应都必须提供 binding、authorizationVersion、permissions 和完整 mindScopemindScope.channelAccountId 必须与请求目标一致
- Mind page HTTP 请求必须带唯一、非空的 `Sinan-Pilot-Workspace-Id`Mind page WebSocket 必须带唯一、非空且大小写敏感的 `workspaceId` query。Center 在 `mind-workspace-context.ts` 统一解析这两个边界,不 trim/重写后再转发;缺失、空白或多值一律失败关闭,绝不选择默认空间或从 hello 推导空间
- Session endpoint 由 Bright 转发原始 Cookie、精确同名的 workspace header,以及仍仅含目标 channelAccountId 的 JSON bodyMind 从可信 Session 与该空间声明校验 user/workspace/权限/active binding。workspace 是请求声明,不是 Center 接受的身份
- 两个 endpoint 的成功响应都必须提供 binding、authorizationVersion、permissions 和完整 mindScopemindScope.channelAccountId 与 mindScope.workspaceId 必须分别匹配 session 请求目标账号与 workspace。binding 请求仍只校验其 binding/账号契约。
- 允许结果映射为 allowed: trueMind 返回的拒绝 code 只能是已登记的稳定 code。Bright 不把 Mind 原始 JSON 传给页面或消息领域。
- Mind page HTTP/WS 不使用 page token,不在 WS hello 中传递 CookieWS handler 从 upgrade request header 读取 Cookie,并调用同一个 Session adapter。
- Mind page WS hello 中的 scope 只是客户端声明,必须与 Session endpoint 返回的 mindScope 完整一致;不能用声明 scope 冒充用户或 workspace。
- Mind 页面位于 mind.<domain>、Bright 位于 bright.<domain> 时,页面 HTTP 必须使用 credentials: "include"Bright CORS 只允许精确 MIND_PAGE_ORIGIN 并返回 credentials,不得使用 *。
- Mind page WS hello 中的 scope 只是客户端声明,必须同时等于 upgrade query 的 workspace 与 Session endpoint 返回的完整 mindScope;不能用声明 scope 冒充用户或 workspace。每个 socket 的 session capability 捕获 query workspace,后续 scope 不同即 `scope_mismatch`,不得调用 Mind。
- Mind 页面位于 mind.<domain>、Bright 位于 bright.<domain> 时,页面 HTTP 必须使用 credentials: "include"Bright CORS 只允许精确 MIND_PAGE_ORIGIN 并返回 credentials,不得使用 *。public-read preflight 只额外允许精确 `Sinan-Pilot-Workspace-Id` header。
- WebSocket 必须按连接类型检查精确 Originmind_page 允许 MIND_PAGE_ORIGINplugin 允许已登记的扩展 Origin。Cookie 不进入 query、Authorization、localStorage、错误、日志或业务表。Bright 只在单次授权调用内存中转发 Cookie。
- Bright 不缓存授权结果跨越复核边界。HTTP 每次请求、WS 连接/业务 frame/heartbeat 都重新授权;binding 或授权版本变化时关闭旧连接。
- Bright 不缓存授权结果跨越复核边界。HTTP 每次请求、WS 连接/业务 frame/heartbeat 都重新授权;Mind publisher、rebuild status 与 pending-send 的无 session-capability fallback 也必须从已登记的完整 `mindScope` 传 account + workspace。binding 或授权版本变化时关闭旧连接。
- contact.profile.observed 复用 plugin 的 `sync` + `read` 授权;资料写入 Bright 的 profile repository,不调用 Mind profile endpoint,也不读取/转发任何 profile HTTP response body。
- profile transaction 完成后,WebSocket handler 仍必须确认 binding、完整 Mind scope、authorizationVersion、read、canonical connection、policy epoch 和 commit guard;远程 revoke/version/read removal 不能产生迟到 ACK。future-skew 则在二次授权前返回稳定的 `profile_observed_at_future`,不写库、不 ACK。
@@ -76,6 +79,9 @@
| 条件 | Mind/Bright 结果 |
| --- | --- |
| Mind HTTP 缺失、空白或歧义 workspace header | 403 scope_mismatch;不调用 reader 或 Bright read service |
| Mind WS 缺失、空白或重复 workspaceId query | upgrade 前 HTTP 403;不创建 socket/session |
| Mind WS query、hello scope 或 Mind 返回 workspace 不相等 | scope_mismatchhello/fence 不产生后续副作用 |
| Cookie 缺失、过期或 Session 无效 | 401 auth_required |
| Session 有效但无目标账号范围 | 403 scope_mismatch |
| binding 不存在、撤销或被接管 | 403 binding_revoked |
@@ -87,16 +93,21 @@
## 6. Good / Base / Bad Cases
- Good:插件只提交账号和 binding;页面只携带浏览器 CookieBright 使用 Mind 返回的真实 scope 做精确路由。
- Good:插件只提交账号和 binding;Mind 页面 HTTP 发送 Cookie 与唯一 workspace headerWS URL 发送相同 workspace queryBright 使用 Mind 返回的真实完整 scope 做精确路由。
- Goodworkspace A 的 reader/WS authorization 只能匹配 AMind 在换绑或空间切换后拒绝旧 scope 时,下一次 heartbeat、send、rebuild 或 publish fence 不产生 ACK、wire send、rebuild status 或页面发布。
- GoodMind 接管新 binding 后旧 binding endpoint 立即拒绝,Bright 关闭旧连接,迟到发送结果保持 delivery_unknown。
- Base:本地开发使用内存 authorization fixture;它模拟两个 endpoint 的结果,但不被称为 Mind view,也不连接 Mind DB。
- BadBright 直接查询 Mind session/binding 表、把 mindUserId header 当作主体、把 Cookie 放到 URL/WS payload,或在授权失败时沿用旧缓存。
- BadBright 直接查询 Mind session/binding 表、把 mindUserId/header workspace 当作主体、把 Cookie 放到 URL/WS payload,或在授权失败时沿用旧缓存。
- Bad:只在 WS hello 校验 workspace、在 HTTP/WS 各入口复制解析逻辑、或让 publisher/rebuild/send fallback 缩窄为 account-only 授权。
## 7. Tests Required
- 两个 Mind endpoint 的 contract test:成功上下文、字段严格解码、Session 无效、scope/权限拒绝、binding 撤销和版本变化。
- HTTP client failure test:超时、网络错误、5xx、非 JSON、未知 code、缺字段均映射为 authorization_unavailable。
- Bright HTTP/WS 共用 Session adapter test:页面伪造 user/workspace 被拒绝,Cookie 不出现在响应、日志或持久化。
- Session adapter test:只对 session endpoint 发送精确 workspace headerJSON body 仍严格等于 `{ channelAccountId }`plugin binding 不获得 header;返回 workspace 不匹配为 scope_mismatch。
- Public-read/WS ingress testCORS preflight 允许精确 headerHTTP 的缺失/空白/多值 header 在 reader/service 前失败;WS 的缺失/空白/重复 query 在 upgrade 前失败,query 与 hello 不同关闭为 scope_mismatch。
- WS fence testsession capability、publisher、rebuild status、pending send 的所有 Mind fallback 都转发 workspaceworkspace mismatch 后 `message.created``sync.status``conversation.updated`、wire send 与 rebuild status 均不产生。
- 同域三级域名 browser smokecredentialed CORS、Cookie、WS handshake 和 Origin allowlist。
- takeover test:旧 binding 的 connect/heartbeat/upload/send/late result 全部拒绝,新设备复用共享锚点。
- SendAttempt/commit fence test:同 ID 并发只 dispatch 一次,pre-wire 与 post-wire 断线映射不同,confirmation terminal/late no-oppause/disconnect 在各数据库异步边界回滚未提交写入。
@@ -115,11 +126,15 @@
### Correct
const workspaceId = readMindWorkspaceHeader(request.headers);
if (workspaceId === undefined) return failClosed("scope_mismatch");
const authorization = await mindClient.authorizeSession({
cookie: request.headers.cookie,
channelAccountId: request.params.channelAccountId,
workspaceId,
});
if (!authorization.allowed) return failClosed(authorization.code);
if (authorization.mindScope.workspaceId !== workspaceId) return failClosed("scope_mismatch");
return service.readHistory(authorization.mindScope, request.params.conversationId);
Cookie 是登录上下文,不是页面可自定义的业务字段;Mind 是身份和 binding 的事实源,Bright 只消费两个 HTTP 判定结果。
+12 -4
View File
@@ -13,8 +13,9 @@ import type { OneTalkCutoverPolicy } from "../../cutover-policy.ts";
import type { OneTalkReadService } from "../../onetalk/index.ts";
import { sendError } from "./read.ts";
import type { OneTalkConnectionRegistry } from "../../websocket/registry.ts";
import { MIND_WORKSPACE_HEADER, readMindWorkspaceHeader } from "../../mind-workspace-context.ts";
const CORS_ALLOWED_REQUEST_HEADERS = new Set(["content-type"]);
const CORS_ALLOWED_REQUEST_HEADERS = new Set(["content-type", MIND_WORKSPACE_HEADER.toLowerCase()]);
const CORS_ALLOWED_REQUEST_METHODS = new Set(["GET", "POST"]);
type AuthorizationResult =
@@ -80,13 +81,17 @@ const authorizeRead = async (
if (channelAccountId.trim() === "") {
return { ok: false, statusCode: 403, code: "scope_mismatch" };
}
const workspaceId = readMindWorkspaceHeader(request.headers);
if (workspaceId === undefined) {
return { ok: false, statusCode: 403, code: "scope_mismatch" };
}
const cookie = typeof request.headers.cookie === "string" ? request.headers.cookie : undefined;
let decision: OneTalkAuthorizationDecision;
try {
decision = await authorization.authorize({
connectionType: "mind_page",
operation: requiredPermission === "rebuild" ? "rebuild" : "read",
scope: { channelAccountId },
scope: { channelAccountId, workspaceId },
...(cookie === undefined ? {} : { cookie }),
});
} catch {
@@ -94,7 +99,10 @@ const authorizeRead = async (
}
if (!decision.allowed) return authorizationCodeFor(decision);
if (decision.mindScope.channelAccountId !== channelAccountId) {
if (
decision.mindScope.channelAccountId !== channelAccountId ||
decision.mindScope.workspaceId !== workspaceId
) {
return { ok: false, statusCode: 403, code: "scope_mismatch" };
}
if (!decision.permissions.includes(requiredPermission)) {
@@ -160,7 +168,7 @@ export const installPublicPreflight = (
reply.header("access-control-allow-credentials", "true");
reply.header("vary", "Origin");
reply.header("access-control-allow-methods", "GET,POST,OPTIONS");
reply.header("access-control-allow-headers", "content-type");
reply.header("access-control-allow-headers", `content-type, ${MIND_WORKSPACE_HEADER}`);
return reply.code(204).send();
});
};
+8 -1
View File
@@ -8,6 +8,7 @@ import {
type OneTalkAuthorizationOperation,
OneTalkAuthorizationError,
} from "@trade-message-center/onetalk-contract";
import { MIND_WORKSPACE_HEADER } from "./mind-workspace-context.ts";
export const MIND_BINDING_AUTHORIZATION_PATH = "/internal/bright/onetalk/authorize-binding";
export const MIND_SESSION_AUTHORIZATION_PATH = "/internal/bright/onetalk/authorize-session";
@@ -48,7 +49,7 @@ const unavailable = (): OneTalkAuthorizationDecision => ({
const decisionFromResponse = async (
response: Response,
expected: { channelAccountId: string; binding?: string },
expected: { channelAccountId: string; binding?: string; workspaceId?: string },
): Promise<OneTalkAuthorizationDecision> => {
let body: unknown;
try {
@@ -82,6 +83,8 @@ const decisionFromResponse = async (
const authorization = decoded.authorization;
if (
authorization.mindScope.channelAccountId !== expected.channelAccountId ||
(expected.workspaceId !== undefined &&
authorization.mindScope.workspaceId !== expected.workspaceId) ||
(expected.binding !== undefined && authorization.binding !== expected.binding)
) {
return { allowed: false, code: ONETALK_ERROR_CODES.scopeMismatch };
@@ -98,6 +101,7 @@ const requestAuthorization = async (
operation: OneTalkAuthorizationOperation,
cookie?: string,
diagnostics?: MindAuthorizationDiagnosticsSink,
workspaceId?: string,
): Promise<Response | null> => {
const payload = JSON.stringify(body);
reportDiagnostic(diagnostics, {
@@ -112,6 +116,7 @@ const requestAuthorization = async (
headers: {
"content-type": "application/json",
...(cookie === undefined ? {} : { cookie }),
...(workspaceId === undefined ? {} : { [MIND_WORKSPACE_HEADER]: workspaceId }),
},
body: payload,
signal: AbortSignal.timeout(timeoutMs),
@@ -198,10 +203,12 @@ export const createMindAuthorizationReader = (
request.operation,
request.cookie,
config.onDiagnostic,
request.scope.workspaceId,
);
if (!response) return unavailable();
const decision = await decisionFromResponse(response, {
channelAccountId: request.scope.channelAccountId,
workspaceId: request.scope.workspaceId,
});
reportResponse(config.onDiagnostic, "session", request.operation, response, decision);
return decision;
+21
View File
@@ -0,0 +1,21 @@
// 解析 Mind 页面声明的 workspace;该值仅随 Session 授权请求转发。
export const MIND_WORKSPACE_HEADER = "Sinan-Pilot-Workspace-Id";
const MIND_WORKSPACE_HEADER_KEY = MIND_WORKSPACE_HEADER.toLowerCase();
const isSingleNonBlank = (value: string | string[] | undefined): value is string =>
typeof value === "string" && value.trim() !== "" && !value.includes(",");
/** 从 Mind HTTP 请求取得唯一、非空的空间声明,保留原值用于上游转发。 */
export const readMindWorkspaceHeader = (
headers: Record<string, string | string[] | undefined>,
): string | undefined => {
const value = headers[MIND_WORKSPACE_HEADER_KEY];
return isSingleNonBlank(value) ? value : undefined;
};
/** 从 Mind WebSocket URL 取得唯一、非空的空间声明。 */
export const readMindWorkspaceQuery = (url: string): string | undefined => {
const values = new URL(url, "http://workspace.invalid").searchParams.getAll("workspaceId");
return values.length === 1 && isSingleNonBlank(values[0]) ? values[0] : undefined;
};
+1
View File
@@ -59,6 +59,7 @@ export type OneTalkWebSocketHandlerOptions = {
origin?: string;
mindPageOrigin?: string;
pluginOrigins?: string[];
workspaceId?: string;
};
cutoverPolicy?: OneTalkCutoverPolicy;
expectedConnectionType: "plugin" | "mind_page";
+12
View File
@@ -21,6 +21,7 @@ import type {
} from "../onetalk/index.ts";
import type { OneTalkDiagnosticsSink } from "./diagnostics.ts";
import type { OneTalkCutoverPolicy } from "../cutover-policy.ts";
import { readMindWorkspaceQuery } from "../mind-workspace-context.ts";
const reportDiagnostic = (
sink: OneTalkDiagnosticsSink | undefined,
@@ -97,6 +98,14 @@ const registerWebsocketRoutes = (
});
return reply.code(403).send();
}
if (routeType === "mind_page" && readMindWorkspaceQuery(request.url) === undefined) {
reportDiagnostic(onDiagnostic, {
event: "ws_decision",
connectionType: "mind_page",
code: "scope_mismatch",
});
return reply.code(403).send();
}
});
const endpointOptions = (request: FastifyRequest) => ({
authorization,
@@ -115,6 +124,9 @@ const registerWebsocketRoutes = (
: {}),
...(mindPageOrigin === undefined ? {} : { mindPageOrigin }),
...(pluginOrigins === undefined ? {} : { pluginOrigins }),
...(readMindWorkspaceQuery(request.url) === undefined
? {}
: { workspaceId: readMindWorkspaceQuery(request.url) }),
},
cutoverPolicy,
authenticatedRouter,
+22 -3
View File
@@ -68,7 +68,11 @@ export const createOneTalkMindWebSocketHandler = (
: context.authorize({
connectionType: "mind_page",
operation: route.operation,
scope: frame.scope,
scope: {
channelAccountId: frame.scope.channelAccountId,
workspaceId:
context.options.requestContext?.workspaceId ?? "",
},
}));
if (!context.isPolicyCurrent(epoch)) return context.closeForPause();
const code = sessionAuthorizationFailure(state, decision, "read");
@@ -96,6 +100,7 @@ export const createOneTalkMindWebSocketHandler = (
context.reportHello(frame);
const sessionAuthorization = new OneTalkMindSessionAuthorization(
context.options.authorization,
context.options.requestContext?.workspaceId ?? "",
context.options.requestContext?.cookie,
);
if (
@@ -109,11 +114,25 @@ export const createOneTalkMindWebSocketHandler = (
context.close(CLOSE_POLICY_VIOLATION, "authorization_rejected");
return;
}
if (
context.options.requestContext.workspaceId === undefined ||
frame.scope.workspaceId !== context.options.requestContext.workspaceId
) {
sessionAuthorization.clear();
context.sendError(frame, ONETALK_ERROR_CODES.scopeMismatch);
context.close(CLOSE_POLICY_VIOLATION, "scope_mismatch");
return;
}
const decision = await context.authorize({
connectionType: "mind_page",
operation: "connect",
scope: frame.scope,
cookie: context.options.requestContext?.cookie,
scope: {
channelAccountId: frame.scope.channelAccountId,
workspaceId: context.options.requestContext.workspaceId,
},
...(context.options.requestContext.cookie === undefined
? {}
: { cookie: context.options.requestContext.cookie }),
});
if (!decision.allowed) {
sessionAuthorization.clear();
+4 -1
View File
@@ -131,7 +131,10 @@ export const createOneTalkMindPublisher = (options: {
: options.authorization.authorize({
connectionType: "mind_page",
operation: "read",
scope: { channelAccountId: connection.mindScope.channelAccountId },
scope: {
channelAccountId: connection.mindScope.channelAccountId,
workspaceId: connection.mindScope.workspaceId,
},
}));
} catch (error: unknown) {
if (
@@ -11,11 +11,17 @@ import type { OneTalkMindSessionCapability } from "../connection-store.ts";
/** 创建仅在 Mind 端点内持有 Cookie 的不可序列化授权 capability。 */
export class OneTalkMindSessionAuthorization implements OneTalkMindSessionCapability {
readonly #authorization: OneTalkAuthorizationReader;
readonly #workspaceId: string;
#cookie: string | undefined;
#revoked = false;
public constructor(authorization: OneTalkAuthorizationReader, cookie?: string) {
public constructor(
authorization: OneTalkAuthorizationReader,
workspaceId: string,
cookie?: string,
) {
this.#authorization = authorization;
this.#workspaceId = workspaceId;
this.#cookie = cookie;
}
@@ -29,6 +35,12 @@ export class OneTalkMindSessionAuthorization implements OneTalkMindSessionCapabi
code: ONETALK_ERROR_CODES.authRequired,
});
}
if (scope.workspaceId !== this.#workspaceId) {
return Promise.resolve({
allowed: false as const,
code: ONETALK_ERROR_CODES.scopeMismatch,
});
}
return this.#authorization.authorize({
connectionType: "mind_page",
operation,
@@ -216,7 +216,10 @@ export const createOneTalkPendingSendCoordinator = (options: {
: await options.authorization.authorize({
connectionType: "mind_page",
operation: "send",
scope: { channelAccountId: mind.mindScope.channelAccountId },
scope: {
channelAccountId: mind.mindScope.channelAccountId,
workspaceId: mind.mindScope.workspaceId,
},
});
if (!authorizationMatches(mindDecision, mind)) {
settleBeforeDispatch("waiting_for_page");
+4 -1
View File
@@ -186,7 +186,10 @@ export const createOneTalkConnectionRegistry = (options: {
: options.authorization.authorize({
connectionType: "mind_page",
operation: "rebuild",
scope: connection.scope,
scope: {
channelAccountId: connection.mindScope.channelAccountId,
workspaceId: connection.mindScope.workspaceId,
},
}));
if (
!decision.allowed ||
+1
View File
@@ -448,6 +448,7 @@ test("does not print raw Mind authorization payloads from the development app",
headers: {
origin: "http://mind.localhost",
cookie: "mind_session=opaque",
"sinan-pilot-workspace-id": "workspace-1",
},
});
assert.equal(response.statusCode, 200);
+37 -5
View File
@@ -51,6 +51,10 @@ test("sends only binding fields to Mind binding authorization", async () => {
binding: "binding-1",
});
assert.equal((request?.headers as Record<string, string>).cookie, undefined);
assert.equal(
(request?.headers as Record<string, string>)["Sinan-Pilot-Workspace-Id"],
undefined,
);
});
test("does not expose raw Mind authorization payload hooks", () => {
@@ -97,7 +101,7 @@ test("emits safe diagnostics for Mind authorization responses and transport fail
await unavailableReader.authorize({
connectionType: "mind_page",
operation: "read",
scope: { channelAccountId: "account-1" },
scope: { channelAccountId: "account-1", workspaceId: "workspace-1" },
cookie: "secret-session",
});
@@ -153,15 +157,43 @@ test("forwards the original Cookie only to session authorization", async () => {
const decision = await reader.authorize({
connectionType: "mind_page",
operation: "read",
scope: { channelAccountId: "account-1" },
scope: { channelAccountId: "account-1", workspaceId: "workspace-1" },
cookie: "mind_session=opaque",
});
assert.equal(decision.allowed, true);
assert.match(url, /authorize-session$/);
assert.equal((request?.headers as Record<string, string>).cookie, "mind_session=opaque");
assert.equal(
(request?.headers as Record<string, string>)["Sinan-Pilot-Workspace-Id"],
"workspace-1",
);
assert.deepEqual(JSON.parse(String(request?.body)), { channelAccountId: "account-1" });
});
test("rejects a Mind session response for another workspace", async () => {
const reader = createMindAuthorizationReader({
baseUrl: "https://mind.example.com",
timeoutMs: 100,
fetch: async () =>
response(200, {
binding: "binding-1",
authorizationVersion: "version-1",
permissions: ["read"],
mindScope: { ...scope, workspaceId: "workspace-2" },
}),
});
assert.deepEqual(
await reader.authorize({
connectionType: "mind_page",
operation: "read",
scope: { channelAccountId: "account-1", workspaceId: "workspace-1" },
cookie: "mind_session=opaque",
}),
{ allowed: false, code: "scope_mismatch" },
);
});
test("maps malformed, unknown and transport failures to unavailable", async () => {
for (const result of [
response(200, { binding: "binding-1" }),
@@ -231,7 +263,7 @@ test("enforces exact Mind status-code pairs and transport failure boundaries", a
input: {
connectionType: "mind_page" as const,
operation: "read" as const,
scope: { channelAccountId: "account-1" },
scope: { channelAccountId: "account-1", workspaceId: "workspace-1" },
cookie: "mind_session=opaque",
},
status: 401,
@@ -242,7 +274,7 @@ test("enforces exact Mind status-code pairs and transport failure boundaries", a
input: {
connectionType: "mind_page" as const,
operation: "read" as const,
scope: { channelAccountId: "account-1" },
scope: { channelAccountId: "account-1", workspaceId: "workspace-1" },
cookie: "mind_session=opaque",
},
status: 403,
@@ -253,7 +285,7 @@ test("enforces exact Mind status-code pairs and transport failure boundaries", a
input: {
connectionType: "mind_page" as const,
operation: "read" as const,
scope: { channelAccountId: "account-1" },
scope: { channelAccountId: "account-1", workspaceId: "workspace-1" },
cookie: "mind_session=opaque",
},
status: 403,
+60 -2
View File
@@ -141,6 +141,7 @@ const rebuildUrl = (): string => historyUrl() + "/rebuild";
const headers = (): Record<string, string> => ({
cookie: "mind_session=opaque",
origin: "http://mind.localhost",
"sinan-pilot-workspace-id": mindScope.workspaceId,
});
const closeApp = async (app: ReturnType<typeof createApp>): Promise<void> => {
@@ -648,7 +649,10 @@ test("preflight rejects retired summary headers and rejected origins cannot invo
assert.equal(preflight.statusCode, 204);
assert.equal(preflight.headers["access-control-allow-origin"], "http://mind.localhost");
assert.equal(preflight.headers["access-control-allow-credentials"], "true");
assert.equal(preflight.headers["access-control-allow-headers"], "content-type");
assert.equal(
preflight.headers["access-control-allow-headers"],
"content-type, Sinan-Pilot-Workspace-Id",
);
assert.equal(preflight.headers.vary, "Origin");
const allowedHeaders = await app.inject({
@@ -657,7 +661,7 @@ test("preflight rejects retired summary headers and rejected origins cannot invo
headers: {
origin: "http://mind.localhost",
"access-control-request-method": "GET",
"access-control-request-headers": "Content-Type",
"access-control-request-headers": "Content-Type, Sinan-Pilot-Workspace-Id",
},
});
assert.equal(allowedHeaders.statusCode, 204);
@@ -696,6 +700,60 @@ test("preflight rejects retired summary headers and rejected origins cannot invo
}
});
test("rejects missing, blank, and repeated workspace headers before authorization or reads", async () => {
let authorizationCalls = 0;
let readCalls = 0;
const delegate = createMockAuthorizationReader([authorizationRecord]);
const app = createApp(testConfig, {
database: createDatabaseStub(),
authorization: {
authorize: async (request) => {
authorizationCalls += 1;
return await delegate.authorize(request);
},
readAuthorizationVersion: delegate.readAuthorizationVersion,
},
readService: createReadService({
listConversations: async () => {
readCalls += 1;
return {
status: "accepted",
conversations: [],
page: { hasMore: false, nextCursor: null },
};
},
}),
});
try {
for (const invalidHeaders of [
{ cookie: "mind_session=opaque", origin: "http://mind.localhost" },
{
cookie: "mind_session=opaque",
origin: "http://mind.localhost",
"sinan-pilot-workspace-id": " ",
},
{
cookie: "mind_session=opaque",
origin: "http://mind.localhost",
"sinan-pilot-workspace-id": ["workspace-1", "workspace-2"],
},
]) {
const response = await app.inject({
method: "GET",
url: conversationsUrl(),
headers: invalidHeaders,
});
assert.equal(response.statusCode, 403);
assert.deepEqual(response.json(), { error: { code: "scope_mismatch" } });
}
assert.equal(authorizationCalls, 0);
assert.equal(readCalls, 0);
} finally {
await closeApp(app);
}
});
test("fails closed for unavailable authorization and never includes request secrets in errors", async () => {
const app = createApp(testConfig, {
database: createDatabaseStub(),
+98 -2
View File
@@ -18,6 +18,7 @@ import {
type OneTalkMessage,
type OneTalkObservedMessage,
type OneTalkAuthorizationReader,
type OneTalkAuthorizationRequest,
type OneTalkSendConfirmationFrame,
type OneTalkSendRequestFrame,
} from "@trade-message-center/onetalk-contract";
@@ -318,8 +319,9 @@ const nextCloseCode = async (socket: {
const openSocket = async (app: ReturnType<typeof createApp>, path = "/ws/plugin") => {
await app.ready();
const origin = path === "/ws/mind" ? "http://mind.localhost" : "http://plugin.localhost";
const socket = await app.injectWS(path, { headers: { origin } });
const url = path === "/ws/mind" ? `/ws/mind?workspaceId=${mindScope.workspaceId}` : path;
const origin = url.startsWith("/ws/mind") ? "http://mind.localhost" : "http://plugin.localhost";
const socket = await app.injectWS(url, { headers: { origin } });
frameReaderFor(socket);
return socket;
};
@@ -1693,6 +1695,100 @@ test("does not publish live facts to a Mind connection that did not request read
);
});
test("fences every Mind publish event when fallback authorization mismatches", async () => {
const eventCases = [
{
type: "message.created",
publish: (registry: ReturnType<typeof createOneTalkConnectionRegistry>) =>
registry.publishMessageCreated({
message: centerMessage(message()),
requestId: "publish-message-mismatch",
scope: mindScope,
}),
},
{
type: "sync.status",
publish: (registry: ReturnType<typeof createOneTalkConnectionRegistry>) =>
registry.publishSyncStatus({
requestId: "publish-sync-mismatch",
scope: mindScope,
status: {
conversationId: "conversation-1",
historyGeneration: "initial",
mode: "full",
syncPhase: "initial",
syncResult: "succeeded",
latestMessageId: null,
historyComplete: true,
messageCount: 1,
anchorAdvanced: false,
},
}),
},
{
type: "conversation.updated",
publish: (registry: ReturnType<typeof createOneTalkConnectionRegistry>) =>
registry.publishConversationUpdated({
conversation: {
conversationId: "conversation-1",
conversationType: "direct",
name: null,
avatarUrl: null,
lastContactTimeLong: null,
messagePreview: null,
},
moveToTop: false,
requestId: "publish-conversation-mismatch",
scope: mindScope,
}),
},
] as const;
for (const eventCase of eventCases) {
const requests: OneTalkAuthorizationRequest[] = [];
const sent: TestFrame[] = [];
const authorization: OneTalkAuthorizationReader = {
authorize: async (request) => {
requests.push(request);
return { allowed: false, code: ONETALK_ERROR_CODES.scopeMismatch };
},
readAuthorizationVersion: async () => "version-1",
};
const registry = createOneTalkConnectionRegistry({
authorization,
onPublishFailure: () => undefined,
});
const socket = fakeSocket((payload) => sent.push(JSON.parse(payload) as TestFrame));
registry.register({
socket,
connectionType: "mind_page",
scope: mindScope,
mindScope,
binding: "binding-1",
authorizationVersion: "version-1",
permissions: ["read"],
});
await eventCase.publish(registry);
assert.deepEqual(requests, [
{
connectionType: "mind_page",
operation: "read",
scope: {
channelAccountId: mindScope.channelAccountId,
workspaceId: mindScope.workspaceId,
},
},
]);
assert.deepEqual(
sent.map((frame) => [frame.type, (frame.payload as { code?: string }).code]),
[["ws.error", ONETALK_ERROR_CODES.scopeMismatch]],
eventCase.type,
);
}
});
test("accepts rebuild ACKs from a rebuild-only plugin session", async () => {
const authorization = createMockAuthorizationReader([
{ ...authorizationRecord, permissions: ["rebuild"] },
+109 -4
View File
@@ -21,6 +21,7 @@ import type { DatabaseConnection } from "../src/database/index.ts";
import type { OneTalkProfileService, OneTalkService } from "../src/onetalk/index.ts";
import { createMindAuthorizationReader } from "../src/mind-authorization.ts";
import { installWebsocket } from "../src/websocket/index.ts";
import { OneTalkMindSessionAuthorization } from "../src/websocket/mind/session-authorization.ts";
import {
createOneTalkConnectionRegistry,
type OneTalkConnectionRegistry,
@@ -190,8 +191,9 @@ const openSocket = async (
headers: Record<string, string> = {},
) => {
await app.ready();
const origin = path === "/ws/mind" ? "http://mind.localhost" : "http://plugin.localhost";
return app.injectWS(path, { headers: { origin, ...headers } });
const url = path === "/ws/mind" ? `/ws/mind?workspaceId=${mindScope.workspaceId}` : path;
const origin = url.startsWith("/ws/mind") ? "http://mind.localhost" : "http://plugin.localhost";
return app.injectWS(url, { headers: { origin, ...headers } });
};
const assertAnchorSnapshot = (snapshot: Record<string, unknown>): void => {
@@ -700,14 +702,112 @@ test("accepts a Mind page handshake without a plugin device identifier", async (
}
});
test("rejects invalid Mind workspace query before creating a socket session", async () => {
let authorizationCalls = 0;
const delegate = createMockAuthorizationReader([authorizationRecord]);
const app = createApp(testConfig, {
database: createDatabaseStub(),
authorization: {
authorize: async (request) => {
authorizationCalls += 1;
return await delegate.authorize(request);
},
readAuthorizationVersion: delegate.readAuthorizationVersion,
},
oneTalkService: createServiceStub(),
});
try {
for (const url of [
"/ws/mind",
"/ws/mind?workspaceId=",
"/ws/mind?workspaceId=workspace-1&workspaceId=workspace-2",
]) {
const response = await app.inject({
method: "GET",
url,
headers: { origin: "http://mind.localhost" },
});
assert.equal(response.statusCode, 403);
}
assert.equal(authorizationCalls, 0);
} finally {
await app.close();
}
});
test("rejects a Mind hello whose workspace differs from its pinned query", async () => {
let authorizationCalls = 0;
const delegate = createMockAuthorizationReader([authorizationRecord]);
const app = createApp(testConfig, {
database: createDatabaseStub(),
authorization: {
authorize: async (request) => {
authorizationCalls += 1;
return await delegate.authorize(request);
},
readAuthorizationVersion: delegate.readAuthorizationVersion,
},
oneTalkService: createServiceStub(),
});
const socket = await openSocket(app, "/ws/mind?workspaceId=workspace-2");
try {
const errorMessage = nextMessage(socket);
const closeCode = nextCloseCode(socket);
socket.send(
JSON.stringify({
...helloFrame(mindScope),
connectionType: "mind_page",
payload: { requestedPermissions: ["read"] },
}),
);
assert.deepEqual((await errorMessage).payload, {
code: ONETALK_ERROR_CODES.scopeMismatch,
});
assert.equal(await closeCode, 1008);
assert.equal(authorizationCalls, 0);
} finally {
await closeApp(app, socket);
}
});
test("does not reauthorize a session capability for a different workspace", async () => {
let authorizationCalls = 0;
const capability = new OneTalkMindSessionAuthorization(
{
authorize: async () => {
authorizationCalls += 1;
return {
allowed: false as const,
code: ONETALK_ERROR_CODES.authorizationUnavailable,
};
},
readAuthorizationVersion: async () => "unused",
},
mindScope.workspaceId,
"mind-session-cookie",
);
assert.deepEqual(
await capability.authorize({ ...mindScope, workspaceId: "workspace-2" }, "read"),
{ allowed: false, code: ONETALK_ERROR_CODES.scopeMismatch },
);
assert.equal(authorizationCalls, 0);
});
test("keeps Mind page authorization input separate from plugin binding", async () => {
const credentials: Array<string | undefined> = [];
const workspaces: string[] = [];
const diagnostics: Record<string, unknown>[] = [];
const cookie = "mind-session-cookie";
const baseAuthorization = createMockAuthorizationReader([authorizationRecord]);
const authorization = {
authorize: async (request: OneTalkAuthorizationRequest) => {
if (request.connectionType === "mind_page") credentials.push(request.cookie);
if (request.connectionType === "mind_page") {
credentials.push(request.cookie);
workspaces.push(request.scope.workspaceId);
}
return baseAuthorization.authorize(request);
},
readAuthorizationVersion: baseAuthorization.readAuthorizationVersion,
@@ -744,6 +844,11 @@ test("keeps Mind page authorization input separate from plugin binding", async (
);
assert.equal((await heartbeat).type, "heartbeat.ack");
assert.deepEqual(credentials, [cookie, cookie, cookie]);
assert.deepEqual(workspaces, [
mindScope.workspaceId,
mindScope.workspaceId,
mindScope.workspaceId,
]);
assert.equal(JSON.stringify(diagnostics).includes(cookie), false);
} finally {
await closeApp(app, socket);
@@ -792,7 +897,7 @@ test("does not treat an arbitrary Bearer header as a Mind WebSocket identity", a
oneTalkService: createServiceStub(),
});
await app.ready();
const socket = await app.injectWS("/ws/mind", {
const socket = await app.injectWS(`/ws/mind?workspaceId=${mindScope.workspaceId}`, {
headers: {
origin: "http://mind.localhost",
authorization: `Bearer ${bearerToken}`,
@@ -86,7 +86,7 @@ export type OneTalkPluginAuthorizationRequest = {
export type OneTalkMindAuthorizationRequest = {
connectionType: "mind_page";
operation: OneTalkAuthorizationOperation;
scope: Pick<OneTalkMindScope, "channelAccountId">;
scope: Pick<OneTalkMindScope, "channelAccountId" | "workspaceId">;
binding?: never;
cookie?: string;
};
@@ -189,7 +189,9 @@ const findRecord = (
);
}
const matching = candidates.filter(
(record) => record.mindScope.channelAccountId === request.scope.channelAccountId,
(record) =>
record.mindScope.channelAccountId === request.scope.channelAccountId &&
record.mindScope.workspaceId === request.scope.workspaceId,
);
return matching.find((record) => record.active) ?? matching[0];
};
@@ -302,6 +304,7 @@ export const createMockAuthorizationReader = (
for (const [key, existing] of records) {
if (
existing.mindScope.channelAccountId === record.mindScope.channelAccountId &&
existing.mindScope.workspaceId === record.mindScope.workspaceId &&
key !== recordKey(record)
) {
existing.active = false;
@@ -2116,6 +2116,16 @@ test("mock authorization separates scope, permission, revoke, and dependency fai
mindScope,
});
const crossWorkspace = await reader.authorize({
connectionType: "mind_page",
operation: "read",
scope: { ...mindScope, workspaceId: "workspace-2" },
});
assert.deepEqual(crossWorkspace, {
allowed: false,
code: ONETALK_ERROR_CODES.scopeMismatch,
});
const readOnly = createMockAuthorizationReader([
{ ...authorizationRecord, permissions: ["read"] },
]);