Skip to content
 
 

Latest commit

 

History

12 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

api-alumni

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


The problem

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.

Who uses it

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 verification workflow

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/me saves 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.

Features

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.


Tech stack

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

Architecture

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.


Database design

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_profiles is its own table, not columns on users. 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_id is 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_number is UNIQUE, and PostgreSQL treats NULLs as distinct, so unfinished drafts do not collide with each other.
  • reviewed_by is ON DELETE SET NULL, not CASCADE. The reviewer is an audit trail; deleting a staff account must not delete the decisions they made.
  • status is a VARCHAR, 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 naive timestamp drops the offset the driver sends and reads back shifted whenever DB_TIMEZONE is not the server's own.
  • permissions.group is a reserved word. GORM quotes identifiers itself, but hand-written SQL does not — see the migration and PermissionRepository.GetAll.

Installation

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.

Environment configuration

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

Migrations

make migrate          # apply
make migrate-status   # current version
make migrate-down     # roll everything back
make seed             # permissions, then roles, then users

Seeders 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:

email 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

Running

make run     # go run .
make help    # every target

Testing

make 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 -race

Two layers:

  • Unit testsenums, 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.


API documentation

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 annotations

To try a protected endpoint: run POST /api/v1/auth/login, copy the access_token, and paste it into Authorize as the bearer token.

The response envelope

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.


Technical decisions

Two 403s, because they call for opposite handling

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.

400 means "I could not read it", 422 means "I read it and no"

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.

CSRF is scoped to the one group that needs it

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.

Refresh rotation retires the pair it replaces

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.

Rate limiting in Redis, not in memory

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.

Repositories translate their own errors

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.

err.Error() never reaches the client

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.


Trade-offs

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.


Future improvement

Known gaps, roughly in the order they are worth closing.

  • Docker. No Dockerfile or docker-compose.yml yet. docker compose up covering 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/auth and app/rbac testable 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_ACTION to an update but 400 to a delete, for the same underlying reason. Both are arguably 409.
  • CI. make check is 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages