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

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.