mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
feat: enforce Mind workspace authorization
This commit is contained in:
@@ -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,并只含目标 channelAccountId;Mind 从 Session 推导 user/workspace/权限/active binding。
|
||||
- 两个 endpoint 的成功响应都必须提供 binding、authorizationVersion、permissions 和完整 mindScope;mindScope.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 body;Mind 从可信 Session 与该空间声明校验 user/workspace/权限/active binding。workspace 是请求声明,不是 Center 接受的身份。
|
||||
- 两个 endpoint 的成功响应都必须提供 binding、authorizationVersion、permissions 和完整 mindScope;mindScope.channelAccountId 与 mindScope.workspaceId 必须分别匹配 session 请求目标账号与 workspace。binding 请求仍只校验其 binding/账号契约。
|
||||
- 允许结果映射为 allowed: true;Mind 返回的拒绝 code 只能是已登记的稳定 code。Bright 不把 Mind 原始 JSON 传给页面或消息领域。
|
||||
- Mind page HTTP/WS 不使用 page token,不在 WS hello 中传递 Cookie;WS 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 必须按连接类型检查精确 Origin:mind_page 允许 MIND_PAGE_ORIGIN,plugin 允许已登记的扩展 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_mismatch;hello/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;页面只携带浏览器 Cookie;Bright 使用 Mind 返回的真实 scope 做精确路由。
|
||||
- Good:插件只提交账号和 binding;Mind 页面 HTTP 发送 Cookie 与唯一 workspace header,WS URL 发送相同 workspace query;Bright 使用 Mind 返回的真实完整 scope 做精确路由。
|
||||
- Good:workspace A 的 reader/WS authorization 只能匹配 A;Mind 在换绑或空间切换后拒绝旧 scope 时,下一次 heartbeat、send、rebuild 或 publish fence 不产生 ACK、wire send、rebuild status 或页面发布。
|
||||
- Good:Mind 接管新 binding 后旧 binding endpoint 立即拒绝,Bright 关闭旧连接,迟到发送结果保持 delivery_unknown。
|
||||
- Base:本地开发使用内存 authorization fixture;它模拟两个 endpoint 的结果,但不被称为 Mind view,也不连接 Mind DB。
|
||||
- Bad:Bright 直接查询 Mind session/binding 表、把 mindUserId header 当作主体、把 Cookie 放到 URL/WS payload,或在授权失败时沿用旧缓存。
|
||||
- Bad:Bright 直接查询 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 header,JSON body 仍严格等于 `{ channelAccountId }`;plugin binding 不获得 header;返回 workspace 不匹配为 scope_mismatch。
|
||||
- Public-read/WS ingress test:CORS preflight 允许精确 header;HTTP 的缺失/空白/多值 header 在 reader/service 前失败;WS 的缺失/空白/重复 query 在 upgrade 前失败,query 与 hello 不同关闭为 scope_mismatch。
|
||||
- WS fence test:session capability、publisher、rebuild status、pending send 的所有 Mind fallback 都转发 workspace;workspace mismatch 后 `message.created`、`sync.status`、`conversation.updated`、wire send 与 rebuild status 均不产生。
|
||||
- 同域三级域名 browser smoke:credentialed 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-op,pause/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 判定结果。
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -59,6 +59,7 @@ export type OneTalkWebSocketHandlerOptions = {
|
||||
origin?: string;
|
||||
mindPageOrigin?: string;
|
||||
pluginOrigins?: string[];
|
||||
workspaceId?: string;
|
||||
};
|
||||
cutoverPolicy?: OneTalkCutoverPolicy;
|
||||
expectedConnectionType: "plugin" | "mind_page";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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"] },
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user