一、先把结果摆出来
我先不铺垫,直接说结果。
git submodule update --init 把引擎拉下来,make docker 起站点,make example-curl 打一遍断言,三条命令之后 http://127.0.0.1:10097 上就有一个能跑的落地页,页面上有一排场景卡片,点一下就打一次 POST /api/authorize。examples/authorize-curl.sh 里那 5 条断言一条条跑完,全绿。
结果长这样(前三条来自脚本断言,后两条在落地页的场景卡片里重放):
alice读自己的学段stage-1→PERMIT;- 同一个
alice读stage-9→DENY,reason 里带着[stage ... not in whitelist]这样的片段; ghost这个账号根本不存在 →DENY,走的是第 4 步 fail-closed 那条路;admin读report→PERMIT,而alice读同一份report→DENY,因为grade 6 < level 8;admin在hour=22调manage→DENY。
再在落地页点一下「夜间管理」那张卡片,然后拉 GET /api/audit,拒绝日志里真的躺着一行:
[env.hour=22 violates 'no-admin-actions-outside-business-hours']
这些判定,全部发生在站点自己的进程里,一次网络都没出去。没有策略服务,没有 sidecar,没有一次额外的 RPC。
这里要校准一句话:上面说的「零网络」指的是授权决策路径——每一次 /api/authorize 都在站点进程内完成,不向任何外部进程发起 RPC。但系统并不是没有网络依赖:身份数据靠中央 kb-auth 产出的只读卷分发(挂载本身是带外操作),引擎源码与运行时版本靠 git submodule + 镜像重建来更新。准确地说,移除的是「每次授权决策的跨进程网络调用」,不是「系统所有的网络依赖」。把这个边界摆明,收益和代价都更诚实。
然后说一句实话:三周前我写的就是那个"标准"答案——独立鉴权服务,网关在前面拦、PDP 在后面判。它没有死在草稿里,而是长成了 spring-abac:九个微服务,gateway 做 PEP、abac-service 做 PDP,SpEL 策略求值加 deny-override 合并,完整拆解在《spring-abac:属性化授权(ABAC)微服务拆解》里。跑通它之后,我开始想另一个问题:这套语义能不能不付"每次一跳网络"的代价?这篇就是那个回答——把同一套 ABAC,压进站点自己的进程。两篇是同一个问题的两个解,不互相替代。
架构速览
在展开细节之前,先用一张图把系统模型立起来:
kb-auth (中央身份权威 · 唯一写入者)
写 账号表 + htpasswd
│
中央身份卷(带外产出)
│ 只读挂载 (AUTHORITIES_DIR)
┌───────────────┴────────────────┐
▼ ▼
站点进程 A(kb-abac demo) 站点进程 B(kb-auth 自己也跑引擎)
│ import │ import
└───────────┬────────────────────┘
▼
kb-auth-shared (引擎库 · git submodule 钉 commit)
abac.lume 判定链 + 两层合并 + 审计
───────────────────────────────────────────
运行时底座:lume 官方静态二进制 v0.11.0(宿主树)
一句话:身份集中写、只读分发;判定在各自进程内零网络完成;引擎源码靠 submodule 单源一致;运行时靠官方静态二进制,升级只是换 tag。
二、我当时卡在哪:三个约束同时压过来
我在给一个内容站做权限。不是那种"登录了就能看"的权限,是页面级、资源级、还带一点上下文的那种。真正卡住我的是三件事同时压过来。
约束一:每个页面要判好几次。
一个页面渲染出来,导航要不要显示、列表里每一行要不要出现、某个按钮能不能点,都是独立的一次授权询问。如果判定在另一个进程里,这就是每次多一跳 RPC。一跳本身不贵,贵的是它把一个纯本地的函数调用,变成了一个有超时、有重试、有失败语义的网络操作。而且它多了一个能挂掉全站的单点——PDP 一旦出事,本来只是"看不到某行",会变成"整站打不开"。
约束二:我有两个站点,学段词表互不通用。
这条是最要命的。一个站点用 vocab-A-1 / vocab-A-2 / ...,另一个用 vocab-B-1 / vocab-B-2 / ...,同一个"学段"概念,两套词。我一开始想的是集中管理多省事——一份 accounts.json 管全部。后来发现不对:一份中央 accounts.json 没法同时当两个项目的学段权威。你没法在一张表里同时表达"这个账号在 A 项目能看 A-3"和"在 B 项目能看 B-7",除非你给每一列都加项目前缀,那这张表三个月后没人敢动。
是这条逼我把"身份"和"授权"拆成两层,而不是反过来。
约束三:规则会变。
改一条"夜间禁止管理操作",我不想为此重新打镜像、重新发版、重新走一遍发布流程。规则应该是数据,不是构建产物。
这里把「动态」说准:所谓「规则是数据,不是构建产物」特指资源表——它住在 data/resources.json,server.lume 的 resources_for() 每次请求都重新读取,改完下一个请求即生效,既不必重建镜像,也不必重启进程。但要分清楚两条发布节奏:① 改资源表或 env.deny_when 这类数据 = 改一个 JSON,热加载;② 改判定语义(动 shared/abac.lume) = 改引擎代码,要 submodule 升 commit + 重建镜像。前者是数据迭代,后者是引擎升级,别把两者混成一句「动态」就完了。
我当时的纠结。
我的直觉做法是网关 + 独立 PDP,就是 OPA 那条路。它看起来把三件事都解决了:规则是外置的、判定是集中的、网关是统一入口。但它在解决第三个问题的同时,把第一个问题放大了——它把我最不想要的那个东西做成了架构的核心:一个决策单点。
于是我把问题反过来问了一遍:如果判定的核心只是一段确定性的本地逻辑——给定主体、资源、动作与环境就确定地返回 PERMIT 或 DENY,那它为什么必须住在另一个进程里?
它不必。它可以是库。
三、把"身份"和"授权"拆成两层
这一步想通了,后面全是顺的。
现在的结构是三个仓库,职责边界卡得很死:
| 仓库 | 角色 | 形态与产出 |
|---|---|---|
kb-auth-shared |
ABAC 决策引擎——一个库,没有进程,没有网络 | abac.lume(判定链 + 两层合并)+ admin.lume(账号 CRUD、$6$ SHA-512 crypt 哈希、htpasswd 重建)+ json.lume(safe_json)+ 引擎单测,以 git submodule 被各站点钉 commit 引用 |
kb-auth |
中央身份权威——账号 CRUD、密码哈希、htpasswd,唯一的写入者 |
一个完整站点:/api/accounts CRUD + /api/audit,产出中央身份卷(账号表 + htpasswd)供消费方只读挂载;它自己也 import 同一份 shared/abac.lume 跑本站授权 |
kb-abac |
消费方演示站点——在自己进程里跑引擎,把判定过程展示出来 | 单仓自演的 demo:本地 accounts.json 种子 + 落地页十张场景卡片 + /api/authorize / /api/audit / /api/resources |
关键的一句话是:站点在本地做授权决策(每次请求零网络),只有身份是集中的。 数据流上这是两条单向边:kb-auth 写中央身份卷 → 各消费方只读挂载(谁都不能反写中央,admin.lume 在中央模式下有写保护);kb-auth-shared 以 submodule 钉 commit → kb-auth 和 kb-abac 各自 import,引擎源只有一份。消费方把 AUTHORITIES_DIR 指向那个只读卷就进入中央模式(role 取中央、stages 取本地);不设这个变量就回退纯本地模式,行为与单站版本完全一致——kb-abac 的 demo 就是后一种,所以它能 make docker 一个仓自己跑起来。
具体到属性上,是我拿"这个属性是不是项目无关的"这一把尺子切开的:
| 属性 | 权威在哪 | 为什么 |
|---|---|---|
谁能登录(htpasswd 哈希) |
中央 | 跟项目无关,各项目只读挂载同一个卷 |
role(student/teacher/admin) |
中央 accounts.json |
角色词表在整个家族里是一致的 |
grade / title |
本地优先,缺失回落中央 | 只用来展示,不参与判定 |
| 资源表 | 项目自己的 configure() 注入 |
按领域设计的,中央那份只服务跨项目查询 |
合并发生在 abac.accounts_map() 里,以中央的账号集合为定义域:
- 中央有 + 本地有 →
role取中央,stages取本地,_grant是local(stages 非空)或all(stages 是[]); - 中央有 + 本地没有 →
_grant打上central-only,所有学段类资源DENY,非学段资源按角色照常; - 中央没有 + 本地有 → 直接丢弃。中央的
htpasswd里根本没这个账号,留着只会让本地的陈旧条目参与判定。
这套划分不是我拍脑袋想出来的对称美学,它是被约束二逼出来的唯一解:词表不通用的东西,不能放在通用层。
两个常被追问的边界,先说在前面。第一,请求里的 user 不是客户端能任意声明的——生产部署下 server.lume 用 HTPASSWD_FILE 让 lume httpd 在 decide() 之前就完成基本认证,未认证的请求根本到不了判定逻辑;user == null → PERMIT 那条捷径只活在本地 make dev,生产环境不可能触发。第二,撤权是准实时的:身份表在每次 /api/authorize 时由 accounts_map() 从只读卷 read_file 重新读取,进程内不缓存账号集合,所以中央在 kb-auth 删改一个账号,下一笔请求即按新状态判定,没有应用层的陈旧窗口(真正的边界在只读卷本身的同步,那是挂载层的事,不归引擎管)。
四、引擎做成库,不是服务
shared/ 在 kb-abac 里是一个 git submodule,指向 kb-auth-shared,钉在某个具体 commit 上。克隆要带 --recursive,或者补一句 git submodule update --init;忘了也不要紧,make test 会报一个明确的错。
我特意在 README.md 里写了一句:判定逻辑来自那一份共享的引擎源码,不是一份拷贝。
这句话是整个方案的地基。如果每个站点 cp 一份 abac.lume 进去,那"两个站点词表不通用"的问题会在半年后变成"两个站点的判定逻辑开始不一致"——更糟,因为那是不报错的漂移。用 submodule + import,同一个引擎源被三个项目同时引用,行为天然一致。
代价也很清楚:shared/README.md 里我自己写了,"a single change here affects three projects at once"。所以那个仓库的第一段不是介绍功能,是"改这里之前先跑什么"。这是模块化的账单,我认。
最后把「一致性」这个说法拆成三层,免得读者误以为 submodule 钉版本就万事大吉:① 源码一致性——submodule 钉 commit,保证三个站点引用同一份 abac.lume;② 配置一致性——每个站点自己的 data/resources.json 是独立的,钉了引擎源码不等于各站策略一致;③ 部署版本一致性——各站跑的 lume 运行时 tag 还要各自对齐。submodule 只解决了第一层;后两层是各站自己的账,引擎仓库管不了。
运行时是 lume,一个嵌入式脚本运行时,C11 写的,单个静态二进制,没有 GC。
站点侧那层薄得几乎不存在:一个 shim 只做 import 加 configure() 注入自己的资源表,然后重新导出。调用点一行都不用改。
最后交代一下这三个仓库共同踩着的运行时底座。lume 是双树结构:lume-core 是语言内核树——前端(词法/语法/类型检查)加一棵树遍历解释器,再加手写 LLVM IR 文本和 libLLVM C API 两条原生发射器,除了 libc(可选 libLLVM)什么都不链,这里没有 HTTP 服务器、没有 agent 运行时;lume 是宿主树——同一门语言嵌进 agent-httpd,补上 HTTP server{}、原生 SQLite、agent 工具这些宿主能力。kb-abac 部署时用的是宿主树的官方静态二进制(docker/Dockerfile 按 LUME_VERSION=v0.11.0 从 GitHub Release 拉),.lume 脚本里一句 server{} 声明端口和 docroot,docker compose up -d --build 就是一个站点。两个决策在这层兑现:语言内核是引擎正确性的锚——abac.lume 这种几百行的判定逻辑,经同一前端做类型检查、经解释器执行,不需要 Node/Python 运行时;官方静态二进制是部署锚——消费方升级运行时只是换一个 release tag,三仓库不用同步重建任何本地镜像。所有东西在一个进程里跑——这就是「没有独立策略服务、没有每次判定一次网络跳」能成立的技术前提。
五、八步判定链:每次 DENY 都能说出理由
判定链的设计目标是 deny-by-default:任一步判定为 DENY,就立即短路,并尝试同步写入审计。审计写入失败不影响拒绝结果,但可能导致记录缺失。
kb-abac 的 README.md 和落地页的 STEPS 里把它展开成八步:
1 auth authentication enabled? (off → PERMIT, stops here)
2 resource resource type known? (registry)
3 action action allowed on that resource? (registry `actions`)
4 subject account readable + registered? (fail-closed)
5 role role ≥ resource `min_role`? (registry)
6 stage stage in the granted whitelist? (accounts `stages`)
7 require subject attribute vs resource? (registry `require`)
8 env environment rules satisfied? (registry `env.deny_when`)
(后文所有"第 N 步"均指上面这个八步列表。)
判定链在代码里长什么样,值得直接看一眼——因为它的形态本身就是"每次拒绝都能说出理由"的实现方式(摘自 abac.lume):
export type Decision = {
decision: string, // "PERMIT" | "DENY"
reason: string,
subject: string,
resource: string,
action: string,
};
func decide_with(user, res, action, stage_id,
res_table, stage_res, env_ctx): Decision {
let trace = "";
// 1) 认证关闭 → 不限制
if (user == null) {
trace = trace + "[auth off: unrestricted] ";
return { decision: "PERMIT", reason: trace, subject: "", resource: res, action: action };
}
// ...后续每一步同构:追加一段 trace,DENY 则带整条链返回
}
三个语言层面的细节,都直接服务于这篇文章的主题:判定函数带类型标注(: Decision,结构体类型在编译期由 lume 前端检查——判定契约不是注释,是类型);trace 是逐段累积的字符串(每判一步追加一段,DENY 时整条链作为 reason 一起返回——审计不是事后补的日志,是判定过程的副产品);所有取值走 get(table, key, default)(缺键给默认值而不是抛错,错误是值不是异常,上层才有机会安静地 fail-closed)。整个引擎 573 行(含注释),除内建工具外没有依赖。
第 7 步是我最想讲的一步,因为它做的是两个属性之间的比较——这是一刀切的角色阈值永远表达不了的东西。比如一份密级 level: 8 的报告,只有 grade 够得着的账号才能读:
"report": {
"actions": ["read"], "min_role": "student", "level": 8,
"require": [ { "subject": "grade", "gte": "resource.level",
"reason": "account-grade-below-report-level" } ]
}
操作数是 subject.<attr> / resource.<attr> / literal:<value>(裸名字先按主体属性解析,再按资源属性解析),操作符是 gte gt lte lt eq ne。拒绝的时候两边都给你摆出来:
[require grade gte resource.level failed (got 6 vs 8) 'account-grade-below-report-level']
我一直认为,一条拒绝信息里如果只有"拒绝"两个字,那它等于没写。它必须回答"拿什么比什么、比出来多少、为什么这算不通过"。上面这行同时给了操作符、两侧的实际值、和一段人话 reason。
把覆盖性说死:未知资源(第 2 步)、动作不在允许集(第 3 步)、账号表读不到(第 4 步 fail-closed)、require 缺属性(主体或资源侧取不到值 → 比较短路为 false → 不满足 → DENY)——全部 DENY。这里要补一句精确说明:缺失属性不会被当成 0 去比大小。cmp_ok 在任一侧为 null 时直接返回 false(abac.lume:382),所以即便 grade 缺失、且 level 也是 0,0>=0 那种意外放行根本不会发生——缺失属性走的是「比较不成立 → 拒绝」,而非「参与正常比较」。判定链上不存在「既非 PERMIT 也非 DENY」的漏网分支,授权失效时只会更严,不会更松。
六、fail-closed,和一个最危险的不变量
第 4 步是这一轮改的。以前是"账号表读不出来 → 打个警告 → 放行",现在改成 fail-closed:表坏了、被误删了、卷挂上了但是空的,一律 DENY。落到代码上就这几行(abac.lume 原文):
// 4) 账号属性表:读不到 = fail-closed DENY(不再降级放行)
let accounts = accounts_map();
if (accounts == null) {
trace = trace + "[subject attributes unavailable (fail-closed)] ";
let d = { decision: "DENY", reason: trace, subject: str(user), resource: res, action: action };
audit_write(d);
return d;
}
拒绝分支会同步尝试写入审计,但由于审计写入失败不会阻塞决策返回,因此不能保证每条 DENY 都成功落盘。
旧逻辑的问题不用推演,一句话就够了——它在文件出问题的时候,把整个站打开了。 而文件出问题,恰恰是最可能发生的时候。
配套的是 json.lume 里的 safe_json(raw, label):解析失败返回 null,只打一条带 label 的警告,永远不抛异常。解析器不炸,才有机会在上一层安静地 fail-closed。
补一句关于审计本身的诚实说明:audit_write 用 try() 包住加锁与落盘,锁争用或写失败都不会抛错,也不会阻塞决策,DENY 照样返回。也就是说,fail-closed 保证的是决策不意外放行,而不是审计一定落盘——审计丢失 ≠ 放行,但审计丢失本身是要被监控的事件。这个区分别混。
然后是那个我写进文档、标了"整个模型里最关键的不变量"的东西:
在这个引擎里,stages: [] 的含义是**"对整个库可见"**(从抽取之前的 v1 语义继承下来的)。
所以"没有授权"绝对不能用一个空列表来表达。否则中央每新建一个学生账号,这个账号在所有已接入的项目里立刻变成"对所有学段可见"——一次完全静默的提权,没有报错,没有日志,只有某天有人发现"咦,他怎么能看到这个"。
正确的表达是显式的 _grant="central-only" 标记,只在内存里合成、从不落盘,decide() 的第 6 步和 visible_stages() 都认它。
这件事给我的教训不是"要注意边界情况",而是:当系统里"空"同时能表示"没有"和"全部"的时候,你迟早会把前者写成后者。
七、环境维度,和那个只该活在演示里的开关
第 8 步加的是环境维度。规则写在资源表里,拿运行期事实去判:
"admin": {
"actions": ["manage"], "min_role": "admin",
"env": { "deny_when": [ { "fact": "hour", "not_in": [9, 18],
"reason": "no-admin-actions-outside-business-hours" } ] }
}
拒绝时点名是哪个事实触发的:
[env.hour=22 violates 'no-admin-actions-outside-business-hours']
这里必须把一个坑写在明面上,我自己的 README.md 里也是这么写的:
hour / weekday 是演示专用的覆盖项。环境事实必须来自可信来源。如果客户端自己能声明 hour=14,那它就能绕过"非办公时间禁止管理操作"这条规则——这规则等于没写。
所以这个覆盖默认关闭,只有当环境变量 ALLOW_ENV_OVERRIDE=1 时接口才认它。本仓库的 docker-compose.yml 里把它打开了,因为落地页的那些场景要能重放一次夜间拒绝。
这一行不要带进生产部署。 我在文档里就是这么一句直话。
八、落地页为什么是外部脚本
写到这里顺手记一个纯工程的坑:落地页的 www/app.js 必须是外部脚本,不能内联 <script>。因为 lume httpd 默认的 CSP 是 script-src 'self',内联脚本会被直接拦掉,页面白屏,控制台给你一句很客气的拒绝。
这个文件干四件事:十个预设场景卡片、把引擎返回的 reason 解析成决策链渲染、拉 GET /api/resources 显示生效的资源表与来源(file 还是 fallback)、拉 GET /api/audit 显示最近的拒绝记录。
渲染决策链那部分我返工过一次。最初的写法是按下标去取 reason 里的方括号片段——reason.match(/\[[^\]]*\]/g) 拿到数组,第几个步骤就取第几个片段。后来发现不对:失败的时候引擎只输出一条片段,按下标取会让第 1 步错贴第 6 步的证据,页面就会理直气壮地告诉你"认证状态这一步失败了"。
改成按语义取:
const STEP_PATTERNS = {
auth: /\[auth off/,
resource:/\[resource '/,
action: /\[action '/,
subject: /\[subject /,
role: /\[role '/,
stage: /\[no local stage grant|\[stage '/,
require: /\[require /,
env: /\[env\.\w+=|\[env ok:/,
};
另外认证关闭(user = null)那条捷径也单独处理了:引擎在第 1 步直接 PERMIT 返回,后面七步根本不执行,页面就把连续的 skip 合并成一行,而不是刷七行"未执行"。
九、什么情况下别这么做
我不想把这篇写成"进程内判定优于一切"。它不是。
我在 README.md 里明确写了这个引擎的边界,原话是"Scope, stated honestly":它只有角色阈值、一个学段白名单、一个受限的属性比较(require)、和几个环境原语(hour、weekday、not_in / eq / ne)。它没有通用表达式语言,没有 REVIEW 状态,没有行级过滤。
所以,下面这几种情况,你应该去用网关 + 独立 PDP,而不是学我——那条路我完整走过一遍,就是开头说的 spring-abac,九个子服务、两道闸门、SpEL 沙箱:
- 规则需要通用表达式。一旦有人开始要求"任意布尔组合、任意函数调用",手写的
require字段会迅速长成一个残缺的 DSL,那时候你不如一开始就上 OPA。 - 需要 REVIEW 这种中间态。三值判定(放行 / 拒绝 / 待审)不是往链上再加一步能解决的,它是工作流,不是谓词。
- 需要行级过滤。这个引擎判的是"这个资源类型 + 这个动作 + 这个学段"能不能过。
- 你要管的是几十个异构服务。进程内引擎的前提是"每个消费方都愿意 import 同一份源码"。到了跨语言、跨组织的时候,这个前提就没了。
两篇文章的分界线值得说死,免得看着像同一篇的两个版本。同是 ABAC,spring-abac 的裁决是策略集合 + 优先级 + deny-override 合并——声明式,规则之间没有隐含顺序,条件表达式跑在 SpEL 里、外面套四层沙箱;这里的裁决是固定顺序的八步链——过程式,语义写在引擎代码里,站点自有的资源表只提供数据。审计的取舍也相反:那篇是异步 best-effort(审计抖动不拖慢主业务);这里是拒绝分支同步尝试写入审计,但写入失败不会阻止决策返回——因此不保证每条 DENY 都成功落盘。一个把表达力放在策略语言,一个把可靠性放在判定过程——这也是为什么一个是九个子服务,一个是零网络。
反过来说,如果你跟我一样:判定核心是确定性的本地逻辑、延迟敏感、单点不可接受、并且愿意接受三个仓库共用一个引擎源所带来的耦合,那进程内是更划算的那一侧。
这次我砍掉的是一跳网络。代价是我必须对 shared/ 这个 submodule 保持敬畏——它一动,三个项目一起动。这个交换我觉得值,但它是交换,不是免费。
源码导航
README.md— 站点总览、八步判定链、require/env示例、演示开关的警告、lume 安装shared/README.md— 引擎契约、两层模型的属性权威表、_grant三种状态、stages: []那个危险不变量、fail-closed 的改动说明shared/abac.lume— 决策引擎:属性读取(两层合并)+decide()判定链 + 审计shared/admin.lume— 账号 CRUD、密码哈希($6$SHA-512 crypt)、htpasswd重建、中央模式写保护shared/json.lume—safe_json(raw, label):解析失败返回 null,只打一条警告,不抛server.lume— 站点服务,import "./shared/abac.lume"www/app.js— 落地页:场景卡片、决策链渲染、/api/resources与/api/auditexamples/authorize-curl.sh— curl 断言脚本docs/DESIGN-TRADEOFF.md— 为什么是库而不是网关 + 独立 PDP,以及它做不到什么
kb-auth、kb-auth-shared、kb-abac 三个仓库怎么分工?
kb-auth-shared 是引擎库(submodule 钉 commit,没有进程);kb-auth 是中央身份权威,唯一的写入者,产出中央身份卷供消费方只读挂载,它自己也 import 同一份引擎;kb-abac 是消费方演示站点。授权判定全部发生在各站点进程内,零网络。
lume 和 lume-core 是什么关系?
lume-core 是语言内核树(前端 + 解释器 + 两条 LLVM 发射器,不链 HTTP/agent 能力);lume 是宿主树,同一门语言嵌进 agent-httpd,补上 server{}、原生 SQLite、agent 工具。kb-abac 用宿主树的官方静态二进制(v0.11.0)部署,脚本里一句 server{} 就是一个站点。
为什么判定要放进站点进程,而不是集中到一个 PDP 服务?
因为每个页面要判好几次,独立 PDP 就是每次多一跳 RPC,还多一个能挂掉全站的单点。引擎核心是确定性的本地判定逻辑,做成库之后每次判定零网络。
两个站点的学段词表不通用,怎么还能共用一个引擎?
按”属性是不是项目无关”分两层。
stages: [] 表示什么?为什么说它危险?
它表示”对整个库可见”(继承自 v1 语义)。所以”没有授权”绝不能用空列表表达,必须用只在内存合成、不落盘的 _grant="central-only",否则中央新建一个学生账号就等于在所有已接入项目里静默提权。
账号表读不出来的时候会怎样?
fail-closed,直接 DENY。这一轮就是从”读不出来 → 警告 → 放行”改过来的,旧逻辑在文件损坏或卷空挂的时候等于把整个站打开了。
落地页里的 hour=22 是怎么来的?
那是演示专用的环境事实覆盖,默认关闭,只有 ALLOW_ENV_OVERRIDE=1 时接口才认。环境事实必须来自可信来源,这一行只该活在 docker-compose.yml 里,不要带进生产。
什么情况下我不该用这套方案?
规则需要通用表达式、需要 REVIEW 中间态、需要行级过滤,或者你要管几十个跨语言跨组织的异构服务时,应该用网关 + 独立 PDP。这个引擎只有角色阈值、学段白名单、受限的 require 比较和几个环境原语。
相关阅读
- 项目仓库(三个仓库的分工见正文第三节):
- github.com/erishen/kb-abac — 消费方演示站点
- github.com/erishen/kb-auth — 中央身份权威
- github.com/erishen/kb-auth-shared — ABAC 决策引擎库(git submodule)
- spring-abac:属性化授权(ABAC)微服务拆解 — 本文的对照篇:九微服务 + SpEL 沙箱的中心化方案
- Lume:单二进制 agent DSL 服务(.lume 脚本 + C11 宿主)
- Lume-core:Lume 的语言内核(LLVM IR 文本发射器 / libLLVM C API 双发射器)
发表回复