In-Process ABAC Decision Engine: To Cut a Network Hop From Every Authorization, I Moved the Decision Into the Site’s Own Process

🇨🇳 中文版

1. Start With the Result

No preamble — straight to the result.

git submodule update --init pulls the engine down, make docker brings the site up, make example-curl runs the assertions, and three commands later there's a working landing page at http://127.0.0.1:10097, with a row of scenario cards — click one and it fires a POST /api/authorize. The five assertions in examples/authorize-curl.sh run one by one, all green.

The result looks like this (the first three come from the script's assertions, the last two are replayed from the landing page's scenario cards):

  • alice reading her own stage stage-1 → PERMIT;
  • the same alice reading stage-9 → DENY, with a fragment like [stage ... not in whitelist] in the reason;
  • the account ghost doesn't exist at all → DENY, via the fail-closed path at step 4;
  • admin reading report → PERMIT, while alice reading the same report → DENY, because grade 6 < level 8;
  • admin calling manage at hour=22 → DENY.

Click the "night-time management" card on the landing page, then pull GET /api/audit — there really is a line sitting in the denial log:

[env.hour=22 violates 'no-admin-actions-outside-business-hours']

All of these decisions happen inside the site's own process — not a single network call goes out. No policy service, no sidecar, not one extra RPC.

One calibration, stated up front: the "zero network" above refers to the authorization-decision path — every /api/authorize completes inside the site's process, issuing no RPC to any external process. The system is not without network dependencies: identity data is distributed via the central kb-auth's read-only volume (the mount itself is an out-of-band operation), and the engine source and runtime version move through git submodule + image rebuild. Precisely: what was removed is "a cross-process network call per authorization decision," not "all network dependencies of the system." Stating that boundary makes both the win and the cost honest.

Then let me be honest: three weeks ago I was writing exactly that "standard" answer — a standalone auth service, gateway in front, PDP behind. It didn't die in a draft folder; it grew into spring-abac: nine microservices, the gateway as PEP and abac-service as PDP, SpEL policy evaluation with deny-override merging — fully dissected in the spring-abac dissection. Once it ran, I started asking a different question: can the same semantics be had without paying "one network hop per decision"? This article is that answer — pressing the same ABAC into the site's own process. Two articles, two answers to one question; neither replaces the other.

Architecture at a Glance

Before the details, here is the system model in one diagram:

                kb-auth  (central identity authority · the only writer)
                writes account table + htpasswd
                          │
                central identity volume (produced out-of-band)
                          │  read-only mount (AUTHORITIES_DIR)
          ┌───────────────┴────────────────┐
          ▼                                ▼
   Site process A (kb-abac demo)      Site process B (kb-auth runs engine too)
          │  import                        │  import
          └───────────┬────────────────────┘
                      ▼
   kb-auth-shared  (engine library · git submodule pinned to commit)
        abac.lume decision chain + two-layer merge + audit
   ───────────────────────────────────────────
   Runtime base: lume official static binary v0.11.0 (host tree)

In one sentence: identity is written centrally and distributed read-only; decisions happen in-process with zero network; the engine source is kept single-source via submodule; the runtime is the official static binary, and upgrading is just a tag bump.

2. Where I Got Stuck: Three Constraints at Once

I was building permissions for a content site. Not the "logged in means you can see it" kind — page-level, resource-level, with a bit of context mixed in. What actually blocked me was three things pressing in at once.

Constraint one: every page needs several decisions.

A rendered page needs a decision for whether the nav shows, whether each row in a list appears, whether a button is clickable — each an independent authorization query. If the decision lives in another process, that's one extra RPC hop every time. The hop itself isn't expensive; what's expensive is that it turns a purely local function call into a network operation with timeouts, retries, and failure semantics. And it adds a single point that can take the whole site down — once the PDP has a problem, "can't see a certain row" becomes "the whole site won't open."

Constraint two: I have two sites, and their stage vocabularies aren't interchangeable.

