← Projects
Project status: Completed

BARO FARM

A five-person team project (Programmers Dev Course): a farm-to-table produce marketplace built as a Spring Cloud MSA. My part was the auth boundary — the member/auth service, the gateway's authentication and authorization filters, the OPA policy and its bundle service, and propagation of user and seller status changes. It went as far as the final presentation demo (2026-01-27) with no production use, and the design is described as of that point. At the presentation the policy allowed by default, and on main today the OPA authorization filter is no longer on the routes.

Role

Backend (team of 5) — member/auth service, gateway auth filters, OPA policy and bundle service, user- and seller-status propagation

Period

2025.11.29–2026.01.27 team project (semi & final) · 2026.02 module cleanup (team repo main) · 2026.03 personal branch (not merged)

Spring Cloud Gateway Spring Security · JWT OPA (Rego) Kafka Redis Resilience4j Java 21 · Spring Boot 3.5 OAuth2 (Kakao · Naver) OpenFeign JPA MySQL
BARO FARM system diagram — gold marks my part: the gateway's auth filters, the OPA server and policy bundle service, auth-service and the seller part of seller-service; grey marks teammates' services and platform
As of the final presentation (commit 78eaa987) · gold = my part

84

Path rules by service and role

22 OPA data files · 78eaa987

10–60 s

Bundle polling that carries status changes to OPA

a setting, not a measurement

Teammates built ordering and payment, stock, settlement, AI recommendations and search, and the deployment infrastructure. Along with the authentication and authorization filters, I also set up the gateway’s traffic settings (rate limiting, retries, circuit breaker).

Scope

AreaMineTeammatesKey commits
Member/auth service baro-authSign-up with email verification codes, JWT login (HttpOnly cookies), refresh rotation and logout, password change and reset, Kakao/Naver OAuth and account linking, my profile, account withdrawal (outbox), admin changes to user and seller status, hotlist event publishing—95946295 5739f946
API GatewayAuthentication filter, OPA authorization filter (circuit breaker, bulkhead, 2 s), default filters (Redis RequestRateLimiter, Retry, CircuitBreaker) with Resilience4j settings, fallbackSome route settingsfb81d45f 8cc62c58
OPA policyRego, path rules by service and role (22 files, 84 path entries), static hotlist, opa-configK8s DaemonSet deployment manifestff7f5015 f60ada73
Policy bundle service opa-bundleConsuming Kafka hotlist events, in-memory hotlist, building and serving bundle.tar.gz, internal access filter—c7adf324
Seller domain baro-sellerSeller application and business-number check, seller-status sync from hotlist events, Feign client and token-forwarding interceptor for calling auth (no caller at presentation time)Farm5f76b5cd 1224e1a2
Platform—Eureka and Config servers, K8s manifests, CI/CD, monitoring—
Other services—Order-payment saga, stock, settlement batch, AI recommendations and search—

System architecture

The architecture diagram is the cover image above (gold = my part, grey = teammates, dashed = polling or async).

  • OPA (the PDP) makes the authorization decision; the gateway only enforces it (the PEP).
  • A status change reaches authorization data through auth-service → Kafka (opa-hotlist-events) → opa-bundle → OPA bundle polling. seller-service consumes the same topic to keep seller status in line.
  • Spring Security exists only in auth-service. The other services identify the user from the X-User-* headers written by the gateway, as is. That makes the gateway filters the trust boundary.

How one request is authorized

The authorization path of one request. A request from the browser with its cookie passes the gateway's route filters, the Authentication filter and then the OpaAuthorization filter; the gateway also has default filters (RateLimiter, Retry, CircuitBreaker), and the combined execution order was not verified. The authorization filter sends request, subject and route.service to OPA and, on true, routes to the downstream service. A token that fails validation returns 401, false or no result returns 403, and an OPA error, a delay over 2 s, an open circuit or a full bulkhead returns 503. Without a token, the request passes with the incoming X-User-* headers kept.
Gateway at 78eaa987 · the 403 path applies to 01-22–01-26, when the policy denied by default

Open full size ↗

