fix: bind Gatekeeper sessions to approved actions

This commit is contained in:
冰朔 2026-07-15 21:34:27 +08:00
parent 7cb04cdf97
commit 41c29a35cb
8 changed files with 163 additions and 45 deletions

View File

@ -0,0 +1,50 @@
# GLSV / Gatekeeper 3.2 授权边界与路径映射
更新时间2026-07-15
## 修正结论
邮箱验证码只证明“本次已登记人格体、服务器和范围获得一次人工确认”。它不再解锁客户端提交的任意 shell 命令。Gatekeeper 只接受 `authorized-actions.js` 中登记的动作;未登记动作直接拒绝。
## 判断依据(可复核摘要)
| 判断 | 依据文件 | 修正结果 |
|---|---|---|
| 人格体先发起会话,人类只提供验证码 | `zero-point/core-channel/GLSV-PERSONA-REMOTE-OPS.hdlp` | 保留 |
| 验证码不能等于任意命令执行权 | `zero-point/core-channel/gatekeeper/GLSV-SECURITY-VERIFICATION.hdlp` | 由代码强制 |
| 普通 push 不应直接部署 | `BROADCAST-TOWER.hdlp``server-tools/fifth-domain-sync-agent/MODULE.hdlp` | 同步 Agent 只做快进同步和回执 |
| 部署动作必须另有固定动作白名单 | `server-tools/deployment-receiver/config.example.json` | Gatekeeper 不越权代替部署接收器 |
## 当前路径
```text
BROADCAST-TOWER
→ GLSV / Gatekeeper v3.2
→ session/request人格体 + 最小 scope + 已登记 action
→ 固定登记邮箱验证码
→ session/confirm
→ session/exec仍只能执行同一个 action
→ 只读结果与回执
第五域 main push
→ Forgejo 签名 webhook /forgejo/sync
→ fifth-domain-sync-agent
→ 仓库、分支、SHA、导航守卫核验
→ 快进同步 + 服务器本地回执
需要部署、重启、迁移
→ deployment/requests/*.json
→ deployment-receiver 固定动作白名单
→ 另行授权,不由普通 push 或 Gatekeeper 任意执行
```
## 已登记动作
- `inspect-gatekeeper`:只读检查 Gatekeeper。
- `sync-status`:只读检查第五域受控工作副本状态。
部署、重启、迁移目前没有登记为 Gatekeeper 动作,因此会被拒绝。这是有意的安全边界,不是功能缺失。
## 端口规则
Gatekeeper 兼容实现默认使用 3911服务器实际端口仍以受控健康检查回执为准。3982 属于第五域同步 Agent3981 属于部署接收器,三者不能互相替代。

View File

@ -26,7 +26,11 @@
curl -fsS http://127.0.0.1:3911/health curl -fsS http://127.0.0.1:3911/health
``` ```
5. 确认健康接口返回 `version: 3.2.0` 5. 确认健康接口返回 `version: 3.2.0`,并确认 Gatekeeper 与第五域同步 Agent3982、部署接收器3981分开核验。
## 动作边界
验证码流程只接受 `authorized-actions.js` 中的固定 action不接受客户端传入 shell 命令。升级后旧的未绑定 action 会话必须重新授权;部署、重启和迁移仍需走 `deployment/requests/*.json` 与 deployment-receiver 的独立白名单。
## 回滚 ## 回滚

View File

@ -50,4 +50,6 @@ GLSV 不接受新文档中的任意 shell 命令。
服务凭据留在服务器保险库;仓库、聊天记录与回执不得存放 Token、验证码或密钥。 服务凭据留在服务器保险库;仓库、聊天记录与回执不得存放 Token、验证码或密钥。
``` ```
部署与兼容说明:`DEPLOY-v3.2.md`;固定动作接收器:`server-tools/deployment-receiver/`。 部署与兼容说明:`DEPLOY-v3.2.md`;固定动作接收器:`server-tools/deployment-receiver/`Gatekeeper 动作白名单:`authorized-actions.js`;完整判断与路径映射:`AUTHORIZATION-BOUNDARY-MAP.md`。
安全边界:`/auth/request`、`/auth/confirm`、`/auth/session/exec` 不接受客户端 shell 命令;必须提交已登记 action且 action 的 scope 必须与会话范围一致。

