Skip to content

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

  1. Look up the workspace's organization_id.
  2. If the caller is ORG_OWNER or ORG_ADMIN of that org → pass, no further check. (This is why the shared dev/local admin@playtelly account, which is ORG_ADMIN on the one platform org, passes every workspace check without needing an explicit workspace_members row.)
  3. Otherwise, look for a direct workspace_members row, or a role inherited via team_workspace_roles + team_members, and check whether that role holds the specific permission (e.g. catalogue.manage) in role_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:

  • TicketGroup does it correctly — every mutation goes through s.ticketGroupRepo.FindByIDInWorkspace(ticketGroupId, workspaceID) (domain/catalogue/services/ticket_group_service.go), which fetches the row scoped to the workspace, not just anywhere.
  • TicketGroupIntegrationConfig does not (yet) — UpdateIntegrationConfig/GetIntegrationConfig/DeleteIntegrationConfig take :ticketGroupId from 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 the FindByIDInWorkspace pattern, 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.