Skip to main content

Invite System & Access Control Propagation

How residents, guests, and staff are invited into properties and how their access is propagated across the system.

Overview

The invite system spans three layers:

  1. Clerk (identity provider) -- handles user accounts and organisation membership
  2. bf_resident D1 database -- stores invite records with status tracking
  3. bf_user Durable Objects -- per-property persistent storage that enforces access control at request time

Invites flow one-directionally: API -> Clerk + D1 -> Durable Object -> Access Control decisions.

 Invite created (API)
|
v
+------+-------+
| Clerk Org | <-- user added to organisation
| bf_resident | <-- invite record stored in D1
+------+-------+
|
v (on accept)
+------+-------+
| bf_user DO | <-- guest/tenant record synced via RPC
+------+-------+
|
v (on each request)
+------+-------+
| Permissions | <-- role + capability check per RPC call
+--------------+

Invite Roles

RolepublicMetadata.rolesDO Sync TargetDescription
Tenanttenanttenants tableFull resident access
Guesttenantguests tableCapability-gated access
Installerinstaller--Commissioning access
PropertyManagerproperty_manager--Admin-level management

Note: Guests are mapped to tenant because Clerk has no guest role. The Durable Object's guests table and permission system enforce the real access boundaries.

Clerk organisation role

Every invitation and membership is created with a single organisation role -- INVITEE_CLERK_ROLE in crates/bf_resident/src/services/clerk.rs, currently org:tenant. It carries no meaning for authorisation: everything is driven by publicMetadata.roles, which clerk_auth::roles reads server-side and stores/userRoles.ts reads in the web app.

It is one constant because the role name has to exist in the Clerk instance. Asking for a role the instance does not define (org:property_manager, org:installer, ...) fails the invitation with resource_not_found on role. Adding a per-role mapping back means creating those roles in the Clerk dashboard first.

Invite Creation

Handler: crates/bf_resident/src/handlers/invites.rs -- create_invite()

  1. Authorisation: Only Admins can create Installer/PM invites; PMs can create Tenant/Guest invites
  2. Token generation: A random UUID becomes the accept_token in the invite URL
  3. Clerk invitation: For non-Guest roles, a Clerk organisation invitation is created with a branded email
  4. D1 record: Invite stored with status Pending in bf_resident_db
  5. Auto-accept optimisation: If the user is already a Clerk org member, the invite is immediately accepted and synced to the DO without requiring the email link

Key files:

  • crates/bf_resident/src/handlers/invites.rs -- HTTP handlers
  • crates/bf_resident_db/src/operations/invites.rs -- D1 CRUD operations

Invite Acceptance

Handler: crates/bf_resident/src/handlers/invites.rs -- accept_invite()

When a user clicks the invite link:

  1. Token is looked up in the invites table
  2. Expiry and duplicate-acceptance checks run
  3. User is created in Clerk if they don't exist, then added to the organisation
  4. All pending invites for that email are batch-accepted (handles the case where a user was invited to multiple properties)
  5. For each accepted invite, the appropriate DO sync fires (see below)
  6. Activity log entries are recorded
  7. User is redirected to the app

Durable Object Synchronisation

Once an invite is accepted, the relevant data must be pushed to the property's Durable Object so that the DO can enforce access control locally.

Tenant Sync

Files:

  • crates/bf_resident_db/src/user.rs -- sync_tenants_to_do() (lines 387-404)
  • crates/bf_user/src/durable_object/sync.rs -- sync_tenants_internal()

The tenant sync sends a full list of accepted tenants to the DO via RpcRequest::SyncTenants. The DO replaces its tenants table contents entirely.

Guest Sync

Files:

  • crates/bf_resident_db/src/user.rs -- sync_guest_to_do() (lines 544-558)
  • crates/bf_user/src/durable_object/guests.rs -- invite_guest_internal() (lines 60-81)

Guest sync is incremental -- each accepted guest invite fires an RpcRequest::InviteGuest { user_email } RPC call. The DO creates a guest record with:

  • status: Active
  • is_permanent: true
  • access_start: current timestamp
  • access_end: None (no expiry by default)
  • permissions: default (monitor only)

The email column has a UNIQUE constraint, so re-syncing the same guest is a no-op (INSERT OR IGNORE).

Guest Revocation

