Skip to main content

Workspace-Scoped Endpoints

Scope every API endpoint that operates on workspace data 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);
}
}

The filter resolves the slug and checks workspace access. Controllers and repository queries must still scope their operations to the supplied workspace.

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​

Instance admins can access active workspaces as ADMIN without a membership or an SCM identity. Elevation does not grant OWNER. An explicit OWNER membership still permits owner-only operations. Elevation does not make them a member either.

Declare member-only features, such as Hephaestus, with @PreAuthorize("@workspaceSecure.isMemberWithoutElevation()"). A user view still satisfies this check as the viewed member.

@PreAuthorize("@workspaceSecure.isMember()") admits any effective role, including elevation, but never anonymous access. The Activity endpoints use this check. The dedicated public activity controller does not use this context. It resolves publication eligibility and public scope separately. No publication setting opens member endpoints to non-members. The privilege and audit policy lives in Instance administration.

Check roles through the context:

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

if (!ctx.hasMembership()) {
// no effective workspace role
}

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) { ... }

Where membership rows come from​

Workspace administration or a complete provider access grant creates account membership. Activity and profile paths read membership without granting access. Provider adapters retain exact instance, native identity and team keys. A complete roster can provision an UNCLAIMED human account before sign-in. Unaccepted invitations and bots do not become human principals.

The GitLab adapter retains effective direct, inherited and shared grants. Team graphs and repository collaborators use their own supported access scope. A failed or incomplete listing cannot remove a previous complete grant. A source mirror upsert does not itself grant a workspace role.

Membership joins workspace and account, with role, hidden preference and review selection. Logical removal sets leftAt rather than deleting the row. A former member loses authorization while historical identity remains recorded. Assigning a role reactivates the membership after the normal admin and last-owner checks. The hidden preference remains on that membership.

Account identity resolution​

The authenticated account is the workspace principal. A linked provider login identifies source work but does not grant membership. Provider usernames and display names cannot select another principal. An account can hold membership without an SCM mirror. The founder receives explicit membership, not first-identity elevation.

Sign-in claims the exact roster-created account. Only an unclaimed account can be absorbed by a verified identity-link flow. Overlapping memberships preserve existing authority and scope selections for that same person. Disabled identities cannot regain authority through roster sync.

Results and feedback identify the native account, not a chosen representative SCM user. AI choice and external-feedback settings belong to that account across workspaces. SCM actor mirrors retain their provider attribution and activity facts. No first-linked provider preference chooses the developer's results.

Slack and Outline require their exact provider/team identity plus membership in the target workspace. Active account and identity authority remain separate checks for outgoing conversation access. An elevated instance admin without membership does not inherit a developer identity from a display login. A view-as read keeps its audited actor and does not prove developer feedback delivery.

The account decision owns the identity model. The data map owns erasure and restrictions.

Async Work​

WorkspaceContextExecutor.wrap captures the workspace context and MDC for a background task. It does not propagate Spring authentication or CurrentScmIdentityHolder. Tasks that need the authenticated actor must carry that identity separately.

A conversation turn needs no request identity. Its attempt token (AttemptJwt) names the turn. The worker that claimed the turn knows its conversation and the account that owns the conversation. The worker builds the turn's context for that account. The server finds a conversation by account, never by an SCM identity (Conversation runtime).

For work that needs only workspace context:

CompletableFuture.runAsync(
WorkspaceContextExecutor.wrap(() -> heavyProcessing(ctx)),
executor
);

Global Routes​

Routes that do not 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.