Files
calendars/docs/organizations.md
Sylvain Zimmer 9c18f96090 (all) add organizations, resources, channels, and infra migration (#34)
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.
2026-03-09 09:09:34 +01:00

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

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-Organization for 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:

  1. Add org_id column to principals table
  2. Set it when auto-creating principals (from X-CalDAV-Organization)
  3. Filter discovery and listing methods by org (see below)
  4. CalDAVProxyView always sets X-CalDAV-Organization from 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"

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:

  1. Resource principals get an org association (same org_id column on the principals table as user principals)
  2. Resource discovery is scoped to the user's org
  3. Resource creation requires an org-admin permission
  4. 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)
  5. 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.

  1. Add OIDC_USERINFO_ORGANIZATION_CLAIM setting (default: "", uses email domain)
  2. Add Organization model (id, name, external_id)
  3. Add organization FK on User (non-nullable -- every user has an org)
  4. In post_get_or_create_user(), resolve org from email domain or OIDC claim and set user.organization
  5. Expose organization in GET /users/me/ response
  6. Frontend stores org context from /users/me/

Phase 2: User Discovery Scoping

Goal: User search returns same-org users by default.

  1. Scope UserViewSet queryset by org when org is set
  2. Frontend user search autocomplete uses scoped endpoint
  3. Cross-org sharing still works via explicit email entry
  4. Add X-CalDAV-Organization header to CalDAVProxyView requests

Phase 3: CalDAV Scoping

Goal: CalDAV operations are org-scoped.

  1. Add org_id column to SabreDAV principals table
  2. Set org_id when auto-creating principals (from X-CalDAV-Organization)
  3. Extend AutoCreatePrincipalBackend to filter searchPrincipals(), getPrincipalsByPrefix(), and free/busy by org
  4. 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).

  1. Resource creation endpoint requires org-admin permission
  2. Resource principals get org association
  3. Resource discovery is scoped by org
  4. 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

  1. A user belongs to exactly one org, determined by the OIDC claim at login.
  2. 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.
  3. Cross-org resource booking is not allowed -- the auto-schedule plugin rejects invitations from users outside the resource's org.
  4. Org scoping is enforced in SabreDAV, not in Django. Django only sets X-CalDAV-Organization on proxied requests.
  5. Org is derived from email domain by default. A specific OIDC claim can be configured via OIDC_USERINFO_ORGANIZATION_CLAIM (e.g. siret for French public sector).