View File

@ -0,0 +1,46 @@
"use strict";
const path = require("node:path");
const { execFile } = require("node:child_process");
// This registry is intentionally small. Write or service actions require a
// separate reviewed entry and a matching deployment-receiver rule.
const REPO_ROOT = path.resolve(__dirname, "../../..");
const INSPECT_SCRIPT = path.join(REPO_ROOT, "server-tools/deployment-receiver/scripts/inspect-gatekeeper.js");
const ACTIONS = Object.freeze({
"inspect-gatekeeper": Object.freeze({
label: "只读检查 Gatekeeper",
scope: "system-arch",
mode: "read-only",
run: (timeout) => runFile(process.execPath, [INSPECT_SCRIPT], timeout)
}),
"sync-status": Object.freeze({
label: "只读检查第五域同步状态",
scope: "code-repo",
mode: "read-only",
run: (timeout) => runFile("/usr/bin/git", ["-C", REPO_ROOT, "status", "--short", "--branch"], timeout)
})
});
function runFile(file, args, timeout) {
return new Promise((resolve) => {
execFile(file, args, { timeout: timeout || 30000, maxBuffer: 100000 }, (err, stdout, stderr) => {
resolve({
code: err ? (typeof err.code === "number" ? err.code : 1) : 0,
stdout: (stdout || "").slice(0, 100000),
stderr: (stderr || "").slice(0, 100000)
});
});
});
}
function getAction(action) {
return typeof action === "string" ? ACTIONS[action] || null : null;
}
function listActions() {
return Object.entries(ACTIONS).map(([id, action]) => ({ id, label: action.label, scope: action.scope, mode: action.mode }));
}
module.exports = { getAction, listActions, INSPECT_SCRIPT };

View File

