ABAC Authorization
Loquent uses Attribute-Based Access Control (ABAC) to authorize every API endpoint. Permissions are declared in Rust, serialized as JSON in the database, loaded into the Session at authentication, and checked at the handler layer via the Resource trait.
Architecture Overview
Section titled “Architecture Overview”resources.rs (define_resources! macro) → generates: InstanceAction, InstancePermission, CollectionPermission, Resource marker → Permission enum serializes as "Resource:Level:Variant"
Session (server-side) → carries: user, organization, member_id, permissions, is_super_admin, is_owner
Resource trait (resource_trait.rs) → has_instance_permission() — checks access to a specific entity → has_collection_permission() — checks access to a collection (list/create)Permissions Format
Section titled “Permissions Format”Permissions serialize as colon-separated strings:
"Agent:Instance:Delete" → delete a specific agent"Agent:Collection:List" → list the org's agents"Contact:Instance:ViewOwn" → view contacts assigned to the session's member"ContactNote:Collection:ListOwn" → list notes authored by the session userThe root Permission enum deserializes each string:
let perm: Permission = serde_json::from_str(r#""Agent:Instance:Delete""#).unwrap();// → Permission::Agent(AgentPermission::Instance(AgentInstancePermission::Delete))Invalid strings are silently skipped during session loading — the system won’t reject a login because of an unrecognized permission value.
The define_resources! Macro
Section titled “The define_resources! Macro”All resources are declared in src/bases/auth/types/resources.rs using a single macro call:
define_resources! { Agent { Actions { View, Update, Delete } Instance { View, Update, Delete } Collection { List, Create } }
ContactNote { Actions { View, Update, Delete } Instance { View, Update, Delete, ViewOwn, UpdateOwn, DeleteOwn } Collection { List, ListOwn, Create } }
Settings { Actions { View, Update } Instance { View, Update } // No Collection block — Settings is a singleton per org } // ... 15 resources total}For each resource block, the macro generates:
| Generated type | Example |
|---|---|
<R>InstanceAction | AgentInstanceAction::Delete |
<R>InstancePermission | AgentInstancePermission::Delete |
<R>CollectionPermission | AgentCollectionPermission::List |
<R>Permission | AgentPermission::Instance(...) / AgentPermission::Collection(...) |
<R>Resource (server only) | AgentResource — implements Resource trait |
All Resources
Section titled “All Resources”| Resource | Instance permissions | Collection permissions |
|---|---|---|
Contact | View, Update, Delete, ViewAssigned, UpdateAssigned, DeleteAssigned | List, ListAssigned, Create |
ContactNote | Update, Delete, UpdateOwn, DeleteOwn | List, Create |
Agent | View, Update, Delete | List, Create |
Settings | View, Update | — |
Role | View, Update, Delete | List, Create |
Member | View, Update, Remove | List |
Analyzer | View, Update, Delete | List, Create |
Call | View, ViewAssigned, Update | List, ListAssigned, MakeCall |
Dashboard | ViewOrg, ViewOwn | — |
Knowledge | View, Update, Delete | List, Create |
Message | View | List, Create, ViewAttachment, UploadAttachment |
Phone | View, Update | List, Create |
TextAgent | View, Update, Delete | List, Create |
Task | View, Update, Delete | List, Create |
Plan | View, Approve, Reject | List, Create |
PlanTemplate | View, Update, Delete | List, Create |
Twilio | View, Update | List, Create |
Notification | View, MarkRead | List |
Report | Send | — |
Assistant | Use | — |
Session Loading
Section titled “Session Loading”The Session struct is populated via the get_auth_state_service and used as an Axum extractor:
pub struct Session { pub id: Uuid, pub user: User, pub organization: Organization, pub member_id: Uuid, pub permissions: Vec<Permission>, pub is_super_admin: bool, pub is_owner: bool,}Permissions are loaded from the role.permissions JSONB column for all roles assigned to the member. Unknown permission strings are filtered out silently (no login failure).
The Resource Trait
Section titled “The Resource Trait”Every <R>Resource struct implements the Resource trait to define instance-level authorization logic:
pub trait Resource: ResourceCore { type Instance: ResourceInstance; // carries org_id + any ownership fields
/// Custom logic: does `permission` grant `action` on `instance`? fn check_instance( session: &Session, action: Self::InstanceAction, instance: &Self::Instance, permission: &Self::InstancePermission, ) -> bool;
/// Enforcement chain — call this from handlers. fn has_instance_permission( session: &Session, action: Self::InstanceAction, instance: &Self::Instance, ) -> bool { /* see below */ }
/// Collection check — call this from list/create handlers. fn has_collection_permission( session: &Session, permission: Self::CollectionPermission, ) -> bool { /* see below */ }}Enforcement Chain
Section titled “Enforcement Chain”Instance endpoints
Section titled “Instance endpoints”1. is_super_admin? → allow (cross-org access)2. instance.org_id != session.org_id? → deny (hard org-scope)3. is_owner? → allow (full access within org)4. iterate session.permissions: try_from<Permission> → if matches resource, call check_instance → allow on first match5. no match → deny (403)Collection endpoints
Section titled “Collection endpoints”1. is_super_admin || is_owner? → allow2. session.permissions contains the required CollectionPermission? → allow3. no match → deny (403)Ownership-Based Permissions
Section titled “Ownership-Based Permissions”Resources like ContactNote implement check_instance with ownership logic:
// Pseudocode showing the pattern for ContactNotefn check_instance( session: &Session, action: ContactNoteInstanceAction, instance: &ContactNoteInstance, permission: &ContactNoteInstancePermission,) -> bool { let is_author = session.user.id == instance.author_id;
match action { ContactNoteInstanceAction::View => match permission { ContactNoteInstancePermission::View => true, ContactNoteInstancePermission::ViewOwn => is_author, _ => false, }, ContactNoteInstanceAction::Delete => match permission { ContactNoteInstancePermission::Delete => true, ContactNoteInstancePermission::DeleteOwn => is_author, _ => false, }, // ... }}A member with only ContactNote:Instance:ViewOwn can view notes they authored, but gets 403 on notes from other team members.
Using It in Handlers
Section titled “Using It in Handlers”Instance endpoint
Section titled “Instance endpoint”// In an API handler#[get("/api/agents/{id}", session: Session)]pub async fn get_agent_api(id: String) -> Result<AgentData, HttpError> { let db = db_client().await.or_internal_server_error("DB error")?;
// 1. Fetch the instance first (needed for org_id) let agent = AgentEntity::find_by_id(id) .one(&db).await .or_internal_server_error("query failed")? .or_not_found("Agent")?;
// 2. Build the authorization instance let instance = AgentInstance { org_id: agent.organization_id };
// 3. Check permission if !AgentResource::has_instance_permission(&session, AgentInstanceAction::View, &instance) { return Err(HttpError::forbidden("Insufficient permissions")); }
Ok(AgentData::from(agent))}Collection endpoint
Section titled “Collection endpoint”#[get("/api/agents", session: Session)]pub async fn get_agents_api() -> Result<Vec<AgentData>, HttpError> { // Check before querying if !AgentResource::has_collection_permission( &session, AgentCollectionPermission::List, ) { return Err(HttpError::forbidden("Insufficient permissions")); }
let db = db_client().await.or_internal_server_error("DB error")?; let agents = AgentEntity::find() .filter(AgentColumn::OrganizationId.eq(session.organization.id)) .all(&db).await .or_internal_server_error("query failed")?;
Ok(agents.into_iter().map(AgentData::from).collect())}HTTP Error Helpers
Section titled “HTTP Error Helpers”All handlers return HttpError with proper status codes using these extension methods on Option and Result:
| Method | Status |
|---|---|
.or_forbidden("msg") | 403 |
.or_not_found("Entity") | 404 |
.or_bad_request("msg") | 400 |
.or_internal_server_error("msg") | 500 |
Adding a New Resource
Section titled “Adding a New Resource”- Declare it in
src/bases/auth/types/resources.rs:
define_resources! { // ... existing resources ...
Report { Actions { Send } Instance { Send } }}- Define the instance struct and implement
ResourceInstance:
pub struct ReportInstance { pub org_id: Uuid,}
impl ResourceInstance for ReportInstance { fn org_id(&self) -> Uuid { self.org_id }}- Implement
check_instanceforReportResource:
impl Resource for ReportResource { type Instance = ReportInstance;
fn check_instance( _session: &Session, action: ReportInstanceAction, _instance: &ReportInstance, permission: &ReportInstancePermission, ) -> bool { match action { ReportInstanceAction::Send => matches!(permission, ReportInstancePermission::Send), } }}- Use it in handlers with
ReportResource::has_instance_permission(...).
Module Structure
Section titled “Module Structure”src/bases/auth/├── types/│ ├── resources.rs # define_resources! call — all 15 resources│ ├── define_resources_macro.rs # macro definition│ ├── resource_trait.rs # Resource, ResourceCore, ResourceInstance traits│ ├── session_type.rs # Session struct + FromRequestParts impl│ └── ...├── services/│ └── get_auth_state_service.rs # loads Session from cookie + DB└── api/ └── get_session_api.rs # maps Session → ClientSession for the client