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:
- - R
- - sales_user_group
rule: (Q(user__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 (the lead’s user is the salesperson). Note groups is a ManyToMany — it takes a command list (- - R / - - [ids]), never a bare list of identifiers.

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 delete date helper).
uidThe current user’s id.
cidThe current company — the one being acted for. as_company(X) if one is in force, otherwise the user’s stored default company. Not the first company in the selection: that list is the switcher’s checkbox order and names no company in particular.
company_idAn exact alias of cid — the same current company.
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.
gidsA list of all the user’s group ids (including implied groups). Use with __in for group-membership rules, e.g. `Q(folder__access_groups__id__in=gids)
readable_modelsA list of the model names this user holds read access on. For a model that names its parent polymorphically — a row carrying a model string plus a record id, rather than a foreign key — this is the only way a rule can ask about that parent: Q(model__in=readable_models).
"(Q(company__id__in=cids))" # records in ANY of the user's active companies
"(Q(company__id__eq=cid))" # records in just the current company

A few models point at “any record” — a model name and a document/record id instead of a relation. No relational path reaches through that, so Q(parent__…) is not available and a rule cannot ask whether the user may read row 42 of whatever the string names. readable_models is what is decidable before the query runs: which models the user may read at all.

rule: Q(model__in=readable_models) | Q(model__isnull=True)

That keeps a user out of the discussion and files belonging to whole areas they have no access to. It is a model-level answer to a model-level question — row-level scoping of the parent still comes from reading the parent record itself, which applies its own rules.

"(Q(user__id__eq=uid))" # Records owned by current user (ManyToOne)
"(Q(assigned_user__id__eq=uid))" # Records assigned to current user (ManyToOne)
"(Q(created_by__id__eq=uid))" # Records created by current user (the audit field)

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 (Selection values are readable text)
"(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__members__id__eq=uid))"
# Through relationships
"(Q(company__id__eq=cid))" # The current company
"(Q(team__team_leader__id__eq=uid))" # User is the team leader

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

- data_type: RecordRule
identifier: rule_lead_own
model: CrmLead
groups:
- - R
- - sales_user_group
rule: (Q(user__id__eq=uid))
- data_type: RecordRule
identifier: rule_lead_team
model: CrmLead
groups:
- - R
- - sales_user_group
rule: (Q(team__members__id__eq=uid))

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

A record rule takes effect only for users who belong to one of its groups. A rule with an empty groups list is never loaded — there are no “global” groupless rules. To apply a constraint to everyone, attach the rule to the base group every internal user has (core_internal):

- data_type: RecordRule
identifier: rule_lead_active_only
name: 'Leads: active only'
model: CrmLead
groups:
- - R
- - core_internal
rule: (Q(active=True))
read_perm: true
write_perm: true
create_perm: true
delete_perm: true

All record rules that apply to a user for a given model are combined with OR: the user can access a record if any one of their applicable rules matches it. (There is no AND combination — a broader rule does not get narrowed by a more restrictive one.)

Control which operations the rule affects:

- data_type: RecordRule
identifier: rule_order_company
model: SaleOrder
groups:
- - R
- - sales_user_group
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

Write rules are enforced on the backend at save time, so a forbidden update always fails regardless of the client. The standard UI honors them automatically: when a write rule excludes the current record, the form opens read-only rather than presenting inputs the user cannot save. You don’t need to do anything to get this behavior — defining the rule is enough.

Enforcement matches records regardless of their archived (active=False) state — a rule grants access by ownership/company/etc., not by whether the record is active. So a user can still update or delete their own archived record. If you want a rule to stop applying once a record is archived, say so explicitly by adding Q(active=True) to the rule.

- data_type: RecordRule
identifier: rule_order_own
model: SaleOrder
groups:
- - R
- - sales_user_group
rule: (Q(user__id__eq=uid))
- data_type: RecordRule
identifier: rule_contact_company
model: Contact
groups:
- - R
- - core_internal
rule: (Q(company__eq=company_id) | Q(company__isnull=True))
- data_type: RecordRule
identifier: rule_lead_team
model: CrmLead
groups:
- - R
- - sales_user_group
rule: (Q(team__members__id__eq=uid))
- data_type: RecordRule
identifier: rule_lead_manager
model: CrmLead
groups:
- - R
- - sales_manager_group
rule: (Q(id__gte=0))
- data_type: RecordRule
identifier: rule_order_portal
model: SaleOrder
groups:
- - R
- - 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:
- - link
- 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:
- - R
- - sales_user
rule: (Q(user__id__eq=uid))
- data_type: RecordRule
identifier: rule_order_manager
name: 'Orders: manager all'
model: SaleOrder
groups:
- - R
- - sales_manager
rule: (Q(id__gte=0))

Record rules are code-is-truth, exactly like model access: they default to apply_once: false, so every module upgrade re-applies (overwrites) each RecordRule back to its security/ definition. Editing a module-shipped rule in place (its rule expression, groups, or perm scope) is reverted on the next -u. This is intended — a row-level restriction you tighten in code always reaches every install.

To customize durably, be additive:

  • Broaden visibility — add your own rule (own identifier) for the same model + group. Same-model/same-group rules combine with OR (see Rule Modes), so an extra rule can only widen what those users see. Rules not present in any module’s security/ files are never re-applied.
  • Narrow visibility — you cannot tighten by adding a rule: all applicable rules OR-combine, so an extra rule only ever widens. To narrow what a group sees, tighten the rule that grants that group access — and since another module’s rule is overwritten on upgrade, own that rule in your module (grant the group its access from your module with the restriction already applied). There is no groupless “global” rule that AND-tightens across the board.
  • Ship a tunable default — mark a rule your module ships for customers to adjust apply_once: true so it seeds once and is then left alone. Reserve this for genuine defaults, not the security baseline.

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. Attach every rule to a group - Groupless rules are never loaded; gate a broad company/active filter on the base group all internal users share (core_internal)
  2. Layer group rules - More privileged groups get their own broader rule (rules OR-combine)
  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
  6. Prefer a rule on ownership over one on workflow state - Q(company__id__in=cids) or Q(user__id__eq=uid) when either would express the rule; both work, but a rule testing a field your own writes keep changing does more work when records are saved in a loop