外观
权限平台首期 — 技术方案
对应 PRD 原文,总 PRD 见 nexo-im-pc#58。本文档定义"怎么做";"做什么"以迭代 PRD 为准。 状态:草案(待评审)
沿用 0913 确立的真原子化原则:单服务单库、本地 ACID 事务、状态单一事实源。权限平台为独立服务
nexo-perm-api(单库nexo_perm),不与nexo-account合库——权限与账号是两个变更节奏完全不同的领域,且权限平台要服务的是全生态而非单一账号中心。 本文 §7 数据模型与 §9 接口契约是前后端并行开发的冻结源:字段名、错误码、请求响应结构以本文为准,改动先改本文再改代码。
1. 选型现实
与 0913 相同的四条现实约束继续成立:单机 Docker Compose 双环境(test/prod)、无 Kafka/无 Redis/无 SMTP、无独立 API 网关(各服务本地验签 + HMAC 内部通道)、小团队短窗口(09-15 至 09-19 共 5 天,09-20 验收)。
由此决定:
- 技术栈对齐生态:Node.js (Hono) + PostgreSQL 16,复用
@lamolabs/nexo-backend-sdk(日志脱敏、错误响应、HMAC 服务间鉴权、JWKS 本地验签);工程结构遵循后端工程规范模块四件套 - "消息队列必达"等价落地:
nexo-account→nexo-perm-api走 HTTP internal 通知(HMAC 内部通道,同 0913 停用通知模式)+ 收侧 inbox 幂等 + 定时兜底核对,不引入 MQ - 鉴权无缓存:数据量级小(5 人团队、首期三业务方),实时 SQL 判定即可达 p99 < 10ms;索引与判定路径一一对应(§8)
- 审计不落远端:投递适配层 + 本地 outbox(§11)
2. 服务形态与部署
nexo-perm-api:独立容器、独立库nexo_perm,端口 3002(3001 为nexo-account);环境变量前缀PERM_nexo-perm-console:静态站,构建产物走生态既有发布通道,经壳(nexo-app)iframe 装载,nexo-web-sdk取令牌- 三类调用方与通道:
| 调用方 | 通道 | 身份 |
|---|---|---|
| 管理台(浏览器) | /api/*,Bearer JWT(JWKS 本地验签) | user 主体(sub → subjects.external_id) |
| 业务方服务 | /internal/*,HMAC 内部通道 | app 主体(HMAC keyId → subjects app 行) |
nexo-account | /internal/subject-events,HMAC | 固定信任端(不建 app 主体,白名单 keyId) |
3. 配置清单
| 环境变量 | 默认值 | 说明 |
|---|---|---|
PERM_PORT | 3002 | 服务端口 |
PERM_DATABASE_URL | — | PostgreSQL 连接串(必填) |
PERM_COMPOSITION_MAX_DEPTH | 5 | 角色组合深度上限,创建时强拒 |
PERM_CHECK_STATEMENT_TIMEOUT_MS | 200 | 鉴权判定事务级 statement_timeout,超时即 fail-closed |
PERM_RECONCILE_INTERVAL_SECONDS | 300 | 定时兜底核对周期(≤5min) |
PERM_AUDIT_RETRY_MAX | 10 | outbox 投递重试上限,达上限告警并保留行 |
PERM_SUPERADMIN_USER_ID | — | 超管外部用户标识(初始化脚本用,必填) |
PERM_ACCOUNT_BASE_URL / PERM_ACCOUNT_HMAC_* | — | 调 nexo-account internal 的地址与密钥 |
PERM_ALERT_WEBHOOK_URL | — | 告警投递地址(离职告警、outbox 积压、兜底异常) |
SDK 侧(nexo-backend-sdk 使用方):PERM_CHECK_TIMEOUT_MS(默认 2000)。
4. 领域不变量(全部由 DB 层兜底,应用层先校验)
- 单有效行:
UNIQUE (subject_type, external_id) WHERE active部分唯一索引——并发复活/重复创建在 DB 层被拒 - epoch 单调递增:新建行
epoch = COALESCE(历史 max, -1) + 1,在插入语句内子查询完成,不依赖应用层读改写 - 不可变列:
permission_points.scope/org_id、roles.org_id、subjects.bound_namespace_id由触发器RAISE EXCEPTION拒改 - 同组织约束:应用层创建时校验(涉及跨表查当前有效主体行,无法用 CHECK 表达);鉴权第③步组织归属兜底二次校验堵脏数据
- 组合无环且有界:创建事务内
FOR UPDATE锁涉事角色 + 递归 CTE 检测(§10);判定侧递归带深度上限防御 - outbox 幂等:
UNIQUE (dedupe_key);inbox 幂等:UNIQUE (event_id)
5. 架构分层
src/
├── routes/ # api/(管理接口)+ internal/(check、subject-events)
├── services/ # 仅跨模块基础设施:db、audit(outbox 适配层)、account-client
├── modules/ # org / namespace / permission-point / role / subject / grant / check / reconcile
│ └── <m>/ # 四件套:routes.ts + service.ts + repo.ts + types.ts
└── env.ts # zod 校验(§3 全部变量)鉴权判定(check)单独成模块:只读、单事务、无跨模块 import 业务 service——防止管理侧改动波及判定路径。
6. 与 0913 架构原则的对齐与差异
| 0913 原则 | 本期对齐情况 |
|---|---|
| 单服务单库真原子 | ✅ nexo-perm-api + nexo_perm,全部写操作本地事务 |
| 状态单一事实源 | ✅ 账号状态事实源仍在 nexo-account;权限平台持有的是主体投影(active/epoch),由通知+兜底维护,鉴权不回查 |
| 权限平台未上线前不鉴权 | 本期即上线:平台自身接口按 §9 权限点管控;nexo-account 等存量服务的接入改造下期进行 |
| 停用轻量通知 | ✅ 复用同模式,新增 perm 订阅端点(§12) |
7. 数据模型(单库 nexo_perm)
7.1 SQL DDL
sql
-- 1. 组织:全局统一隔离边界
CREATE TABLE organizations (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 2. 业务方命名空间:独立归属实体(非字符串字段)
CREATE TABLE namespaces (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
owner_subject_id BIGINT NULL, -- 归属主体(app 主体创建后回填)
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 3. 权限点:(命名空间, code) 唯一;scope/org_id 一经创建不可变(触发器兜底)
CREATE TABLE permission_points (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
namespace_id BIGINT NOT NULL REFERENCES namespaces(id),
code TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL CHECK (scope IN ('org','general')),
org_id BIGINT NULL REFERENCES organizations(id),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','offline')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (namespace_id, code),
CHECK ((scope = 'org') = (org_id IS NOT NULL)) -- 组织权限必绑组织,通用权限必不绑
);
-- 下线=UPDATE status='offline'(软删,显式拒绝保留);同名重建=把 offline 行翻回 active(继承 deny)
-- 硬删除=DELETE 行(role_permissions/direct_grants/explicit_denies ON DELETE CASCADE 连删)
-- 4. 角色:绑定组织,只软删
CREATE TABLE roles (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
org_id BIGINT NOT NULL REFERENCES organizations(id),
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('org','biz')), -- 组织角色/业务角色
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (org_id, name)
);
-- 5. 角色-权限点关联(创建时应用层校验:scope='org' 时 role.org_id = pp.org_id)
CREATE TABLE role_permissions (
role_id BIGINT NOT NULL REFERENCES roles(id),
pp_id BIGINT NOT NULL REFERENCES permission_points(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (role_id, pp_id)
);
-- 6. 角色组合:composite 聚合 source 的权限(创建时环检测+深度+同组织,见 §10)
CREATE TABLE role_compositions (
composite_role_id BIGINT NOT NULL REFERENCES roles(id),
source_role_id BIGINT NOT NULL REFERENCES roles(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (composite_role_id, source_role_id),
CHECK (composite_role_id <> source_role_id)
);
CREATE INDEX idx_role_comp_source ON role_compositions (source_role_id);
-- 7. 权限主体:代理主键多行历史 + 部分唯一索引单有效行 + epoch
CREATE TABLE subjects (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
subject_type TEXT NOT NULL CHECK (subject_type IN ('user','app')),
external_id TEXT NOT NULL, -- user: nexo-account userId;app: 服务标识
org_id BIGINT NULL REFERENCES organizations(id), -- user 主体:主组织
bound_namespace_id BIGINT NULL REFERENCES namespaces(id), -- app 主体:不可变
active BOOLEAN NOT NULL DEFAULT true,
epoch INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK ((subject_type = 'user') = (org_id IS NOT NULL)),
CHECK ((subject_type = 'app') = (bound_namespace_id IS NOT NULL))
);
CREATE UNIQUE INDEX uq_subject_single_active
ON subjects (subject_type, external_id) WHERE active; -- 单有效行不变量
CREATE INDEX idx_subjects_lookup ON subjects (subject_type, external_id);
-- 8/9/10. 授权三关系:挂 subject 行级 id(旧行作废即自然脱钩)
CREATE TABLE subject_roles (
subject_id BIGINT NOT NULL REFERENCES subjects(id),
role_id BIGINT NOT NULL REFERENCES roles(id),
expires_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (subject_id, role_id)
);
CREATE TABLE direct_grants (
subject_id BIGINT NOT NULL REFERENCES subjects(id),
pp_id BIGINT NOT NULL REFERENCES permission_points(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (subject_id, pp_id)
);
CREATE TABLE explicit_denies (
subject_id BIGINT NOT NULL REFERENCES subjects(id),
pp_id BIGINT NOT NULL REFERENCES permission_points(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (subject_id, pp_id)
-- 不带组织列:范围跟随权限点(org 权限按 pp.org_id 命中,general 全局命中)
);
-- 11. 审计 outbox(§11)与状态事件 inbox(§12)
CREATE TABLE audit_outbox (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event_type TEXT NOT NULL CHECK (event_type IN
('grant_change','check_deny','pp_change','role_change')),
dedupe_key TEXT NOT NULL UNIQUE,
payload JSONB NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
delivered_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_outbox_pending ON audit_outbox (created_at) WHERE delivered_at IS NULL;
CREATE TABLE subject_event_inbox (
event_id TEXT PRIMARY KEY, -- 幂等去重(通知载荷 eventId / 兜底构造 reconcile:<uid>:<ts>)
external_id TEXT NOT NULL, -- 串行化分键
event_type TEXT NOT NULL CHECK (event_type IN ('disabled','enabled','org_changed','reconcile_fix')),
payload JSONB NOT NULL,
processed_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_inbox_pending ON subject_event_inbox (external_id, created_at) WHERE processed_at IS NULL;7.2 不可变列触发器
sql
CREATE OR REPLACE FUNCTION reject_immutable_cols() RETURNS trigger AS $$
BEGIN
IF TG_TABLE_NAME = 'permission_points'
AND (NEW.scope IS DISTINCT FROM OLD.scope OR NEW.org_id IS DISTINCT FROM OLD.org_id) THEN
RAISE EXCEPTION 'IMMUTABLE_FIELD';
ELSIF TG_TABLE_NAME = 'roles' AND NEW.org_id IS DISTINCT FROM OLD.org_id THEN
RAISE EXCEPTION 'IMMUTABLE_FIELD';
ELSIF TG_TABLE_NAME = 'subjects'
AND NEW.bound_namespace_id IS DISTINCT FROM OLD.bound_namespace_id THEN
RAISE EXCEPTION 'IMMUTABLE_FIELD';
END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql;
-- 分别挂 BEFORE UPDATE 触发器于三张表7.3 epoch 与主体行翻转的标准语句
sql
-- 停用(=离职):翻转不新建,epoch 不变;同事务清通用 deny
UPDATE subjects SET active = false, updated_at = now()
WHERE subject_type='user' AND external_id=$1 AND active;
DELETE FROM explicit_denies d USING subjects s, permission_points p
WHERE d.subject_id = s.id AND d.pp_id = p.id
AND s.subject_type='user' AND s.external_id=$1 AND p.scope='general';
-- (删除前 SELECT 快照写 audit_outbox:grant_change / deny_cleared)
-- 启用(=复职)/ 主组织变更(=调岗):作废旧行 + 新建行,epoch 子查询递增
UPDATE subjects SET active=false, updated_at=now()
WHERE subject_type='user' AND external_id=$1 AND active; -- (启用场景该行本已 false,无影响)
INSERT INTO subjects (subject_type, external_id, org_id, active, epoch)
SELECT 'user', $1, $2, true,
COALESCE((SELECT max(epoch) FROM subjects
WHERE subject_type='user' AND external_id=$1), -1) + 1;
-- + 同事务清通用 deny(同上);全程单事务,部分唯一索引兜底并发8. 鉴权判定实现(B08)
单只读事务(SET LOCAL statement_timeout = PERM_CHECK_STATEMENT_TIMEOUT_MS)内五步串行,任一步 SQL 异常/超时 → 捕获为 exception 拒绝:
| 步 | 查询(全部命中唯一索引点查) | 拒绝出口 |
|---|---|---|
| ① 主体在职 | SELECT id, org_id FROM subjects WHERE subject_type='user' AND external_id=$1 AND active(uq_subject_single_active) | 无行 → subject_inactive |
| ② 显式拒绝 | pp 先按 (ns, code) 查出(含 offline 行);SELECT 1 FROM explicit_denies WHERE subject_id AND pp_id;scope='org' 时附加 pp.org_id = s.org_id 才算命中(范围跟随权限点) | 命中 → deny_hit |
| ③ 权限点/命名空间/组织状态 | pp 不存在或 status='offline' → 拒;namespaces.status='disabled' → 拒;scope='org' 且组织 disabled → 拒;scope='org' 且 pp.org_id <> s.org_id → no_grant(归属兜底) | → pp_invalid / no_grant |
| ④ 直接授权 | SELECT 1 FROM direct_grants WHERE subject_id AND pp_id AND (expires_at IS NULL OR expires_at > now()) | 命中 → allow |
| ⑤ 角色路径 | 递归 CTE(见下) | 命中 → allow;否则 no_grant |
sql
-- ⑤ 角色路径:从用户未过期角色出发,沿组合边 composite→source 正向展开
WITH RECURSIVE reachable AS (
SELECT r.id, 0 AS depth
FROM subject_roles sr JOIN roles r ON r.id = sr.role_id
WHERE sr.subject_id = $sid AND r.status = 'active'
AND (sr.expires_at IS NULL OR sr.expires_at > now())
UNION
SELECT r.id, rc.depth + 1
FROM reachable rc
JOIN role_compositions c ON c.composite_role_id = rc.id
JOIN roles r ON r.id = c.source_role_id
WHERE r.status = 'active' AND rc.depth < $max_depth -- 防御性上限,创建侧已强拒
)
SELECT 1 FROM reachable
JOIN role_permissions rp ON rp.role_id = reachable.id
WHERE rp.pp_id = $pp LIMIT 1;要点:
- ②在③前:显式拒绝对已下线权限点仍优先命中(下线保留 deny 永效语义);pp 完全不存在时②必然未命中,落到③报
pp_invalid - 命名空间从已认证身份推导:
/internal/check的 HMAC keyId → app 主体行 →bound_namespace_id,请求体不含命名空间字段(自报无从发生) - 组织状态查询失败:该子查询单独 try——失败按
exception拒 + 投递「角色禁用重检」inbox 任务 + outbox 告警事件 - deny 返回前组装
check_deny事件写 outbox(判定事务外、异步,不阻塞响应);allow 不写 - caller 为 user 主体的管理接口鉴权(B09)走同一函数:①改查
subject_type='user'的当前登录者,②-⑤同构
9. 接口契约(冻结源)
9.1 统一错误响应与业务错误码
错误响应复用 backend-sdk 统一格式:{ "error": { "code": "<CODE>", "message": "<中文说明>" } },HTTP 状态码 4xx。
| 错误码 | 场景 |
|---|---|
FORBIDDEN | 接口鉴权拒绝(附 denyReason) |
VALIDATION_FAILED | 参数校验失败(zod) |
IMMUTABLE_FIELD | 修改 scope / org / bound_namespace |
DUPLICATE | 唯一约束冲突(同名组织/命名空间/权限点/角色、重复授权) |
ORG_DISABLED / NS_DISABLED | 目标组织/命名空间已停用 |
CROSS_ORG | 同组织校验失败(用户-角色 / 角色-组织权限点 / 组合 / 直接授权) |
GENERAL_PP_FORBIDDEN | 非平台管理员注册通用权限点 |
COMPOSITION_CYCLE | 角色组合成环(含自引用) |
COMPOSITION_DEPTH_EXCEEDED | 组合深度超 PERM_COMPOSITION_MAX_DEPTH |
SUBJECT_INACTIVE | 对失活主体授权/建关联 |
SUBJECT_EXISTS | 已存在有效主体行(含失活行禁翻活:提示走状态联动) |
ACCOUNT_UNAVAILABLE | 创建校验时 nexo-account 显示账号非 ACTIVE |
NOT_FOUND | 实体不存在 |
9.2 internal(HMAC 内部通道)
POST /internal/check — 鉴权(业务方唯一入口)
jsonc
// 请求(命名空间不在载荷内,从 HMAC 身份推导)
{ "userId": "u_123", "permissionPoint": "task.close" }
// 响应 200(拒绝也是 200,业务语义在 allow 字段)
{ "allow": false, "denyReason": "deny_hit" } // allow=true 时无 denyReasonPOST /internal/subject-events — nexo-account 状态通知(写 inbox 即返回 202)
jsonc
{ "eventId": "evt_9f2...", "userId": "u_123",
"event": "disabled", // disabled | enabled | org_changed
"orgId": 3, // enabled/org_changed 时必填:目标主组织
"occurredAt": "2026-09-18T03:00:00Z" }
// 响应 202 {"accepted":true};重复 eventId 幂等返回 2029.3 管理接口(/api/*,Bearer JWT + 权限点管控)
通用约定:列表 GET 带 ?status=&orgId=&q=&limit=&offset=,响应 { "items": [...], "total": n };实体时间字段 ISO8601。
| 端点 | 请求体要点 | 响应要点 |
|---|---|---|
POST /api/orgs | {name} | 组织实体 |
PATCH /api/orgs/:id | {name?} 或 {status:'disabled'}(停用=同事务禁用其下全部角色,返回 disabledRoleCount) | 组织实体 |
POST /api/namespaces | {name}(仅平台管理员) | 命名空间实体 |
PATCH /api/namespaces/:id | {status:'disabled'} | 同上 |
POST /api/permission-points | {namespaceId, code, description, scope, orgId?};scope:'general' 仅平台管理员 | 权限点实体 |
POST /api/permission-points/:id/offline / .../activate | — | 权限点实体(下线/重新激活) |
GET /api/permission-points/:id/impact | — | {denyCount, roleLinkCount, affectedUserCount}(硬删前置影响面 N/M/K) |
DELETE /api/permission-points/:id | {confirmCode}=权限点 code(防误触双确认) | 204;CASCADE 连删 + deny 快照进 outbox |
POST /api/roles | {orgId, name, kind, sourceRoleIds?}(建时可一次组装组合) | 角色实体 |
PATCH /api/roles/:id | {name?, status?} | 角色实体 |
PUT /api/roles/:id/permissions | {ppIds: []}(全量替换,逐条同组织校验) | 关联列表 |
PUT /api/roles/:id/compositions | {sourceRoleIds: []}(全量替换,环/深度/同组织校验) | 组合列表 |
GET /api/roles/:id/effective-permissions | — | {items:[{ppId, code, via:'self'|'composed', sourceRoleId?}]}(聚合预览) |
POST /api/subjects | user: {subjectType:'user', externalId, orgId}(调 account 校验 ACTIVE);app: {subjectType:'app', externalId, boundNamespaceId} | 主体实体(含 epoch) |
POST /api/subjects/:id/disable | —(仅 app 主体;user 走状态联动) | 主体实体 |
GET /api/subjects/:id/history | — | 同 (type, externalId) 全部历史行 |
POST /api/subject-roles | {subjectId, roleId, expiresAt?} | 关系实体 |
DELETE /api/subject-roles | {subjectId, roleId} | 204 |
POST /api/direct-grants / DELETE | {subjectId, ppId, expiresAt?} | 同上 |
POST /api/explicit-denies / DELETE | {subjectId, ppId} | 同上(解除的审计走异步口径) |
GET /api/subjects/:id/permissions | — | {roles:[...], directGrants:[...], denies:[...], effective:[{code, via, expiresAt?}]}(F05 按用户全景 / F08 我的权限共用;F08 走 GET /api/me/permissions 别名取当前登录主体) |
9.4 管理接口权限点清单(初始化数据,B09)
按资源粒度建点(待评审确认点之一),全部为通用权限、归平台命名空间 platform:
platform.org.manage / platform.namespace.manage / platform.pp.manage / platform.pp.harddelete(硬删单列,高危独立授权)/ platform.role.manage / platform.subject.manage / platform.grant.manage / platform.grant.read(只读全景查询)。组织管理员/业务方管理员的行权范围收窄(本组织/本命名空间)在 service 层按调用者角色上下文过滤。
10. 环检测与深度上限(B05,创建事务内)
BEGIN;
SELECT ... FROM roles WHERE id IN (composite, sources...) FOR UPDATE; -- 防并发交叉建环
-- 1) 环:从每个 source 沿既有边正向展开(composite→source 方向),途中出现 composite → CYCLE
-- 2) 深度:newDepth = 1 + maxChainDepth(source 向下) + maxChainDepth(composite 向上)
-- newDepth > PERM_COMPOSITION_MAX_DEPTH → DEPTH_EXCEEDED(强拒不截断)
-- 3) 同组织:source.org_id ≠ composite.org_id → CROSS_ORG
-- 全过 → INSERT role_compositions;拒绝 → 写 outbox(role_change, result:'rejected')
COMMIT;11. 审计投递适配层(B11)
- 入口
audit.emit({eventType, dedupeKey, payload}):管理写操作在业务事务内落 outbox(同事务保证"写了必有审计");check_deny在判定事务外异步落(失败仅日志告警,不影响 check 返回) - 投递 worker:轮询
idx_outbox_pending,当前实现 = 结构化日志输出 + 置delivered_at;审计平台上线后仅替换投递函数 - 指数退避重试,
retry_count ≥ PERM_AUDIT_RETRY_MAX告警(webhook)并停止重试保留行 dedupe_key构成:<eventType>:<实体键>:<动作>:<单调因子>(如grant_change:sr:12:34:add:1726...)
12. 用户状态联动(B10)
nexo-account ──HTTP POST /internal/subject-events──▶ inbox(UNIQUE event_id 幂等)
│ 单进程消费循环
定时兜底(每 300s 批量核对 /internal/users)──构造 reconcile_fix 事件──▶│ 按 external_id 分键串行
▼
§7.3 标准语句(单事务:翻转/新建+清通用 deny+outbox)- 消费循环按
external_id分键串行(同键排队、异键并发),事件与兜底校正同入口——天然串行,无分布式锁 - 兜底失活方向:消费时校验当前有效行 epoch 与核对快照一致,不一致(已被 enabled 事件替换新行)跳过
- 兜底复活方向:消费时复查
nexo-account当前状态,非 ACTIVE 跳过;一律新建行(epoch+1),永不翻活旧行 disabled处理完成后向PERM_ALERT_WEBHOOK_URL发告警(含该用户关联计数,供组织管理员人工确认清理)nexo-account侧配套:0913 已有停用通知(POST /internal/users/{id}/disabled)——B10 落地时核对其载荷与订阅机制,缺enabled/org_changed事件则在nexo-account开配套 issue 补推送;perm 侧接口先按本节契约冻结
13. 初始化与超管引导(B06/B09)
幂等初始化脚本(可重复执行,ON CONFLICT DO NOTHING 语义):
- 建平台命名空间
platform+ §9.4 权限点(通用权限) - 建组织
lamolabs(首个组织,供超管主体归属)与角色平台管理员,关联全部platform.*权限点 - 建超管 user 主体(
external_id = PERM_SUPERADMIN_USER_ID,epoch 0)并分配平台管理员角色
此后业务方/组织管理员全部经正常 RBAC 链路由超管分配,无硬编码特权路径。
14. 排期(开发窗口 09-15 至 09-19,09-20 验收)
| 日期 | 后端(@snailuu) | 前端(@wshiqyuan) |
|---|---|---|
| 09-15 | B01 骨架与全量 DDL | F01 骨架与登录态 |
| 09-16 | B02 组织/命名空间 + B03 权限点 | F02 组织与命名空间页 |
| 09-17 | B04 角色关联 + B05 组合 + B06 主体/初始化 | F03 权限点页 + F04 角色页(对 mock 契约开发) |
| 09-18 | B07 授权三关系 + B08 鉴权判定 | F05 授权页 + F06 主体页 |
| 09-19 | B09 接口鉴权 + B10 状态联动 + B11 审计 + B12 SDK | F07 壳挂载 + F08 我的权限 + 联调 |
| 09-19 晚 | T01 预跑与证据归档(含 N01-N03 取数) | 配合截图证据 |
前端 09-17 起按 §9 契约以 mock 先行,09-18 起逐页切真接口——契约冻结(§9)是并行的前提。
15. 发布计划
- test 环境(09-19):compose 新增
nexo-perm-api(3002)+nexo_perm库;跑迁移与初始化脚本;nexo-perm-console发测试站;壳配置权限管理台入口地址 - prod 环境(验收通过后):同序执行;
PERM_SUPERADMIN_USER_ID指向真实超管账号 - 回滚:本期无存量业务方接入改造(
nexo-account/IM 均未接 SDK),回滚 = 停nexo-perm-api容器 + 壳隐藏入口,对现有业务零影响;库保留不回滚 nexo-backend-sdk发 alpha 版(B12),业务方接入留待下期
16. 稳定性保障
- 鉴权事务级
statement_timeout(200ms)+ SDK 超时(2s)双层,超时即 fail-closed - 连接池上限独立配置,池耗尽时新请求快速失败(SDK 侧表现为
exception拒绝,不排队堆积) - outbox 未投递行数、inbox 未处理行数、兜底任务连续失败次数三项过阈值 webhook 告警
- 判定路径全部点查 + 递归有界:数据规模增长时性能退化线性可控;p99 基准随 T01 归档,后续迭代按基准回归
17. 风险与待评审确认点
- 组合深度上限默认 5 是否合适(当前业务两层足够,上限只防失控)
- 管理接口权限点按资源粒度(§9.4)而非按端点逐一建点,避免首期权限点爆炸——待评审确认
nexo-account事件通知覆盖面(enabled/org_changed是否需 account 侧本期补推送)——B10 落地时核对后定,缺则开配套 issue- 鉴权 p99 基准数据集:100 主体 × 50 角色 × 500 权限点(远超当前真实规模),压测脚本随 T01 归档
- 首期组织管理员/业务方管理员的行权范围过滤在 service 层实现(§9.4),过滤规则的单测覆盖是 B09 验收重点