This one was the killer. One site uses vocab-A-1 / vocab-A-2 / ..., the other uses vocab-B-1 / vocab-B-2 / ... — the same "stage" concept, two vocabularies. At first I thought centralized management would be the easy way — one accounts.json governing everything. Then I realized that was wrong: one central accounts.json cannot serve as the stage authority for two projects at the same time. You can't express in one table both "this account can see A-3 in project A" and "B-7 in project B," unless you prefix every column with the project — and then nobody dares touch that table three months later.

It was this that forced me to split "identity" and "authorization" into two layers, not the other way round.

Constraint three: rules change.

When I change a "no management operations at night" rule, I don't want to rebuild the image, re-release, and walk the whole release process again. Rules should be data, not build artifacts.

Let me make "dynamic" precise: "rules are data, not build artifacts" refers specifically to the resource table — it lives in data/resources.json, and server.lume's resources_for() re-reads it on every request, so a change takes effect on the next request: no image rebuild, no process restart. But there are two distinct release rhythms: ① changing the resource table or env.deny_when — data — is editing a JSON, hot-loaded; ② changing decision semantics (touching shared/abac.lume) is changing engine code, requiring a submodule commit bump + image rebuild. One is data iteration, the other is an engine upgrade; don't collapse both into a single word "dynamic."

My dilemma at the time.

My instinct was gateway + standalone PDP, the OPA path. It looks like it solves all three: rules are externalized, decisions are centralized, the gateway is a unified entry point. But while solving the third problem it amplified the first — it made the thing I least wanted the core of the architecture: a single decision point.

So I turned the question around: if the core of the decision is just deterministic local logic — given a subject, resource, action, and environment, it returns PERMIT or DENY deterministically — why must it live in another process?

It doesn't have to. It can be a library.

3. Splitting "Identity" and "Authorization" Into Two Layers

Once that clicked, everything after it fell into place.

The structure is now three repos, with hard-drawn responsibility boundaries:

Repo Role Shape and outputs
kb-auth-shared the ABAC decision engine — a library, no process, no network abac.lume (decision chain + two-layer merge) + admin.lume (account CRUD, $6$ SHA-512 crypt hashing, htpasswd rebuild) + json.lume (safe_json) + engine unit tests, referenced by each site as a git submodule pinned to a commit
kb-auth the central identity authority — account CRUD, password hashing, htpasswd. The only writer. a full site: /api/accounts CRUD + /api/audit, producing the central identity volume (account table + htpasswd) that consumers mount read-only; it also imports the same shared/abac.lume for its own authorization
kb-abac consuming demo site — runs the engine in its own process and shows the decision process a self-contained demo: local accounts.json seeds + ten scenario cards + /api/authorize / /api/audit / /api/resources

The key sentence is: sites make authorization decisions locally (zero network per request); only identity is centralized. In data-flow terms this is two one-way edges: kb-auth writes the central identity volume → every consumer mounts it read-only (nobody writes back to central; admin.lume enforces write protection in central mode); kb-auth-shared is pinned by submodule commit → kb-auth and kb-abac each import it, so there is exactly one engine source. Pointing AUTHORITIES_DIR at that read-only volume puts a consumer into central mode (role from central, stages from local); leaving it unset falls back to pure local mode, behaving exactly like the single-site version — kb-abac's demo is the latter, which is why it can run with a single make docker in one repo.

Concretely, I split the attributes with one ruler: "is this attribute project-independent?"

Attribute Where the authority lives Why
Who can log in (htpasswd hashes) central project-independent; each project mounts the same volume read-only
role (student/teacher/admin) central accounts.json the role vocabulary is consistent across the whole family
grade / title local first, fall back to central display only, doesn't participate in decisions
resource table injected by the project's own configure() designed per domain; the central one only serves cross-project queries

The merge happens in abac.accounts_map(), with the central account set as the domain:

  • central has + local has → role from central, stages from local, _grant is local (stages non-empty) or all (stages is []);
  • central has + local doesn't → _grant is tagged central-only, all stage-type resources DENY, non-stage resources behave per role as usual;
  • central doesn't have + local has → dropped outright. There's no such account in the central htpasswd; keeping it would only let stale local entries into decisions.

