What this system demonstrates
spring-abac is a controlled experiment paired with the sibling project spring-rbac (spring-rbac project article): two almost identical microservice skeletons (Spring Boot 3.2.5 + Spring Cloud 2023.0.3 + Next.js) that differ only in the authorization model.
This article answers three questions:
- What does ABAC solve that RBAC cannot?
- How do attributes, policies and decisions land in microservices?
- How does this authorization model extend to agent tool calls?
RBAC: user ──belongs to──> role ──has──> permission ──> can access?
ABAC: (subject, resource, environment) ──policy expression──> PERMIT / DENY
RBAC asks "what role are you"; ABAC asks "who you are (attributes), what you want to touch (resource attributes), and in what environment". Attribute-based authorization expresses things RBAC cannot:
- the same role gets different decisions under different clearance, region and time-of-day;
- an exception is expressed directly as one high-priority DENY, no new roles needed;
- change the attributes, change the access — policies stay decoupled from code.
The evolution of authorization systems is, at its core, the shift from static relationship configuration to dynamic attribute evaluation. RBAC folds permissions into roles and implicitly assumes "few people, few roles, stable scenarios"; once department, clearance, region and time-of-day start stacking, the role matrix explodes combinatorially — every new dimension demands a hand-built batch of new roles. ABAC does not eliminate complexity; it moves complexity from "composing roles" to "composing conditions", where expressiveness comes from the policy language itself: composable, hot-reloadable, maintainable by non-engineers. Put differently, RBAC asks "who are you", ABAC asks "what are you, the resource, and the environment, right now" — demoting "identity" from the protagonist of authorization to just one attribute among many. That is the shift this system exists to demonstrate.
Four components, nine services
The mapping between the standard ABAC components and the project's services:
| Component | Service | Responsibility |
|---|---|---|
| PAP policy administration | abac-service | /api/policies CRUD; each policy has scope / SpEL condition / effect / priority |
| PEP policy enforcement point | gateway-service | JWT validation, action mapping, asks PDP, injects attribute headers, emits audit |
| PDP policy decision point | abac-service | policy scoping, SpEL evaluation, deny-override merge, PIP backfill |
| PIP policy information point | auth + document + gateway | subject attributes (JWT), resource attributes (backfill endpoint), environment attributes (request context) |
The rest: eureka-server (registry), config-server (config center), audit-service (append-only audit), risk-service (trading risk control), agent-service (agent pre-check), web (Next.js BFF rewriting /api/* to the gateway).
Where attributes come from
Policies read three kinds of attributes, each with a trusted source:
Subject attributes subject.* — on login, auth-service snapshots department / clearance / region / title into the JWT attrs; the gateway parses them and sends them along with every decision request. Attributes ride the token so the PDP never round-trips to the identity service; the cost is that attributes are a snapshot — after a change, old tokens keep the old values until expiry (default 24h), and the target user must re-login for the new ones to apply.
Resource attributes resource.* — when the PDP only has a resource id, it backfills via PIP from document-service /internal/attributes/{id} (classification, department, and so on). A failed backfill follows app.pip-fail-closed = true: "if you cannot get the attributes you cannot know whether to allow, so do not allow."
Environment attributes env.* — the gateway generates hour / dayOfWeek / ip per request and sends them along.
Trust boundary: attributes cannot be self-reported
Attributes are the sole input to policies, so the attribute source must be trusted. The project took a detour here: the register endpoint originally accepted client-supplied clearance / title, which meant anyone could register as clearance=5, title=admin — a direct bypass of the whole ABAC system. It was fixed after a security review, with regression tests:
POST /api/registeraccepts only username/password; new accounts always land at default low privilege (ENG / clearance 1 / CN / engineer);- attribute changes are admin-only: gateway policy
USR-60(USER/UPDATE admin-only) is the first gate, and auth-service re-checks the caller'stitle == 'admin'in the business layer as the second — direct in-network calls cannot tamper with attributes either; - the audit write header
X-Internal-Auditswitched from a hard-coded value to the environment variableAPP_INTERNAL_SECRET, read from config by both the gateway and audit-service.
Policies and evaluation
Policies live in H2 and have four elements: scope (resource type + action), SpEL condition, effect, priority.
DOC-100 DENY DOCUMENT / DELETE env.hour < 9 || env.hour >= 18 priority 100
DOC-95 DENY DOCUMENT / READ resource.classification == 'CONFIDENTIAL'
&& subject.region != 'CN' priority 95
DOC-90 DENY DOCUMENT / READ subject.clearance < resource.requiredClearance 90
DOC-30 PERMIT DOCUMENT / READ resource.classification == 'PUBLIC' 30
DOC-20 PERMIT DOCUMENT / READ resource.department == subject.department 20
DOC-18 PERMIT DOCUMENT / LIST (unconditional; list passes, row filter is the backstop) 18
DOC-10 PERMIT DOCUMENT / * resource.owner == subject.username 10
DOC-05 PERMIT DOCUMENT / * subject.title == 'admin' 5
Three key semantics:
- deny-override: DENY has the highest priority and short-circuits as soon as it hits, so "admin full access" (DOC-05) cannot overturn "no deletion after hours" (DOC-100) — exactly what RBAC's
ROLE_ADMINpass-everything cannot do; - default deny: no policy matched = DENY; no implicit allow;
- three-state effects: PERMIT allow, DENY deny, REVIEW escalate to manual review (e.g. TRD-85 single large transfer, EML-99 bulk send) — REVIEW lands in a queue owned by the business service and only takes effect after a manager/admin approves.
LIST is a collection action: a list endpoint has no concrete resource attributes, and asking as READ would hit the default deny everywhere, so the gateway asks "may I enumerate this domain" for GET /api/documents; the real row-level boundary lives in the business service.
SpEL sandbox: four layers of defense
Policy conditions are SpEL expressions, and SpEL natively allows T(...) type calls (syntactically T(java.lang.Runtime).getRuntime().exec(...) is perfectly legal), so evaluation is guarded by four layers of defense:
- regex blacklist intercepts dangerous fragments;
- the type locator throws at parse time, blocking blacklisted class instantiation;
- a custom read-only
MapPropertyAccessorreturns null for missing attributes instead of raising a global error; - a
ConcurrentHashMapcaches parsed expressions, reducing repeated parse cost on hot updates.
Under the current policy-expression constraints, expressions can only read authorization attributes and compare them — they cannot access restricted types or invoke JVM methods.
Two gates: edge PEP + row-level filtering
GET /api/documents ──> gateway(PEP): JWT ✓ → map (DOCUMENT, LIST) → ask PDP
│ PERMIT, inject X-User / X-Attr-*
▼
document-service: ask /api/decide/batch per row (full attributes)
only rows with permitted == true → then paginate
Both gates ask the same PDP, so there is no policy divergence:
- Gate 1 (gateway) protects "may this endpoint be touched at all" — cheap, one config, global effect;
- Gate 2 (business service) does the row-level filtering — the gateway only knows the id in the URL, not each row's classification; only the service holding the data can filter.
When the PDP is unavailable, both gates fail closed: the gateway circuit breaker opens and returns 403; the business service throws ForbiddenException. Deny rather than allow — the baseline of any authorization system.
Audit: async, append-only, forgery-resistant
The gateway asynchronously emits an audit event after each decision (actor, action, path, decision, hit policy), best-effort and fail-open — an audit outage never slows down the main traffic. audit-service only appends, never modifies, and keeps fields minimal (no request bodies, no document content). Write protection relies on the shared APP_INTERNAL_SECRET between gateway and audit-service.
Risk control and agent domains: REVIEW in practice
- risk-service (trading risk): order pre-check — TRD-85 single large transfer escalates to REVIEW manual review, TRD-70 daily accumulation over limit is denied;
- agent-service (agent pre-check): tool-level policies — EML-99 bulk-send review, COD-75 dangerous commands, PAY-90 large transfers.
Both follow the same pattern as document: the gateway cuts the first gate by URL, the service cuts the second with full resource attributes; REVIEW is not an automatic allow — it lands in the service's manual review queue and takes effect only after approval.
Comparison with spring-rbac
| dimension | spring-rbac | spring-abac |
|---|---|---|
| authorization basis | user → role → permission (static binding) | attributes → policy expressions (dynamic evaluation) |
| decision entry | POST /api/check?user&permission |
POST /api/decide (full attribute bundle) |
| who can see what | permissions bound to roles (seeded in data) | change attributes, change access, no code change |
| admin | admin role passes everything |
title == 'admin' PERMIT still constrained by DENY |
| row-level filtering | none (lists return everything) | per-row PDP calls, only readable rows returned |
| conflict handling | permissions accumulate, no exceptions | deny-override: high-priority DENY expresses exceptions |
| port block | 8761 / 8888 / 41xx / web 3000 | 8762 / 8889 / 411x / web 3001 |
A related project: tsm-hub
This authorization semantics is not an isolated demo: my other project tsm-hub (an LLM gateway and capability pool) has key authentication that is exactly the minimal form of ABAC — subject = key, action = route or tool call, only missing resource and environment attributes, a policy engine and a review state. The capability-call semantics described here (action + attribute bundle → policy decision, default deny, REVIEW escalation) are precisely the authorization model the capability pool needs one level down.
Design philosophy: four things to take away from this demo
Deny by default, deny first
An authorization system's security does not come from "listing every allow" — it comes from "everything unsaid is denied". deny-override lets exceptions be expressed as "add one high-priority DENY", which is exactly the firewall philosophy: a whitelist plus explicit exceptions, not a blacklist plus default-allow. Every evaluation loop in the code short-circuits on this: once a DENY matches it returns immediately, and later PERMITs are never even read.
Authorization happens where the data lives
The gateway can only decide "may this endpoint be touched at all"; row-level filtering can only be done by the service that holds the data. Authorization is not a single call — it is a chain from the edge to the data, where every layer asks the same PDP. There is therefore no policy divergence and no "endpoint open, data naked" intermediate state.
Decision and approval separated: the REVIEW state
An authorization system is a decision system: machine-decidable cases are decided, ambiguous ones go to a human. REVIEW is not a compromise over performance — it is a requirement of decision-chain completeness, elevating manual review from "a status inside the business system" to "a first-class citizen of the authorization protocol". Both the trading-risk and agent domains reuse the same semantics: REVIEW lands in a queue and only takes effect after approval.
Trust boundary matters more than rules
Attributes are the sole input to policies, so the attribute source must be trusted. Registration not accepting self-reported attributes, attribute changes being admin-only with dual checks, internal headers using a shared secret — these three are not accessories of ABAC, they are the preconditions for ABAC to hold at all. No matter how elegant the rules, if attributes can be forged, the whole system is made of paper.
The road ahead: authorization in the agent era
Every agent tool call is an action carrying subject (who drives it), resource (tool/file/account) and environment (session context) attributes; a policy then expresses "may this agent call this tool in this scenario". The project's agent-service is implemented on exactly this semantics: authorization is moving from "people accessing systems" to "programs accessing systems", and attribute-based authorization is ready for it. The four principles distilled in this demo get reused directly in the next wave of automation.
Tests and quality
Six modules, 63 cases in total (auth 11, abac 15, document 23, gateway 7, risk 3, agent 4), run via make test or mvn test. The regression chain is deliberately arranged around the security path: PEP auth boundary → PDP client contract → row-level filtering → fail-closed — each link has tests, so changing policy semantics or wiring in a new domain cannot silently break the security boundary.
Three ways to run
make start # bare jars: nine services + frontend :3001 in background
make docker-up # Docker Compose
make k3s-build && make k3s-deploy # k3s (OrbStack / single node)
Open http://localhost:3001. The demo accounts admin / carol / alice / bob have distinctly different attributes, so the same policy yields different decisions.
Privacy and compliance scope
Demo data is limited to username + PBKDF2 password hash + department/clearance/region/title attributes, collected only to demonstrate authorization semantics. A production deployment must add its own privacy policy, consent mechanism and account-deletion endpoints to satisfy local privacy laws (e.g. PIPL in China). The JWT secret defaults to dev-only-secret-change-me-please; production sets APP_JWT_SECRET to override it — no code change needed.
Source navigation
Repository
- GitHub: erishen/spring-abac — source code and all docs
Docs
README.md— English quick startREADME.zh.md— Chinese versionARCHITECTURE.md— attribute sources, evaluation algorithm, sandbox, two gates, auditdocs/adr/— architecture decision records: why ABAC (0001), SpEL as policy language (0002), attributes in JWT (0003), deny-override (0004), two enforcement points (0005), LIST collection action (0006)
Key code entry points
PolicyEngine(abac-service) — PDP evaluation: policy scoping, SpEL evaluation, deny-override short-circuitPipClient(abac-service) — PIP backfill: resource attributes, fail-closed on failureAuthGlobalFilter(gateway-service) — PEP: JWT validation, action mapping, attribute header injection, audit emissionAttributesController(document-service) — resource attribute endpoint/internal/attributes/{id}DocumentService(document-service) — row-level filtering: asks PDP per row, only readable rows returnedJwtUtil(common) — JWT attribute issuance and parsingAuditService(audit-service) — append-only auditRiskService/AgentService(risk / agent) — REVIEW manual review queues
Run & deploy
make start/make stop— bare jars, start/stop everythingdocker-compose.yml— Docker Composek8s/spring-abac.yaml— k3s manifestsscripts/demo.sh— demo script
What this demo leaves you with is not a nine-service skeleton but four portable principles: explicit over implicit (default deny), exceptions expressed at high priority (deny-override), decisions where the data lives (row-level filtering), and trust boundary before rules (trusted attributes). Put them into any authorization design and the RBAC-era "role explosion" problem has a way out — the real value of attribute-based authorization is not that it is more complex, but that it puts the complexity where it belongs.
主体属性(如职级、密级)变更后为何不能立即生效?
属性被快照进 JWT Claims,换取 PDP 无需跨网络回查身份服务;旧 token 带旧属性直到过期(默认 24h),目标用户重新登录才生效。演示口径接受此延迟,生产可引入属性版本号或 token 黑名单做热失效。
注册接口为什么不接收 clearance / title 等属性?
属性是策略的唯一输入,允许自报等于任何人都能注册成管理员,击穿整个 ABAC。注册只收账号密码,新账号统一为默认低权限,提权由管理员在用户属性面板操作(网关 USR-60 + auth-service 业务层双校验)。
策略条件用 SpEL 求值,如何防止恶意代码执行?
四层纵深防御:正则黑名单拦危险片段、类型定位器在解析期抛错阻止黑类实例化、只读 MapPropertyAccessor 防缺失属性引发全局报错、表达式缓存降低重复解析开销。在当前表达式约束下,表达式只能读取授权属性并进行比较,无法访问受限类型或执行 JVM 方法调用。
PIP 回源拿不到资源属性时会怎样?
按 app.pip-fail-closed = true 处理:”拿不到属性就不知道该不该放行,那就别放行”,PDP 直接拒绝,不做降级代理。这保障最小特权,代价是内部属性接口抖动会切断上游业务。
多条策略冲突时如何裁决?
deny-override 合并语义:粗筛作用域后按优先级降序求值,一旦命中 DENY 立即短路返回,后续 PERMIT 不会被读取;没有任何策略命中时默认拒绝,不给隐式放行。
Leave a reply