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": [["L", "invoicing_bookkeeper"]]
},
{
"data_type": "Group",
"identifier": "invoicing_bookkeeper",
"implied_groups": [["L", "invoicing_user"]]
},
{
"data_type": "Group",
"identifier": "invoicing_user",
"implied_groups": [["L", "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
[
{
"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.

The current user’s merged permissions (the union across all their groups and implied groups) are available on the environment via env.user_data["model_access"], a list of per-model dicts ({model, read, write, create, delete}):

from fullfinity.engine.context import env_ctx
env = env_ctx.get()
access = {a["model"]: a for a in env.user_data.get("model_access", [])}
if access.get("CrmLead", {}).get("read"):
leads = await CrmLead.filter().all()

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

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.)

So inside an elevate() block the current user effectively sees and may modify every record, exactly as the admin would.

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.
  • Use it deliberately and narrowly; broad or long-lived elevation defeats the security model.
  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