This split isn't symmetrical aesthetics I invented; it's the only solution constraint two forced out: things whose vocabularies aren't interchangeable can't live in the common layer.

Two boundaries that get asked about, stated up front. First, the user in a request is not something a client can declare arbitrarily — in production, server.lume uses HTPASSWD_FILE so lume httpd completes basic auth before decide(); an unauthenticated request never reaches the decision logic. The user == null → PERMIT shortcut lives only in local make dev and can never fire in production. Second, revocation is near-real-time: the identity table is re-read from the read-only volume via accounts_map() on every /api/authorize, with no in-process cache of the account set. So when the center deletes or edits an account in kb-auth, the next request judges by the new state — there is no application-layer staleness window. (The real boundary is the read-only volume's own synchronization, which is the mount layer's job, not the engine's.)

4. The Engine Is a Library, Not a Service

shared/ inside kb-abac is a git submodule pointing at kb-auth-shared, pinned to a specific commit. Cloning needs --recursive, or an extra git submodule update --init; forget it and it's no big deal — make test fails with a clear error.

I deliberately wrote in README.md: the decision logic comes from that one shared engine source, not a copy.

That sentence is the foundation of the whole scheme. If each site cps a copy of abac.lume in, then the "two sites' vocabularies aren't interchangeable" problem becomes "the two sites' decision logic starts to diverge" half a year later — worse, because that's drift with no error. With a submodule + import, one engine source is referenced by three projects at once, and behavior is consistent by construction.

The cost is clear too: I wrote it myself in shared/README.md — "a single change here affects three projects at once." So the first paragraph of that repo isn't a feature intro, it's "what to run before changing anything here." That's modularization's bill, and I accept it.

Finally, let me split "consistency" into three layers, so readers don't assume a pinned submodule commit solves everything: ① source consistency — the submodule pins a commit, guaranteeing all three sites reference the same abac.lume; ② configuration consistency — each site's own data/resources.json is independent, so pinning the engine source does not mean the sites' policies match; ③ deployment-version consistency — the lume runtime tag each site runs must be aligned separately. The submodule only solves the first layer; the other two are each site's own accounting, which the engine repo cannot govern.

The runtime is lume, an embedded script runtime written in C11, a single static binary, no GC.

The site-side layer is so thin it barely exists: a shim that only does import plus a configure() injecting its own resource table, then re-exports. Not a single call site has to change.

Finally, the runtime substrate all three repos stand on. Lume is a two-tree structure: lume-core is the language-kernel tree — a front end (lexer/parser/typecheck) plus a tree-walking interpreter, plus two native emitters (hand-written LLVM IR text and the libLLVM C API), linking nothing but libc (and optionally libLLVM); no HTTP server, no agent runtime lives here. lume is the host tree — the same language embedded into agent-httpd, adding HTTP server{}, native SQLite, and agent tools. kb-abac deploys on the host tree's official static binary (the docker/Dockerfile pulls it from GitHub Releases at LUME_VERSION=v0.11.0); one server{} declaration in the .lume script sets the port and docroot, and docker compose up -d --build is a site. Two decisions cash out at this layer: the language kernel is the anchor for engine correctness — a few hundred lines of decision logic like abac.lume gets type-checked by the same front end and executed by the interpreter, with no Node/Python runtime involved; the official static binary is the anchor for deployment — upgrading the runtime is just bumping a release tag, no locally rebuilt images to synchronize across the three repos. Everything runs in one process — that's the technical precondition for "no standalone policy service, no network hop per decision."

5. The Eight-Step Decision Chain: Every DENY Can State Its Reason

The design goal of the decision chain is deny-by-default: when any step returns DENY, it short-circuits immediately and attempts to synchronously write an audit record. A failed audit write does not affect the denial result, but may cause the record to be missing.

kb-abac's README.md and the landing page's STEPS lay it out in eight 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`)

(Whenever this article says "step N", it means the eight-step list above.)

What the chain looks like in code is worth a direct look, because its shape is the implementation of "every denial can state its reason" (excerpted from 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) 认证关闭 → 不限制 (auth off → unrestricted)
  if (user == null) {
    trace = trace + "[auth off: unrestricted] ";
    return { decision: "PERMIT", reason: trace, subject: "", resource: res, action: action };
  }
  // ...every later step has the same shape: append a trace fragment, return the whole chain on DENY
}

Three details at the language level, each serving this article's theme directly: the decision function is typed (: Decision — the struct is checked by the lume front end at compile time; the decision contract is a type, not a comment); the trace is an accumulating string (every step appends a fragment, and a DENY returns the whole trail as reason — audit is not a log bolted on afterwards, it's a by-product of deciding); every read goes through get(table, key, default) (missing keys yield defaults instead of throwing — errors are values, not exceptions, which is what lets the layer above fail closed quietly). The whole engine is 573 lines including comments, with no dependencies beyond the built-in tools.

Step 7 is the one I most want to talk about, because what it does is compare two attributes — something a one-size-fits-all role threshold can never express. For example, a report with classification level: 8 can only be read by accounts whose grade reaches it:

"report": {
  "actions": ["read"], "min_role": "student", "level": 8,
  "require": [ { "subject": "grade", "gte": "resource.level",
                 "reason": "account-grade-below-report-level" } ]
}

Operands are subject.<attr> / resource.<attr> / literal:<value> (a bare name resolves to a subject attribute first, then a resource attribute); operators are gte gt lte lt eq ne. On denial, both sides are laid out for you:

[require grade gte resource.level failed (got 6 vs 8) 'account-grade-below-report-level']

I've always believed that a denial message containing only the word "denied" might as well say nothing. It has to answer "what was compared to what, what came out, and why that counts as a failure." The line above gives the operator, the actual value on both sides, and a human-readable reason at the same time.

Stating coverage explicitly: unknown resource (step 2), action not in the allowed set (step 3), account table unreadable (step 4 fail-closed), and a require with a missing attribute (either side resolves to null → the comparison short-circuits to false → not satisfied → DENY) — all DENY. One precision point: a missing attribute is not coerced to 0 and compared. cmp_ok returns false the moment either operand is null (abac.lume:382), so even if both grade and level were 0, the 0>=0 accidental-permit path never happens — a missing attribute takes the "comparison fails → deny" branch, not "participates in a normal comparison." There is no "neither PERMIT nor DENY" gap in the chain; when authorization fails it only gets stricter, never looser.

6. Fail-Closed, and the Most Dangerous Invariant

Step 4 was changed in this round. It used to be "account table can't be read → warn → permit"; it is now fail-closed: table corrupted, accidentally deleted, volume mounted but empty — every one of them DENY. In code, that paragraph is exactly these lines (verbatim from 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;
}

The denial branch attempts to synchronously write the audit, but because a failed audit write does not block the decision from returning, it cannot be guaranteed that every DENY is successfully persisted.

The problem with the old logic needs no derivation; one sentence is enough — when the file breaks, it opens the whole site. And a file breaking is exactly when this is most likely to happen.

The companion piece is safe_json(raw, label) in json.lume: on parse failure it returns null, logs one warning carrying the label, and never throws. Only a parser that does not blow up gives the layer above a chance to fail closed, quietly.

One honest note about audit itself: audit_write wraps locking and writing in try(), so lock contention or a write failure neither throws nor blocks the decision — DENY is still returned. In other words, fail-closed guarantees the decision won't accidentally permit, not that the audit always lands — a lost audit record is not a permit, but it is an event that must be monitored. Don't confuse the two.

Then there is the thing I wrote into the docs and marked "the most critical invariant in the whole model":

In this engine, stages: [] means "visible to the entire library" (inherited from the v1 semantics, before the extraction).

So "no authorization" must never be expressed as an empty list. Otherwise, every time the center creates a new student account, that account instantly becomes "visible to all stages" in every connected project — a completely silent privilege escalation: no error, no log, just someone noticing one day, "wait, why can he see this?"

The correct expression is the explicit _grant="central-only" marker, synthesized in memory only and never persisted; step 6 of decide() and visible_stages() both honor it.

The lesson here is not "watch out for edge cases." It is: when "empty" can simultaneously mean "none" and "all" in a system, sooner or later you will write the former where you meant the latter.

7. The Environment Dimension, and a Switch That Should Only Live in Demos

Step 8 adds the environment dimension. The rule lives in the resource table and is judged against runtime facts:

"admin": {
  "actions": ["manage"], "min_role": "admin",
  "env": { "deny_when": [ { "fact": "hour", "not_in": [9, 18],
                            "reason": "no-admin-actions-outside-business-hours" } ] }
}

On denial, it names which fact triggered it:

[env.hour=22 violates 'no-admin-actions-outside-business-hours']

One trap has to be stated plainly — my own README.md says it too:

hour / weekday are demo-only overrides. Environment facts must come from a trusted source. If a client can declare hour=14 itself, it can bypass "no admin actions outside business hours" — the rule might as well not exist.

So the override is off by default; the API honors it only when the environment variable ALLOW_ENV_OVERRIDE=1 is set. This repo's docker-compose.yml turns it on, because the landing page's scenarios need to replay a nighttime denial.

Do not carry that line into production. That is the doc, verbatim.

8. Why the Landing Page Is an External Script

While we are here, a purely engineering trap: the landing page's www/app.js must be an external script, not an inline <script>. Lume httpd's default CSP is script-src 'self'; inline scripts get blocked outright, the page goes blank, and the console hands you a very polite refusal.

The file does four things: ten preset scenario cards, parsing the engine's reason into a rendered decision chain, pulling GET /api/resources to show the effective resource table and its source (file vs fallback), and pulling GET /api/audit to show recent denials.

The decision-chain rendering got reworked once. The first version indexed into the bracketed fragments of reason — reason.match(/\[[^\]]*\]/g) gives you an array, and step n takes fragment n. That was wrong: on failure the engine outputs exactly one fragment, so index-based matching pasted step 6's evidence onto step 1, and the page would confidently tell you "authentication failed at this step."

The fix was to match by semantics:

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:/,
};

The auth-off shortcut (user = null) is handled separately too: the engine returns PERMIT right at step 1 and the other seven steps never execute, so the page merges the consecutive skips into one line instead of printing seven "not executed" rows.

9. When Not to Do This

I do not want this to read as "in-process evaluation beats everything." It does not.

My README.md states the engine's boundary plainly, in the words "Scope, stated honestly": a role threshold, one stage whitelist, one constrained attribute comparison (require), and a few environment primitives (hour, weekday, not_in / eq / ne). It has no general expression language, no REVIEW state, no row-level filtering.

So in the following situations, you should take the gateway + standalone PDP route — the route I walked end to end; it is the spring-abac from the opening, nine sub-services, two gates, a SpEL sandbox — rather than copy me:

  • Rules need a general expression language. The moment someone asks for "arbitrary boolean composition, arbitrary function calls," a hand-written require field will balloon into a crippled DSL; you might as well start on OPA.
  • You need a REVIEW intermediate state. A three-valued decision (permit / deny / pending review) is not one more step on a chain; it is a workflow, not a predicate.
  • You need row-level filtering. This engine decides "this resource type + this action + this stage" — that is all.
  • You are managing dozens of heterogeneous services. The in-process engine assumes every consumer is willing to import the same source. Across languages and organizations, that assumption dies.

It's worth nailing down the boundary between the two articles, so they don't read as two versions of the same one. Both are ABAC, but spring-abac's decision is a policy set + priorities + deny-override merge — declarative, no implied order between rules, with condition expressions running in SpEL behind a four-layer sandbox; this engine's decision is a fixed-order eight-step chain — procedural, with semantics living in the engine code, and the site's own resource table only supplying data. The audit trade-off is inverted too: there it's async best-effort (audit jitter never slows the main path); here the denial branch attempts a synchronous audit write, but a write failure does not block the decision from returning — so it does not guarantee every DENY is persisted. One puts expressive power in the policy language, the other puts reliability in the decision process — which is also why one is nine sub-services and the other is zero-network.

Conversely, if you are like me: the core decision is deterministic local logic, latency-sensitive, a single point of failure is unacceptable, and you can accept the coupling of three repos sharing one engine source — then in-process is the cheaper side.

What I cut this time was one network hop. The price is that I must treat the shared/ submodule with respect — when it moves, three projects move. I think the trade is worth it, but it is a trade, not a free lunch.

Source Map

  • README.md — site overview, the eight-step chain, require / env examples, the demo-toggle warning, Lume install
  • shared/README.md — engine contract, the two-layer attribute-authority table, the three _grant states, the dangerous stages: [] invariant, the fail-closed change note
  • shared/abac.lume — decision engine: attribute reads (two-layer merge) + the decide() chain + audit
  • shared/admin.lume — account CRUD, password hashing ($6$ SHA-512 crypt), htpasswd rebuild, central-mode write protection
  • shared/json.lume — safe_json(raw, label): parse failure returns null, one warning, no throw
  • server.lume — the site service, import "./shared/abac.lume"
  • www/app.js — landing page: scenario cards, decision-chain rendering, /api/resources and /api/audit
  • examples/authorize-curl.sh — the curl assertion script
  • docs/DESIGN-TRADEOFF.md — why a library instead of gateway + standalone PDP, and what it cannot do

How do kb-auth, kb-auth-shared, and kb-abac divide the work?

kb-auth-shared is the engine library (a submodule pinned to a commit, no process); kb-auth is the central identity authority and the only writer, producing the central identity volume for consumers to mount read-only — and it imports the same engine itself; kb-abac is the consumer demo site. All authorization decisions happen inside each site’s process, zero network.

What is the relationship between lume and lume-core?

lume-core is the language-kernel tree (front end + interpreter + two LLVM emitters, no HTTP/agent capabilities); lume is the host tree — the same language embedded into agent-httpd with server{}, native SQLite, and agent tools. kb-abac deploys on the host tree’s official static binary (v0.11.0); one server{} line in the script is a site.

Why put the decision inside the site's process instead of a central PDP service?

Because every page needs several decisions, a standalone PDP means one extra RPC hop each time, plus a single point that can take down the whole site. The engine core is deterministic local decision logic; as a library, every decision is zero-network.

The two sites' stage lexicons do not overlap — how can they share one engine?

By splitting attributes into two layers: project-agnostic vs project-specific.

What does stages: [] mean, and why is it dangerous?

It means “visible to the whole library” (inherited from v1). So “not authorized” must never be an empty list; it must be the _grant="central-only" marker, synthesized in memory and never persisted — otherwise a newly created student account at the center silently escalates in every connected project.

What happens when the account table cannot be read?

Fail-closed, straight DENY. This round changed it from “cannot read → warn → permit”; the old logic effectively opened the whole site when the file broke or the volume mounted empty.

Where does the landing page's hour=22 come from?

It is a demo-only environment-fact override, off by default; the API honors it only with ALLOW_ENV_OVERRIDE=1. Environment facts must come from trusted sources — that line belongs in docker-compose.yml, not production.

When should I not use this approach?

When rules need a general expression language, a REVIEW state, or row-level filtering, or when you are managing dozens of heterogeneous services across languages and organizations — use gateway + standalone PDP. This engine has only a role threshold, a stage whitelist, constrained require comparisons, and a few environment primitives.

Related Reading

Comments

Leave a reply

Your email address will not be published. Required fields are marked *

AI Engineering Practices & Open Source Projects

Shop Web Chat Nsbp About Privacy

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号