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)
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
| Area | Mine | Teammates | Key commits |
|---|---|---|---|
Member/auth service baro-auth | Sign-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 Gateway | Authentication filter, OPA authorization filter (circuit breaker, bulkhead, 2 s), default filters (Redis RequestRateLimiter, Retry, CircuitBreaker) with Resilience4j settings, fallback | Some route settings | fb81d45f 8cc62c58 |
| OPA policy | Rego, path rules by service and role (22 files, 84 path entries), static hotlist, opa-config | K8s DaemonSet deployment manifest | ff7f5015 f60ada73 |
Policy bundle service opa-bundle | Consuming Kafka hotlist events, in-memory hotlist, building and serving bundle.tar.gz, internal access filter | — | c7adf324 |
Seller domain baro-seller | Seller 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) | Farm | 5f76b5cd 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
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
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
| Path | How | What it guarantees | Gaps |
|---|---|---|---|
User and seller status → opa-hotlist-events (at presentation) | KafkaTemplate. inside the transaction, result not awaited. Key = subjectId | Per-user ordering. The consumer drops duplicates by eventId | An 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 failures | If the withdrawal commits, the event row exists | No 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( → Kafka | The approval commit and the response are not blocked by the broker. A rolled-back approval is not sent | If 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
- 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
- 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 passopa 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 commit78eaa987(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/bundlevs/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, andbaro-userdoes 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 inf60ada73, but the tests end inrego_recursion_errorwhen 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:
| Limitation | Impact | If I fixed it |
|---|---|---|
For requests without a token, the X-User-* headers sent by the client go downstream unchanged | Downstream services trust these headers, so headers alone can impersonate another user | Always 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 BCrypt | This exceeds BCrypt’s 72-byte input limit, so sign-up and password change fail for passwords of 9 or more characters | Drop 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 open | Grant 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 refresh | Short-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 default | Anyone inside the cluster can fetch the bundle | Turn 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.