On login, auth-service issues an access JWT and a refresh JWT only as HttpOnly, Secure, SameSite=Strict cookies. The access JWT is signed with HS256 and carries sub (email), uid, role, user_status, seller_status and flags. Access tokens are valid for 7 days, refresh tokens for 14.

Decision and enforcement are separate. The point is to change path and role rules through policy and data, without redeploying the gateway. The cost is one OPA call per request, with a 2 s limit.

Rules are data buckets per service and role. The gateway puts route.service into the query, and OPA evaluates only that service’s buckets. A method index (index.method) narrows the candidate rules further. A dynamic data[service] lookup pointed back at the policy package itself and caused a recursion error; an explicit data root fixed it. The cost: every new service needs a rego change, and the service name is tied to the route id convention. One rule looks like this (excerpt from baro-cloud/opa/policy/data/buyer/seller.json at 78eaa987, indentation tidied only):

"buyer:products-create": {
  "name": "buyer:products-create",
  "path": "^(/buyer-service)?/api/v1/products$",
  "methods": ["POST"],
  "roles": ["SELLER", "ADMIN"],
  "deny_flags": ["PRODUCT_PUBLISH_BLOCKED"]
}

When OPA fails, the gateway closes. An OPA error, a delay over 2 s, an open circuit breaker or a full bulkhead all return 503. This gives up availability; the fail-open alternative is recorded in the design document.

When a status changes

Sequence of one seller approval propagating. An admin requests a seller status change; inside a transaction, auth-service sets users.user_type to SELLER and, before the commit, sends an event to the opa-hotlist-events topic keyed by subjectId, without awaiting the result. opa-bundle consumes the event, drops duplicates by eventId and rewrites bundle.tar.gz; OPA pulls the bundle by polling every 10–60 s and activates it. seller-service consumes the same event and updates seller_status. At the presentation commit the policy allowed by default, and the seller-approval rule had been removed on 01-26.
One seller approval propagating — as of 78eaa987

Open full size ↗

The token carries coarse permissions; status that must apply now goes through the hotlist. An issued JWT keeps stating the old role until it expires (access: 7 days). So user suspensions, blocks and withdrawals, and seller approvals, rejections and suspensions, go to OPA as hotlist data. The token’s seller_status is always UNKNOWN, so seller status is known only through the hotlist. The cost is a delay of up to one OPA bundle polling interval (10–60 s, a setting).

OPA pulls the bundle, and every event rebuilds the whole bundle. On startup, opa-bundle restores the hotlist from data.json. OPA keeps using the last good bundle. The cost: the store is in memory, so there can be only one instance, and the bundle file is overwritten without a temporary file.

The Kafka key is subjectId. This keeps per-user ordering. The bundle service drops duplicates by eventId (the last 10,000).

The seller hotlist was used in gateway authorization only while the approval rule was in the policy, from 01-22 to 01-26. The policy at the presentation commit does not read the seller hotlist. Even in that period, the product service re-checked the seller from the role header carried by the token.

Three ways the service publishes events

PathHowWhat it guaranteesGaps
User and seller status → opa-hotlist-events (at presentation)KafkaTemplate.send inside the transaction, result not awaited. Key = subjectIdPer-user ordering. The consumer drops duplicates by eventIdAn event can go out even if the transaction rolls back. If the broker is down, it is only logged and lost
Account withdrawal → user-events (at presentation, 01-20)Outbox: an event row in the same transaction → 1 s scheduler → send().get(5s) → PUBLISHED; FAILED after 5 failuresIf the withdrawal commits, the event row existsNo consumer yet. No locking or backoff for multiple instances. The hotlist propagation of the same withdrawal is a direct send before commit, not through the outbox
Seller approval (2026-03 personal branch, not merged)afterCommit synchronization → @Async("opaEventExecutor") → KafkaThe approval commit and the response are not blocked by the broker. A rolled-back approval is not sentIf the send fails, the change does not apply until the next token refresh (worst case 7 days). Admin path only (grantSeller still sends synchronously inside the transaction). No tests

Authorization data that must apply quickly and a withdrawal event that must not be lost were handled differently, but the hotlist path at presentation time prevented neither lost events nor events for rolled-back changes (ghost events). The March branch’s send-after-commit prevents only the ghost events.

