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:
- Clerk (identity provider) -- handles user accounts and organisation membership
- bf_resident D1 database -- stores invite records with status tracking
- 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
| Role | publicMetadata.roles | DO Sync Target | Description |
|---|---|---|---|
Tenant | tenant | tenants table | Full resident access |
Guest | tenant | guests table | Capability-gated access |
Installer | installer | -- | Commissioning access |
PropertyManager | property_manager | -- | Admin-level management |
Note: Guests are mapped to
tenantbecause Clerk has no guest role. The Durable Object'sgueststable 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()
- Authorisation: Only Admins can create Installer/PM invites; PMs can create Tenant/Guest invites
- Token generation: A random UUID becomes the
accept_tokenin the invite URL - Clerk invitation: For non-Guest roles, a Clerk organisation invitation is created with a branded email
- D1 record: Invite stored with status
Pendinginbf_resident_db - 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 handlerscrates/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:
- Token is looked up in the
invitestable - Expiry and duplicate-acceptance checks run
- User is created in Clerk if they don't exist, then added to the organisation
- All pending invites for that email are batch-accepted (handles the case where a user was invited to multiple properties)
- For each accepted invite, the appropriate DO sync fires (see below)
- Activity log entries are recorded
- 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:Activeis_permanent:trueaccess_start: current timestampaccess_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:
- Resolves user roles (
get_user_roles(), line 47):- Checks Clerk JWT for Admin/Installer/Tenant roles
- Queries the DO's
tenantstable for tenant status - Queries the DO's
gueststable forstatus = 'Active'
- Checks any role grants access for the given request and property status
Property Status Gates
| Property Status | Allowed Roles |
|---|---|
Setup | Admin, Installer |
Commissioning, Commissioned, PreTenancy | Admin, Installer |
ScheduledMovedIn, Tenancy, ScheduledMovedOut | Admin, 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:
| Capability | Controls |
|---|---|
| monitor (always on) | List devices, read states, notifications, access logs |
security_control | Arm/disarm alarm hub |
front_door_and_camera | Lock/unlock doors, doorbell sessions |
thermostat_control | Temperature setpoints, HVAC modes |
view_energy_usage | Energy 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 useserde_json::to_string()for simple enum status fields, as this wraps the value in JSON double-quotes that break SQL WHERE clause comparisons. See thePropertyStatuspattern incrates/bf_types/src/resident/property.rsfor 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
- Check the
gueststable in the property's DO -- does the record exist? - Verify
statuscolumn value is exactlyActive(no JSON quotes) - Check property status is occupied (
ScheduledMovedIn,Tenancy, orScheduledMovedOut) - Verify the guest's email matches (case-insensitive lookup via
LOWER())
Invite Sync Not Reaching DO
- Check
bf_resident_db::user::sync_guest_to_do()logs for RPC errors - Verify the property has a valid DO binding in wrangler config
- Check that
bf_user::proxy()can reach the DO (service auth must be configured)