From RBAC Authentication to a Small Microservice System: Spring Cloud + Zero-Dependency JWT + CRM + Audit + BFF

🇨🇳 中文版

Motivation: From a Minimal RBAC Start to a Small System, Grown Through Exploration

As a frontend-leaning full-stack developer, microservice authentication was always a thin spot in my understanding. When doing RBAC, the common approach is to deploy the full Spring Security + Nacos + Gateway stack right away – heavy and slow to pick up, where the version and configuration pitfalls the docs gloss over keep getting people stuck. So I wanted a lighter approach: have AI write a minimal RBAC auth loop, read and learn along the way, make sense of the whole picture, then explore on top of it.

At first the goal really was "minimally viable": user registration/login, JWT issuance and verification, RBAC roles and permissions. But once that auth loop worked, the next question came naturally: if a real business service sits behind the gateway, can this auth actually govern it? So I added a CRM customer domain. Then I wanted to see what actually happened on each allow/deny decision, so I added cross-service audit. Finally I wanted a UI to click around in, so I added a Next.js frontend. This article records that evolution – the point isn't "what I wrote," but "what each piece actually does and why it's built this way."

Current stack: Java 17 · Spring Boot 3.2.5 · Spring Cloud 2023.0.3 · Spring Cloud Gateway (WebFlux) · Eureka · Config Server (native) · Spring Data JPA · H2 · Resilience4j · Maven multi-module (7 modules) · Next.js 14 frontend. Three runtime modes (bare jar / Docker / k3s) with zero config changes – the same source runs directly in all three.

Architecture Overview

7 backend services + 1 Next.js frontend:

