Middleware
Partly outdated (audited 2026-09-24)
HasRole / HasAnyRole no longer exist. Current middleware (ProtectedRS256, RequireOrgPermission, RequireWorkspacePermission, RequirePlatformPermission, CheckBlocklist, RequireInternalToken) is documented in the linked section. The permission ≠ ownership warning below is still true. See Auth, Identity & RBAC — handover guide for the verified picture.
Rewritten 2026-09-16 — the previous version of this page only described the legacy scheme
CoreAPI runs two different auth schemes side by side, not one. The
previous version of this page documented only middleware.Protected +
HasRole/HasAnyRole (HS256), which is now customer-facing-only.
Every admin route — including everything the ticketing admin UI
(SpatioViewAdmin) and its integration-config screens call — moved to
RS256 + workspace permissions during the Aug 19-21 auth migration
(01f0be6/fde57fd). Verified directly against
pkg/middleware/auth_middleware.go and domain/catalogue/routes/routes.go.
Middleware lives in pkg/middleware and is applied per route in each
domain's routes/routes.go.
Scheme 1 — legacy HS256 (Protected, HasRole/HasAnyRole)
Still real, still in use — but only for customer-facing ticketing routes:
/auth/logout, /auth/validate, /api/customer/profile,
/api/customer/password, /api/orderTicketGroups, and similar.
// pkg/middleware/auth_middleware.go
middleware.Protected(jwtService) // validates JWT, rejects if missing or invalid
middleware.HasRole("ADMIN") // exact role match
middleware.HasAnyRole("ADMIN", "MEMBER") // matches any of the listed roles
Claims are set on c.Locals after Protected runs:
userID := c.Locals("userId").(string)
role := c.Locals("role").(string)
userType := c.Locals("userType").(string)
Do not use this scheme for new admin functionality. It predates the platform's org/workspace RBAC and has no concept of either.
Scheme 2 — RS256 + workspace permissions (every admin route today)
This is what SpatioViewAdmin, the integration-config screens, and every other ticketing/catalogue admin mutation actually run through.
// pkg/middleware/auth_middleware.go
auth := middleware.ProtectedRS256(rs256Validator) // validates the AuthAPI-issued RS256 JWT
// Per-request workspace, read from the :workspaceId URL param — the
// current, correct pattern (ticketGroup/tags/banners/railMenus admin routes):
requireX := middleware.RequireWorkspacePermission(coreDB, permissions.WorkspaceCatalogueManage)
// One hardcoded workspace constant — the OLDER pattern, still used by a
// few route groups that haven't been migrated to per-request scoping yet:
requireX := middleware.RequireWorkspacePermissionFixed(coreDB, spatioWorkspaceID, permissions.WorkspaceCatalogueManage)
Registered per route, in order — auth must come before the permission
check, which reads c.Locals("authClaims") that auth sets:
// domain/catalogue/routes/routes.go
ticketGroupAdmin := ticketGroup.Group("/:workspaceId")
ticketGroupAdmin.Post("/", auth, requireTicketGroupWorkspacePermission, legacyLocals, ticketGroupHandler.CreateTicketGroup)
What RequireWorkspacePermission actually checks
WorkspaceHasPermission (pkg/middleware/auth_middleware.go:195):
- Look up the workspace's
organization_id. - If the caller is
ORG_OWNERorORG_ADMINof that org → pass, no further check. (This is why the shared dev/localadmin@playtellyaccount, which isORG_ADMINon the one platform org, passes every workspace check without needing an explicitworkspace_membersrow.) - Otherwise, look for a direct
workspace_membersrow, or a role inherited viateam_workspace_roles+team_members, and check whether that role holds the specific permission (e.g.catalogue.manage) inrole_permissions.
PopulateLegacyLocals
A bridge, not a security check — it re-populates the old userId/userType/fullName/role locals (for audit strings like CreatedBy) so legacy handler code that predates the RS256 migration keeps compiling and working unchanged. Register it after the permission check, never as a substitute for one.
Known gap — permission check ≠ resource-ownership check
RequireWorkspacePermission only proves "the caller has this permission in the workspace named in the URL." It does not, by itself, prove that the specific resource being mutated (e.g. :ticketGroupId) actually belongs to that workspace. Two examples in the same codebase, doing this differently:
TicketGroupdoes it correctly — every mutation goes throughs.ticketGroupRepo.FindByIDInWorkspace(ticketGroupId, workspaceID)(domain/catalogue/services/ticket_group_service.go), which fetches the row scoped to the workspace, not just anywhere.TicketGroupIntegrationConfigdoes not (yet) —UpdateIntegrationConfig/GetIntegrationConfig/DeleteIntegrationConfigtake:ticketGroupIdfrom the URL and never check it against:workspaceId. With one workspace in production use today this is invisible; the moment a second tenant has its own ticket groups, this is a real cross-tenant read/write. If you're adding a new admin mutation, copy theFindByIDInWorkspacepattern, not the integration-config one.
Adding a new admin route today
Use Scheme 2, not Scheme 1:
// routes.go
requireYourPermission := middleware.RequireWorkspacePermission(coreDB, permissions.WorkspaceYourPermission)
yourAdmin := yourGroup.Group("/:workspaceId")
yourAdmin.Post("/", auth, requireYourPermission, yourHandler.Create)
// service.go — fetch scoped to the workspace, don't trust the ID alone
row, err := s.repo.FindByIDInWorkspace(id, workspaceID)
Tips
Not all routes need middleware — public read endpoints often don't (see /api/ticketGroups's customer-facing GETs, which run with no auth at all).
Order matters — the auth middleware must come before the permission check; the permission check must come before PopulateLegacyLocals.
Two schemes, one codebase. When reading an unfamiliar route, check which one it's actually wired to before assuming — middleware.Protected vs middleware.ProtectedRS256 look similar at a glance but mean very different things.