Module: organizations¶
Purpose¶
Multi-tenancy layer. Each project, user, API key, webhook, integration, and audit event belongs to an organization. OrganizationContextFilter extracts the orgId from the JWT Bearer token on every request and stores it in OrganizationContextHolder (ThreadLocal). All per-org resource queries must filter by OrganizationContextHolder.get(). SSO configs (OIDC) are stored per-installation with AES-GCM encrypted client secrets.
Entities Owned¶
| Entity | Table | Key Fields |
|---|---|---|
Organization |
organizations |
name VARCHAR(100), slug VARCHAR(50) UNIQUE, extends AuditableEntity (id, createdAt, updatedAt) |
OrganizationMember |
organization_members |
composite PK OrganizationMemberId(orgId UUID, userId UUID), role: OrgRole |
OrgRole values: OWNER, ADMIN, MEMBER.
sso_configs (V18) is logically coupled to the org SSO feature. Key columns: issuerUrl, clientId, clientSecretEnc (AES-GCM ciphertext), enabled, autoProvision.
V19 added org_id UUID REFERENCES organizations(id) to: users, projects, api_keys, webhooks, project_integrations, audit_events. All existing rows were backfilled to a seed "Default" org on migration.
DB Schema¶
sso_configs (V18)¶
CREATE TABLE sso_configs (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
name VARCHAR(100) NOT NULL,
issuer_url VARCHAR(500) NOT NULL,
client_id VARCHAR(255) NOT NULL,
client_secret_enc VARCHAR(500) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
auto_provision BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
organizations, organization_members (V19)¶
CREATE TABLE organizations (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE organization_members (
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
PRIMARY KEY (org_id, user_id)
);
slug must match ^[a-z0-9-]+$; enforced by @Pattern on CreateOrganizationRequest.
API Endpoints¶
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/v1/organizations |
ADMIN | List all organizations |
POST |
/api/v1/organizations |
ADMIN | Create org; body: {"name":"...","slug":"..."} (slug regex ^[a-z0-9-]+$); creator added as OWNER; returns 201 |
GET |
/api/v1/organizations/mine |
USER | List orgs the authenticated user belongs to |
GET |
/api/v1/organizations/{id} |
USER | Get org by UUID; calls requireMembershipOrAdmin() |
GET |
/api/v1/organizations/{id}/members |
USER | List members; calls requireMembershipOrAdmin() |
POST |
/api/v1/organizations/{id}/members |
ADMIN | Add member; body: {"userId":"...","role":"MEMBER"} |
DELETE |
/api/v1/organizations/{id}/members/{userId} |
ADMIN | Remove member; returns 204 |
requireMembershipOrAdmin(orgId, user) in OrganizationService permits SystemRole.ADMIN unconditionally; for other users it loads all org members and checks if user.id appears in the list, throwing AccessDeniedException if not.
Events Emitted¶
None. The organizations module does not publish domain events.
Events Consumed¶
None. Organization context is established per-request by OrganizationContextFilter, not via events.
Key Files¶
backend/src/main/kotlin/com/taskowolf/organizations/domain/Organization.ktbackend/src/main/kotlin/com/taskowolf/organizations/domain/OrganizationMember.ktbackend/src/main/kotlin/com/taskowolf/organizations/domain/OrganizationMemberId.ktbackend/src/main/kotlin/com/taskowolf/organizations/domain/OrgRole.ktbackend/src/main/kotlin/com/taskowolf/organizations/application/OrganizationService.ktbackend/src/main/kotlin/com/taskowolf/organizations/infrastructure/OrganizationContextHolder.ktbackend/src/main/kotlin/com/taskowolf/organizations/infrastructure/OrganizationContextFilter.ktbackend/src/main/kotlin/com/taskowolf/organizations/infrastructure/OrganizationRepository.ktbackend/src/main/kotlin/com/taskowolf/organizations/infrastructure/OrganizationMemberRepository.ktbackend/src/main/kotlin/com/taskowolf/organizations/api/OrganizationController.ktbackend/src/main/resources/db/migration/V18__sso_configs.sqlbackend/src/main/resources/db/migration/V19__organizations.sql
Extension Points¶
- To scope a new resource to an organization: add an
org_id UUID REFERENCES organizations(id)column in a new migration (V23+); filter all repository queries byOrganizationContextHolder.get(). - To add org-aware JWT claims: update
JwtServiceto embedorgIdwhen issuing tokens;OrganizationContextFilterwill propagate the claim automatically because it readsorgIdviajwtService.extractOrgId(token). - To add a new org role: add the value to
OrgRole; no migration required (stored as VARCHAR). UpdaterequireMembershipOrAdmin()if the new role requires different access semantics.
Common Pitfalls¶
OrganizationContextHolderusesThreadLocal. It is cleared in thefinallyblock ofOrganizationContextFilterafter every request. Never cache the result ofOrganizationContextHolder.get()outside the current request thread (e.g., in a@Componentfield, a coroutine, or an@Asyncmethod). Reading it from a different thread returnsnull.- SSO client secrets are AES-GCM encrypted at rest. The
client_secret_enccolumn stores ciphertext only. Never write a plaintext OIDC client secret tosso_configs; decryption is handled by the auth module before use. GET /orgs/{id}membership check is O(n). The endpoint correctly callsrequireMembershipOrAdmin()— membership enforcement is intentional and correct. However, the check loads all org members into memory viafindByIdOrgId(orgId)and scans the list in-process; there is no index onuser_idalone inorganization_members. For organizations with many members, this creates a performance risk on every authenticated request to this endpoint. Add afindByIdOrgIdAndIdUserIdderived query if per-user lookup frequency becomes a bottleneck.org_idcolumns are nullable. V19 backfilled existing rows to the default org but did not add NOT NULL constraints. New queries must handleorg_id IS NULLrows or explicitly filter them.OrganizationContextHolder.get()returnsnullfor unauthenticated requests.OrganizationContextFilteronly callsset()when aBearertoken is present. Public endpoints receivenull; guard against it before dereferencing.
Example¶
Reading the current org from OrganizationContextHolder inside a service:
@Service
class ProjectService(
private val projectRepository: ProjectRepository
) {
@Transactional(readOnly = true)
fun listForCurrentOrg(): List<Project> {
val orgId = OrganizationContextHolder.get()
?: error("No organization context — endpoint requires authentication")
return projectRepository.findByOrgId(orgId)
}
}
OrganizationContextFilter runs at @Order(5), before Spring Security's authentication filter processes the JWT, so orgId is available for the full duration of the request.
Test Patterns¶
OrganizationServiceTest— pure unit test with MockK. Verifies:create()saves the org then saves the creator asOWNER;listOrgsForUser()returns empty list when user has no memberships;addMember()persists the correct role;removeMember()callsdeleteByIdwith the composite key;requireMembershipOrAdmin()permitsSystemRole.ADMINwithout loading members; throwsAccessDeniedExceptionfor a non-member withSystemRole.MEMBER.OrganizationContextFilterTest— usesMockHttpServletRequestandMockHttpServletResponse; mocksJwtService. Verifies that aBearer tokenheader causesOrganizationContextHolder.get()to return the UUID extracted byjwtService.extractOrgId()duringdoFilter. Captures the value inside the filter chain lambda.