Role-Based Access Control
The permission system combines Clerk organization roles with property-status-based access control.
Key Files
crates/clerk-auth/src/roles.rs- Role definitions and checkingcrates/bf_user/src/permissions.rs- DO-level permission checkingcrates/bf_resident/src/middleware/authorization.rs- Route authorization
Role Hierarchy
┌─────────────────────────────────────────────────────────────────────────┐
│ Role Hierarchy │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Admin (Highest) │
│ │ │
│ ├── ItAdmin │
│ │ │
│ ├── PropertyManager │
│ │ │
│ ├── Installer │
│ │ │
│ └── Tenant (Lowest) │
│ │ │
│ └── Guest (Special - DB-defined, not Clerk role) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Role Definitions
// crates/clerk-auth/src/roles.rs
pub enum Role {
Admin,
ItAdmin,
PropertyManager,
Installer,
Tenant,
}
impl Role {
pub fn role_name(&self) -> &'static str {
match self {
Role::Admin => "admin",
Role::ItAdmin => "it_admin",
Role::PropertyManager => "property_manager",
Role::Installer => "installer",
Role::Tenant => "tenant",
}
}
}
Role Checking Functions
// crates/clerk-auth/src/roles.rs
pub fn is_admin(auth: &ClerkAuth) -> bool {
auth.has_role("admin")
}
pub fn is_it_admin(auth: &ClerkAuth) -> bool {
auth.has_role("it_admin") || is_admin(auth)
}
pub fn is_property_manager(auth: &ClerkAuth) -> bool {
auth.has_role("property_manager") || auth.has_role("pm") || is_admin(auth)
}
pub fn is_installer(auth: &ClerkAuth) -> bool {
auth.has_role("installer") || is_admin(auth)
}
pub fn is_tenant(auth: &ClerkAuth) -> bool {
auth.has_role("tenant") || is_admin(auth)
}
/// Get the primary (highest priority) role
pub fn get_primary_role(auth: &ClerkAuth) -> Option<Role> {
if is_admin(auth) { return Some(Role::Admin); }
if is_it_admin(auth) { return Some(Role::ItAdmin); }
if is_property_manager(auth) { return Some(Role::PropertyManager); }
if is_installer(auth) { return Some(Role::Installer); }
if is_tenant(auth) { return Some(Role::Tenant); }
None
}
Property Status-Based Access
Access depends on whether a property is vacant or occupied:
┌─────────────────────────────────────────────────────────────────────────┐
│ Property Status Lifecycle │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ VACANT STATES OCCUPIED STATES │
│ (Admin, Installer access) (Tenant, Guest access) │
│ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ Setup │ ──────────────────▶ │ ScheduledMovedIn│ │
│ └─────────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │Commissioning│ │ Tenancy │ │
│ └─────────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │Commissioned │ ◀────────────────────│ScheduledMovedOut│ │
│ └─────────────┘ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ PreTenancy │ │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Status Helper Methods
// crates/bf_user/src/models.rs
impl PropertyStatus {
pub fn is_setup(&self) -> bool {
matches!(self, PropertyStatus::Setup)
}
pub fn is_vacant(&self) -> bool {
matches!(self,
PropertyStatus::Commissioning
| PropertyStatus::Commissioned
| PropertyStatus::PreTenancy
)
}
pub fn is_occupied(&self) -> bool {
matches!(self,
PropertyStatus::ScheduledMovedIn
| PropertyStatus::Tenancy
| PropertyStatus::ScheduledMovedOut
)
}
}
DO-Level Permission Checking
Permission Check Flow
// crates/bf_user/src/permissions.rs
pub async fn check_permission(
conn: &mut DOConnection,
auth: Option<&ClerkAuth>,
request: &RpcRequest,
property_status: PropertyStatus,
) -> bf_types::Result<()> {
// 1. Service-only operations
if is_service_only_operation(request) {
return if auth.is_some() {
Err(AppError::Forbidden("Service-only operation".into()))
} else {
Ok(())
};
}
// 2. Require authentication
let auth = auth.ok_or_else(|| AppError::Unauthorized("Auth required".into()))?;
// 3. Get user's roles (Clerk + DB)
let roles = get_user_roles(conn, auth).await?;
// 4. Check based on property status
if property_status.is_setup() || property_status.is_vacant() {
// Vacant properties: Admin, Installer
if roles.contains(&UserRole::Admin) {
return check_admin_permission(property_status, request);
}
if roles.contains(&UserRole::Installer) {
return check_installer_permission(property_status, request);
}
} else {
// Occupied properties: Tenant, Guest
if roles.contains(&UserRole::Tenant) {
return check_tenant_permission(property_status, request);
}
if roles.contains(&UserRole::Guest) {
return check_guest_permission(conn, auth, property_status, request).await;
}
}
Err(AppError::Forbidden("No permission for this property".into()))
}
Service-Only Operations
These operations bypass user auth and require service authentication:
fn is_service_only_operation(request: &RpcRequest) -> bool {
matches!(request,
// Lifecycle operations
RpcRequest::Seed(_)
| RpcRequest::MoveIn(_)
| RpcRequest::MoveOut(_)
| RpcRequest::Backup(_)
// Sync operations
| RpcRequest::SyncProperty(_)
| RpcRequest::SyncDevices(_)
| RpcRequest::SyncTenants(_)
// Device state updates from webhooks
| RpcRequest::UpdateDeviceStateFromSmart(_)
// PIN code callbacks
| RpcRequest::ActivatePinCode { .. }
| RpcRequest::ConfirmPinCodeDeletion { .. }
// Energy collection
| RpcRequest::CreateEnergyReading(_)
// Device token management
| RpcRequest::RegisterDeviceToken(_)
| RpcRequest::UnregisterDeviceToken(_)
)
}
Admin Permission
fn check_admin_permission(
property_status: PropertyStatus,
_request: &RpcRequest,
) -> bf_types::Result<()> {
// Admins can access Setup and Vacant properties
if property_status.is_setup() || property_status.is_vacant() {
Ok(())
} else {
// For occupied properties, admin needs tenant/guest status
Err(AppError::Forbidden(
"Admin access only for vacant properties. For occupied properties, admin must also be tenant/guest.".into()
))
}
}
Installer Permission
fn check_installer_permission(
property_status: PropertyStatus,
request: &RpcRequest,
) -> bf_types::Result<()> {
// Only Setup and early commissioning phases
let can_access = matches!(
property_status,
PropertyStatus::Setup
| PropertyStatus::Commissioning
| PropertyStatus::Commissioned
);
if !can_access {
return Err(AppError::Forbidden("Installer access denied for this status".into()));
}
// Read-only operations only
match request {
RpcRequest::ListDevices
| RpcRequest::GetDeviceState { .. }
| RpcRequest::GetAllDeviceStates
| RpcRequest::RefreshDeviceStates
| RpcRequest::ListInstallerErrors { .. }
| RpcRequest::CreateInstallerError { .. }
| RpcRequest::ResolveInstallerError { .. }
| RpcRequest::ClearInstallerErrors => Ok(()),
_ => Err(AppError::Forbidden("Operation not allowed for installers".into())),
}
}
Tenant Permission
fn check_tenant_permission(
property_status: PropertyStatus,
request: &RpcRequest,
) -> bf_types::Result<()> {
// Only occupied properties
if !property_status.is_occupied() {
return Err(AppError::Forbidden("Tenant access only for occupied properties".into()));
}
// Most operations allowed except device token management (service-only via HTTP)
match request {
RpcRequest::RegisterDeviceToken(_)
| RpcRequest::UnregisterDeviceToken(_) => {
Err(AppError::Forbidden("Use HTTP endpoint for device tokens".into()))
}
_ => Ok(()),
}
}
Guest Permission Model
Guests have granular, capability-based permissions:
GuestPermissions Structure
// crates/bf_user/src/models.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuestPermissions {
/// View device states, notifications, access logs
pub monitor: bool,
/// Lock/unlock front door, view doorbell camera
pub front_door_and_camera: bool,
/// Control thermostat temperature
pub thermostat_control: bool,
/// Control alarm hub, silence alarms
pub security_control: bool,
/// View energy usage data
pub view_energy_usage: bool,
}
impl Default for GuestPermissions {
fn default() -> Self {
Self {
monitor: true,
front_door_and_camera: true,
thermostat_control: false,
security_control: false,
view_energy_usage: false,
}
}
}
Guest Permission Checking
async fn check_guest_permission(
conn: &mut DOConnection,
auth: &ClerkAuth,
property_status: PropertyStatus,
request: &RpcRequest,
) -> bf_types::Result<()> {
if !property_status.is_occupied() {
return Err(AppError::Forbidden("Guest access only for occupied properties".into()));
}
// Get guest record from DB
let guest = storage::get_guest_by_email(conn, auth.user_id())
.await?
.ok_or_else(|| AppError::Forbidden("Guest not found".into()))?;
// Check guest is active
if !matches!(guest.status, GuestStatus::Active) {
return Err(AppError::Forbidden("Guest access expired or revoked".into()));
}
let perms = &guest.permissions;
match request {
// Monitor permission
RpcRequest::GetAllDeviceStates
| RpcRequest::GetDeviceState { .. }
| RpcRequest::ListNotifications(_)
| RpcRequest::MarkNotificationRead { .. }
if perms.monitor => Ok(()),
// Access logs
RpcRequest::ListAccessLogs(_) if perms.monitor => Ok(()),
// Front door & camera
RpcRequest::ExecuteSmartCapability { capability, .. } => {
match capability {
DeviceCapability::Lock
| DeviceCapability::Unlock
| DeviceCapability::GetLockStatus
if perms.front_door_and_camera => Ok(()),
DeviceCapability::StartVideoSession { .. }
| DeviceCapability::EndVideoSession { .. }
if perms.front_door_and_camera => Ok(()),
DeviceCapability::SetHeatingSetpoint { .. }
| DeviceCapability::SetCoolingSetpoint { .. }
| DeviceCapability::SetThermostatMode { .. }
if perms.thermostat_control => Ok(()),
DeviceCapability::SetAlarmHubMode { .. }
| DeviceCapability::SilenceAlarm
if perms.security_control => Ok(()),
_ => Err(AppError::Forbidden("Permission denied for this capability".into())),
}
}
// Energy readings
RpcRequest::GetEnergyReadings(_) if perms.view_energy_usage => Ok(()),
_ => Err(AppError::Forbidden("Permission denied".into())),
}
}
Route-Level Authorization
Role Middleware
// crates/bf_resident/src/middleware/authorization.rs
pub async fn require_admin(
Extension(auth): Extension<ClerkAuth>,
request: Request,
next: Next,
) -> Response {
if !is_admin(&auth) {
return unauthorized_response("Admin role required");
}
next.run(request).await
}
pub async fn require_property_manager(
Extension(auth): Extension<ClerkAuth>,
request: Request,
next: Next,
) -> Response {
if !is_property_manager(&auth) {
return unauthorized_response("Property Manager role required");
}
next.run(request).await
}
pub async fn require_installer(
Extension(auth): Extension<ClerkAuth>,
request: Request,
next: Next,
) -> Response {
if !is_installer(&auth) {
return unauthorized_response("Installer role required");
}
next.run(request).await
}
PM Portfolio Filtering
Property Managers only see properties in their assigned portfolios:
pub async fn verify_pm_property_access(
conn: &mut D1Connection,
auth: &ClerkAuth,
property_id: &str,
) -> Result<Property> {
let property = get_property_by_id(conn, property_id).await?;
// Admins can access all
if is_admin(auth) {
return Ok(property);
}
// PMs must be assigned to the portfolio
let portfolio = get_portfolio_by_id(conn, &property.portfolio_id).await?;
let user_email = auth.user_id();
if !portfolio.assigned_pms.contains(&user_email.to_string()) {
return Err(AppError::Forbidden("Not assigned to this portfolio".into()));
}
// Only Commissioned or PreTenancy for PMs
if !matches!(
property.status,
PropertyStatus::Commissioned | PropertyStatus::PreTenancy
) {
return Err(AppError::BadRequest("Property not in PM-accessible status".into()));
}
Ok(property)
}
Permission Matrix
| Role | Setup | Commissioning | Commissioned | PreTenancy | ScheduledMovedIn | Tenancy | ScheduledMovedOut |
|---|---|---|---|---|---|---|---|
| Admin | ✅ | ✅ | ✅ | ✅ | ❌* | ❌* | ❌* |
| ItAdmin | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| PropertyManager | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| Installer | ✅ | ✅ | ✅ (RO) | ❌ | ❌ | ❌ | ❌ |
| Tenant | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| Guest | ❌ | ❌ | ❌ | ❌ | ✅** | ✅** | ✅** |
*Admin can access occupied if also tenant/guest
**Based on GuestPermissions
See Also
- authentication.md - User authentication
- service-to-service-auth.md - Service auth
- user-durable-object.md - DO permissions
- property-lifecycle.md - Status transitions