@ -3,6 +3,7 @@ const test = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const fs = require("node:fs"); const fs = require("node:fs");
const source = fs.readFileSync(__dirname + "/engine-v3.js", "utf8"); const source = fs.readFileSync(__dirname + "/engine-v3.js", "utf8");
const actions = require(__dirname + "/authorized-actions.js");
test("direct email bypass is removed", () => { test("direct email bypass is removed", () => {
assert.doesNotMatch(source, /requestEmail|email_mode:\s*requestEmail|直接使用,不查白名单/); assert.doesNotMatch(source, /requestEmail|email_mode:\s*requestEmail|直接使用,不查白名单/);
@ -17,3 +18,15 @@ test("Zhulan and Canger identities are registered", () => {
assert.match(source, /PTS-VA-001-EED/); assert.match(source, /PTS-VA-001-EED/);
assert.match(source, /ICE-GL-CA001/); assert.match(source, /ICE-GL-CA001/);
}); });
test("arbitrary shell commands are not accepted by the authorization flow", () => {
assert.doesNotMatch(source, /execCmd\(body\.cmd/);
assert.doesNotMatch(source, /execCmd\(op\.cmd/);
assert.match(source, /必须指定已登记 action/);
assert.deepEqual(actions.listActions().map((item) => item.id), ["inspect-gatekeeper", "sync-status"]);
assert.equal(actions.getAction("not-registered"), null);
});
test("Gatekeeper and sync-agent ports are separated", () => {
assert.match(source, /process\.argv\[2\] \|\| '3911'/);
const syncConfig = fs.readFileSync(__dirname + "/../../../server-tools/fifth-domain-sync-agent/config.example.json", "utf8");
assert.match(syncConfig, /"listen_port": 3982/);
});

View File

@ -22,11 +22,12 @@
*/ */
const http = require('http'), fs = require('fs'), path = require('path'), const http = require('http'), fs = require('fs'), path = require('path'),
crypto = require('crypto'), { exec } = require('child_process'), crypto = require('crypto'),
os = require('os'), tls = require('tls'); os = require('os'), tls = require('tls');
const { SessionManager } = require('./session-manager'); const { SessionManager } = require('./session-manager');
const { getAction, listActions } = require('./authorized-actions');
const PORT = parseInt(process.env.ENGINE_PORT || process.env.GATEKEEPER_PORT || process.argv[2] || '3910', 10); const PORT = parseInt(process.env.ENGINE_PORT || process.env.GATEKEEPER_PORT || process.argv[2] || '3911', 10);
const DATA_DIR = path.join('/opt/zhuyuan', 'gatekeeper'); const DATA_DIR = path.join('/opt/zhuyuan', 'gatekeeper');
const CMD_TIMEOUT = 30000, MAX_OUTPUT = 100000; const CMD_TIMEOUT = 30000, MAX_OUTPUT = 100000;
const CODE_TTL = 300; // 验证码 5 分钟过期 const CODE_TTL = 300; // 验证码 5 分钟过期
@ -120,7 +121,7 @@ const SMTP = {
// ═══════════════════════════════════════ // ═══════════════════════════════════════
// 运行时状态 // 运行时状态
// ═══════════════════════════════════════ // ═══════════════════════════════════════
const pendingOps = {}; // { challenge_id: { code, expires, op_type, cmd, caller, target_email, target_name, server } } const pendingOps = {}; // { challenge_id: { code, expires, op_type, action, caller, target_email, target_name, server } }
const rateLimitMap = {}; // { ip: [timestamp, ...] } const rateLimitMap = {}; // { ip: [timestamp, ...] }
// ═══════════════════════════════════════ // ═══════════════════════════════════════
@ -221,17 +222,10 @@ function fmtBytes(b) {
return (b / 1073741824).toFixed(1) + ' GB'; return (b / 1073741824).toFixed(1) + ' GB';
} }
function execCmd(cmd, timeout) { function runAuthorizedAction(actionId, timeout) {
return new Promise((resolve) => { const action = getAction(actionId);
const t = timeout || CMD_TIMEOUT; if (!action) return Promise.resolve({ code: 126, stdout: '', stderr: 'action_not_allowed' });
const child = exec(cmd, { timeout: t, maxBuffer: MAX_OUTPUT, shell: '/bin/bash' }, (err, stdout, stderr) => { return action.run(timeout);
resolve({
code: err ? (err.code || 1) : 0,
stdout: (stdout || '').slice(0, MAX_OUTPUT),
stderr: (stderr || '').slice(0, MAX_OUTPUT)
});
});
});
} }
// ═══════════════════════════════════════ // ═══════════════════════════════════════
@ -541,12 +535,15 @@ const server = http.createServer(async (req, res) => {
cleanExpiredOps(); cleanExpiredOps();
const sourceServer = body.source_server || identity.server || SERVER_ID; const sourceServer = body.source_server || identity.server || SERVER_ID;
const scopes = Array.isArray(body.scopes) ? [...new Set(body.scopes)] : ['code-repo']; const scopes = Array.isArray(body.scopes) ? [...new Set(body.scopes)] : ['code-repo'];
const action = getAction(body.action);
if (!action) { jr(res, 400, { error: '必须指定已登记 action', available: listActions() }); return; }
if (!scopes.includes(action.scope)) { jr(res, 400, { error: 'action 与 scopes 不匹配', required_scope: action.scope }); return; }
if (!scopes.length || scopes.some(s => !OP_TYPES[s])) { jr(res, 400, { error: '无效 scopes', available: Object.keys(OP_TYPES) }); return; } if (!scopes.length || scopes.some(s => !OP_TYPES[s])) { jr(res, 400, { error: '无效 scopes', available: Object.keys(OP_TYPES) }); return; }
const wlResult = checkWhitelist(sourceServer, identity.pid); const wlResult = checkWhitelist(sourceServer, identity.pid);
if (!wlResult.ok) { jr(res, 403, { error: wlResult.reason }); return; } if (!wlResult.ok) { jr(res, 403, { error: wlResult.reason }); return; }
const triad = wlResult.triad; const triad = wlResult.triad;
const code = generateCode(), challengeId = crypto.randomBytes(16).toString('hex'); const code = generateCode(), challengeId = crypto.randomBytes(16).toString('hex');
pendingOps[challengeId] = { code, expires: Date.now()/1000 + CODE_TTL, is_session: true, scopes, caller: identity, server: sourceServer, target_email: triad.email, target_name: triad.human }; pendingOps[challengeId] = { code, expires: Date.now()/1000 + CODE_TTL, is_session: true, action: body.action, scopes, caller: identity, server: sourceServer, target_email: triad.email, target_name: triad.human };
const sent = await sendVerificationEmail(code, scopes[0], identity.name, identity.pid, '开启工作会话 · 范围: ' + scopes.join(', '), triad.email, triad.human, SERVER_ID); const sent = await sendVerificationEmail(code, scopes[0], identity.name, identity.pid, '开启工作会话 · 范围: ' + scopes.join(', '), triad.email, triad.human, SERVER_ID);
jr(res, 200, { ok:true, challenge_id:challengeId, email_sent:sent, email_mode:'whitelist', scopes, expires_in:CODE_TTL, message:'验证码已发送到固定登记邮箱' }); jr(res, 200, { ok:true, challenge_id:challengeId, email_sent:sent, email_mode:'whitelist', scopes, expires_in:CODE_TTL, message:'验证码已发送到固定登记邮箱' });
return; return;
@ -559,7 +556,7 @@ const server = http.createServer(async (req, res) => {
if (op.caller.pid !== identity.pid) { jr(res, 403, { error:'会话身份不匹配' }); return; } if (op.caller.pid !== identity.pid) { jr(res, 403, { error:'会话身份不匹配' }); return; }
if (op.code !== String(body.code || '')) { jr(res, 403, { error:'验证码错误' }); return; } if (op.code !== String(body.code || '')) { jr(res, 403, { error:'验证码错误' }); return; }
delete pendingOps[body.challenge_id]; delete pendingOps[body.challenge_id];
const created = sessions.create(identity, op.server, op.scopes); const created = sessions.create(identity, op.server, op.scopes, [op.action]);
log('INFO','session_started','pid='+identity.pid+' scopes='+op.scopes.join(',')); log('INFO','session_started','pid='+identity.pid+' scopes='+op.scopes.join(','));
jr(res,200,{ok:true,session_token:created.token,scopes:op.scopes,expires_in:created.expires_in,idle_timeout:created.idle_timeout,message:'工作会话已开启'}); jr(res,200,{ok:true,session_token:created.token,scopes:op.scopes,expires_in:created.expires_in,idle_timeout:created.idle_timeout,message:'工作会话已开启'});
return; return;
@ -569,10 +566,12 @@ const server = http.createServer(async (req, res) => {
const sessionToken = req.headers['x-session-token'] || body.session_token || ''; const sessionToken = req.headers['x-session-token'] || body.session_token || '';
const opType = body.op_type || 'code-repo'; const opType = body.op_type || 'code-repo';
const sourceServer = body.source_server || identity.server || SERVER_ID; const sourceServer = body.source_server || identity.server || SERVER_ID;
const verified = sessions.verify(sessionToken, identity, sourceServer, opType); const action = getAction(body.action);
const verified = sessions.verify(sessionToken, identity, sourceServer, opType, body.action);
if (!verified.ok) { jr(res,403,{error:verified.reason}); return; } if (!verified.ok) { jr(res,403,{error:verified.reason}); return; }
if (!body.cmd) { jr(res,400,{error:'缺少 cmd 参数'}); return; } if (!action) { jr(res,400,{error:'必须指定已登记 action',available:listActions()}); return; }
const result = await execCmd(body.cmd, body.timeout); if (action.scope !== opType) { jr(res,400,{error:'action 与 op_type 不匹配',required_scope:action.scope}); return; }
const result = await runAuthorizedAction(body.action, body.timeout);
log('INFO','session_exec','pid='+identity.pid+' op='+opType+' code='+result.code); log('INFO','session_exec','pid='+identity.pid+' op='+opType+' code='+result.code);
jr(res,200,{ok:true,executed:true,session:true,exit_code:result.code,stdout:result.stdout,stderr:result.stderr}); jr(res,200,{ok:true,executed:true,session:true,exit_code:result.code,stdout:result.stdout,stderr:result.stderr});
return; return;
@ -591,7 +590,6 @@ const server = http.createServer(async (req, res) => {
cleanExpiredOps(); cleanExpiredOps();
const opType = body.op_type || 'code-repo'; const opType = body.op_type || 'code-repo';
const cmd = body.cmd || '';
const description = body.description || '未提供描述'; const description = body.description || '未提供描述';
const sourceServer = body.source_server || identity.server || SERVER_ID; const sourceServer = body.source_server || identity.server || SERVER_ID;
if (body.email || body.target_name) { jr(res, 400, { error: '禁止客户端指定邮箱 · 请先注册服务器白名单' }); return; } if (body.email || body.target_name) { jr(res, 400, { error: '禁止客户端指定邮箱 · 请先注册服务器白名单' }); return; }
@ -601,7 +599,9 @@ const server = http.createServer(async (req, res) => {
return; return;
} }
if (!cmd) { jr(res, 400, { error: '缺少 cmd 参数' }); return; } const action = getAction(body.action);
if (!action) { jr(res, 400, { error: '必须指定已登记 action', available: listActions() }); return; }
if (action.scope !== opType) { jr(res, 400, { error: 'action 与 op_type 不匹配', required_scope: action.scope }); return; }
let targetEmail, targetName; let targetEmail, targetName;
@ -627,7 +627,7 @@ const server = http.createServer(async (req, res) => {
code: code, code: code,
expires: now + CODE_TTL, expires: now + CODE_TTL,
op_type: opType, op_type: opType,
cmd: cmd, action: body.action,
caller: { pid: identity.pid, name: identity.name }, caller: { pid: identity.pid, name: identity.name },
target_email: targetEmail, target_email: targetEmail,
target_name: targetName, target_name: targetName,
@ -635,7 +635,7 @@ const server = http.createServer(async (req, res) => {
}; };
// 发送验证码邮件 // 发送验证码邮件
const cmdPreview = cmd.length > 200 ? cmd.slice(0, 200) + '...' : cmd; const cmdPreview = 'action=' + body.action;
const sent = await sendVerificationEmail(code, opType, identity.name, identity.pid, cmdPreview, targetEmail, targetName, SERVER_ID); const sent = await sendVerificationEmail(code, opType, identity.name, identity.pid, cmdPreview, targetEmail, targetName, SERVER_ID);
log('INFO', 'auth_request', 'challenge=' + challengeId.slice(0, 16) + ' op=' + opType + ' email_sent=' + sent); log('INFO', 'auth_request', 'challenge=' + challengeId.slice(0, 16) + ' op=' + opType + ' email_sent=' + sent);
@ -645,7 +645,6 @@ const server = http.createServer(async (req, res) => {
challenge_id: challengeId, challenge_id: challengeId,
email_sent: sent, email_sent: sent,
email_mode: 'whitelist', email_mode: 'whitelist',
target_email: targetEmail,
caller: { pid: identity.pid, name: identity.name }, caller: { pid: identity.pid, name: identity.name },
op_type: opType, op_type: opType,
op_label: OP_TYPES[opType].label, op_label: OP_TYPES[opType].label,
@ -692,12 +691,13 @@ const server = http.createServer(async (req, res) => {
delete pendingOps[challengeId]; delete pendingOps[challengeId];
log('INFO', 'auth_confirm_ok', challengeId.slice(0, 16) + ' op=' + op.op_type + ' caller=' + op.caller.pid); log('INFO', 'auth_confirm_ok', challengeId.slice(0, 16) + ' op=' + op.op_type + ' caller=' + op.caller.pid);
const result = await execCmd(op.cmd, body.timeout); const result = await runAuthorizedAction(op.action, body.timeout);
jr(res, 200, { jr(res, 200, {
ok: true, ok: true,
executed: true, executed: true,
op_type: op.op_type, op_type: op.op_type,
action: op.action,
caller: op.caller, caller: op.caller,
exit_code: result.code, exit_code: result.code,
stdout: result.stdout, stdout: result.stdout,
@ -775,7 +775,7 @@ const server = http.createServer(async (req, res) => {
// ═══ /exec — 旧接口兼容(自动转发到验证码流程) ═══ // ═══ /exec — 旧接口兼容(自动转发到验证码流程) ═══
if (route === '/exec') { if (route === '/exec') {
if (!body.cmd) { jr(res, 400, { error: '缺少 cmd 参数' }); return; } if (!body.action) { jr(res, 400, { error: '缺少 action 参数' }); return; }
// v3 安全策略: /exec 不再直接执行,返回提示要求走验证码流程 // v3 安全策略: /exec 不再直接执行,返回提示要求走验证码流程
log('WARN', 'exec_blocked_v3', 'caller=' + identity.pid + ' 尝试绕过验证码调用 /exec'); log('WARN', 'exec_blocked_v3', 'caller=' + identity.pid + ' 尝试绕过验证码调用 /exec');
@ -783,9 +783,9 @@ const server = http.createServer(async (req, res) => {
jr(res, 403, { jr(res, 403, {
error: 'Gatekeeper v3 安全策略: /exec 已停用 · 请使用分层授权流程', error: 'Gatekeeper v3 安全策略: /exec 已停用 · 请使用分层授权流程',
required_flow: 'POST /auth/request → 邮箱验证码 → POST /auth/confirm', required_flow: 'POST /auth/request → 邮箱验证码 → POST /auth/confirm',
help: '将原有的 /exec 请求改为: ① POST /auth/request (相同 cmd + op_type) → ② 查收邮箱验证码 → ③ POST /auth/confirm (challenge_id + code)', help: '将原有的 /exec 请求改为: ① POST /auth/request (action + op_type) → ② 查收邮箱验证码 → ③ POST /auth/confirm (challenge_id + code)',
example: { example: {
step1: 'curl -X POST http://' + SERVER_ID + ':' + PORT + '/auth/request -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d \'{"cmd":"' + body.cmd.slice(0, 100) + '","op_type":"code-repo"}\'', step1: 'curl -X POST http://' + SERVER_ID + ':' + PORT + '/auth/request -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d \'{"action":"sync-status","op_type":"code-repo"}\'',
step2: 'curl -X POST http://' + SERVER_ID + ':' + PORT + '/auth/confirm -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d \'{"challenge_id":"<从step1返回>","code":"<邮箱验证码>"}\'' step2: 'curl -X POST http://' + SERVER_ID + ':' + PORT + '/auth/confirm -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d \'{"challenge_id":"<从step1返回>","code":"<邮箱验证码>"}\''
} }
}); });
@ -831,7 +831,7 @@ const server = http.createServer(async (req, res) => {
log('WARN', 'file_write_blocked_v3', '文件写入需要验证码授权'); log('WARN', 'file_write_blocked_v3', '文件写入需要验证码授权');
jr(res, 403, { jr(res, 403, {
error: 'Gatekeeper v3: 文件写入需要验证码授权', error: 'Gatekeeper v3: 文件写入需要验证码授权',
required_flow: 'POST /auth/request { cmd: "write file to ' + (body.path || '?') + '", op_type: "system-arch" } → 验证码 → /auth/confirm' required_flow: 'POST /auth/request { action: "已登记动作", op_type: "system-arch" } → 验证码 → /auth/confirm'
}); });
return; return;
} }

View File

@ -11,20 +11,23 @@ class SessionManager {
this.sessions = new Map(); this.sessions = new Map();
this.load(); this.load();
} }
create(identity, server, scopes, now = Date.now() / 1000) { create(identity, server, scopes, actions = [], now = Date.now() / 1000) {
if (typeof actions === "number") { now = actions; actions = []; }
const token = crypto.randomBytes(32).toString("hex"); const token = crypto.randomBytes(32).toString("hex");
const hash = this.hash(token); const hash = this.hash(token);
this.sessions.set(hash, { pid: identity.pid, name: identity.name, server, scopes: [...new Set(scopes)], created: now, last_used: now, expires: now + this.absoluteTtl }); this.sessions.set(hash, { pid: identity.pid, name: identity.name, server, scopes: [...new Set(scopes)], actions: [...new Set(actions)], created: now, last_used: now, expires: now + this.absoluteTtl });
this.persist(); this.persist();
return { token, expires_in: this.absoluteTtl, idle_timeout: this.idleTtl }; return { token, expires_in: this.absoluteTtl, idle_timeout: this.idleTtl };
} }
verify(token, identity, server, scope, now = Date.now() / 1000) { verify(token, identity, server, scope, action = "", now = Date.now() / 1000) {
if (typeof action === "number") { now = action; action = ""; }
const hash = this.hash(token || ""); const hash = this.hash(token || "");
const session = this.sessions.get(hash); const session = this.sessions.get(hash);
if (!session) return { ok: false, reason: "session_not_found" }; if (!session) return { ok: false, reason: "session_not_found" };
if (now > session.expires || now - session.last_used > this.idleTtl) { this.sessions.delete(hash); this.persist(); return { ok: false, reason: "session_expired" }; } if (now > session.expires || now - session.last_used > this.idleTtl) { this.sessions.delete(hash); this.persist(); return { ok: false, reason: "session_expired" }; }
if (session.pid !== identity.pid || session.server !== server) return { ok: false, reason: "session_binding_mismatch" }; if (session.pid !== identity.pid || session.server !== server) return { ok: false, reason: "session_binding_mismatch" };
if (!session.scopes.includes(scope)) return { ok: false, reason: "session_scope_denied" }; if (!session.scopes.includes(scope)) return { ok: false, reason: "session_scope_denied" };
if (!Array.isArray(session.actions) || !session.actions.includes(action)) return { ok: false, reason: "session_action_denied" };
session.last_used = now; session.last_used = now;
this.persist(); this.persist();
return { ok: true, session }; return { ok: true, session };

View File

@ -9,22 +9,22 @@ const zhulan = { pid: "ICE-GL-ZL-001", name: "铸澜" };
test("one approval creates a reusable bounded session", () => { test("one approval creates a reusable bounded session", () => {
const m = new SessionManager({ absoluteTtl: 100, idleTtl: 20 }); const m = new SessionManager({ absoluteTtl: 100, idleTtl: 20 });
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10); const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], ["sync-status"], 10);
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 11).ok, true); assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", "sync-status", 11).ok, true);
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 12).ok, true); assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", "sync-status", 12).ok, true);
}); });
test("session is bound to identity server and scope", () => { test("session is bound to identity server and scope", () => {
const m = new SessionManager(); const m = new SessionManager();
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10); const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], ["sync-status"], 10);
assert.equal(m.verify(token, { pid: "ICE-GL-ZY001" }, "BS-SG-001", "code-repo", 11).ok, false); assert.equal(m.verify(token, { pid: "ICE-GL-ZY001" }, "BS-SG-001", "code-repo", "sync-status", 11).ok, false);
assert.equal(m.verify(token, zhulan, "OTHER", "code-repo", 11).ok, false); assert.equal(m.verify(token, zhulan, "OTHER", "code-repo", "sync-status", 11).ok, false);
assert.equal(m.verify(token, zhulan, "BS-SG-001", "system-arch", 11).ok, false); assert.equal(m.verify(token, zhulan, "BS-SG-001", "system-arch", "sync-status", 11).ok, false);
}); });
test("end signal revokes immediately", () => { test("end signal revokes immediately", () => {
const m = new SessionManager(); const m = new SessionManager();
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10); const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], ["sync-status"], 10);
assert.equal(m.end(token, zhulan), true); assert.equal(m.end(token, zhulan), true);
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 11).ok, false); assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", "sync-status", 11).ok, false);
}); });
test("session survives process reload without persisting plaintext token", () => { test("session survives process reload without persisting plaintext token", () => {
@ -33,12 +33,12 @@ test("session survives process reload without persisting plaintext token", () =>
try { try {
const now = Date.now() / 1000; const now = Date.now() / 1000;
const first = new SessionManager({ absoluteTtl: 100, idleTtl: 20, stateFile }); const first = new SessionManager({ absoluteTtl: 100, idleTtl: 20, stateFile });
const created = first.create(zhulan, "BS-SG-001", ["code-repo"], now); const created = first.create(zhulan, "BS-SG-001", ["code-repo"], ["sync-status"], now);
const onDisk = fs.readFileSync(stateFile, "utf8"); const onDisk = fs.readFileSync(stateFile, "utf8");
assert.equal(onDisk.includes(created.token), false); assert.equal(onDisk.includes(created.token), false);
const reloaded = new SessionManager({ absoluteTtl: 100, idleTtl: 20, stateFile }); const reloaded = new SessionManager({ absoluteTtl: 100, idleTtl: 20, stateFile });
assert.equal(reloaded.verify(created.token, zhulan, "BS-SG-001", "code-repo", now + 1).ok, true); assert.equal(reloaded.verify(created.token, zhulan, "BS-SG-001", "code-repo", "sync-status", now + 1).ok, true);
} finally { } finally {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
} }