owner-scoped rbac: enforcing 'you can only see your own'
a two-tier global-vs-owned permission model in go, and the authorization inconsistency that hides when you scatter the check across layers
two users hit GET /dojos. same route, same handler, same line of sql underneath. the admin gets back every dojo in the database. the dojo owner gets back exactly one — theirs. nobody wrote a second endpoint, nobody passed a flag. the query just quietly narrowed itself on the way down, and the owner never learns the other rows exist.
the first time that worked in mojodojo i felt clever. “you can only see your own” is the whole ballgame in a multi-tenant app, and here it was falling out of the architecture for free. then i went looking for where it happened, and the answer was: two different files, in two different layers, that don’t fully agree with each other. one of them has a hole you could drive a truck through.
this is the rbac post i teased at the end of the boilerplate tax post — the one that ended on “the permission model i just showed you pasting wrong.” same 2024 learning project: mojodojo, a martial-arts gym manager, dojos and fighters and belts, on go 1.22. nothing shipped, no users, just me and a self-imposed rule about the domain never learning how it’s served . the copy-paste bug that post closed on is still in here. it turns out to be a symptom of the bigger thing this post is actually about: where does authorization live, and what happens when you can’t answer that in one sentence.
the two-tier idea: global vs owned
the model has two flavors of every dangerous verb. a global permission — DOJO_GET — means “view any dojo.” an owned permission — DOJO_GET_OWNED — means “view your own dojo.” same verb, two blast radii. an admin holds the global one; a dojo owner holds the owned one.
they’re just string ids, declared as value objects and seeded into postgres so roles can be composed from them:
internal/domain/valueobjects/permission.go
var (
DojoList = "DOJO_LIST"
DojoCreate = "DOJO_CREATE"
DojoDelete = "DOJO_DELETE"
DojoDeleteOwned = "DOJO_DELETE_OWNED"
DojoGet = "DOJO_GET"
DojoGetOwned = "DOJO_GET_OWNED"
DojoUpdate = "DOJO_UPDATE"
DojoUpdateOwned = "DOJO_UPDATE_OWNED"
// ... the same eight-ish for every other entity
)the roles that bundle them are seeded in the first migration — this is the entire authorization model as data, which i genuinely like:
migrations/000001_setup_database.up.sql
INSERT INTO roles VALUES
('FIGHTER', 'Fighter', 'Visualize own statistics'),
('DOJO_OWNER', 'Dojo Owner', 'Manage a dojo'),
('OPERATOR', 'Operator', 'Manage non-dojo related operations'),
('ADMIN', 'Admin', 'Gordon Ryan');
-- admin gets literally everything
INSERT INTO role_permissions (role_id, permission_id)
SELECT 'ADMIN', id FROM permissions;
-- the operator manages any dojo: GLOBAL grants
INSERT INTO role_permissions VALUES
('OPERATOR', 'DOJO_GET'),
('OPERATOR', 'DOJO_UPDATE'),
-- ...
-- the dojo owner manages only theirs: OWNED grants
('DOJO_OWNER', 'DOJO_GET_OWNED'),
('DOJO_OWNER', 'DOJO_UPDATE_OWNED'),
('DOJO_OWNER', 'DOJO_DELETE_OWNED');read the last two blocks side by side and the whole design is right there: the operator gets DOJO_GET, the owner gets DOJO_GET_OWNED, and the difference between “any” and “mine” is one seed row. that’s the good part. now watch it get enforced in two places that don’t quite line up.
the gate at the door
every authenticated route is wrapped in a middleware that checks permissions before the handler runs:
internal/ports/http/permission_middleware.go
func PermissionsMiddleware(
permissions []*valueobjects.Permission,
) gin.HandlerFunc {
return func(ctx *gin.Context) {
session, err := sessionFromContext(ctx)
if err != nil {
// ... 401 and abort
}
hasAccess := session.HasPermissions(permissions)
if !hasAccess {
logger.Error("[PermissionsMiddleware] %v", services.ErrInsufficientPermissions)
ctx.JSON(http.StatusUnauthorized, gin.H{"error": services.ErrInsufficientPermissions.Error()})
ctx.Abort()
return
}
ctx.Next()
}
}the interesting bit is HasPermissions, because it’s an any-of check — the session passes if it holds any one of the listed permissions:
internal/domain/aggregates/session.go
func (entity *Session) HasPermissions(
permissions []*valueobjects.Permission,
) bool {
if len(permissions) < 1 {
return true
}
for _, sessionPermission := range entity.User.Permissions {
for _, currentSessionPermission := range permissions {
if sessionPermission.ID == currentSessionPermission.ID {
return true
}
}
}
return false
}so a route that lists both tiers is saying “global or owned gets you through this door”:
internal/ports/http/server.go
authenticated.GET(
"/dojos/:id",
PermissionsMiddleware(
[]*valueobjects.Permission{
{ID: valueobjects.DojoGet},
{ID: valueobjects.DojoGetOwned},
}),
instance.dojoHandler.Get,
)the operator passes on DojoGet. the owner passes on DojoGetOwned. same door, same handler. the gate deliberately does not decide which rows you get — it only decides whether you’re allowed to knock. that decision lives one layer down, and that split is the whole story.
the narrowing inside
the service is where “your own” actually gets enforced. for a single-row read, it fetches the row and then checks ownership only if you lack the global grant:
internal/application/services/dojo.go
func (svc *DojoService) GetDojo(
dojoID string,
session *aggregates.Session,
) (*entities.Dojo, error) {
dojo, err := svc.dojoRepo.Get(dojoID)
if err != nil {
return nil, err
}
if !session.HasPermissions(DojoAll) {
if dojo.OwnerID != session.User.ID {
logger.Warn("[DojoService] - [GetDojo] "+
"%v tried to access entity without permissions: %v",
session.User.Email, dojoID)
return nil, repositories.ErrDojoGet
}
}
return dojo, nil
}for a list, there’s no per-row loop — the service reaches into the filter and pins the owner id before the query is ever built:
internal/application/services/dojo.go
func (svc *DojoService) ListDojos(
filter *valueobjects.DojoFilter,
session *aggregates.Session,
) ([]*entities.Dojo, error) {
if !session.HasPermissions(DojoAll) {
filter.Owned(session.User.ID)
}
return svc.dojoRepo.List(filter)
}and Owned is about as dumb as it gets — it stamps a WHERE owner_id = ? onto the query by setting one field:
internal/domain/valueobjects/dojo_filter.go
func (f *DojoFilter) Owned(userID string) {
f.OwnerID = userID
}that’s the magic from the cold open. the admin skips the if, so the filter stays wide and the list comes back whole. the owner fails the if, the filter narrows, and the database hands back only their rows. the caller never sees a permission error — the data just quietly shrinks to what they’re allowed to have. no separate “my dojos” endpoint, no if isAdmin in the handler. one branch in the service, and the query does the rest.
so far this reads like a clean two-tier design: coarse gate at the route, fine-grained scoping in the service. and for Dojo it basically works. the problem is that “basically” is doing a lot of load-bearing.
where it falls apart
three cracks, in ascending order of how much they scared me.
one: the two layers don’t even agree on what “global” means. look again at DojoAll — the thing GetDojo and ListDojos check to decide “does this user skip the ownership narrowing?”
internal/application/services/dojo.go
var (
DojoAll = []*valueobjects.Permission{
{ID: valueobjects.ClassGet},
{ID: valueobjects.ClassUpdate},
{ID: valueobjects.ClassDelete},
}
)ClassGet. ClassUpdate. ClassDelete. in the dojo service. this is the exact copy-paste bug the boilerplate tax post
closed on — i built the file from class.go, renamed the variables, and never touched the contents. so the route gate asks “do you have DojoGet?” and the service, one layer down, asks “do you have ClassGet?” the two layers that are supposed to jointly enforce dojo access are keyed off different permissions. it happens to produce correct-looking results for the seed roles — the admin has both, the owner has neither — which is exactly why it survived. it’s not a crash, it’s a coincidence, and coincidences rot the moment someone adds a role.
two: create doesn’t take a session at all. every other verb threads the caller’s session down so the service can scope. create doesn’t:
internal/application/services/dojo.go
func (svc *DojoService) CreateDojo(
dojo *entities.Dojo,
) (*entities.Dojo, error) {
return svc.dojoRepo.Create(dojo)
}no session parameter, no check. the payload carries its own OwnerID, and the service writes whatever it’s handed. authorization for create lives entirely at the route gate — which means the service layer, the one i just spent two sections calling the source of truth, has a verb where it enforces nothing. the trust boundary moved and i didn’t write it down anywhere.
three: two verbs accept the session and then ignore it. this is the one that actually made me sit up. the Fee, Class, and Practice services all guard update/delete by calling their own Get first, which runs the ownership check. Graduation takes the same session argument, with the same signature — and drops it on the floor:
internal/application/services/graduation.go
func (svc *GraduationService) UpdateGraduation(
graduationID string,
graduation *aggregates.Graduation,
session *aggregates.Session,
) (*aggregates.Graduation, error) {
return svc.graduationRepo.Update(graduationID, graduation)
}
func (svc *GraduationService) DeleteGraduation(
graduationID string,
session *aggregates.Session,
) error {
return svc.graduationRepo.Delete(graduationID)
}compare that to how the same project’s fee service does it:
internal/application/services/fee.go
func (svc *FeeService) DeleteFee(
feeID string,
session *aggregates.Session,
) error {
_, err := svc.GetFee(feeID, session)
if err != nil {
return repositories.ErrFeeDelete
}
return svc.feeRepo.Delete(feeID)
}the graduation version has the session right there in its hand and never asks it a single question. walk it through: a dojo owner holds GRADUATION_UPDATE_OWNED, the route gate lists [GraduationUpdate, GraduationUpdateOwned], and HasPermissions is any-of, so the owned grant gets them through the door — and then the service does zero ownership scoping. result: any authenticated dojo owner can update or delete anyone’s graduation by id. that’s not a narrowing that silently failed open — it’s a textbook broken object level authorization ↗
hole, OWASP’s #1 api risk, and it exists purely because one file in a fleet of near-identical files skipped the two lines its siblings all have. the compiler was thrilled: the session was passed, the types matched, nothing was unused enough to warn about. one file, two missing lines, a full privilege escalation — and that’s the shit that never shows up in review, because the diff looks exactly like the safe file sitting next to it.
and a smaller fourth crack while we’re being honest: /metrics and /swagger sit inside the authenticated group but behind no permission middleware at all —
internal/ports/http/server.go
authenticated.GET("/metrics", gin.WrapH(promhttp.Handler()))
authenticated.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))— so the lowest-privilege fighter in the system can read prometheus internals and the full api surface. auth without authz. it’s not nothing, and it’s the same root cause: i decided per-route, by hand, sixty-something times, whether to bother.
the actual question: where does authz belong
here’s the thing the graduation hole taught me. i didn’t have one authorization decision with a bug in it. i had authorization smeared across two layers, each doing half the job, with no written contract about which half.
- the route gate does coarse admission — any-of over a permission list — and explicitly punts on scoping.
- the service does the scoping — global-vs-owned narrowing — and assumes the gate already happened.
that only adds up to a correct check when both halves fire. CreateDojo skips the service half. UpdateGraduation skips it too, but keeps the signature that says it didn’t, which is worse, because the call site looks identical to the safe ones. the gate can’t save you, because the gate’s whole job was to let owned-permission holders in so the service could sort out ownership. when the service forgets, the door’s already open.
owasp’s authorization cheat sheet ↗ says the quiet part plainly: “permission should be validated correctly on every request” and “even if just a single access control check is ‘missed’, the confidentiality and/or integrity of a resource can be jeopardized.” one missed check. i missed it in exactly one file out of fourteen and that was enough. the cheat sheet’s fix is the lesson i should’ve started with: enforce access control server-side, at a single trusted layer, applied “global, application-wide” rather than “individually.”
my model wasn’t wrong to be two-tier — global-vs-owned is a genuinely good shape, and the silent narrowing is the right behavior. the bug is that i implemented the enforcement of that model twice, by hand, per verb, with copy-paste, in two layers that could disagree. the two-tier permission is the design. the two-layer enforcement is the mistake.
lessons learned
- pick one layer and make it the authority. for a service like this, that’s the service layer — the thing holding the row and the session in the same function, the only place that can answer “can this user touch this object.” the route gate is fine as a cheap early-out, but it must not be load-bearing. the moment i let it be load-bearing for create, i had a verb with no real check.
- an unused
sessionargument is a code smell that the linter can’t see.UpdateGraduationtook the session and ignored it, and nothing flagged it because it is used — it’s passed, it’s just never questioned. if authz went through one enforcement helper that every verb had to call, “forgot to check” would be “forgot to call a function,” which the compiler and reviewer both catch. - any-of admission plus deferred scoping is a two-part harness, and two-part harnesses fail at the part you forgot. the gate lets owned-permission holders through on the promise that the service will scope them, so a service that doesn’t scope is a hole by construction, not by accident.
- the copy-paste bug and the authz bug are the same bug.
DojoAll = ClassGetandUpdateGraduationignoring its session both come from fourteen near-identical files nobody re-reads. scattering security logic across a copy-paste fleet is how you get a privilege check quietly guarding the wrong thing.
next up: i want mojodojo to stop trusting the payload’s OwnerID on create — the server should stamp ownership from the session, never the request body, so a user can’t create a resource owned by someone else. which is really a post about never letting the client tell you who it is.
it’s a learning project — warts, a frozen ClassGet, and one very real idor and all — which, around here, is the point.