Skip to content

Model Access

Model access controls define CRUD (Create, Read, Update, Delete) permissions for models.

Define which groups can perform operations on models.

- data_type: ModelAccess
identifier: access_crm_lead_user
name: CrmLead Access (User)
model: CrmLead
group: sales_user_group
read_perm: true
create_perm: true
write_perm: true
delete_perm: false

A name is required on every ModelAccess entry, model is the model class name, and group is the group’s bare identifier (no module.name dotted form).

PermissionDescription
read_permCan read/view records
create_permCan create new records
write_permCan update existing records
delete_permCan delete records
- data_type: ModelAccess
identifier: access_product_portal
name: Product Access (Portal)
model: Product
group: core_portal
read_perm: true
create_perm: false
write_perm: false
delete_perm: false
- data_type: ModelAccess
identifier: access_crm_lead_manager
name: CrmLead Access (Manager)
model: CrmLead
group: sales_manager_group
read_perm: true
create_perm: true
write_perm: true
delete_perm: true

Public (anonymous) visitors belong to the built-in core_public group. Grant guest access by targeting that group — every ModelAccess entry names a group:

- data_type: ModelAccess
identifier: access_product_public
name: Product Access (Public)
model: Product
group: core_public
read_perm: true
create_perm: false
write_perm: false
delete_perm: false

Users get the union of all applicable permissions:

- data_type: ModelAccess
identifier: access_invoice_user
name: Invoice Access (User)
model: FinancialDocument
group: invoicing_user
read_perm: true
create_perm: true
write_perm: false
delete_perm: false
- data_type: ModelAccess
identifier: access_invoice_manager
name: Invoice Access (Manager)
model: FinancialDocument
group: invoicing_admin
read_perm: true
create_perm: true
write_perm: true
delete_perm: true

A user with the invoicing_admin group can do everything because:

  • They inherit invoicing_user permissions via implied_groups
  • Plus their own manager permissions

Permissions flow through the implied_groups chain. When defining a group hierarchy like:

- data_type: Group
identifier: invoicing_admin
implied_groups:
- - link
- invoicing_bookkeeper
- data_type: Group
identifier: invoicing_bookkeeper
implied_groups:
- - link
- invoicing_user
- data_type: Group
identifier: invoicing_user
implied_groups:
- - link
- core_internal

A user in invoicing_admin automatically gets all ModelAccess permissions from:

  • invoicing_admin (direct)
  • invoicing_bookkeeper (implied)
  • invoicing_user (implied by bookkeeper)
  • core_internal (implied by user)

This means: You only need to define ModelAccess for the specific group level. Higher groups automatically inherit lower group permissions

Security records (groups, model access, record rules) live in the module’s security/ directory. Every .yaml/.yml/.json file in that directory is auto-discovered and imported — there is no data: manifest key to list them. Most modules keep all three record kinds in a single security/security.yaml:

my_module/
├── security/
│ └── security.yaml # Groups + ModelAccess + RecordRule entries
├── views/ # also auto-discovered
├── data/ # also auto-discovered
└── manifest.yaml

The manifest declares metadata and dependencies, not data files:

name: My Module
identifier: my_module
dependencies:
- core

Security records are code-is-truth: like all data records they default to apply_once: false, so every module upgrade re-applies (overwrites) each ModelAccess back to its security/ definition. This is deliberate — a permission you tighten in code always reaches every install, and no environment can silently keep a stale, looser grant.

The consequence: editing a module-shipped access rule in place does not survive an upgrade. Changing write_perm on a framework ModelAccess (in the database, or from an admin screen) is reverted on the next -u.

To customize durably, be additive instead of editing:

  • Grant more — add your own ModelAccess with your own identifier targeting the group. Permissions are a union (see Multiple Access Rules), so an extra grant widens access without touching the shipped record. Records that aren’t in any module’s security/ files are never re-applied, so your addition sticks.
  • Restrict more — you can’t subtract a grant additively; tighten the rule in the module that owns it (or don’t grant the group in the first place).
  • Ship a tunable default — if your own module ships an access rule customers are meant to adjust, mark that record apply_once: true so it’s seeded once and then left to the user. Reserve this for genuine defaults, not the security baseline — the whole point of re-applying is that the baseline can’t drift.
