Workspace-Scoped Endpoints
Every API endpoint that operates on workspace data must be scoped to a workspace slug. This keeps tenants isolated and enables per-workspace authorization.
Quick Start
@WorkspaceScopedController // auto-prefixes routes with /workspaces/{workspaceSlug}
@RequestMapping("/teams") // becomes /workspaces/{slug}/teams
@RequiredArgsConstructor
public class TeamController {
private final TeamService teamService;
@GetMapping
public List<TeamDTO> list(WorkspaceContext ctx) { // injected by framework
return teamService.findByWorkspace(ctx.id());
}
@PostMapping
@RequireAtLeastWorkspaceAdmin // only ADMIN or OWNER can create
public TeamDTO create(WorkspaceContext ctx, @RequestBody CreateTeamDTO dto) {
return teamService.create(ctx.id(), dto);
}
}
That's it. The filter handles slug resolution, membership checks, and 403/404 responses.
What You Get
| Annotation | Effect |
|---|---|
@WorkspaceScopedController | Adds /workspaces/{workspaceSlug} prefix to all routes |
WorkspaceContext parameter | Auto-injected with workspace ID, slug, and user's roles |
@RequireAtLeastWorkspaceAdmin | Requires ADMIN or OWNER role |
@RequireWorkspaceOwner | Requires OWNER role only |
Authorization
Super admins (users with the Keycloak admin realm role, configured via KEYCLOAK_GITHUB_ADMIN_USERNAME) are automatically elevated to workspace ADMIN level for workspaces where they have membership. They must be members of the workspace (any role) to access it, but their permissions are elevated to ADMIN. They cannot perform OWNER-only operations (ownership transfer remains explicit).
Check roles via the context:
if (ctx.hasRole(WorkspaceRole.OWNER)) {
// owner-only logic
}
if (!ctx.hasMembership()) {
// user has no role in this workspace
// Note: super admins still need workspace membership
}
Or use method-level annotations that fail fast with 403:
@DeleteMapping("/{id}")
@RequireWorkspaceOwner
public void delete(WorkspaceContext ctx, @PathVariable Long id) { ... }
@PatchMapping("/settings")
@RequireAtLeastWorkspaceAdmin // Also allows super admins
public void updateSettings(WorkspaceContext ctx, @RequestBody SettingsDTO dto) { ... }
Async Work
When spawning background tasks, wrap the runnable to preserve context:
CompletableFuture.runAsync(
WorkspaceContextExecutor.wrap(() -> heavyProcessing(ctx)),
executor
);
Global Routes
Routes that don't belong to a specific workspace (like /workspaces listing or creation) stay in a plain @RestController:
@RestController
@RequestMapping("/workspaces")
public class WorkspaceRegistryController {
@PostMapping
public WorkspaceDTO create(@RequestBody CreateWorkspaceDTO dto) { ... }
@GetMapping
public List<WorkspaceListItemDTO> list() { ... }
}
Testing
Integration tests must create a workspace and grant membership before hitting scoped endpoints:
@Test
@WithMentorUser
void canListTeams() {
User user = persistUser("mentor");
Workspace ws = createWorkspace("test-ws", "Test", "org", AccountType.ORG, user);
addMembership(ws, user, WorkspaceRole.ADMIN);
webTestClient.get()
.uri("/workspaces/{slug}/teams", ws.getWorkspaceSlug())
.headers(TestAuthUtils.withCurrentUser())
.exchange()
.expectStatus().isOk();
}
See AbstractWorkspaceIntegrationTest for helper methods.