# Organizations How organizations and multi-tenancy work in La Suite Calendars: scoping users, calendars, resources, and permissions by organization. ## Table of Contents - [Overview](#overview) - [What Is an Organization in Calendars?](#what-is-an-organization-in-calendars) - [Where Organization Context Comes From](#where-organization-context-comes-from) - [OIDC Claims](#oidc-claims) - [DeployCenter and Entitlements](#deploycenter-and-entitlements) - [What Is Org-Scoped](#what-is-org-scoped) - [Data Model](#data-model) - [Auto-Population on Login](#auto-population-on-login) - [Why a Local Model?](#why-a-local-model) - [CalDAV and Multi-Tenancy](#caldav-and-multi-tenancy) - [The Core Problem](#the-core-problem) - [SabreDAV Principal Backend Filtering](#sabredav-principal-backend-filtering) - [User Discovery and Sharing](#user-discovery-and-sharing) - [Resource Scoping](#resource-scoping) - [Entitlements Integration](#entitlements-integration) - [Frontend Considerations](#frontend-considerations) - [Implementation Plan](#implementation-plan) - [Phase 1: Org Context Propagation](#phase-1-org-context-propagation) - [Phase 2: User Discovery Scoping](#phase-2-user-discovery-scoping) - [Phase 3: CalDAV Scoping](#phase-3-caldav-scoping) - [Phase 4: Resource Scoping](#phase-4-resource-scoping) - [Key Files](#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: ```json { "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](entitlements.md) already forwards OIDC claims to DeployCenter. The `oidc_claims` parameter in the DeployCenter backend config specifies which claims to include: ```json { "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. ```python 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: ```python 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. ```python # 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. ```php 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: ```python # 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](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](entitlements.md) 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: ```json { "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: ```json { "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](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).