Skip to content

Open Routes, Guarded Routes: Where the Permission Seed Goes

Adityo Guni Waluyo

Admin routes return 403 for every role except superadmin because the permission rows were never seeded. The guard is only the enforcer.

TL;DR

Admin accounts got 403 errors due to missing permission rows. The permission middleware checked and denied access. Adding permission rows fixed the issue.

An organization admin opened the dashboard, clicked the Organizations menu, and got 403. The account held the admin role, not superadmin, and its token was still valid. The route GET /admin/organizations was registered, and the middleware guard was mounted. What was missing was not code.

A check against the permission table explained everything: the organisasi:read and organisasi:write rows had never existed. The middleware checked, found nothing, and denied. Superadmin passed because the superadmin path bypasses the row check, not because the rows are there.

Guards Installed Are Not Guards That Work

The permission middleware in chi is an ordinary function mounted on a route group. It reads the role from the request context, loads the permission list, and compares. When the list is empty, any comparison result is deny.

The two-layer route split hides this failure during development. The public GET /organizations route is deliberately left open without a guard so the catalog can be read anonymously. The admin route uses a separate group with a guard. Both share similar handlers, so the only difference lives in the route group definition.

A consistent 403 on every admin route while public routes work normally points at the guard, not the handler or the database connection. The status difference between superadmin and admin accounts narrows the suspicion to the permission list.

r.Route("/organizations", func(r chi.Router) {
    r.Get("/", listOrganizations)
    r.Get("/{id}", getOrganization)
})

r.Route("/admin/organizations", func(r chi.Router) {
    r.Use(requirePermission("organisasi:read", "organisasi:write"))
    r.Get("/", listOrganizationsAdmin)
    r.Post("/", createOrganization)
    r.Put("/{id}", updateOrganization)
    r.Delete("/{id}", deleteOrganization)
})

Permissions as Data, Not Code Configuration

The common mistake is treating the middleware guard as the source of truth for permissions. The guard is only the enforcer; the permission list lives in the database and changes through migrations. When the migration that seeds permission rows lags behind, nothing fails at build time. The compiler passes, the handler unit tests pass, and the runtime path still denies every non-superadmin request [1].

OWASP places authorization as a decision that must be complete on every request rather than assumed from existing configuration [1]. The practice that closes this gap: treat permissions as part of the schema, migrate them alongside the role table, and test the negative path, meaning the role that should be allowed but is not yet covered.

The difference between a working route and a failing one can be checked with a single query against the permission table. If that query returns zero rows for the admin role, the problem is neither in the handler nor in the middleware.

A faster way to find it: test with an admin role, not only superadmin, in CI. Superadmin passes the permission check, so a suite that only exercises superadmin stays green even when the permission table is empty. The reverse also holds. A guard mounted on the right route group but reading the wrong permission name can open access wider, not narrower.

Seed Migration and Down Migration Hygiene

The fix taken was migration 000084, which seeds organisasi:read and organisasi:write for the admin and editor roles. The up migration inserts two rows per role; the down migration removes them again. Tools like golang-migrate run both sequentially and track the schema version in a dedicated table [2].

Idempotency matters here. If the seed runs again, for example after a rollback and another up, an unguarded insert collides with the unique constraint or duplicates rows. The safe pattern is INSERT ... ON CONFLICT DO NOTHING, or checking for the rows before inserting.

A clean down migration matters because test environments are often reset. If the down does not remove the seeded rows, a schema reset leaves stale data behind and tests may pass or fail falsely.

chi provides the route group pattern that makes the public and admin split explicit in one place [3]. Public and guarded routes sit in different blocks, so adding a new permission only touches one group. chi does not enforce the contents of the permission table, though; that stays the responsibility of migrations and tests.

Verification after the seed: call the admin endpoint again with a regular admin account, not superadmin. If the response changes from 403 to 200, the seed ran the way it should.

The decision for the next module: every time a new admin route is added, the seed migration for its permission ships in the same commit, not a separate one. If the guard and the permission data live in two places, they will drift sooner or later, and the one who discovers the drift is a user, not a test.

Sources:

Related articles