Skip to main content

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 checking
  • crates/bf_user/src/permissions.rs - DO-level permission checking
  • crates/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

RoleSetupCommissioningCommissionedPreTenancyScheduledMovedInTenancyScheduledMovedOut
Admin❌*❌*❌*
ItAdmin
PropertyManager
Installer✅ (RO)
Tenant
Guest✅**✅**✅**

*Admin can access occupied if also tenant/guest
**Based on GuestPermissions

See Also