Member and auth data

Tables of the member/auth service. Around users, auth_credential (1:1, email sign-ups only), oauth_account (1:N), refresh_token (1:1, one per user) are linked logically by user_id, and outbox_event by aggregate_id (the user id as a string); email_verification is keyed by email and not linked to a user. On the right are seller from seller-service, a separate service at presentation time (1:1 with users, synced by hotlist events), farm, built by teammates, and the OPA bundle data, which is not tables (hotlist users and sellers, rules by service and role).
Tables of the member/auth service and the OPA bundle data — 78eaa987

Open full size ↗

  • The member/auth tables are linked logically by user_id (BINARY(16) UUID), without JPA associations.
  • There is one refresh token per user, rotated on refresh and deleted on password change or reset, suspension and withdrawal. Withdrawal, in one transaction, anonymizes the user, deletes credentials, social accounts and the refresh token, and then writes an outbox row.

After the final presentation

In 2026-02 I cleaned up the modules on the team repo’s main (PRs #92, #94, #102). Auth and seller were merged into baro-user, baro-cloud/* became top-level modules, and notifications were split into baro-notification. I removed the Eureka client from the gateway. In this process the OPA authorization filter was dropped from the routes (my commit 3f98d694). The prod-profile routes on main today use K8s service DNS addresses.

In 2026-03, on a personal branch that was not merged, I split the seller admin API into its own domain. Post-approval OPA propagation moved to a separate executor that runs after commit, and the decision is recorded in an ADR (commit 59f643a6). The change covers only the admin path and has no tests. The branch’s tests do not compile.

Current state and limitations

State of the policy and of main (in order)
  • 2026-01-22 14:29, 1224e1a2: added the seller-approval rule. The policy denied by default at this point.
  • From 01-25 19:15 (218193b4) to 01-26 00:51 (36edd3fe), about 5.5 hours, the policy did not pass opa check.
  • 01-26 00:51, 36edd3fe (mine): removed the seller-approval rule. 00:54, d4b67d1b (mine): switched the policy to allow by default. The presentation commit 78eaa987 (01-27) also allows by default.
  • 2026-02-12, 801d3b9e (mine): the OPA bundle path setting and the controller path no longer match (/baro-opa/bundle vs /opa/bundle). 02-22, 3f98d694 (mine): removed the OPA authorization filter from the routes.
  • Main today (1ef2f99b) has no deployment manifests for OPA or the bundle service, and baro-user does not build from a clean clone.
  • The rego tests have never passed, from their introduction (ff7f5015) through main. The policy’s recursion error (the ‘Rules’ paragraph above) was fixed in f60ada73, but the tests end in rego_recursion_error when run.
  • Order to turn it back on: deny by default → reattach the route filters → match the bundle path → rego tests that actually run.

Limitations of the design at presentation time:

LimitationImpactIf I fixed it
For requests without a token, the X-User-* headers sent by the client go downstream unchangedDownstream services trust these headers, so headers alone can impersonate another userAlways strip incoming X-User-*, and pass a request without a token only as an anonymous subject
The password plus a 64-character salt is fed into BCryptThis exceeds BCrypt’s 72-byte input limit, so sign-up and password change fail for passwords of 9 or more charactersDrop the separate salt and use only BCrypt’s built-in salt
The grant-seller endpoint is open without authentication (commit message: “temporarily, for the semi project”)A path to seller permissions is openGrant only through the admin approval path
Access tokens are valid for 7 days (a value changed from 15 minutes “for testing” that stayed)Old roles stay valid for a long time, and a lost status propagation does not apply until the next token refreshShort-lived access tokens with refresh rotation; status propagation through an outbox
The bundle endpoint allows the whole private address range, and the internal token check is off by defaultAnyone inside the cluster can fetch the bundleTurn the token check on by default and narrow the allowed range

The team repository is public. The design is described at presentation commit 78eaa987; the current state refers to main 1ef2f99b. Key commits not listed in the scope table: member/auth 5632084d 4fcc80c3 7baffb26 727501fb, gateway b40ca9e9, seller 8e3decda a8aad588.