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.
48 KiB
Calendar Resources
This document describes the design and implementation plan for calendar resources in La Suite Calendars: meeting rooms, vehicles, projectors, and any other bookable assets that people can reserve alongside events.
Table of Contents
- Overview
- What Is a Calendar Resource?
- CalDAV Standards and Interoperability
- Data Model
- Resource Lifecycle
- Booking Flow
- Auto-Scheduling and Conflict Detection
- Free/Busy and Availability
- Access Rights and Administration
- Sharing and Delegation
- Resource Discovery
- Interoperability with CalDAV Clients
- What SabreDAV Provides (and What It Doesn't)
- Implementation Plan
- Database Schema Changes
- API Design
- SabreDAV Plugin Design
- Frontend Components
- Migration and Deployment
Overview
Calendar resources allow organizations to manage shared physical assets (rooms, vehicles, equipment) through the calendar. Users book resources by adding them as attendees to events, just like inviting a person. The system handles availability checking, conflict prevention, and automatic accept/decline responses.
Key principles:
- Resources are modeled as CalDAV principals, following RFC 6638
- Any CalDAV client can book a resource by inviting its email address
- The server handles auto-scheduling (accept if free, decline if busy)
- Resource provisioning (create/delete) happens through Django; all other management through CalDAV
- Double-booking prevention is enforced server-side
What Is a Calendar Resource?
A calendar resource is a bookable entity that is not a person. Resources fall into two categories defined by the iCalendar standard (RFC 5545):
| Type | CUTYPE | Examples |
|---|---|---|
| Room | ROOM |
Conference rooms, meeting rooms, auditoriums, phone booths |
| Resource | RESOURCE |
Projectors, vehicles, cameras, whiteboards, parking spots |
Each resource has:
- A display name (e.g., "Room 101 - Large Conference")
- A scheduling address (a
mailto:URI used as a CalDAV identifier -- see below) - A calendar that shows its bookings
- Metadata (capacity, location, description, equipment list)
- Availability hours (e.g., 8:00-20:00 on weekdays)
- An auto-schedule policy (auto-accept, require approval, etc.)
- One or more administrators who manage the resource
Resource Scheduling Addresses (Not Real Emails)
In CalDAV, every principal (user or resource) is identified by a mailto: URI in the calendar-user-address-set property. This is how the scheduling protocol matches an ATTENDEE on an event to a principal on the server. These addresses do not need to be real, routable email addresses.
When SabreDAV receives a scheduling request for mailto:c_a1b2c3d4@resource.calendar.example.com, it looks up the email column in the principals table. If a matching principal is found locally, the iTIP message is delivered internally (inbox-to-inbox on the same server). See Email Safety for how outbound emails to resource addresses are prevented.
Recommended convention: {opaque-id}@resource.calendar.{APP_DOMAIN}
The address uses:
- An opaque identifier (UUID or short hash, prefixed with
c_) rather than a human-readable slug, so renaming a resource doesn't change its address - A subdomain you control (
resource.calendar.{APP_DOMAIN}), which avoids collisions with real user emails and leaves the door open for future inbound email (e.g., resources acting as meeting organizers)
Examples (with APP_DOMAIN=example.com):
c_a1b2c3d4@resource.calendar.example.comc_f6g7h8i9@resource.calendar.example.com
By default, no MX record is configured for this subdomain, so inbound email silently fails -- same practical effect as a non-routable address, but reversible if resources need to send/receive email in the future (e.g., room-initiated meetings with external attendees).
Org scoping is handled at the CalDAV/application level, not encoded in the email address.
The system identifies resource principals via the calendar_user_type column in the SabreDAV principals table, not by email pattern.
Email Safety
Resource scheduling addresses are not real mailboxes. No email should ever be sent to or from them. However, the current invitation system (HttpCallbackIMipPlugin -> Django -> email) will attempt to email any attendee unless explicitly prevented. This must be handled at two levels:
Internal: Preventing Outbound Emails to Resources
In the current flow, when a user adds attendees to an event, HttpCallbackIMipPlugin POSTs to Django for each attendee, and Django sends an invitation email. Without intervention, Django would attempt to email the resource address.
Fix 1 (SabreDAV): The ResourceAutoSchedulePlugin (priority 120) sets $message->scheduleStatus on the iTIP message before HttpCallbackIMipPlugin runs. The base IMipPlugin class skips messages that already have a status set, preventing the callback entirely.
Fix 2 (Django, safety net): CalendarInvitationService.send_invitation() checks if the recipient is a resource address (by checking calendar_user_type of the principal or matching the @resource.calendar.example.com domain) and skips email sending.
Both should be implemented. The plugin is the primary gate; Django is the fallback.
External: What Happens When Outside Systems See the Address
When an event includes both a resource and external attendees, the ICS attachment in invitation emails lists all ATTENDEE properties, including the resource's mailto:c_abc123@resource.calendar.example.com. This is visible to external recipients. Here's why this is safe:
| Scenario | What happens | Risk |
|---|---|---|
| External email client tries to send iTIP REPLY | REPLY goes to ORGANIZER only (the human), not to other attendees | None |
| External email client tries to contact all attendees | Email to @resource.calendar.example.com bounces (no MX record by default) |
None |
| External CalDAV server tries to book the resource | iTIP REQUEST email to @resource.calendar.example.com bounces |
None -- resources are only bookable through this server |
| CalDAV client connected to THIS server | Uses SCHEDULE-AGENT=SERVER; all scheduling goes through SabreDAV, no email |
None |
By default no MX record exists for resource.calendar.{APP_DOMAIN}, so external email attempts bounce harmlessly. All legitimate resource interactions go through the CalDAV server. If resources need to send/receive email in the future (e.g., room-initiated meetings), an MX record can be added and inbound mail routed to the application.
CalDAV Standards and Interoperability
Relevant Standards
| Standard | Role |
|---|---|
| RFC 5545 (iCalendar) | Defines CUTYPE parameter (ROOM, RESOURCE) on ATTENDEE properties |
| RFC 6638 (CalDAV Scheduling) | Defines calendar-user-type DAV property on principals; scheduling transport (inbox/outbox); SCHEDULE-AGENT parameter |
| RFC 4791 (CalDAV) | Calendar collections, free-busy-query REPORT |
| RFC 7953 (Calendar Availability) | VAVAILABILITY component for defining operating hours (future) |
| draft-cal-resource-schema | Resource metadata schema (capacity, booking window, manager, etc.) |
| draft-pot-caldav-sharing | Calendar sharing between principals |
How Resources Work in CalDAV
In CalDAV, a resource is a regular principal with a special calendar-user-type property set to ROOM or RESOURCE. It has its own calendar home, schedule inbox, and schedule outbox, exactly like a user principal.
When a user creates an event with a resource as an attendee:
ATTENDEE;CUTYPE=ROOM;ROLE=NON-PARTICIPANT;PARTSTAT=NEEDS-ACTION;
RSVP=TRUE:mailto:c_a1b2c3d4@resource.calendar.example.com
The CalDAV scheduling server (RFC 6638):
- Detects the
ATTENDEEon the event - Delivers an iTIP
REQUESTto the resource's schedule inbox - A server-side agent checks the resource's calendar for conflicts
- Sends an iTIP
REPLYback withPARTSTAT=ACCEPTEDorPARTSTAT=DECLINED
Auto-scheduling is not standardized -- it is a server implementation feature. RFC 6638 only defines the transport mechanism. This project implements auto-scheduling as a custom SabreDAV plugin (see What SabreDAV Provides for rationale).
What This Means for Interoperability
The critical insight: resource booking does not require client-side support. Any CalDAV client that can add an attendee by email address can book a resource. The server handles everything else. This means:
- Apple Calendar, Thunderbird, GNOME Calendar, and all CalDAV clients work out of the box
- Clients that support
CUTYPEcan display resources differently from people - Clients that support
principal-property-searchcan discover available resources - The web frontend provides the richest experience (resource browser, availability view)
Data Model
Design Principle: CalDAV as Single Source of Truth
Resources are entirely managed in CalDAV -- metadata, calendar data, and access control. No Django model is needed.
- Metadata (name, capacity, location, equipment, etc.): DAV properties via PROPFIND/PROPPATCH
- Calendar data (bookings, free/busy): CalDAV calendar collections
- Access control: CalDAV sharing (
CS:share) with privilege levels, the same mechanism already used for user calendar sharing - Scheduling config (auto-schedule mode, booking policies): Custom DAV properties read by the SabreDAV plugin
Django's role is limited to:
- Provisioning: A REST endpoint to create/delete resource principals. CalDAV has no standard operation to create a principal, so this is the one justified exception to the "Django is a pass-through" rule. Django makes CalDAV requests to SabreDAV to set up the principal + calendar + initial properties.
- Proxying: The existing CalDAV proxy (
CalDAVProxyView) forwards all CalDAV requests, including those for resource principals
This means zero new Django models for resources. The frontend manages resource permissions via CalDavService.shareCalendar() / getCalendarSharees(), exactly like it already does for user calendars.
Resource as a CalDAV Principal
Each resource exists as a principal in SabreDAV with a single dedicated calendar:
principals/resources/{resource-id}
-> calendar-home-set: /calendars/resources/{resource-id}/
-> schedule-inbox-URL: /calendars/resources/{resource-id}/inbox/
-> schedule-outbox-URL: /calendars/resources/{resource-id}/outbox/
-> calendar-user-type: ROOM | RESOURCE
-> calendar-user-address-set: mailto:{opaque-id}@resource.calendar.example.com
The mailto: address is a CalDAV internal identifier, not a real
mailbox (see Resource Scheduling Addresses).
A resource principal has exactly one calendar. Although CalDAV
allows any principal to own multiple calendar collections, this
doesn't make sense for resources (a room has one schedule).
MKCALENDAR requests targeting a resource principal's calendar
home are rejected by a SabreDAV plugin (hooking
beforeMethod:MKCALENDAR). The single calendar is created during
provisioning and cannot be added to or removed independently.
Resource Properties (CalDAV)
All resource metadata is stored as DAV properties on the resource's principal or default calendar, using standard properties where they exist and a project namespace ({urn:lasuite:calendars}) for the rest.
SabreDAV's PropertyStorage plugin persists custom properties in a propertystorage table automatically -- any property set via PROPPATCH is stored and returned via PROPFIND.
Standard Properties (on the principal or calendar collection)
| Property | Namespace | Where | Description |
|---|---|---|---|
displayname |
{DAV:} |
Principal | Resource name |
calendar-user-type |
{urn:ietf:params:xml:ns:caldav} |
Principal | ROOM or RESOURCE |
calendar-description |
{urn:ietf:params:xml:ns:caldav} |
Calendar | Free-form description |
calendar-color |
{http://apple.com/ns/ical/} |
Calendar | Hex color (e.g., #4CAF50) |
calendar-timezone |
{urn:ietf:params:xml:ns:caldav} |
Calendar | VTIMEZONE component |
Semi-Standard Properties (Apple CalendarServer)
| Property | Namespace | Where | Description |
|---|---|---|---|
capacity |
{http://calendarserver.org/ns/} |
Principal | Integer, number of seats/units |
Custom Properties (Project Namespace)
| Property | Namespace | Where | Type | Description |
|---|---|---|---|---|
location |
{urn:lasuite:calendars} |
Principal | String | Building, floor, address |
equipment |
{urn:lasuite:calendars} |
Principal | JSON array | ["Projector", "Whiteboard"] |
tags |
{urn:lasuite:calendars} |
Principal | JSON array | ["building-a", "video"] |
auto-schedule-mode |
{urn:lasuite:calendars} |
Principal | String | See auto-schedule modes below |
is-active |
{urn:lasuite:calendars} |
Principal | Boolean | true / false |
restricted-access |
{urn:lasuite:calendars} |
Principal | Boolean | Restrict booking to explicit access |
multiple-bookings |
{urn:lasuite:calendars} |
Principal | Integer | Max concurrent bookings (1 = no overlap) |
max-booking-duration |
{urn:lasuite:calendars} |
Principal | Duration | ISO 8601 (e.g., PT4H) |
booking-window-start |
{urn:lasuite:calendars} |
Principal | Duration | How far ahead (e.g., P90D) |
booking-window-end |
{urn:lasuite:calendars} |
Principal | Duration | Minimum notice (e.g., PT1H) |
Example: Reading Resource Properties
PROPFIND /principals/resources/room-101/
<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:caldav"
xmlns:A="http://apple.com/ns/ical/"
xmlns:CS="http://calendarserver.org/ns/"
xmlns:LS="urn:lasuite:calendars">
<D:prop>
<D:displayname/>
<C:calendar-user-type/>
<CS:capacity/>
<LS:location/>
<LS:equipment/>
<LS:tags/>
<LS:auto-schedule-mode/>
<LS:is-active/>
</D:prop>
</D:propfind>
Example: Setting Resource Properties
PROPPATCH /principals/resources/room-101/
<?xml version="1.0" encoding="utf-8"?>
<D:propertyupdate xmlns:D="DAV:"
xmlns:CS="http://calendarserver.org/ns/"
xmlns:LS="urn:lasuite:calendars">
<D:set>
<D:prop>
<CS:capacity>20</CS:capacity>
<LS:location>Building A, Floor 2</LS:location>
<LS:equipment>["Projector", "Whiteboard", "Video conferencing"]</LS:equipment>
<LS:tags>["video-conference", "whiteboard", "building-a"]</LS:tags>
<LS:auto-schedule-mode>automatic</LS:auto-schedule-mode>
</D:prop>
</D:set>
</D:propertyupdate>
Auto-Schedule Modes
Stored as the {urn:lasuite:calendars}auto-schedule-mode property on the resource principal. Inspired by Apple Calendar Server's proven model:
| Mode | Behavior |
|---|---|
automatic |
Accept if free, decline if busy (default) |
accept-always |
Accept all invitations regardless of conflicts |
decline-always |
Decline all invitations (resource offline) |
manual |
Require a resource manager to accept/decline |
The ResourceAutoSchedulePlugin reads this property from SabreDAV's propertystorage table (same PostgreSQL instance) during scheduling -- no Django roundtrip needed.
Resource Access (CalDAV Sharing)
Resource access uses the same CalDAV sharing mechanism (CS:share) already used for user calendar sharing. No Django model is needed.
The resource's calendar is shared with administrators/managers using the existing privilege levels:
| CalDAV Privilege | Role | Can Do |
|---|---|---|
read |
Viewer | See bookings, free/busy |
read-write |
Manager | See bookings, modify/cancel any booking |
admin |
Admin | Full control: edit properties, manage sharing, override bookings |
The frontend manages this via CalDavService.shareCalendar() and getCalendarSharees() -- the same code already used for sharing user calendars.
The resource creator is automatically the calendar owner (the principal itself owns the calendar collection). Additional admins are added via sharing.
What About Search and Filtering?
Storing metadata in CalDAV raises the question: how do you filter resources by capacity, tags, or location?
For most deployments, the number of resources is small (tens to low hundreds). The frontend can:
- PROPFIND on
principals/resources/to fetch all resource principals with their properties in a single request - Filter and sort client-side in JavaScript (capacity >= 10, tags include "projector", etc.)
This is simple, avoids data duplication, and works well up to ~1000 resources. If a deployment needs SQL-level search across thousands of resources, a Django read-only index synced from CalDAV can be added later as an optimization -- but this is not needed for v1.
Resource Lifecycle
Creating a Resource
- An org admin (
can_adminentitlement) calls the Django REST API to create a resource (name, type) - Django calls SabreDAV's
/internal-api/resources/endpoint (POST with JSON body). TheInternalApiPluginhandles both principal creation and default calendar creation atomically. The admin'sorg_idis passed in the request body. - The frontend sets additional metadata via PROPPATCH (capacity, location, equipment, etc.)
- The resource is immediately available for booking
No Django model is created -- the CalDAV principal is the resource.
Architecture note: All resource CRUD goes through the
/internal-api/namespace in SabreDAV, which is completely separate from the CalDAV protocol namespace. This avoids direct database access from Django to SabreDAV tables. The internal API is gated by theX-Internal-Api-Keyheader (different from theX-Api-Keyused by the CalDAV proxy) and is explicitly blocked by the Django proxy's path validation.
Updating a Resource
All metadata changes go through CalDAV PROPPATCH directly (from the frontend or via a Django proxy endpoint). There is no Django model to keep in sync.
- Display properties (name, color, description): PROPPATCH on the calendar collection
- Resource properties (capacity, equipment, location, tags): PROPPATCH on the principal
- Scheduling config (auto-schedule-mode, booking policies): PROPPATCH on the principal
- Deactivating a resource: set
{urn:lasuite:calendars}is-activetofalse
Deleting a Resource
- Django calls
DELETE /internal-api/resources/{resource-id}on SabreDAV - The
InternalApiPlugindeletes all calendars, calendar objects, scheduling objects, and the principal row atomically - Sharing entries are automatically cleaned up with the calendar
Existing events in user calendars that reference the resource as
an attendee are left as-is. The resource's mailto: address
becomes an unresolvable address -- same as if an external attendee
disappeared. The resource will simply stop responding to scheduling
requests. This avoids the complexity of modifying events across
all user calendars.
Booking Flow
Standard Booking (Any CalDAV Client)
User CalDAV Server Auto-Scheduler
| | |
|-- PUT event ------------>| |
| (ATTENDEE=c_...@resource.calendar.example.com)
| |-- iTIP REQUEST --------->|
| | |-- check free/busy
| | |-- no conflict?
| |<-- iTIP REPLY -----------|
| | (PARTSTAT=ACCEPTED) |
|<-- schedule-status ------| |
| (1.2 = delivered) | |
Web Frontend Booking
The web UI provides a richer experience:
- User opens event creation modal
- User clicks "Add Room" or "Add Resource"
- A resource picker shows available resources with metadata
- User selects a resource; frontend checks free/busy in real-time
- Frontend adds the resource as an
ATTENDEEwithCUTYPE=ROOM - On save, the CalDAV flow triggers auto-scheduling
- The event updates with the resource's
PARTSTATresponse
Booking with Conflicts
When a resource is already booked:
- User creates event with resource as attendee
- Auto-scheduler detects conflict
- Resource declines (
PARTSTAT=DECLINED) - Organizer sees the declined status
- Frontend shows a warning: "Room 101 is unavailable at this time"
Recurring Event Booking
For recurring events, the auto-scheduler must check every instance within a reasonable window (e.g., 1 year) for conflicts. If any instance conflicts:
- Option A (strict): Decline the entire series
- Option B (lenient): Accept the series but decline specific instances via
EXDATE
The recommended approach is Option A for simplicity, with the UI helping users find conflict-free times.
What SabreDAV Provides (and What It Doesn't)
SabreDAV is the most mature open-source CalDAV server, but its support for resource principals (ROOM/RESOURCE) has gaps. Understanding exactly what's built in vs. what we extend is important.
CUTYPE: Extensible but Not Enabled by Default
RFC 6638 §2.4.2 defines the {urn:ietf:params:xml:ns:caldav}calendar-user-type property on principals, with values from RFC 5545's CUTYPE parameter (INDIVIDUAL, ROOM, RESOURCE, GROUP, etc.). SabreDAV acknowledges this property but doesn't fully implement it:
- The
Schedule\Pluginhardcodescalendar-user-typeto'INDIVIDUAL'for all principals, with an inline comment: "The server currently reports every principal to be of type INDIVIDUAL." - The
PrincipalBackend\PDObase class only maps two columns in its$fieldMap:displaynameandemail. There is nocalendar_user_typecolumn in the default schema.
However, SabreDAV was designed for this to be extended:
- The
$fieldMapinPrincipalBackend\PDOis aprotectedproperty that subclasses can override to add custom DB columns and map them to WebDAV properties. - When a property is in the
$fieldMap, thePrincipalnode exposes it viagetProperties(), which runs before the Schedule Plugin'shandle()callback. - The Schedule Plugin uses
$propFind->handle()which is a no-op when the property is already set. So the hardcoded'INDIVIDUAL'only serves as a fallback for principals that lack the column.
This means adding CUTYPE support is a one-line extension of the fieldMap, not a hack:
// In our AutoCreatePrincipalBackend (extends PrincipalBackend\PDO)
protected $fieldMap = [
'{DAV:}displayname' => ['dbField' => 'displayname'],
'{http://sabredav.org/ns}email-address' => ['dbField' => 'email'],
// This is all it takes -- SabreDAV handles the rest
'{urn:ietf:params:xml:ns:caldav}calendar-user-type' => ['dbField' => 'calendar_user_type'],
];
With this in place, getPrincipalByPath(), getPrincipalsByPrefix(), and searchPrincipals() all automatically include the real CUTYPE value. CalDAV clients that support principal-property-search (Apple Calendar) can discover rooms and resources. PROPFIND on resource principals returns the correct type. No custom plugin is needed for CUTYPE -- just the standard extension point.
Auto-Scheduling: Not Built In Anywhere
While CUTYPE is a matter of data exposure (and SabreDAV provides the extension points), auto-scheduling is a different story. No existing CalDAV server component provides automatic accept/decline for resource principals:
SabreDAV Core: The Schedule\Plugin handles iTIP delivery (inbox/outbox) but has no logic to auto-accept or auto-decline based on calendar-user-type or availability. After delivering a scheduling message, it's done.
Nextcloud: Built resource scheduling on top of SabreDAV, but deeply coupled to the Nextcloud framework (\OCP\ interfaces, DI container, app framework). None of the code (ResourcePrincipalBackend.php, RoomPrincipalBackend.php, CalDavBackend.php) is extractable as a standalone plugin.
SOGo: Has resource auto-accept, but written in Objective-C with its own CalDAV stack -- not SabreDAV-based.
Bedework: The one enterprise CalDAV server with built-in room/resource scheduling, but it's a Java application, not a SabreDAV plugin.
Packagist / Open Source: No third-party SabreDAV resource scheduling package exists.
What This Means
- CUTYPE support: Uses SabreDAV's built-in
$fieldMapextension. One line of code, fully idiomatic. - Auto-scheduling: Requires a custom
ResourceAutoSchedulePlugin(~350 lines of PHP). Follows the same pattern as our existingHttpCallbackIMipPlugin: hook into thescheduleevent, inspect the iTIP message, act on it. The plugin reads resource configuration from the shared PostgreSQL database directly (same instance SabreDAV already uses).
Auto-Scheduling and Conflict Detection
How Auto-Scheduling Works
Auto-scheduling is implemented as a custom SabreDAV plugin that intercepts scheduling deliveries to resource principals. It runs after Sabre\CalDAV\Schedule\Plugin delivers the iTIP message. No existing SabreDAV plugin provides this functionality (see What SabreDAV Provides).
class ResourceAutoSchedulePlugin extends ServerPlugin
{
// Hook into the 'schedule' event
function schedule(ITip\Message $message)
{
// 1. Is the recipient a resource principal?
// 2. What is the resource's auto_schedule_mode?
// 3. For 'automatic' mode: check free/busy
// 4. Set $message->scheduleStatus accordingly
// 5. Update PARTSTAT in the delivered calendar object
}
}
Conflict Detection Algorithm
function hasConflict(resource, newEvent):
// Get the resource's calendar
calendar = resource.getDefaultCalendar()
// For each instance of the new event (expand recurrence)
for instance in expandInstances(newEvent, maxWindow=1year):
if instance.transp == TRANSPARENT:
continue // transparent events don't block
// Query existing events in this time range
existing = calendar.getEvents(instance.start, instance.end)
// Check against max concurrent bookings
overlapping = countOverlapping(existing, instance)
if overlapping >= resource.multiple_bookings:
return true // conflict
return false // no conflict
Edge Cases
- All-day events: Treated as blocking the entire day
- Tentative events: Count as busy (configurable per resource)
- Cancelled events: Do not count as busy
- Transparent events (
TRANSP=TRANSPARENT): Do not count as busy - Recurring with exceptions: Must check each expanded instance
Free/Busy and Availability
Free/Busy Queries
Resources support standard CalDAV free/busy queries. Two methods:
Method 1: CALDAV:free-busy-query REPORT (RFC 4791)
REPORT /calendars/resources/room-101/default/
<?xml version="1.0" encoding="utf-8"?>
<C:free-busy-query xmlns:C="urn:ietf:params:xml:ns:caldav">
<C:time-range start="20260305T000000Z" end="20260306T000000Z"/>
</C:free-busy-query>
Returns a VFREEBUSY component with busy intervals.
Method 2: Schedule outbox POST (RFC 6638)
The organizer POSTs a VFREEBUSY request to their outbox, specifying the resource as an attendee. The server returns the resource's free/busy data.
Availability Hours (VAVAILABILITY) — Future
Resources could define operating hours using RFC 7953
VAVAILABILITY (e.g., Monday-Friday 8:00-20:00, booking outside
these hours auto-declined). However, SabreDAV does not support
RFC 7953 out of the box — the auto-schedule plugin would need to
implement VAVAILABILITY parsing. This is deferred to Phase 5.
Frontend Availability View
The web frontend shows:
- A day/week timeline of the resource's bookings
- Color-coded slots: free (green), busy (red), tentative (yellow)
- A multi-resource view to compare several rooms side by side
Access Rights and Administration
Resource access control uses CalDAV sharing (CS:share), the same mechanism already used for sharing user calendars. No Django model is needed.
Privilege Levels
| CalDAV Privilege | Role | Capabilities |
|---|---|---|
| (no share) | Any authenticated user | Discover resource, view free/busy, book by adding as attendee |
read |
Shared viewer | All of the above + see full booking details on the calendar |
read-write |
Manager | All of the above + modify/cancel any booking on the resource |
admin |
Administrator | All of the above + edit resource properties (PROPPATCH), manage sharing, override auto-schedule decisions, delete resource |
How It Works
- The resource principal owns its calendar collection. This is the "owner" in CalDAV terms.
- Admins are added by sharing the resource's calendar with
adminprivilege viaCS:share(same asCalDavService.shareCalendar()) - Managers get
read-writeprivilege - Viewers get
readprivilege - Any authenticated user can book the resource (add it as an attendee to their event) and query free/busy -- this does not require sharing. The CalDAV scheduling protocol handles this via the resource's schedule inbox.
This maps directly to how user calendar sharing already works in the frontend (CalendarShareModal, CalDavService.shareCalendar(), getCalendarSharees()).
Restricted Resources
For resources that should not be bookable by everyone (executive rooms, specialized equipment), the {urn:lasuite:calendars}restricted-access property can be set to true. When set, the auto-schedule plugin only accepts invitations from users who have been explicitly shared on the resource's calendar.
Organization Scoping and Permissions
See docs/organizations.md for the full org design. Key points for resources:
- Resource discovery is org-scoped: SabreDAV filters resource principals by the
org_idcolumn on theprincipalstable, using theX-CalDAV-Organizationheader set by Django. - Cross-org resource booking is not allowed: the auto-schedule plugin rejects invitations from users outside the resource's org.
- Resource creation requires the
can_adminentitlement (returned by the entitlements system alongsidecan_access).
Sharing and Delegation
Delegation to Resource Managers
When auto-schedule-mode=manual, incoming booking requests require approval:
- User creates event with resource as attendee
- Auto-scheduler detects
manualmode - Resource stays in
PARTSTAT=NEEDS-ACTION - Resource managers receive a notification
- Manager approves or declines via:
- The web UI (resource management panel)
- Direct calendar interaction (change PARTSTAT on the resource's calendar)
- iTIP REPLY sent back to the organizer
Resource Discovery
How Users Find Resources
In major calendar apps, users pick rooms from a list scoped to their organization. This works because those apps are tied to an organization directory (Workspace domain, Exchange GAL, etc.). In CalDAV, there are two discovery mechanisms:
Discovery via CalDAV (principal-property-search)
RFC 3744 defines a DAV:principal-property-search REPORT that lets clients search for principals by property. For example, to find all rooms:
REPORT /principals/
<?xml version="1.0" encoding="utf-8"?>
<D:principal-property-search xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:property-search>
<D:prop>
<C:calendar-user-type/>
</D:prop>
<D:match>ROOM</D:match>
</D:property-search>
<D:prop>
<D:displayname/>
<C:calendar-user-address-set/>
</D:prop>
</D:principal-property-search>
Limitations:
- Returns all matching principals on the server -- there is no built-in scoping by organization or tenant
- Only Apple Calendar uses this for resource discovery in practice
- Thunderbird and GNOME Calendar do not support resource discovery via CalDAV
- CalDAV properties are limited to basic fields (name, email, CUTYPE) -- no capacity, equipment, location metadata
In a single-tenant deployment, this works fine: all resources belong to the same organization, so returning all of them is correct.
In a multi-tenant deployment, SabreDAV filters results by the org_id on the principals table (see Organization Scoping).
Discovery via CalDAV + Client-Side Filtering (Web Frontend)
The web frontend fetches all resource principals via a single
PROPFIND on principals/resources/ (through the CalDAV proxy)
and filters/sorts client-side in JavaScript. This is the
primary and richest discovery method:
- Full metadata: capacity, equipment, location, tags
- Client-side filtering: by type, capacity, location, tags
- Real-time availability: via
free-busy-queryREPORT - Org-scoped: SabreDAV filters by
org_idbefore returning
No Django endpoint is needed for resource discovery. The frontend does the same kind of PROPFIND it already does for calendars.
Discovery via Email (All Clients)
Any CalDAV client can book a resource by typing its scheduling address in the attendee field, even without a discovery UI. This is the universal fallback that works everywhere.
Interoperability with CalDAV Clients
| Client | Book by Email | See CUTYPE | Discover via CalDAV | Free/Busy |
|---|---|---|---|---|
| Apple Calendar | Yes | Yes | Yes (principal-property-search) |
Yes |
| Thunderbird | Yes | No (shows as person) | No | Yes |
| GNOME Calendar | Yes | Partial | No | Partial |
| Web Frontend | Yes | Yes | Yes (PROPFIND + client-side) | Yes |
| Any CalDAV client | Yes | Varies | Varies | Yes |
The key takeaway: booking works universally (any client can invite a resource by email). Discovery (browsing available rooms) is richest in the web frontend and limited in native CalDAV clients.
Implementation Plan
Phase 1: Resource Principals in SabreDAV
Goal: Resources exist as CalDAV principals and can receive scheduling messages.
Changes:
- Extend SabreDAV principals table: Add
calendar_user_typecolumn (andorg_idfor multi-tenancy) - Extend
AutoCreatePrincipalBackend.$fieldMap: Add{urn:ietf:params:xml:ns:caldav}calendar-user-type→calendar_user_type. This is SabreDAV's idiomatic extension point for principal properties -- the basePrincipalBackend\PDOautomatically includes mapped fields in all queries, and theSchedule\Plugin's hardcodedINDIVIDUALbecomes a fallback (see What SabreDAV Provides) - Add nested principal prefixes:
principals/users/for user principals andprincipals/resources/for resource principals, with a customCalendarsRootnode to handle the nested structure (SabreDAV's defaultCalendarRootonly supports flat principal prefixes) - Verify scheduling delivery: Ensure
Schedule\Plugindelivers iTIP messages to resource inboxes
Files to modify:
src/caldav/sql/pgsql.principals.sql-- add columnssrc/caldav/src/AutoCreatePrincipalBackend.php-- extend$fieldMapsrc/caldav/src/CalendarsRoot.php-- custom DAV Collection for nested prefixessrc/caldav/server.php-- use CalendarsRoot and nested principal collections
Phase 2: Django Provisioning API
Goal: REST endpoint to create/delete resource principals, gated by org-level permissions.
Changes:
- Resource provisioning endpoint: Creates the SabreDAV principal + calendar via CalDAV requests (no direct DB writes). Checks the user's
can_adminentitlement. - Resource deletion endpoint: Cleans up CalDAV principal + calendar. Same permission check.
No Django model is needed. Metadata is managed via CalDAV PROPPATCH. Access control uses CalDAV sharing (CS:share), the same way user calendars are shared. Org-level permission to create/delete resources comes from the can_admin entitlement.
Files to create/modify:
src/backend/core/api/viewsets.py-- add resource provisioning viewsetsrc/backend/core/services/resource_service.py-- provisioning logic (CalDAV calls)
Phase 3: Auto-Scheduling Plugin
Goal: Resources automatically accept/decline based on availability.
No existing SabreDAV plugin provides this -- see What SabreDAV Provides. The plugin is ~350 lines of PHP, following the same pattern as the existing HttpCallbackIMipPlugin.
Changes:
ResourceAutoSchedulePlugin.php: SabreDAV plugin that hooks into thescheduleevent (same hookHttpCallbackIMipPluginalready uses)- Conflict detection: Query the resource's calendar for overlapping events
- Auto-schedule modes: Read the
{urn:lasuite:calendars}auto-schedule-modeproperty from SabreDAV'spropertystoragetable (same PostgreSQL instance, no Django roundtrip) - iTIP REPLY generation: Send
ACCEPTEDorDECLINEDback to organizer - Availability hours: Not in v1 (see Phase 5)
Files to create/modify:
src/caldav/src/ResourceAutoSchedulePlugin.php-- new pluginsrc/caldav/server.php-- register plugin
Design choice: The plugin reads the auto_schedule_mode from the database directly (same PostgreSQL instance SabreDAV already uses) rather than calling Django's API, to avoid circular HTTP dependencies during scheduling.
Phase 4: Frontend Resource UI
Goal: Users can discover, browse, and book resources from the web interface.
Components:
- Resource directory: Searchable/filterable list of all resources
- Resource detail panel: Shows metadata, availability timeline, current bookings
- Resource picker in event modal: Add room/resource when creating an event
- Availability checker: Real-time free/busy display when selecting a resource
- Multi-resource timeline: Side-by-side availability comparison
- Resource management panel: For admins to create/edit/configure resources
Files to create:
src/frontend/apps/calendars/src/features/resources/-- new feature moduletypes.ts-- TypeScript types for resource propertiescomponents/ResourceDirectory.tsxcomponents/ResourcePicker.tsxcomponents/ResourceDetail.tsxcomponents/ResourceTimeline.tsxcomponents/ResourceAdmin.tsx
src/frontend/apps/calendars/src/services/dav/CalDavService.ts-- extend with resource PROPFIND/PROPPATCH methods- Updates to event modal for resource attendee support
The frontend reads/writes resource metadata via CalDAV (PROPFIND/PROPPATCH), the same way it already manages calendar properties. The Django REST API is only used for provisioning (create/delete).
Phase 5: Advanced Features
Goal: Polish and power-user features.
- Approval workflow: Notification system for
manualmode resources - Booking policies: Max duration, booking window, recurring limits
- Resource groups: Group rooms by building/floor for easier browsing
- Capacity warnings: Warn when event attendee count exceeds room capacity
- Resource calendar overlay: Show resource bookings in the main calendar view
- Reporting: Usage statistics, popular times, underutilized resources
- VAVAILABILITY editor: UI for configuring resource operating hours
- Bulk resource import: CSV/JSON import for provisioning many resources
Database Schema Changes
SabreDAV: Principals Table
The principals table includes two extra columns beyond the SabreDAV defaults (defined in src/caldav/sql/pgsql.principals.sql):
CREATE TABLE principals (
id SERIAL NOT NULL,
uri VARCHAR(200) NOT NULL,
email VARCHAR(80),
displayname VARCHAR(80),
calendar_user_type VARCHAR(20) DEFAULT 'INDIVIDUAL', -- INDIVIDUAL, ROOM, RESOURCE
org_id VARCHAR(200), -- organization scoping
PRIMARY KEY (id),
UNIQUE (uri)
);
CREATE INDEX idx_principals_org_id ON principals (org_id) WHERE org_id IS NOT NULL;
CREATE INDEX idx_principals_cutype ON principals (calendar_user_type)
WHERE calendar_user_type IN ('ROOM', 'RESOURCE');
The calendar_user_type column is exposed as the standard {urn:ietf:params:xml:ns:caldav}calendar-user-type DAV property via AutoCreatePrincipalBackend.$fieldMap. The org_id column is used internally for org-scoped filtering and is not exposed as a DAV property.
Resource metadata (capacity, location, equipment, etc.) is stored in SabreDAV's existing propertystorage table via PROPPATCH -- no additional SabreDAV schema changes needed.
Django: No New Tables
No Django models are needed for resources. Access control uses CalDAV sharing (stored in SabreDAV's calendarinstances table). Resource metadata uses SabreDAV's propertystorage table. Both are managed through CalDAV protocol, not direct database access.
API Design
Resource metadata is read/written via CalDAV (PROPFIND/PROPPATCH). The Django REST API only handles provisioning and deletion.
Django REST API (Provisioning)
POST /api/v1.0/resources/ # Create resource (provision principal + calendar)
DELETE /api/v1.0/resources/{resource-id}/ # Delete resource (cleanup principal + calendar)
Both POST and DELETE require the can_admin entitlement. Access control (sharing) is managed via CalDAV CS:share on the resource's calendar -- no Django access endpoints needed.
Create Resource Request
POST /api/v1.0/resources/
{
"name": "Room 101 - Large Conference",
"resource_type": "ROOM"
}
Django checks the user's can_admin entitlement, provisions the CalDAV principal and calendar, then the frontend sets additional metadata via PROPPATCH.
CalDAV API (Metadata and Discovery)
All resource metadata is managed via standard CalDAV protocol:
| Operation | Method | URL |
|---|---|---|
| List all resources | PROPFIND |
/api/v1.0/caldav/principals/resources/ |
| Get resource properties | PROPFIND |
/api/v1.0/caldav/principals/resources/{resource-id}/ |
| Update resource properties | PROPPATCH |
/api/v1.0/caldav/principals/resources/{resource-id}/ |
| Get resource calendar | PROPFIND |
/api/v1.0/caldav/calendars/resources/{resource-id}/default/ |
| Query free/busy | REPORT |
/api/v1.0/caldav/calendars/resources/{resource-id}/default/ |
The frontend fetches all resource principals with their properties in a single PROPFIND request and filters/sorts client-side. This is the same pattern used for fetching calendars today.
SabreDAV Plugin Design
CUTYPE support uses SabreDAV's built-in $fieldMap extension point (no plugin needed). Auto-scheduling requires a custom plugin because SabreDAV has no built-in resource auto-scheduling, and no reusable third-party implementation exists (see What SabreDAV Provides).
ResourceAutoSchedulePlugin
class ResourceAutoSchedulePlugin extends DAV\ServerPlugin
{
function getPluginName() { return 'resource-auto-schedule'; }
function initialize(DAV\Server $server)
{
$server->on('schedule', [$this, 'autoSchedule'], 120);
// Priority 120: runs after Schedule\Plugin (110)
}
function autoSchedule(ITip\Message $message)
{
// Only handle messages TO resource principals
if (!$this->isResourcePrincipal($message->recipient)) {
return;
}
// Read auto-schedule-mode from propertystorage table
$mode = $this->getAutoScheduleMode($message->recipient);
switch ($mode) {
case 'accept_always':
$this->acceptInvitation($message);
break;
case 'decline_always':
$this->declineInvitation($message);
break;
case 'automatic':
if ($this->hasConflict($message)) {
$this->declineInvitation($message);
} else {
$this->acceptInvitation($message);
}
break;
case 'manual':
// Do nothing; leave PARTSTAT=NEEDS-ACTION
// Managers see pending requests on the resource calendar
break;
}
}
}
Integration with Existing Plugins
The plugin runs in this order:
CalendarSanitizerPlugin(priority 85) -- strips binaries, truncatesAttendeeNormalizerPlugin(priority 90) -- normalizes emailsCalDAV\Schedule\Plugin(priority 110) -- delivers iTIP messagesResourceAutoSchedulePlugin(priority 120) -- auto-accept/declineHttpCallbackIMipPlugin-- notifies Django (for email delivery)
Frontend Components
Data Flow
The frontend reads/writes resource metadata via CalDAV, just like it does for calendars:
CalDavService.fetchResourcePrincipals() → PROPFIND /principals/resources/
CalDavService.getResourceProperties(id) → PROPFIND /principals/resources/{resource-id}/
CalDavService.updateResourceProperties() → PROPPATCH /principals/resources/{resource-id}/
CalDavService.fetchResourceEvents() → REPORT on resource calendar
CalDavService.queryResourceFreeBusy() → free-busy-query REPORT
The Django REST API is only called for provisioning (create/delete). Everything else goes through CalDAV.
Resource Directory (/resources)
A browsable directory of all resources with:
- Filter sidebar: Type (room/resource), capacity range, tags, location, availability -- all filtering is client-side after a single PROPFIND
- List/grid view: Cards showing name, location, capacity, availability status
- Search: Real-time search across name, description, location
- Quick book: Click to start creating an event with the resource
Resource Picker (Event Modal)
When creating/editing an event:
- "Add Room" / "Add Resource" button in the attendees section
- Opens a filtered dropdown/modal showing available resources
- Shows real-time availability for the selected event time
- Displays capacity and key metadata inline
- Selected resources appear in the attendees list with a room/resource icon
Resource Timeline
A horizontal timeline showing:
- One row per resource
- Colored blocks for existing bookings
- Grey blocks for unavailable hours
- Ability to click an empty slot to book
Resource Admin Panel
For resource administrators:
- Create/edit resource metadata (PROPPATCH to CalDAV)
- Configure auto-schedule mode and booking policies (PROPPATCH)
- Manage access via CalDAV sharing (
CS:share) -- same UI as calendar sharing - View booking history and usage stats
- Override booking decisions (accept/decline pending requests)
Migration and Deployment
Rolling Deployment Steps
- Database migration: Add
calendar_user_typeto SabreDAV principals (no Django migration needed) - Deploy SabreDAV: Updated principal backend + auto-schedule plugin (no user impact -- new code paths only activate for resource principals)
- Deploy Django backend: Resource provisioning + access endpoints (additive, no breaking changes)
- Deploy frontend: Resource UI (feature-flagged if needed)
Feature Flag
Consider a RESOURCES_ENABLED feature flag in settings to:
- Show/hide resource UI in the frontend
- Enable/disable resource API endpoints
- Allow gradual rollout
Data Migration
If importing resources from an external system:
- Call the Django provisioning endpoint for each resource (creates CalDAV principals)
- PROPPATCH to set metadata properties on each resource principal
- Import historical bookings as calendar events via ICS import
- Verify free/busy accuracy after import