Skip to content

权限平台首期 — 技术方案 ​

对应 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 验收)。

由此决定:

  1. 技术栈对齐生态:Node.js (Hono) + PostgreSQL 16,复用 @lamolabs/nexo-backend-sdk(日志脱敏、错误响应、HMAC 服务间鉴权、JWKS 本地验签);工程结构遵循后端工程规范模块四件套
  2. "消息队列必达"等价落地:nexo-account → nexo-perm-api 走 HTTP internal 通知(HMAC 内部通道,同 0913 停用通知模式)+ 收侧 inbox 幂等 + 定时兜底核对,不引入 MQ
  3. 鉴权无缓存:数据量级小(5 人团队、首期三业务方),实时 SQL 判定即可达 p99 < 10ms;索引与判定路径一一对应(§8)
  4. 审计不落远端:投递适配层 + 本地 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_PORT3002服务端口
PERM_DATABASE_URL—PostgreSQL 连接串(必填)
PERM_COMPOSITION_MAX_DEPTH5角色组合深度上限,创建时强拒
PERM_CHECK_STATEMENT_TIMEOUT_MS200鉴权判定事务级 statement_timeout,超时即 fail-closed
PERM_RECONCILE_INTERVAL_SECONDS300定时兜底核对周期(≤5min)
PERM_AUDIT_RETRY_MAX10outbox 投递重试上限,达上限告警并保留行
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 层兜底,应用层先校验) ​

  1. 单有效行:UNIQUE (subject_type, external_id) WHERE active 部分唯一索引——并发复活/重复创建在 DB 层被拒
  2. epoch 单调递增:新建行 epoch = COALESCE(历史 max, -1) + 1,在插入语句内子查询完成,不依赖应用层读改写
  3. 不可变列:permission_points.scope/org_id、roles.org_id、subjects.bound_namespace_id 由触发器 RAISE EXCEPTION 拒改
  4. 同组织约束:应用层创建时校验(涉及跨表查当前有效主体行,无法用 CHECK 表达);鉴权第③步组织归属兜底二次校验堵脏数据
  5. 组合无环且有界:创建事务内 FOR UPDATE 锁涉事角色 + 递归 CTE 检测(§10);判定侧递归带深度上限防御
  6. 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 时无 denyReason

POST /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 幂等返回 202

9.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/subjectsuser: {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 语义):

  1. 建平台命名空间 platform + §9.4 权限点(通用权限)
  2. 建组织 lamolabs(首个组织,供超管主体归属)与角色 平台管理员,关联全部 platform.* 权限点
  3. 建超管 user 主体(external_id = PERM_SUPERADMIN_USER_ID,epoch 0)并分配 平台管理员 角色

此后业务方/组织管理员全部经正常 RBAC 链路由超管分配,无硬编码特权路径。

14. 排期(开发窗口 09-15 至 09-19,09-20 验收) ​

日期后端(@snailuu)前端(@wshiqyuan)
09-15B01 骨架与全量 DDLF01 骨架与登录态
09-16B02 组织/命名空间 + B03 权限点F02 组织与命名空间页
09-17B04 角色关联 + B05 组合 + B06 主体/初始化F03 权限点页 + F04 角色页(对 mock 契约开发)
09-18B07 授权三关系 + B08 鉴权判定F05 授权页 + F06 主体页
09-19B09 接口鉴权 + B10 状态联动 + B11 审计 + B12 SDKF07 壳挂载 + F08 我的权限 + 联调
09-19 晚T01 预跑与证据归档(含 N01-N03 取数)配合截图证据

前端 09-17 起按 §9 契约以 mock 先行,09-18 起逐页切真接口——契约冻结(§9)是并行的前提。

15. 发布计划 ​

  1. test 环境(09-19):compose 新增 nexo-perm-api(3002)+ nexo_perm 库;跑迁移与初始化脚本;nexo-perm-console 发测试站;壳配置权限管理台入口地址
  2. prod 环境(验收通过后):同序执行;PERM_SUPERADMIN_USER_ID 指向真实超管账号
  3. 回滚:本期无存量业务方接入改造(nexo-account/IM 均未接 SDK),回滚 = 停 nexo-perm-api 容器 + 壳隐藏入口,对现有业务零影响;库保留不回滚
  4. nexo-backend-sdk 发 alpha 版(B12),业务方接入留待下期

16. 稳定性保障 ​

  • 鉴权事务级 statement_timeout(200ms)+ SDK 超时(2s)双层,超时即 fail-closed
  • 连接池上限独立配置,池耗尽时新请求快速失败(SDK 侧表现为 exception 拒绝,不排队堆积)
  • outbox 未投递行数、inbox 未处理行数、兜底任务连续失败次数三项过阈值 webhook 告警
  • 判定路径全部点查 + 递归有界:数据规模增长时性能退化线性可控;p99 基准随 T01 归档,后续迭代按基准回归

17. 风险与待评审确认点 ​

  1. 组合深度上限默认 5 是否合适(当前业务两层足够,上限只防失控)
  2. 管理接口权限点按资源粒度(§9.4)而非按端点逐一建点,避免首期权限点爆炸——待评审确认
  3. nexo-account 事件通知覆盖面(enabled / org_changed 是否需 account 侧本期补推送)——B10 落地时核对后定,缺则开配套 issue
  4. 鉴权 p99 基准数据集:100 主体 × 50 角色 × 500 权限点(远超当前真实规模),压测脚本随 T01 归档
  5. 首期组织管理员/业务方管理员的行权范围过滤在 service 层实现(§9.4),过滤规则的单测覆盖是 B09 验收重点