Skip to content

Record Rules

Record rules provide row-level security, controlling which specific records a user can access.

While model access controls CRUD operations on entire models, record rules filter which records within a model are visible.

{
"data_type": "RecordRule",
"identifier": "rule_lead_salesperson",
"name": "Leads: salesperson sees own",
"model": "CrmLead",
"groups": ["crm_user"],
"rule": "(Q(salesperson__id__eq=uid))",
"read_perm": true,
"write_perm": true,
"create_perm": true,
"delete_perm": false
}

This rule ensures CRM users can only see leads assigned to them.

Rules use Q expressions to filter records.

A rule string is parsed by the same safe Q-evaluator used for view modifiers (ast-walked against a whitelist — never raw eval). Inside a rule you may reference Q, the connectors & | ~, Python literals, and the following context names bound from the current request’s environment:

NameBinds to
QThe Q-expression builder (the only callable, alongside the D date helper).
uidThe current user’s id.
cidThe primary (first) active company id, or None.
company_idAn exact alias of cid — the same primary company id.
cidsA list of all active company ids (the user’s allowed companies). Use with __in for cross-company rules, e.g. Q(company__id__in=cids).
contact_idThe current portal contact id.
"(Q(company__id__in=cids))" # records in ANY of the user's active companies
"(Q(company__id__eq=cid))" # records in just the primary company
"(Q(user__id__eq=uid))" # Records owned by current user (ManyToOne)
"(Q(salesperson__id__eq=uid))" # Records assigned to current user (ManyToOne)
"(Q(create_uid__id__eq=uid))" # Records created by current user (ManyToOne)

Important: For ManyToOne fields, use field__id__eq=uid syntax to traverse the relationship and compare the ID.

"(Q(active=True))" # Only active records
"(Q(state='draft'))" # Only draft records
"(Q(amount__gt=0))" # Positive amounts
# AND conditions
"(Q(active=True) & Q(user__id__eq=uid))"
# OR conditions
"(Q(user__id__eq=uid) | Q(team__member__id__eq=uid))"
# Through relationships
"(Q(company__id__eq=cid))" # Current user's company
"(Q(team__manager__id__eq=uid))" # User is team manager

Multiple rules for the same model/groups are combined with OR:

[
{
"data_type": "RecordRule",
"identifier": "rule_lead_own",
"model": "CrmLead",
"groups": ["crm_user"],
"rule": "(Q(salesperson__id__eq=uid))"
},
{
"data_type": "RecordRule",
"identifier": "rule_lead_team",
"model": "CrmLead",
"groups": ["crm_user"],
"rule": "(Q(team__member__id__eq=uid))"
}
]

Users see leads where they’re the salesperson OR they’re a team member.

Rules without groups apply to everyone:

{
"data_type": "RecordRule",
"identifier": "rule_lead_active_only",
"name": "Leads: active only",
"model": "CrmLead",
"groups": [],
"rule": "(Q(active=True))"
}

Global rules are combined with AND with other rules.

Control which operations the rule affects:

{
"data_type": "RecordRule",
"identifier": "rule_invoice_company",
"model": "Invoice",
"groups": ["account_user"],
"rule": "(Q(company__eq=company_id))",
"read_perm": true,
"write_perm": true,
"create_perm": true,
"delete_perm": false
}
PermissionDescription
read_permRule applies to read operations
write_permRule applies to update operations
create_permRule applies to create operations
delete_permRule applies to delete operations
{
"data_type": "RecordRule",
"identifier": "rule_task_own",
"model": "Task",
"groups": ["project_user"],
"rule": "(Q(assignee__id__eq=uid))"
}
{
"data_type": "RecordRule",
"identifier": "rule_contact_company",
"model": "Contact",
"groups": ["core_internal"],
"rule": "(Q(company__eq=company_id) | Q(company__isnull=True))"
}
{
"data_type": "RecordRule",
"identifier": "rule_lead_team",
"model": "CrmLead",
"groups": ["crm_user"],
"rule": "(Q(team__member__id__eq=uid))"
}
{
"data_type": "RecordRule",
"identifier": "rule_lead_manager",
"model": "CrmLead",
"groups": ["crm_manager"],
"rule": "(Q(id__gte=0))"
}
{
"data_type": "RecordRule",
"identifier": "rule_order_portal",
"model": "SaleOrder",
"groups": ["core_portal"],
"rule": "(Q(contact__eq=contact_id))"
}
[
{
"data_type": "Group",
"identifier": "sales_user",
"name": "User",
"category": "Sales"
},
{
"data_type": "Group",
"identifier": "sales_manager",
"name": "Manager",
"category": "Sales",
"implied_groups": [["L", "sales_user"]]
},
{
"data_type": "ModelAccess",
"identifier": "access_order_user",
"name": "SaleOrder Access (User)",
"model": "SaleOrder",
"group": "sales_user",
"read_perm": true,
"create_perm": true,
"write_perm": true,
"delete_perm": false
},
{
"data_type": "ModelAccess",
"identifier": "access_order_manager",
"name": "SaleOrder Access (Manager)",
"model": "SaleOrder",
"group": "sales_manager",
"read_perm": true,
"create_perm": true,
"write_perm": true,
"delete_perm": true
},
{
"data_type": "RecordRule",
"identifier": "rule_order_user",
"name": "Orders: salesperson own",
"model": "SaleOrder",
"groups": ["sales_user"],
"rule": "(Q(salesperson__id__eq=uid))"
},
{
"data_type": "RecordRule",
"identifier": "rule_order_manager",
"name": "Orders: manager all",
"model": "SaleOrder",
"groups": ["sales_manager"],
"rule": "(Q(id__gte=0))"
}
]

Use elevate() context manager when needed:

from fullfinity.engine.models import elevate
# Normal query (respects record rules)
my_orders = await SaleOrder.filter().all()
# Elevated query (bypasses record rules)
with elevate():
all_orders = await SaleOrder.filter().all()

Enable debug logging to see applied rules:

import logging
logging.getLogger("fullfinity.security").setLevel(logging.DEBUG)
  1. Start with global rules - Apply company/active filters globally
  2. Layer group rules - More privileged groups get broader access
  3. Test as different users - Verify rules work as expected
  4. Document complex rules - Explain the business logic
  5. Use meaningful names - Rule names should describe what they do