- data_type: ModelAccess
identifier: access_sale_order_user
name: SaleOrder Access (User)
model: SaleOrder
group: sales_user_group
read_perm: true
create_perm: true
write_perm: true
delete_perm: false
- data_type: ModelAccess
identifier: access_sale_order_manager
name: SaleOrder Access (Manager)
model: SaleOrder
group: sales_manager_group
read_perm: true
create_perm: true
write_perm: true
delete_perm: true
- data_type: ModelAccess
identifier: access_sale_order_line_user
name: SaleOrderLine Access (User)
model: SaleOrderLine
group: sales_user_group
read_perm: true
create_perm: true
write_perm: true
delete_perm: true
- data_type: ModelAccess
identifier: access_product_sales
name: Product Access (Sales User)
model: Product
group: sales_user_group
read_perm: true
create_perm: false
write_perm: false
delete_perm: false

Model access is enforced automatically by the ORM on every query and CRUD operation — you don’t call a check function before reading or writing. An unpermitted operation raises AccessError.

When you need the access question rather than the enforcement — deciding what to load or what to offer, not what to allow — ask the model:

Model.probe_access("read") # -> bool; "create" | "update" | "delete" also accepted
if get_model("CrmLead").probe_access("read"):
leads = await CrmLead.filter().all()

It answers the same question enforcement does, so the two can never disagree. Two limits to keep in mind:

  • Model level only. Record rules judge individual records, and probe_access does not consult them: True means “not barred from this model”, not “allowed on this record”.
  • Never a substitute for enforcement. The real operation is still checked. Use the probe to avoid asking for something you can’t have, not to decide whether something is permitted.

Under elevate() it always returns True, matching the bypass that block gives.

Relations you can’t read are skipped, not refused

Section titled “Relations you can’t read are skipped, not refused”

A model does not control which relations it carries: any module may add one pointing at a model it owns (installing a payroll app gives Employee a payslips collection). So when the ORM bulk-loads a record’s relations, it probes each target first and skips the ones this user may not read, rather than failing the read of the record that carries them.

The effect: reading an Employee as a user without payroll access succeeds, and the payroll relation simply isn’t loaded. Nothing is disclosed — the relation is left unloaded, output filtering drops what the user may not see, and reaching for it directly still raises. Practically, this means adding a restricted model with a relation to a shared one cannot break other apps’ reads of that shared model.

elevate() is the permission-bypass primitive — the controlled escape hatch when server code must act outside the current user’s permissions (a public website route, a background job, a system computation).

It bypasses permissions, not company isolation. If you are used to a “sudo” that lifts everything at once, this is the difference that matters: an elevated read is still restricted to the companies currently selected. See Multi-Company for what to use instead when you need to work on a specific company.

from fullfinity.engine.base import elevate
# Regular query (respects access rules)
leads = await CrmLead.filter().all()
# Elevated query (bypasses access checks)
with elevate():
leads = await CrmLead.filter().all()

What it bypasses — both layers at once:

  • Model access (ModelAccess) — the read/create/write/delete CRUD gate is skipped.
  • Record rules — no row-level rule WHERE clause is injected, and write/delete rule checks are skipped. (Controlled-edit field locks are also lifted.)

What it does NOT bypass — the multi-company filter. A query on a model with a stored company field is still restricted to the selected companies, elevated or not. So inside an elevate() block the user sees every record their company selection covers, as an admin of those companies would — not every record in the database.

This asymmetry catches people out, because writes are not company-filtered at all. A read that finds nothing followed by a write that succeeds is the classic result:

# WRONG — for a company outside the current selection, the read finds nothing and
# the elevated write creates a duplicate.
existing = await Mapping.filter(company__id=company_id, key=key).first()
if not existing:
with elevate():
await Mapping.create(company=company_id, key=key)

When you are working on a specific company — an argument, a loop over companies, a record’s own company — say so with as_company, which is about which company you are acting for rather than what you are permitted to do:

from fullfinity.engine.base import as_company, elevate
with as_company(company_id): # the read can now see this company's rows
existing = await Mapping.filter(company__id=company_id, key=key).first()
if not existing:
with elevate(): # permissions: a separate question
await Mapping.create(company=company_id, key=key)

