Backend for an alumni verification portal: the REST API a campus secretariat uses to confirm that the people claiming to be its graduates actually are.
Go · Gin · GORM · PostgreSQL · Redis
A campus keeps a register of its graduates. Alumni want to be listed — for a directory, an event invitation, a certificate reprint. Anyone can say they graduated, so somebody has to check each claim against the campus records.
Done by hand that is a mailbox and a spreadsheet: claims arrive in no particular order, half of them are incomplete, nobody can tell which have been looked at, and when a decision is disputed there is no record of who made it or why.
This API is the part of that process a machine should own:
- an alumnus fills in their data over time and submits when ready;
- the secretariat gets one queue with everything pending, searchable and filterable;
- every decision records who made it, when, and — for a rejection — why, in words the alumnus can act on;
- a rejected claim is corrected and resubmitted rather than starting over.
| what they can do | |
|---|---|
| Alumni | their own record, and nothing else |
| Secretariat (Admin) | the review queue, approve and reject, manage users |
| Campus stakeholder | read the queue without deciding on it — a role you can create through the API, because show and review are separate permissions |
| Super-Admin | everything, including roles and permissions |
The whole business process is one state machine, held as data in
enums/alumni_status.go rather than as a chain of ifs — so an illegal move is
impossible to express, not merely unlikely to be written.
draft ──────submit──────▶ submitted ──────approve──────▶ approved
▲ │ (terminal)
│ │
(first save) reject
│
▼
rejected ──────resubmit──────▶ submitted
What each edge means in practice:
- A draft may be incomplete.
PATCH /alumni/mesaves whatever was sent, so the form can be filled in over several visits. Completeness is demanded only at submit, which names every missing field at once rather than failing on the first. - Submitting locks the record. It is in someone's queue now, and an edit behind the reviewer's back is exactly what the process exists to prevent. That answers 409, not 400: the request is well formed and the caller is allowed to make it — it is the state of the record that refuses it.
- Approved is terminal. Re-editing verified data would silently invalidate the verification it was granted under.
- A rejection carries a reason and hands the record back. A rejection the alumnus cannot act on just sends them back to submit the same thing again.
- Resubmitting clears the previous decision, or the queue would show a record that is both pending and already rejected.
- A reviewer may not decide on their own record. A staff account can also be an alumnus of the same campus; holding the permission is not the same as being allowed to use it on yourself.
Alumni verification — the workflow above, with a review queue that supports search (owner name, email, student number), status filter, sorting and pagination.
Authentication — email and password, JWT access and refresh tokens, refresh rotation that retires the pair it replaces, and a Redis blacklist so a logged-out token is worthless rather than merely forgotten by the client.
Authorization — roles and permissions, enforced by middleware on every management route. No Super-Admin bypass in code: that role is powerful because the seeder grants it every permission, so what a role can do is always visible in the database.
Security — CSRF defence, brute-force protection, three layers of rate
limiting, Origin verification, and error responses that never leak driver text
or stack traces.
Operations — structured JSON logging with a request id, a liveness endpoint, and an OpenAPI reference the process serves itself.
| why | ||
|---|---|---|
| Go | 1.25 | |
| Gin | v1.12 | |
| GORM | v1.31 | PostgreSQL driver |
| PostgreSQL | 16 | real uuid and timestamptz columns |
| Redis | 8 | JWT blacklist and rate limit counters |
| gofortify | v1.1 | JWT issuing, validation and blacklist |
| logrus | v1.9 | structured logging |
| swaggo | v1.16 | OpenAPI generated from handler annotations |
| gartisan | — | migrations, over golang-migrate |
Layered, wired once at startup:
routes/container.go builds every repository, service and handler, once
handler binds and validates the request, calls one service method,
renders a resource — and returns an error rather than writing
a failure response itself
│
service every business rule. Never touches *gin.Context, except where
it has to read query parameters
│
repository owns every query, and translates every error it can name
Handlers never reach for config.DB. A repository gets its *gorm.DB from the
container, which is what makes WithTransaction(tx) possible.
main.go CustomRecovery → Logger → CORS → ErrorHandler → RateLimit
apidoc/ response shapes that exist only for the doc generator
app/
auth/ register, login, refresh, logout, token issuance
alumni/ the verification workflow
user/ accounts and the caller's own profile
rbac/ roles, permissions, and the pivots joining them
config/ env, database, redis, logger
database/
migrations/ *.up.sql / *.down.sql
seeders/ permissions → roles → users, in that order
enums/ response codes, permissions, roles, alumni status
errors/ constructors that build *utils.HTTPError
middleware/ auth, refresh, cors, logger, error handler, rate limit, uuid params
routes/ container.go wires dependencies, route.go declares the URLs
utils/ response envelope, pagination, hashing, sorting, formatting
validator/ BindAndValidate and the custom tags
Error handling is centralised. middleware/ErrorHandler is the only place
that turns an error into a response and the only place that logs one. It is
registered ahead of every route, which is load-bearing: a middleware registered
before it aborts into nothing and answers 200 with an empty body.
users ──< user_has_roles >── roles ──< role_has_permissions >── permissions
│
├──1:1── alumni_profiles.user_id ON DELETE CASCADE
└──0:N── alumni_profiles.reviewed_by ON DELETE SET NULL
| table | notes |
|---|---|
users |
id uuid PK, number unique sequential, email unique, password bcrypt, is_active, last_activity |
roles |
is_mutable = false for seeded roles, which the API refuses to rename or delete |
permissions |
name unique, plus group for sectioning the UI |
user_has_roles |
composite PK, ON DELETE CASCADE both sides |
role_has_permissions |
composite PK, ON DELETE CASCADE both sides |
alumni_profiles |
one per account, the verification record |
Decisions worth explaining:
alumni_profilesis its own table, not columns onusers. Staff accounts carry no alumni data at all, and half a table of NULLs on every admin row would say nothing about who is actually an alumnus.user_idis UNIQUE. That constraint is what makes the relationship 1:1 — enforced by the database rather than by convention in a service somebody can forget to call.student_numberis UNIQUE, and PostgreSQL treats NULLs as distinct, so unfinished drafts do not collide with each other.reviewed_byisON DELETE SET NULL, not CASCADE. The reviewer is an audit trail; deleting a staff account must not delete the decisions they made.statusis aVARCHAR, not a PostgreSQL enum type, so adding a state is a code change rather than a migration that locks the table.- Timestamps are
timestamptz. A naivetimestampdrops the offset the driver sends and reads back shifted wheneverDB_TIMEZONEis not the server's own. permissions.groupis a reserved word. GORM quotes identifiers itself, but hand-written SQL does not — see the migration andPermissionRepository.GetAll.
Requires Go 1.25+, PostgreSQL, Redis, and the gartisan CLI for migrations.
Developed against PostgreSQL 16 and Redis 8. Nothing here uses a recent feature
of either — uuid, timestamptz, ILIKE and SET NX with a TTL are all long
established — but older versions have not been tested.
git clone <repository>
cd api-alumni
go mod download
cp .env.example .env # then fill in the database and JWT values
createdb api_alumni
gartisan migrate
gartisan db:seed
make run # or: go run .The API listens on APP_PORT.
Everything lives in .env, which is not committed. .env.example documents
each key.
| key | notes |
|---|---|
APP_ENV |
local relaxes cookie Secure/SameSite; anything else is strict |
APP_DEBUG |
true adds the raw error to responses. Fails closed: missing or unparseable means off, so a broken .env cannot switch it on in production |
DB_* |
the app builds a key/value DSN, so a password containing @ or / needs no escaping |
GARTISAN_DATABASE_URL |
gartisan takes a URL, where it does |
FRONTEND_URL |
the single CORS origin — a wildcard cannot be used with credentials |
JWT_* |
signing, TTLs, and the Redis blacklist |
TRUSTED_PROXIES |
empty trusts none. Not cosmetic: rate limits are keyed on ClientIP, and trusting every proxy lets a caller pick a new identity per request |
RATE_LIMIT_* |
defaults on. Unparseable means on, so a typo cannot remove the protection |
make migrate # apply
make migrate-status # current version
make migrate-down # roll everything back
make seed # permissions, then roles, then usersSeeders are idempotent and run in that order, because roles look permissions up by name and users look roles up by name.
Seeded accounts, all with the password password:
| role | |
|---|---|
superadmin@alumni.test |
Super-Admin — every permission |
admin@alumni.test |
Admin — user management and alumni review |
john.doe@alumni.test |
Alumni |
jane.doe@alumni.test |
Alumni |
make run # go run .
make help # every targetmake test # 107 tests, with names and a verdict
make test-race # what CI should run
make test-cover # 79.7%
make check # fmt, vet, docs freshness, and the suite under -raceTwo layers:
- Unit tests —
enums,utils,validator,middleware,app/alumni. They need nothing at all; Redis is faked with miniredis. - Integration tests — the root package. They run the real router, the real middleware chain and the real SQL against PostgreSQL, and cover every endpoint.
The integration suite uses a separate <DB_NAME>_test database, created if
missing, so a run cannot touch development data. Its schema is rebuilt from the
migration files each time — which makes "reproducible from empty" something the
suite proves rather than something this README claims. With no PostgreSQL
reachable it skips with the reason, so go test ./... still runs the unit tests
anywhere.
[no test files] on app/user, app/auth and app/rbac does not mean
untested: they sit at 83–100%, exercised by the integration tests. Go attributes
coverage by where the test files are, so only -coverpkg=./... shows the real
figure.
Generated from annotations on the handlers and served by the process itself:
GET /docs/index.html |
Swagger UI, with Try it out |
GET /docs/doc.json |
the raw specification — imports into Postman or Insomnia |
make docs-gen # regenerate after changing an annotation
make docs-check # fail if docs/ no longer matches the annotationsTo try a protected endpoint: run POST /api/v1/auth/login, copy the
access_token, and paste it into Authorize as the bearer token.
Every response, success or failure, has the same shape:
{ "code": "SUCCESS", "message": "...", "status_code": 200,
"timestamp": "2026-08-23T10:00:00+07:00",
"payload": { "data": {}, "meta": {} } }Branch on code, not on status_code — several codes share a status and mean
opposite things.
| code | meaning | what a client should do |
|---|---|---|
ERR_ACTION_UNAUTHORIZED |
signed in, lacks this permission | show the message, stay put |
ERR_ACCOUNT_INACTIVE |
the account was deactivated | clear the session |
They used to share a code, which logged a user out for opening a page they simply lacked rights to.
Split by what the client has to change: how it sends, or what it sends. An empty body, malformed JSON or a path parameter that is not a UUID is 400. A refused value — with the offending field named — is 422.
Every route outside /api/v1/auth is behind AuthMiddleware, which demands an
Authorization header. A cross-site page cannot set a custom header without
triggering a preflight, and the CORS policy fails it — so that header is those
routes' CSRF defence.
/auth cannot require it: it is what issues the token. It carries VerifyOrigin
and RequireJSON instead, which attack the problem from opposite ends —
RequireJSON stops the request being sent, VerifyOrigin stops it being
processed, on the server, before the handler.
This scoping holds only while the access token travels in a header. Move it
into a cookie and every route becomes cookie-only, at which point both
middlewares belong in main.go.
Without that, rotation only changes which token the client uses: a copy captured earlier stays a working session for the rest of its seven-day TTL. The blacklist call comes after the new cookies are set, so a failure in between does not leave the caller with nothing.
An in-memory counter multiplies the real limit by the replica count — the one
number nobody watching the dashboard would think to check. SET NX then INCR
rather than INCR then EXPIRE, because a process dying between the latter two
leaves a key with no expiry and a caller locked out until somebody deletes it by
hand.
The login guard counts rejected credentials only. A limit on all logins would have to be loose enough for a busy office behind one address, which is loose enough to guess through.
One repository method has many callers, so translating at the call site means
many chances to forget — and a raw error reaching ErrorHandler gets a generic
500, losing the message that would have explained it.
The underlying error goes in an unexported cause, which is therefore never serialised. The log gets the full story; the response keeps a safe message.
Things that were decided one way and could reasonably go the other.
A spent refresh token is a hard failure. Two refreshes racing on the same cookie means the loser gets a 401. The SPA serialises this per tab, but two tabs waking at once can still collide. The alternatives are a short grace window on the old token, or full reuse detection that revokes the whole family; neither is implemented.
Rate limiting is a fixed window, keyed by IP. Fixed window costs two commands and no bookkeeping, at the price of up to 2× the limit across a window boundary. Keying by IP means a distributed attempt against one account is not caught; catching it means keying on the submitted email, which means buffering and restoring the request body in middleware.
The limiter fails open. Redis already carries the JWT blacklist, so an outage has broken authentication anyway; refusing every request on top turns a degraded service into no service. The cost is that the brute-force guard is only as available as Redis.
Services hold a concrete *Repository, not an interface. So they cannot be
given a fake, and their rules are covered by integration tests instead of unit
tests. That buys tests that catch the SQL, the migrations and the middleware
order — and costs a suite that needs a database.
null in a PATCH means "leave it alone", not "clear it". The binder produces
a nil pointer for a null and for a missing key alike, so the service cannot tell
them apart. Sending "" empties a field; there is currently no way to set a
nullable column back to SQL NULL through the API.
Documentation is generated from annotations. Payload schemas are read from
the structs, so they cannot drift — at the cost of nine wrapper types in
apidoc/ that nothing ever constructs, and Swagger 2.0's inability to describe
the cookie half of the auth scheme.
Seeded roles are closed to the API entirely, even for a permissions-only edit. They are looked up by name in code. Changing what one may do means editing the seeder and re-seeding, which is less convenient and much harder to get wrong.
Known gaps, roughly in the order they are worth closing.
- Docker. No
Dockerfileordocker-compose.ymlyet.docker compose upcovering the API, PostgreSQL and Redis is the single biggest convenience left. - Unit tests for the services. Needs an interface on the repositories. Would
make
app/user,app/authandapp/rbactestable without a database. - Setting a column back to NULL. Bind into
json.RawMessage, or a wrapper type that records whether the key was present at all. - Per-account brute-force protection, keyed on the submitted email rather than only on the caller's address.
- An audit trail. Every status change is recorded on the row it happened to, so the current reviewer is known but the history is not. An append-only event table would give the alumnus a timeline and settle disputes.
- Consistent refusal codes. An immutable role answers 403
ERR_INVALID_ACTIONto an update but 400 to a delete, for the same underlying reason. Both are arguably 409. - CI.
make checkis what a pipeline should run; nothing runs it automatically yet. - Caching. The permission catalogue and the role list change rarely and are read on every management page.