Skip to content

Contact Access Control

Every contact API endpoint calls check_contact_access before reading or mutating data. This function enforces assignment-based permissions — members with *Assigned permissions only access contacts where they appear in the contact_user table.

API request hits a contact endpoint
→ check_contact_access(db, session, contact_id, action)
1. Load contact WHERE id = ? AND organization_id = session.org_id
2. Not found? → 404
3. Query contact_user for assigned_member_ids
4. Build ContactInstance { org_id, assigned_member_ids }
5. ContactResource::has_instance_permission(session, action, &instance)
6. Denied? → 403 "You do not have permission to access this contact"
7. Granted? → return contact model (saves a second DB query)

File: src/mods/contact/services/check_contact_access_service.rs

pub async fn check_contact_access(
db: &DatabaseConnection,
session: &Session,
contact_id: Uuid,
action: ContactInstanceAction,
) -> Result<contact::Model, AppError>

The function returns the contact model on success. This eliminates redundant queries — most endpoints need the contact data after verifying access.

The ABAC engine resolves permissions in this order:

  1. Super admin → allow (cross-org access)
  2. Wrong org → deny
  3. Org owner → allow
  4. View / Update / Delete → allow (org-wide access)
  5. ViewAssigned / UpdateAssigned / DeleteAssigned → allow only if assigned_member_ids contains session.member_id
  6. No match → deny (403)
PermissionScopeWhat it grants
Contact:Instance:ViewOrg-wideView any contact
Contact:Instance:ViewAssignedAssignedView contacts assigned to you
Contact:Instance:UpdateOrg-wideEdit any contact
Contact:Instance:UpdateAssignedAssignedEdit contacts assigned to you
Contact:Instance:DeleteOrg-wideDelete any contact
Contact:Instance:DeleteAssignedAssignedDelete contacts assigned to you
Contact:Collection:ListOrg-wideList all contacts
Contact:Collection:ListAssignedAssignedList only assigned contacts
Contact:Collection:CreateOrg-wideCreate new contacts

The list endpoint uses a two-tier check:

let show_all = ContactResource::has_collection_permission(
&session, ContactCollectionPermission::List,
);
if !show_all {
ContactResource::has_collection_permission(
&session, ContactCollectionPermission::ListAssigned,
).or_forbidden("Not allowed to list contacts")?;
}

When show_all is false, the query adds a subquery filter:

let assigned_subquery = contact_user::Entity::find()
.select_only()
.column(contact_user::Column::ContactId)
.filter(contact_user::Column::MemberId.eq(session.member_id))
.into_query();
condition = condition.add(contact::Column::Id.in_subquery(assigned_subquery));

Members with ListAssigned only see contacts in their contact_user rows.

Access control cascades through contact access. Notes, messages, and attachments all verify contact access first:

EndpointContact checkAdditional check
GET /api/contacts/{id}/notesViewContactNote:Collection:List
POST /api/contacts/{id}/notesViewContactNote:Collection:Create
PUT /api/contacts/{id}/notes/{note_id}ViewContactNote:Instance:Update or UpdateOwn
DELETE /api/contacts/{id}/notes/{note_id}ViewContactNote:Instance:Delete or DeleteOwn
GET /api/contacts/{id}/tasksViewTask:Collection:List
GET /api/messages?contact_idViewMessage:Collection:List
POST /api/messages/sendViewMessage:Collection:Create
GET /api/messages/attachments/{id}View (if linked)Message:Collection:ViewAttachment

Contact assignment is the sole access boundary for viewing notes. If you can view the contact, you can see all its notes. Authorship only affects edit and delete:

PermissionGrants
ContactNote:Instance:UpdateEdit any note
ContactNote:Instance:UpdateOwnEdit notes you authored
ContactNote:Instance:DeleteDelete any note
ContactNote:Instance:DeleteOwnDelete notes you authored

The server returns can_edit and can_delete flags with each note, computed via ABAC against ContactNoteInstance { org_id, author_id }.

When a 403 reaches the UI, the toast notification system displays a user-friendly message:

// In any component
let mut toaster = use_context::<Toaster>();
toaster.mutation_error(&error_string);
// Detects 403 → shows "Access denied" title
// Strips HTTP status prefix → shows clean message body

Key utilities:

  • is_forbidden_error() — checks if error starts with "403" (src/shared/utils/is_forbidden_error.rs)
  • friendly_server_error() — strips "403 Forbidden: " prefix (src/shared/utils/friendly_server_error.rs)

All contact CRUD, detail mutations (phones, emails, addresses, tags, field values), notes, messages, and attachment serving now call check_contact_access. The full list:

  • get_contact_api, update_contact_api, delete_contact_api
  • All create_/update_/delete_contact_{phone,email,address}_api
  • assign_contact_tag_api, unassign_contact_tag_api
  • upsert_contact_field_value_api, delete_contact_field_value_api
  • get_contact_tasks_api, get_contact_calls_api, get_contacts_list_api
  • get_contact_notes_api, create_contact_note_api, update_contact_note_api, delete_contact_note_api
  • get_contact_messages_api, send_message_api, serve_attachment_api