🔗 Live demo: https://customer-management-hj57iqzdmq-nw.a.run.app — sign in as
admin/admin123(oruser/user123). API docs at/swagger-ui.html.
A reactive customer-management backend built with Spring Boot 3.5 (WebFlux), Kotlin coroutines, and R2DBC. It provides JWT authentication (login only — no self-registration), customer CRUD with search/pagination, per-IP rate limiting, a database-level audit trail, and a multi-environment deployment setup.
| Area | Choice |
|---|---|
| Language / runtime | Kotlin 1.9, Java 21 |
| Framework | Spring Boot 3.5, Spring WebFlux (reactive), Kotlin coroutines |
| Persistence | Spring Data R2DBC, H2 (in-memory) |
| Migrations | Flyway (runs over JDBC) |
| Security | Spring Security resource server, HS256 JWT, BCrypt |
| Rate limiting | Token bucket — in-memory or Redis |
| API docs | springdoc-openapi (Swagger UI) |
| Build / test | Gradle (Kotlin DSL), JUnit 5, MockK, WebTestClient, Testcontainers, JaCoCo |
- Authentication —
POST /api/auth/loginissues a stateless HS256 JWT; login is the only entry point (accounts are seeded). Constant-time credential check (BCrypt off the event loop) to resist user enumeration. - Self-service password change — any authenticated user changes their own password.
- Authorization — two roles (
USER,ADMIN) enforced per endpoint with@PreAuthorize. - Customer CRUD — create / read / update / delete plus list with search, pagination,
and sorting.
ADMINwrites;USER+ADMINread. - Rate limiting — per-IP token bucket; a strict tier for
/api/auth/**and a looser tier elsewhere; returns429+Retry-After. In-memory locally, Redis in deployed envs. - Audit trail — every customer update/delete is recorded in
audit_logby a database trigger; row-levelcreated_by/last_modified_by/ timestamps via Spring Data auditing. - Operability — Actuator health (liveness/readiness), OpenAPI docs, Docker image, Kubernetes manifests, and a CI pipeline.
Prerequisites: JDK 21 (the Gradle wrapper is included).
./gradlew bootRunThe app starts on http://localhost:8080 with the in-memory (local) profile. bootRun
injects a development JWT_SECRET automatically.
| Username | Password | Role |
|---|---|---|
admin |
admin123 |
ADMIN |
user |
user123 |
USER |
# 1) Log in and capture the token
TOKEN=$(curl -s localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}' | jq -r .accessToken)
# 2) Call a protected endpoint
curl -s localhost:8080/api/me -H "Authorization: Bearer $TOKEN"
# 3) Create a customer (ADMIN)
curl -s localhost:8080/api/customers -X POST \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"firstName":"Jane","lastName":"Doe","email":"jane@example.com"}'| Method | Path | Access | Notes |
|---|---|---|---|
| POST | /api/auth/login |
public | Returns { accessToken, tokenType, expiresIn, username, role } |
| POST | /api/auth/change-password |
authenticated | { currentPassword, newPassword } → 204 |
| GET | /api/me |
USER, ADMIN | Current principal (username + roles) |
| GET | /api/admin/ping |
ADMIN | Role-check probe |
| POST | /api/customers |
ADMIN | Create customer |
| GET | /api/customers/{id} |
USER, ADMIN | Fetch by id |
| GET | /api/customers |
USER, ADMIN | List — search, page, size, sort=field,dir |
| PUT | /api/customers/{id} |
ADMIN | Update |
| DELETE | /api/customers/{id} |
ADMIN | Delete |
API docs: Swagger UI at /swagger-ui.html, OpenAPI JSON at /v3/api-docs (both public).
Every endpoint carries a description and example responses, and running the login operation
auto-applies the returned token — no manual Authorize step needed. A Postman collection +
environment live in postman/; see postman/README.md for
the run/Newman guide.
The environment is selected with SPRING_PROFILES_ACTIVE:
| Profile | Rate-limit backend |
|---|---|
local (default) |
in-memory |
dev, qa, sandbox, prod |
Redis |
Key settings (env-overridable):
| Variable | Purpose | Default |
|---|---|---|
JWT_SECRET |
HS256 signing key, ≥ 32 bytes (required; app fails fast otherwise) | — (dev/test injected) |
SPRING_PROFILES_ACTIVE |
Active environment profile | local |
RATELIMIT_BACKEND |
memory or redis |
per profile |
RATELIMIT_ENABLED |
Toggle rate limiting | true |
RATELIMIT_TRUST_XFF |
Trust X-Forwarded-For (only behind a known proxy) |
false |
REDIS_HOST / REDIS_PORT |
Redis location (Redis profiles) | localhost / 6379 |
The app reads/writes via R2DBC; Flyway owns the schema and seed data and runs over
JDBC (Flyway has no R2DBC support). Both point at the same in-memory H2 database
(DB_CLOSE_DELAY=-1 keeps it alive for the JVM). Migrations are in
src/main/resources/db/migration:
V1__init.sql— tables + the audit triggerV2__seed_users.sql— seededadmin/useraccounts
H2 is used for every environment today. Moving to Postgres means adding
r2dbc-postgresql, a JDBC URL for Flyway, and Postgres-flavoured migrations (the audit trigger is H2-specific). This also unlocks running more than one replica.
./gradlew test # unit + integration tests; writes a JaCoCo report88 tests covering services (MockK) and full HTTP flows (WebTestClient), including a
Testcontainers-backed Redis test for the distributed rate limiter. Coverage report:
build/reports/jacoco/test/html/index.html.
docker compose up --build # app (prod profile) + Redis on :8080The multi-stage Dockerfile builds a non-root JRE image with an Actuator
healthcheck. Override the profile with SPRING_PROFILES_ACTIVE=qa docker compose up.
Manifests are in k8s/ (namespace, per-env ConfigMap, Secret, Redis, app
Deployment/Service with probes and a hardened security context). See
k8s/README.md for the build → load → apply flow.
terraform/ provisions a minimal, scale-to-zero (≈ $0 idle) Cloud Run stack —
Artifact Registry, a Secret Manager JWT, a runtime service account, and the service itself. The
React SPA is built same-origin and bundled into the backend jar (an SpaWebFilter forwards
client-side routes to index.html), so a single Cloud Run URL serves both the UI and the API
with no CORS between them. See terraform/README.md for the
apply → build/push → deploy flow.
.github/workflows/ci.yml runs build + tests on main, the env
branches, and PRs, then builds and (on non-PR pushes) publishes a per-environment image to
GHCR. On pushes to main a deploy job authenticates to GCP keylessly (Workload Identity
Federation), pushes the full-stack image to Artifact Registry, and runs gcloud run deploy —
see terraform/README.md for the one-time WIF setup and the repo
variables it needs.
src/main/kotlin/com/allica/customermanagement/
├── auth/ # login, password change, JWT minting service, DTOs
├── security/ # Spring Security config, JWT decoder, properties, user details
├── user/ # AppUser entity + repository
├── customer/ # Customer entity, repository, service, controller, DTOs
├── audit/ # audit_log entity/repository + the H2 audit trigger
├── ratelimit/ # token bucket, in-memory & Redis limiters, WebFilter
├── web/ # /api/me and /api/admin endpoints
└── config/ # OpenAPI + R2DBC auditing configuration
main is the integration branch; dev, qa, sandbox, and prod are long-lived
environment branches (same code — the environment is chosen at deploy time via the profile).
This project was built with AI assistance (Claude). AI_USAGE.md is the
transparency record of how — documented phase by phase: what was asked, the key
decisions (and who made them), what was implemented, and how each phase was
verified. It spans the initial setup/research, then authentication & login, customer
management (CRUD + search), method-level authorization & audit logging, containerization &
Kubernetes, the CI pipeline, a final review/hardening pass, self-service password change,
the Flyway integration, the React frontend, and the API documentation (OpenAPI examples +
Swagger auto-auth). Each entry separates human decisions from AI-generated work and notes the
trade-offs that were consciously accepted.