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:
- Define a command object for the new action and implement
AdminOperationon it. Select the minimal role set necessary for the task and provide a concise audit payload viato_audit_log. - Process the command through
AuditLayer::wrap(orwrap_without_recordif the action should skip audit logging) so that permission checks and audit persistence always run. - 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.