Files:

  • crates/bf_resident_db/src/user.rs -- revoke_guest_from_do() (lines 561-574)
  • crates/bf_user/src/durable_object/guests.rs -- revoke_guest_by_email_internal()

When an invite is deleted, RpcRequest::GuestRevoked { user_email } is sent to the DO, which sets the guest's status to Revoked.

Full Property Sync

File: crates/bf_resident_db/src/user.rs -- sync_property_to_do() (lines 467-511)

Used during property creation or manual re-sync. Pushes all property metadata, devices, tenants, documents, and guests in a single batch via the RefreshMetadata RPC.

Access Control in the Durable Object

File: crates/bf_user/src/permissions.rs

Every RPC request to the DO passes through check_permission(), which:

  1. Resolves user roles (get_user_roles(), line 47):
    • Checks Clerk JWT for Admin/Installer/Tenant roles
    • Queries the DO's tenants table for tenant status
    • Queries the DO's guests table for status = 'Active'
  2. Checks any role grants access for the given request and property status

Property Status Gates

Property StatusAllowed Roles
SetupAdmin, Installer
Commissioning, Commissioned, PreTenancyAdmin, Installer
ScheduledMovedIn, Tenancy, ScheduledMovedOutAdmin, Tenant, Guest

Guest Permission Capabilities

File: crates/bf_user/src/permissions.rs -- check_guest_permission() (line 328)

Guests have fine-grained capability-based access rather than blanket role access:

CapabilityControls
monitor (always on)List devices, read states, notifications, access logs
security_controlArm/disarm alarm hub
front_door_and_cameraLock/unlock doors, doorbell sessions
thermostat_controlTemperature setpoints, HVAC modes
view_energy_usageEnergy readings and graphs

Denied to all guests: Guest/PIN management, admin operations, backup/restore, sync operations.

GuestStatus Serialisation Convention

Guest status is stored as plain text in the DO's SQLite database (e.g., Active, Revoked, Expired). The GuestStatus enum uses strum derives for string conversion:

// crates/bf_user/src/models.rs
#[derive(strum_macros::AsRefStr, strum_macros::EnumString, ...)]
#[strum(serialize_all = "PascalCase")]
pub enum GuestStatus {
Active,
Expired,
Revoked,
}

SQL queries compare against plain text values:

-- Correct:
SELECT COUNT(*) FROM guests WHERE email = ? AND status = 'Active'

-- Incorrect (legacy bug pattern -- do NOT use serde_json::to_string for enum status columns):
-- status = '"Active"'

Convention: Always use .as_str() or .as_ref() (via strum) when storing enum values in SQL columns. Never use serde_json::to_string() for simple enum status fields, as this wraps the value in JSON double-quotes that break SQL WHERE clause comparisons. See the PropertyStatus pattern in crates/bf_types/src/resident/property.rs for the canonical example.

Sequence Diagram

PM/Admin                  bf_resident              Clerk              bf_user DO
| | | |
|-- POST /invites -------->| | |
| |-- create org invite->| |
| |-- INSERT invite ----->| |
| |<-- invite created ----| |
|<-- 201 Created ----------| | |
| | | |
| User clicks email link | |
| | | |
| |<-- GET /accept?token=.. |
| |-- create/add user -->| |
| |-- UPDATE status=Accepted |
| |-- RPC: InviteGuest --|-----------------> |
| | | upsert guest |
| | | status=Active |
| |<-- redirect to app --| |
| | | |
| Guest accesses property | |
| | | |
|-- RPC request (via proxy)|-----------------------------------> |
| | | check_permission |
| | | get_user_roles |
| | | -> Guest role |
| | | check_guest_perm |
|<-- response (or 403) ----|<------------------------------------| |

Troubleshooting

Guest Cannot Access After Accepting Invite

  1. Check the guests table in the property's DO -- does the record exist?
  2. Verify status column value is exactly Active (no JSON quotes)
  3. Check property status is occupied (ScheduledMovedIn, Tenancy, or ScheduledMovedOut)
  4. Verify the guest's email matches (case-insensitive lookup via LOWER())

Invite Sync Not Reaching DO

  1. Check bf_resident_db::user::sync_guest_to_do() logs for RPC errors
  2. Verify the property has a valid DO binding in wrangler config
  3. Check that bf_user::proxy() can reach the DO (service auth must be configured)