browser ──► web :3000  (Next.js BFF: /api/* rewritten to gateway, no CORS)
               │
               ▼
    gateway-service :4100   PEP: JWT validation + permission decision + audit side-channel + trace ID
               │   via Eureka (lb://)
   ┌───────────┼───────────┬──────────────┐
   ▼           ▼           ▼              ▼
 auth:4101   rbac:4102  customer:4103   audit:4104
 register/   PDP        CRM+approval    append-only audit
 login      /api/check  protected res   (gateway async emit / GET query)

    config-server :8888 (native config center)    eureka-server :8761 (service registry)
Service Port Responsibility
eureka-server 8761 Service registry (@EnableEurekaServer)
config-server 8888 Config center (@EnableConfigServer, native backend)
gateway-service 4100 PEP: JWT validation + permission decision + audit side-channel + trace ID
auth-service 4101 Register / login / /api/me, issues JWT
rbac-service 4102 PDP: roles / permissions / grants, BFS for effective permissions
customer-service 4103 CRM customer domain CRUD + delete approval workflow
audit-service 4104 Cross-service append-only audit log
web 3000 Next.js BFF dashboard, permission-driven UI

Auth Core: PEP/PDP + Zero-Dependency JWT

Auth is strictly split into two layers – "allow/deny" and "on what basis" each mind their own business:

  • PEP (Policy Enforcement Point) lives in the gateway at gateway-service/.../filter/AuthGlobalFilter.java (@Order(-1) GlobalFilter).
  • PDP (Policy Decision Point) lives in rbac-service, answering "does this user actually have this permission."

AuthGlobalFilter flow:

  1. Trace ID: read X-Trace-Id, generate an 8-char hex if absent; write it back to the response and forward it downstream for end-to-end traceability.
  2. Public routes bypass: /api/login, /api/register, /health, /actuator/** pass through; non-/api/** paths too.
  3. Validate JWT: require Authorization: Bearer <token>, call jwtUtil.verify; missing token -> 401 (audit auth:missing), invalid -> 401 (audit auth:invalid).
  4. Inject identity: on success, add X-User: <username> header to the downstream request.
  5. Compute required permission: mapPermission(path, method) maps the route to a permission; unmapped routes (e.g. /api/me, /api/check) only require login.
  6. Call PDP: use WebClient to call http://rbac-service/api/check?user=&permission=, wrapped in circuit breaker rbac-check; on allowed let it through and emit an ALLOW audit event, otherwise 403 and emit a DENY audit event.
  7. Audit side-channel: after each decision, asynchronously fire-and-forget an event to audit-service (audit itself is fail-open, never blocks business).

Zero-dependency JWT: no jjwt or nimbus-jose-jwt – HS256 is implemented directly with JDK-native Mac + Base64 + Jackson (auth-service/util/JwtUtil.java and gateway-service/util/JwtUtil.java). Zero dependency is just a side benefit; the real point is to lay bare what JWT is actually computing – how header / payload / base64url are assembled, how HMAC signs, how the signature is verified – so a few dozen lines stop being a black box. The shared secret is injected via app.jwt-secret (auth and gateway share one symmetric key), TTL via app.jwt-ttl defaulting to 86400000 (24h). The gateway's JwtUtil deliberately keeps only the verify path. Password hashing uses PBKDF2WithHmacSHA256 (auth-service/util/PasswordUtil.java): 65536 iterations, 256-bit key, 16-byte salt, output as salt:hash.

Circuit breaker fallback (fail-closed): the gateway's PDP call is wrapped with Resilience4j, instance rbac-check, with:

  • slidingWindowType: COUNT_BASED, slidingWindowSize: 10
  • minimumNumberOfCalls: 5
  • failureRateThreshold: 50, slowCallRateThreshold: 50
  • slowCallDurationThreshold: 800ms
  • waitDurationInOpenState: 5s
  • permittedNumberOfCallsInHalfOpenState: 3
  • automaticTransitionFromOpenToHalfOpenEnabled: true

The choice is fail-closed: when the breaker is open or the PDP errors, the gateway returns 403 directly – never 200. In security components, deny-by-default is safer than allow-by-default. Breaker status is exposed via Actuator: management.endpoints.web.exposure.include: health,circuitbreakers.

RBAC Model: Roles, Permissions, Grants

Seed data lives in rbac-service/.../service/RbacService.java's seedIfEmpty(), injected at startup by a CommandLineRunner (H2 ddl-auto, clean seed every start):

  • Roles: admin / editor / viewer (three tiers, no inheritanceparentId is null across the seed).
  • 11 permissions:
    • User/role/permission management: users:read, users:write, roles:read, roles:write, permissions:read
    • CRM domain: customers:read, customers:create, customers:update, customers:delete, customers:approve
    • Audit: audit:read
  • Grants:
    • admin: all 11 (incl. customers:approve, audit:read)
    • editor: users:read, roles:read, permissions:read + customers:read/create/update/delete (7; no approve, no audit:read)
    • viewer: users:read, roles:read, permissions:read, customers:read (4, read-only)
  • User -> role: admin -> admin, user -> editor, viewer -> viewer
  • Logins: admin/admin123, user/user123, viewer/viewer123

BFS effective permissions: resolveEffectivePermissions(username) walks the parentId chain in BFS collecting permissions (visited guards against cycles), supporting arbitrary inheritance depth. The current seed doesn't use inheritance, so it effectively reduces to the union of directly-assigned role permissions – the mechanism is there; set parentId in the seed whenever multi-level inheritance is needed. PDP endpoint: GET /api/check?user=&permission=.

Business Domain and Audit: Making Auth Actually Govern Something

Once the auth loop works, adding a business resource shows whether it actually holds.

customer-service (:4103, CRM customer domain): CRUD + search, with gateway routes mapped to customers:read/create/update/delete. The highlight is a delete approval workflow: on DELETE /api/customers/{id}, if the caller has customers:approve (admin) it deletes directly (200); otherwise it returns 202 and creates a pending approval. /api/approvals provides the list + approve / reject (requires customers:approve, approver taken from the X-User header). It also has its own RbacClient calling rbac /api/check to decide direct-delete vs. approval (on error it fails over to the approval flow). H2 ./data/customer, ddl-auto=update.

audit-service (:4104, cross-service append-only audit): the gateway is the sole emitter – after each PEP decision it asynchronously, best-effort, fires an audit event. Writes are allowed only from the gateway via service discovery (with the private header X-Internal-Audit: gateway); the external route /api/audit is GET-only, so writes can't be forged from outside. Queries: GET /api/audit (paginated, filterable by decision / traceId), GET /api/audit/stats (today's overview). Reads require audit:read (admin only). H2 ./data/audit, ddl-auto=update.

With these two added, "authentication" is no longer in the air: customer is a protected business resource, and audit makes every decision traceable.

Frontend BFF: Permission-Driven Dashboard

web (:3000, Next.js 14 + React 18 + TypeScript): BFF pattern – the browser only calls same-origin /api/*, and Next.js rewrites those server-side to the gateway :4100, so there's no CORS. The dashboard has 7 tabs: Roles / Permissions / Users / Customers / Approvals / Audit / Check.

The key part is permission-driven UI: after login it calls PDP /api/check once per permission, and tabs the user lacks show a lock. The frontend never decides permissions itself – it only shows/hides based on PDP results, so permission logic stays centralized in rbac and the UI is just a visualization shell.

Design Tradeoffs: Why These Choices

This combination isn't the only solution, nor necessarily the "optimal" one – it's a deliberate tradeoff under the constraints of minimal dependencies, it actually runs, and each step answers a question:

  • PDP stays inside rbac-service, not wired to OPA / Casbin: first validate that the layering "gateway only enforces, decisions live in one place" actually holds, without taking on a policy engine's complexity up front.
  • Gateway uses Spring Cloud Gateway, not Kong / APISIX: those are mature production gateways but introduce an extra operational entity. The goal is "stand up PEP with minimal cost inside the Spring ecosystem," so it stays at the application layer.
  • JWT implemented natively with the JDK, not jjwt / nimbus: less about saving a dependency than laying the signing / verification mechanism bare – at the cost of losing a mature library's edge-case coverage and audit assurance. In production, use a mature library; for the demo, this implementation is a way to actually understand JWT.
  • No Spring Security for auth: same reasoning – don't pull in "version conflicts + config entanglement" prematurely; get the minimal RBAC loop running first.
  • customer / audit / web came out of exploration, not scope-padding: each one answers a concrete question – how do you govern a business resource? How do you trace decisions? Is there a UI to click around?

Moving toward production, you'd typically still need: secrets in KMS / Vault instead of hardcoding; PDP split into a standalone service backed by a policy engine; observability (trace / metric / circuit-breaker event alerting); horizontal scaling and statelessness; and stricter auth strength (token revocation, real-time permission-change propagation). This article validates that the approach holds – not that this setup is production-ready.

Implementation Notes

A few things worth knowing before running this stack for real. Wording is aligned with DOCKER.md.

Eureka cold-start 503

Eureka's server response cache defaults to 30000ms and the client fetch interval to 30s; right after a service registers, the gateway doesn't have the instance list yet, so lb://service-name resolves to 503. Fix: set eureka-server's response-cache-update-interval-ms to 3000 (default 30000) and the gateway's registry-fetch-interval-seconds to 3 (default 30); in docker-compose the gateway also sleep 6 as a buffer (comment: "server cache 3s + gateway fetch 3s").

Map-type config injected via -D in containers

eureka.client.serviceUrl is a Map type, and spring.config.import is a bootstrap-phase property. Injecting these via flat env vars (EUREKA_CLIENT_SERVICEURL_DEFAULTZONE / SPRING_CONFIG_IMPORT) is unreliable: the Map won't bind to the defaultZone key, and the config-import URI is resolved during the bootstrap phase where flat env is often not read either, so the client falls back to the localhost default in application.yml and registration fails. The fix is to inject JVM system properties (-D, highest precedence, binds Maps too) via JAVA_TOOL_OPTIONS. This is what actually works in Docker / k3s (see DOCKER.md).

k8s container exits immediately (CrashLoopBackOff)

Each service Dockerfile only has COPY ... app.jar + EXPOSE, no ENTRYPOINT / CMD; docker-compose covers it with command:, but the k8s manifest has no command, so the container has no process to run and exits immediately (Completed = exit 0) -> CrashLoopBackOff. Fix: add command: ["java","-Xmx256m","-jar","app.jar"] to each container in k8s (web is npm run start; the gateway adds a sleep 5 buffer).

k3s image can't be pulled (ContainerCreating stuck)

imagePullPolicy: Never occasionally fails to resolve the local image under OrbStack, leaving kubelet stuck creating the container. Switched to IfNotPresent (use the local image directly, no pull).

initContainer waits for config, not eureka

The manifest's initContainer is named wait-config (waits for config-server:8888); the gateway one waits for config + auth + rbac + customer + audit (five ports); the web one waits for the gateway (wait-gateway). Not eureka.

Three Runtime Modes: One Source, Zero Config Changes

application.yml is never edited; all addresses are injected via env / JAVA_TOOL_OPTIONS.

Bare Jar Mode

make build        # mvn clean package -DskipTests -> seven jars
make start        # start all 7 services + web in order, wait for readiness
make demo         # bash scripts/demo.sh, full flow through gateway :4100

Docker Compose Mode

make docker-up      # build + compose up -d --build
make docker-demo    # wait for readiness + run demo
make docker-logs    # compose logs -f
make docker-stop    # compose down (preserves data volumes)

k3s Mode

orb start k8s                 # OrbStack: enable k3s (or: k3s server)
make k3s-build                # actually an alias for docker-build
make k3s-deploy               # kubectl apply -f k8s/spring-rbac.yaml
make k3s-demo                 # port-forward gateway 41000 + web 3000, self-check Pods Running, demo

k8s/spring-rbac.yaml: 8 Deployments + 8 Services, namespace rbac-demo, initContainers wait for dependencies, config injected via env + JAVA_TOOL_OPTIONS. All three modes reuse the same source with no config changes.

Result: From an Auth Loop to a Small Microservice System

Feature checklist:

  • User registration/login + JWT issuance and verification (zero-dependency, JDK native)
  • RBAC: 3 roles / 11 permissions / 3 grants / BFS effective permissions (inheritance supported, unused in seed)
  • Gateway edge auth: PEP + fail-closed circuit breaker + trace ID + audit side-channel
  • Centralized PDP decisions: rbac /api/check
  • CRM domain: customer-service CRUD + delete approval workflow
  • Cross-service audit: audit-service append-only, gateway async emit
  • Frontend BFF: Next.js permission-driven dashboard
  • Service discovery (Eureka) + config center (Config Server); three runtime modes with zero config changes

It's still a learning / exploration-grade skeleton, far from production – auth strength, secret management, observability, scaling, token revocation all still need work. But it's far more complete than the original "minimal RBAC," and every increment maps to a concrete question.

Source Code Navigation

  • README.md / README.zh.md – project description, architecture overview, running guide
  • pom.xml – parent POM, declares the Spring Cloud BOM and 7 sub-modules
  • eureka-server/ – service registry (@EnableEurekaServer, response cache tuned to 3000ms)
  • config-server/ – config center (@EnableConfigServer, native backend, configs in src/main/resources/config-repo/, 5 business-service configs)
  • auth-service/src/main/java/com/example/rbac/auth/util/JwtUtil.java – HS256 JWT issuance and verification
  • auth-service/src/main/java/com/example/rbac/auth/util/PasswordUtil.java – PBKDF2WithHmacSHA256 password hashing
  • auth-service/src/main/java/com/example/rbac/auth/AuthApplication.java – seeded login accounts
  • gateway-service/src/main/java/com/example/rbac/gateway/filter/AuthGlobalFilter.java – gateway PEP: JWT validation + permission decision + audit side-channel + trace ID
  • gateway-service/src/main/java/com/example/rbac/gateway/util/JwtUtil.java – gateway-side JWT parsing (verify-only)
  • gateway-service/src/main/resources/application.yml – 5 routes (lb://) + circuit breaker config + fetch interval 3s
  • rbac-service/src/main/java/com/example/rbac/rbac/service/RbacService.java – seed data + BFS effective permissions + check
  • rbac-service/src/main/java/com/example/rbac/rbac/controller/RbacController.java – PDP endpoint /api/check
  • customer-service/ – CRM customer domain + delete approval workflow, includes RbacClient
  • audit-service/ – append-only cross-service audit log
  • web/ – Next.js 14 BFF frontend (next.config.mjs rewrite, lib/permissions.ts permission-driven)
  • k8s/spring-rbac.yaml – k3s / Kubernetes deployment manifest (8 Deployments + 8 Services)
  • docker-compose.yml – Docker Compose orchestration (8 services)
  • Makefile – all make targets
  • DOCKER.md – Docker Compose and k3s running manual (with detailed pitfall notes)
  • ARCHITECTURE.md – in-depth architecture analysis and design decisions

Full project on GitHub: https://github.com/erishen/spring-rbac

Home Resume About Privacy Shop Web Chat Nsbp

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