Skip to main content

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

AnnotationEffect
@WorkspaceScopedControllerAdds /workspaces/{workspaceSlug} prefix to all routes
WorkspaceContext parameterAuto-injected with workspace ID, slug, and user's roles
@RequireAtLeastWorkspaceAdminRequires ADMIN or OWNER role
@RequireWorkspaceOwnerRequires OWNER role only

Authorization

Check roles via the context:

if (ctx.hasRole(WorkspaceRole.OWNER)) {
// owner-only logic
}

if (!ctx.hasMembership()) {
// user has no role in this workspace
}

Or use method-level annotations that fail fast with 403:

@DeleteMapping("/{id}")
@RequireWorkspaceOwner
public void delete(WorkspaceContext ctx, @PathVariable Long id) { ... }

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.