Scope and caveats:

  • Block-scoped, not record-scoped. Elevation is in effect for the entire with elevate(): body — every ORM operation reached from inside it, including nested calls into other model methods. It is restored on exit (even on exception). Keep the block as tight as possible around the privileged operation.
  • Propagates through await. It is backed by a ContextVar, so it applies to all awaited calls in the same task and nests correctly.
  • Does not bypass licensing. Enterprise licensing gates and enterprise-field serialization are a separate mechanism — elevate() does not unlock them. A call blocked by licensing fails with HTTP 403 carrying a stable body code: ENTERPRISE_LICENSE_REQUIRED (a specialized AccessError, so existing except AccessError handlers still catch it). The app treats that specific code as a prompt to upgrade rather than a plain permission error — distinguish it from an ordinary 403 by that code.
  • Licensing failures are only explained to staff. The code above, and the message naming the licence, are sent only when an internal user is behind the request. For everyone else — an anonymous website visitor, a portal customer, someone opening an emailed link — the same 403 carries a neutral message and no code. A licence is a commercial matter between you and the operator; a visitor can neither renew one nor uninstall a module, so nothing about it is stated on a public page. Two consequences if you build public routes: a client keying on code must treat its absence as “no reason given” rather than “not a licence problem”, and if a public surface of yours needs to behave differently when unlicensed, decide that server-side — the response will not tell the browser why. Ordinary permission denials are unaffected on every surface: their messages are written for whoever is reading them and are sent unchanged.
  • Use it deliberately and narrowly; broad or long-lived elevation defeats the security model.

The static security gate (check --only security)

Section titled “The static security gate (check --only security)”

Both halves of access control fail silently, in opposite directions. A model with no ModelAccess is unusable by everyone — access is default-deny and there is no admin bypass at the model level. A model whose grant nobody scoped is readable in full by everyone the grant names. Neither raises at load time, so both are checked at build time instead, with no database:

Terminal window
./fullfinity-server check --only security # exit 1 on any violation
./fullfinity-server check --only security --module acme # scope to one module

It parses every module’s models/ and reads its security/, data/ and views/ YAML, then applies four rules:

RuleWhat it requires
A1Every persistent model has at least one ModelAccess. Transient models and ManyToMany link tables are exempt — a grant on either is never consulted.
A2No grant targets a link table, or a model name that nothing declares. Both are inert, and dead config that reads as a live grant is worse than none.
R1A model granted to core_portal or core_public carries a record rule for that group.
R2A model that names a person (a user or employee relation) and is granted read+write to a non-admin group carries some row scoping.

R1 is the one that catches real leaks. A portal user authenticates like any other user, so the generic data API is reachable to them: POST /api/query/<Model> consults the grant, and scoping written inside a portal route never runs. A portal grant without a matching record rule means every customer can read every other customer’s rows of that model, regardless of what the portal pages show.

R1 and R2 are judgment calls, and some data really is meant to be world-readable (published website pages, a public course catalog) or genuinely shared (a payroll officer works on everyone’s payslips). Those go in fullfinity/engine/security_exceptions.yaml, with a reason:

exceptions:
- model: EventTicket
rule: R1
reason: Ticket types and prices offered on the public event page.

An entry without a reason does not waive anything. The point is that “this is deliberate” becomes a line in a diff someone can disagree with, instead of an omission indistinguishable from a bug.

The gate runs in CI and from .githooks/pre-commit whenever a model or any security/data/views YAML is staged, and app-store intake runs it per submitted module with --module.

A grant is not only an enforcement rule — it is what the navigation is built from. A menu whose action opens a model is shown only to users who may read that model, and a settings tab only to users holding its module’s groups, so a missing grant no longer produces a permission error on click: it produces an entry point nobody can find. Full rules, and the static gate that catches the authoring mistakes this makes silent, are in Menu and Settings Visibility.

  1. Start restrictive - Give minimal permissions, add more as needed
  2. Use group hierarchy - Managers inherit from users
  3. Document permissions - Comment why each access rule exists
  4. Test thoroughly - Verify users can/cannot do expected operations
  5. Pair every external grant with a record rule - a core_portal/core_public grant with no rule reaches every row through the data API (see the gate above)