Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Role-based Access Control

The Manage module implements role-based access control (RBAC) with a compact, code-first model. Every administrative API call flows through an authorization check that compares the caller’s stored role with a whitelist embedded in the operation being executed. This section documents the concrete design so other modules can plug into the same pattern.

Admin roles

Administrator accounts persist a strongly-typed role in the admin.admin_account table. The role enum is defined in Rust as AdminRole with four variants: SuperAdmin, Moderator, CustomerSupport, and SupportBot. Each variant describes the maximum authority an operator can have, and the enum provides helpers for serialising the value into JWT claims:

#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize)]
#[serde(rename_all = "snake_case")]
#[sqlx(type_name = "admin.admin_role", rename_all = "snake_case")]
pub enum AdminRole {
    /// The super admin is the highest level of admin. Super admin can use all manage APIs.
    SuperAdmin,
    /// The moderator is a lower level of admin. Moderator can use all non-sensitive APIs.
    Moderator,
    /// The customer support is a lower level of admin. Customer support can only access user management APIs.
    CustomerSupport,
    /// The support bot can access most of non-sensitive read APIs, but cannot access any write APIs.
    SupportBot,
}

impl AdminRole {
    pub fn to_jwt_string(&self) -> String {
        match self {
            AdminRole::SuperAdmin => "super_admin".to_string(),
            AdminRole::Moderator => "moderator".to_string(),
            AdminRole::CustomerSupport => "customer_support".to_string(),
            AdminRole::SupportBot => "support_bot".to_string(),
        }
    }
}

The current Manage APIs are conservative: every write path and most read paths are restricted to SuperAdmin. That choice is encoded directly in the service layer and can be relaxed by expanding the allowed-role lists as more granular policies are introduced. For example, here are some typical operation implementations:

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CreateInvite {
   pub inviter_id: Uuid,
   pub role: AdminRole,
}

impl AdminOperation for CreateInvite {
   const ALLOWED_ROLES: &'static [AdminRole] = &[AdminRole::SuperAdmin];
   const OPERATION_NAME: &'static str = "create_invite";
   const OPERATION_TARGET: &'static str = "admin";

   fn to_audit_log(&self) -> Result<String, serde_json::Error> {
      // ...
   }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ChangeRole {
    pub admin_id: Uuid,
    pub role: AdminRole,
}

impl AdminOperation for ChangeRole {
   const ALLOWED_ROLES: &'static [AdminRole] = &[AdminRole::SuperAdmin];
   const OPERATION_NAME: &'static str = "change_role";
   const OPERATION_TARGET: &'static str = "admin";

   fn to_audit_log(&self) -> Result<String, serde_json::Error> {
       // ...
   }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct ListAdmins {
   pub limit: i64,
   pub offset: i64,
}

impl AdminOperation for ListAdmins {
   const ALLOWED_ROLES: &'static [AdminRole] = &[AdminRole::SuperAdmin];
   const OPERATION_NAME: &'static str = "list_admins";
   const OPERATION_TARGET: &'static str = "admin";

   fn to_audit_log(&self) -> Result<String, serde_json::Error> {
      // ...
   }
}

Operation gating

Each RPC-facing action is modelled as a struct (for example CreateInvite or ListAdmins) that implements the AdminOperation trait. The trait requires the operation to publish three constants—ALLOWED_ROLES, OPERATION_NAME, and OPERATION_TARGET—and a method for serialising audit metadata. The ALLOWED_ROLES array is the crucial RBAC rule: it declares which AdminRole values may invoke the action:

pub trait AdminOperation {
   const ALLOWED_ROLES: &'static [AdminRole];
   const OPERATION_NAME: &'static str;
   const OPERATION_TARGET: &'static str;

   fn check_permission(rule: AdminRole) -> bool {
      // implemented function
   }

   fn with_admin_id(self, admin_id: Uuid) -> RecordedAdminOperation<Self>
   where
        Self: Sized,
   {
      // implemented function
   }

   // required function
   fn to_audit_log(&self) -> Result<String, serde_json::Error>;
}

Requests are wrapped in an AuditLayer before they reach the underlying service. The layer looks up the caller’s account, verifies that the stored role is present in ALLOWED_ROLES, records an audit entry when appropriate, and only then dispatches the call to the service implementation. Any mismatch results in an immediate PermissionsDenied error without touching the business logic. This keeps enforcement centralised and guarantees that logging and RBAC stay in sync:

#[derive(Debug, Clone)]
pub struct AuditLayer {
   // private fields
}

impl AuditLayer {
   pub fn new(database_processor: DatabaseProcessor) -> Self {
      // implemented function
   }

   // Main audited wrapper
   async fn wrap<Oper, Output, Proc>(
      &self,
      processor: &Proc,
      input: RecordedAdminOperation<Oper>,
   ) -> Result<Output, Error>
   where
      Oper: AdminOperation + Send,
      Proc: Processor<RecordedAdminOperation<Oper>, Result<Output, Error>> + Send + Sync,
   {
      // implemented function
   }
}

Reusing the RBAC pattern

Other Manage submodules—or even external crates—should follow the same contract when adding administrative features:

  1. Define a command object for the new action and implement AdminOperation on it. Select the minimal role set necessary for the task and provide a concise audit payload via to_audit_log.
  2. Process the command through AuditLayer::wrap (or wrap_without_record if the action should skip audit logging) so that permission checks and audit persistence always run.
  3. When exposing the command through gRPC or HTTP, ensure the request handler obtains the caller’s ID from the authentication middleware and forwards it by calling .with_admin_id(...) on the operation before handing it to the service.

By embedding role checks inside the operation type instead of scattering them through handler logic, the Manage module keeps RBAC auditable, testable, and easy to extend. Future modules can adopt finer-grained policies simply by expanding the enum or splitting operations with different allowed-role sets without having to rework middleware.