Add multi-tenant organization model populated from OIDC claims with org-scoped user discovery, CalDAV principal filtering, and cross-org isolation at the SabreDAV layer. Add bookable resource principals (rooms, equipment) with CalDAV auto-scheduling that handles conflict detection, auto-accept/decline, and org-scoped booking enforcement. Fixes #14. Replace CalendarSubscriptionToken with a unified Channel model supporting CalDAV integration tokens and iCal feed URLs, with encrypted token storage and role-based access control. Fixes #16. Migrate task queue from Celery to Dramatiq with async ICS import, progress tracking, and task status polling endpoint. Replace nginx with Caddy for both the reverse proxy and frontend static serving. Switch frontend package manager from yarn/pnpm to npm and upgrade Node to 24, Next.js to 16, TypeScript to 5.9. Harden security with fail-closed entitlements, RSVP rate limiting and token expiry, CalDAV proxy path validation blocking internal API routes, channel path scope enforcement, and ETag-based conflict prevention. Add frontend pages for resource management and integration channel CRUD, with resource booking in the event modal. Restructure CalDAV paths to /calendars/users/ and /calendars/resources/ with nested principal collections in SabreDAV.
17 KiB
Organizations
How organizations and multi-tenancy work in La Suite Calendars: scoping users, calendars, resources, and permissions by organization.
Table of Contents
- Overview
- What Is an Organization in Calendars?
- Where Organization Context Comes From
- What Is Org-Scoped
- Data Model
- CalDAV and Multi-Tenancy
- User Discovery and Sharing
- Resource Scoping
- Entitlements Integration
- Frontend Considerations
- Implementation Plan
- Key Files
Overview
La Suite Calendars scopes users, calendars, and resources by organization. Every user belongs to exactly one org, determined by their email domain (default) or a configurable OIDC claim. Orgs are created automatically on first login.
The overarching constraint is that CalDAV has no native concept of organizations or tenants. The protocol operates on principals, calendars, and scheduling. Org scoping is layered on top via SabreDAV backend filtering.
What Is an Organization in Calendars?
An organization is a boundary for user and resource visibility. Within an organization:
- Users discover and share calendars with other members
- Resources (meeting rooms, equipment) are visible and bookable
- Admins manage resources and org-level settings
Across organizations:
- Users cannot discover each other (unless explicitly shared with by email)
- Resources are invisible
- Scheduling still works via email (iTIP), just like scheduling with external users
An organization maps to a real-world entity: a company, a government agency, a university department. It is identified by a unique ID -- either a specific OIDC claim value (like a SIRET number in France) or an email domain (the default).
Where Organization Context Comes From
OIDC Claims
The user's organization is identified at authentication time via an OIDC claim. The identity provider (Keycloak) includes an org identifier in the user info response:
{
"sub": "abc-123",
"email": "alice@ministry.gouv.fr",
"siret": "13002526500013"
}
The claim used to identify the org (e.g. siret) is configured via
OIDC_USERINFO_ORGANIZATION_CLAIM. When no claim is configured, the email
domain is used as the org identifier.
The claim names and their meaning depend on the Keycloak configuration and the identity federation in use (AgentConnect for French public sector, ProConnect, etc.).
DeployCenter and Entitlements
The entitlements system already forwards
OIDC claims to DeployCenter. The oidc_claims parameter in the
DeployCenter backend config specifies which claims to include:
{
"oidc_claims": ["siret"]
}
DeployCenter uses the siret claim to determine which organization
the user belongs to and whether they have access to the Calendars
service. It also knows the organization name (e.g. "Ministere
X") and returns it in the entitlements response. This means the
entitlements system is the source of truth for org names --
Calendars does not need a separate OIDC claim for the org name.
What Is Org-Scoped
| Feature | Behavior |
|---|---|
| User discovery (search when sharing) | Same-org users only |
| Calendar sharing suggestions | Same-org users; cross-org by typing full email |
| Resource discovery | Same-org resources only |
| Resource creation | Org admins only (can_admin entitlement) |
| Resource booking | Same-org users only |
| Free/busy lookup | Same-org principals |
Things that are not org-scoped:
- Event scheduling via email: iTIP works across orgs (same as external users)
- Calendar sharing by email: A user can share a calendar with anyone by typing their email address
- CalDAV protocol operations: PUT, GET, PROPFIND on a user's own calendars
- Subscription tokens: Public iCal URLs
Data Model
A lightweight Organization model stores just enough to scope
data. It is auto-populated on login from the OIDC claim (or email
domain) and the entitlements response.
class Organization(BaseModel):
"""Organization model, populated from OIDC claims and entitlements."""
name = models.CharField(max_length=200, blank=True)
external_id = models.CharField(
max_length=128, unique=True, db_index=True
)
class Meta:
db_table = "calendars_organization"
A FK on User links each user to their org:
class User(AbstractBaseUser, ...):
organization = models.ForeignKey(
Organization, on_delete=models.PROTECT, related_name="members"
)
Auto-Population on Login
On OIDC login, post_get_or_create_user() resolves the org. The
org identifier (external_id) comes from the OIDC claim or
email domain. The org name comes from the entitlements response.
# 1. Determine the org identifier
claim_key = settings.OIDC_USERINFO_ORGANIZATION_CLAIM # e.g. "siret"
if claim_key:
reg_id = user_info.get(claim_key)
else:
# Default: derive org from email domain
reg_id = user.email.split("@")[-1] if user.email and "@" in user.email else None
# 2. Get org name from entitlements (looked up from DeployCenter)
org_name = entitlements.get("organization_name", "")
# 3. Create or update the org
if reg_id:
org, created = Organization.objects.get_or_create(
external_id=reg_id,
defaults={"name": org_name}
)
if not created and org_name and org.name != org_name:
org.name = org_name
org.save(update_fields=["name"])
if user.organization_id != org.id:
user.organization = org
user.save(update_fields=["organization"])
By default, the org is derived from the user's email domain
(e.g. alice@ministry.gouv.fr → org ministry.gouv.fr). Orgs
are always created automatically on first login.
OIDC_USERINFO_ORGANIZATION_CLAIM can override this with a specific OIDC
claim (e.g. "siret" for French public sector, "organization_id"
for other identity providers).
The org name is kept in sync: each login updates it from the entitlements response if it has changed. If entitlements are unavailable on login (fail-open), the org is still created from the OIDC claim or email domain, but the name is left empty until a subsequent login succeeds.
Why a Local Model?
- Efficient queries:
User.objects.filter(organization=org)for user search scoping, instead of JSONField queries on claims - Org-level settings: Place to attach resource creation policy, default timezone, branding, etc.
- SabreDAV integration: The org's Django UUID is forwarded to
SabreDAV as
X-CalDAV-Organizationfor principal scoping - Claim-agnostic: The claim name is a setting, not hardcoded
CalDAV and Multi-Tenancy
The Core Problem
CalDAV principals live in a flat namespace: principals/{username}.
When a frontend does a PROPFIND on principals/ or a
principal-property-search, SabreDAV returns all principals.
There is no built-in way to scope results by organization.
The same applies to scheduling: calendar-free-busy-set returns
free/busy for any principal the server knows about.
A design principle is that Django should not inspect or filter
CalDAV traffic. The CalDAVProxyView is a pass-through proxy --
it sets authentication headers and forwards requests, but never
parses CalDAV XML. Org scoping must happen either in SabreDAV
itself or in the frontend.
SabreDAV Principal Backend Filtering
Org scoping is enforced server-side in SabreDAV by filtering
principal queries by org_id. Django never inspects CalDAV
traffic -- it only sets the X-CalDAV-Organization header.
class OrgAwarePrincipalBackend extends AutoCreatePrincipalBackend
{
public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof')
{
$orgId = $this->server->httpRequest->getHeader('X-CalDAV-Organization');
// Add WHERE org_id = $orgId to the query
return parent::searchPrincipals(...) + org filter;
}
}
Implementation:
- Add
org_idcolumn toprincipalstable - Set it when auto-creating principals (from
X-CalDAV-Organization) - Filter discovery and listing methods by org (see below)
CalDAVProxyViewalways setsX-CalDAV-Organizationfrom the authenticated user's org
Which backend methods are org-filtered:
| Method | Filtered? | Why |
|---|---|---|
searchPrincipals() |
Yes | Used for user/resource discovery |
getPrincipalsByPrefix() |
Yes | Used for listing principals |
getPrincipalByPath() |
No | Used for sharing and scheduling with a specific principal — must work cross-org |
| Schedule outbox free/busy | Yes | Aggregates all calendars for a principal — scoped to same-org |
free-busy-query on a specific calendar |
No | If a user has access to a shared calendar, they can query its free/busy regardless of org |
This keeps principal paths stable (principals/{username} -- no
org baked into the URI), enforces scoping at the CalDAV level for
both web and external clients (Apple Calendar, Thunderbird), and
allows cross-org sharing to work when explicitly granted.
User Discovery and Sharing
When a user types an email to share a calendar, the frontend currently searches all users. With orgs:
Same-Org Discovery
The user search endpoint (GET /api/v1.0/users/?q=alice) should
return only users in the same organization by default. This is a
Django-side filter:
# In UserViewSet.get_queryset():
queryset = queryset.filter(organization=request.user.organization)
Cross-Org Sharing
Typing a full email address that doesn't match any same-org user
should still work. The frontend sends the sharing request to CalDAV
with the email address. SabreDAV resolves the recipient via
getPrincipalByPath(), which is not org-filtered -- so
cross-org sharing works. If the recipient is external (not on this
server), an iTIP email is sent.
Once shared, the recipient can see that calendar's events and
query its free/busy (via free-busy-query on the specific
calendar collection), regardless of org. They still cannot
discover the sharer's other calendars or query their aggregate
free/busy via the scheduling outbox.
The UI should make this distinction clear:
- Autocomplete results: same-org users
- Manual email entry: "This user is outside your organization"
CalDAV User Search
The CalDAV principal-property-search REPORT is how external
CalDAV clients discover users. SabreDAV only returns principals
from the user's org.
Resource Scoping
Resource discovery and booking scoping follows the same pattern as user scoping. See docs/resources.md for the full resource design.
Key points for org scoping of resources:
- Resource principals get an org association (same
org_idcolumn on theprincipalstable as user principals) - Resource discovery is scoped to the user's org
- Resource creation requires an org-admin permission
- Resource email addresses follow the convention
{opaque-id}@resource.calendar.{APP_DOMAIN}-- the org is not encoded in the email address (see resources.md for rationale) - No cross-org resource booking -- the auto-schedule plugin rejects invitations from users outside the resource's org
The resource creation permission gate checks the user's
can_admin entitlement, returned by the entitlements system
alongside can_access.
Entitlements Integration
The entitlements system controls whether a user can access Calendars at all. Organizations add a layer on top:
User authenticates
→ Entitlements check: can_access? (DeployCenter, per-user)
→ Org resolution: which org? (OIDC claim or email domain)
→ Org name: from entitlements response
→ Scoping: show only org's users/resources
The entitlements backend already receives OIDC claims (including
siret). DeployCenter resolves the organization and returns the
org name alongside the access decision:
{
"can_access": true,
"can_admin": false,
"organization_name": "Ministere X"
}
On login, post_get_or_create_user() uses the entitlements
response to populate the organization name. The org's
external_id is determined locally (from the OIDC claim or
email domain), but the display name comes from DeployCenter.
This avoids requiring a separate OIDC claim for the org name and
keeps DeployCenter as the single source of truth for org metadata.
Frontend Considerations
Org-Aware UI Elements
- User search/autocomplete: Filter results to same-org users by default; show a "search all users" or "invite by email" option for cross-org
- Resource picker: Only show resources from the user's org
- Calendar list: No change (users only see calendars they own or are shared on)
- No-access page: Already exists (from entitlements). Could show org-specific messaging
- Org switcher: Not needed (a user belongs to exactly one org)
Org Context in Frontend State
The frontend needs to know the user's org ID to:
- Scope user search API calls
- Scope resource PROPFIND requests
- Display org name in the UI
This can come from the GET /users/me/ response:
{
"id": "user-uuid",
"email": "alice@ministry.gouv.fr",
"organization": {
"id": "org-uuid",
"name": "Ministere X"
}
}
Implementation Plan
Phase 1: Org Context Propagation
Goal: Every request knows the user's org.
- Add
OIDC_USERINFO_ORGANIZATION_CLAIMsetting (default:"", uses email domain) - Add
Organizationmodel (id, name, external_id) - Add
organizationFK onUser(non-nullable -- every user has an org) - In
post_get_or_create_user(), resolve org from email domain or OIDC claim and setuser.organization - Expose
organizationinGET /users/me/response - Frontend stores org context from
/users/me/
Phase 2: User Discovery Scoping
Goal: User search returns same-org users by default.
- Scope
UserViewSetqueryset by org when org is set - Frontend user search autocomplete uses scoped endpoint
- Cross-org sharing still works via explicit email entry
- Add
X-CalDAV-Organizationheader toCalDAVProxyViewrequests
Phase 3: CalDAV Scoping
Goal: CalDAV operations are org-scoped.
- Add
org_idcolumn to SabreDAVprincipalstable - Set
org_idwhen auto-creating principals (fromX-CalDAV-Organization) - Extend
AutoCreatePrincipalBackendto filtersearchPrincipals(),getPrincipalsByPrefix(), and free/busy by org - Test with external CalDAV clients (Apple Calendar, Thunderbird)
Phase 4: Resource Scoping
Goal: Resources are org-scoped (depends on resources being implemented -- see docs/resources.md).
- Resource creation endpoint requires org-admin permission
- Resource principals get org association
- Resource discovery is scoped by org
- Resource booking respects org boundaries
Key Files
| Area | Path |
|---|---|
| User model (claims) | src/backend/core/models.py |
| OIDC auth backend | src/backend/core/authentication/backends.py |
| OIDC settings | src/backend/calendars/settings.py |
| CalDAV proxy | src/backend/core/api/viewsets_caldav.py |
| Entitlements system | src/backend/core/entitlements/ |
| User serializer | src/backend/core/api/serializers.py |
| SabreDAV principal backend | src/caldav/src/AutoCreatePrincipalBackend.php |
| SabreDAV server config | src/caldav/server.php |
| Resource scoping details | docs/resources.md |
| Entitlements details | docs/entitlements.md |
Design Decisions
- A user belongs to exactly one org, determined by the OIDC claim at login.
- Cross-org calendar sharing is allowed -- a user can share by email with anyone. Autocomplete only shows same-org users; cross-org sharing requires typing the full email.
- Cross-org resource booking is not allowed -- the auto-schedule plugin rejects invitations from users outside the resource's org.
- Org scoping is enforced in SabreDAV, not in Django. Django
only sets
X-CalDAV-Organizationon proxied requests. - Org is derived from email domain by default. A specific OIDC
claim can be configured via
OIDC_USERINFO_ORGANIZATION_CLAIM(e.g.siretfor French public sector).