Record Rules
Record rules provide row-level security, controlling which specific records a user can access.
Overview
Section titled “Overview”While model access controls CRUD operations on entire models, record rules filter which records within a model are visible.
Basic Record Rule
Section titled “Basic Record Rule”- 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: falseThis 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.
Rule Syntax
Section titled “Rule Syntax”Rules use Q expressions to filter records.
Eval Namespace
Section titled “Eval Namespace”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:
| Name | Binds to |
|---|---|
Q | The Q-expression builder (the only callable, alongside the delete date helper). |
uid | The current user’s id. |
cid | The 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_id | An exact alias of cid — the same current company. |
cids | A 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_id | The current portal contact id. |
gids | A 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_models | A 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 companyScoping a polymorphic parent
Section titled “Scoping a polymorphic parent”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.
Current User
Section titled “Current User”"(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.
Field Comparisons
Section titled “Field Comparisons”"(Q(active=True))" # Only active records"(Q(state='Draft'))" # Only draft records (Selection values are readable text)"(Q(amount__gt=0))" # Positive amountsCombining Conditions
Section titled “Combining Conditions”# AND conditions"(Q(active=True) & Q(user__id__eq=uid))"
# OR conditions"(Q(user__id__eq=uid) | Q(team__members__id__eq=uid))"Related Fields
Section titled “Related Fields”# Through relationships"(Q(company__id__eq=cid))" # The current company"(Q(team__team_leader__id__eq=uid))" # User is the team leaderRule Modes
Section titled “Rule Modes”Restrictive Rules (Default)
Section titled “Restrictive Rules (Default)”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.
Rules require groups
Section titled “Rules require groups”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: trueAll 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.)
Permission Scope
Section titled “Permission Scope”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| Permission | Description |
|---|---|
read_perm | Rule applies to read operations |
write_perm | Rule applies to update operations |
create_perm | Rule applies to create operations |
delete_perm | Rule applies to delete operations |
Write rules and the UI
Section titled “Write rules and the UI”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.
Common Patterns
Section titled “Common Patterns”Own Records Only
Section titled “Own Records Only”- data_type: RecordRule identifier: rule_order_own model: SaleOrder groups: - - R - - sales_user_group rule: (Q(user__id__eq=uid))Company-Based Multi-Tenancy
Section titled “Company-Based Multi-Tenancy”- data_type: RecordRule identifier: rule_contact_company model: Contact groups: - - R - - core_internal rule: (Q(company__eq=company_id) | Q(company__isnull=True))Team-Based Access
Section titled “Team-Based Access”- data_type: RecordRule identifier: rule_lead_team model: CrmLead groups: - - R - - sales_user_group rule: (Q(team__members__id__eq=uid))Manager Sees All Team Records
Section titled “Manager Sees All Team Records”- data_type: RecordRule identifier: rule_lead_manager model: CrmLead groups: - - R - - sales_manager_group rule: (Q(id__gte=0))Portal User Access
Section titled “Portal User Access”- data_type: RecordRule identifier: rule_order_portal model: SaleOrder groups: - - R - - core_portal rule: (Q(contact__eq=contact_id))Complete Security Setup
Section titled “Complete Security Setup”- 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))Upgrades & Customization
Section titled “Upgrades & Customization”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’ssecurity/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
rulethat 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: trueso it seeds once and is then left alone. Reserve this for genuine defaults, not the security baseline.
Bypassing Record Rules
Section titled “Bypassing Record Rules”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()Debugging Rules
Section titled “Debugging Rules”Enable debug logging to see applied rules:
import logginglogging.getLogger("fullfinity.security").setLevel(logging.DEBUG)Best Practices
Section titled “Best Practices”- 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) - Layer group rules - More privileged groups get their own broader rule (rules OR-combine)
- Test as different users - Verify rules work as expected
- Document complex rules - Explain the business logic
- Use meaningful names - Rule names should describe what they do
- Prefer a rule on ownership over one on workflow state -
Q(company__id__in=cids)orQ(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
Next Steps
Section titled “Next Steps”- Groups and Users - Managing groups
- Model Access - CRUD permissions
- Field-Level Security - Column-level read/write restrictions