Model Access
Model access controls define CRUD (Create, Read, Update, Delete) permissions for models.
Access Control Lists
Section titled “Access Control Lists”Define which groups can perform operations on models.
Basic Access Definition
Section titled “Basic Access Definition”- 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: falseA
nameis required on everyModelAccessentry,modelis the model class name, andgroupis the group’s bareidentifier(nomodule.namedotted form).
Permission Types
Section titled “Permission Types”| Permission | Description |
|---|---|
read_perm | Can read/view records |
create_perm | Can create new records |
write_perm | Can update existing records |
delete_perm | Can delete records |
Access Patterns
Section titled “Access Patterns”Read-Only Access
Section titled “Read-Only Access”- 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: falseFull Access for Managers
Section titled “Full Access for Managers”- 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: truePublic / Guest Access
Section titled “Public / Guest Access”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: falseMultiple Access Rules
Section titled “Multiple Access Rules”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: trueA user with the invoicing_admin group can do everything because:
- They inherit
invoicing_userpermissions viaimplied_groups - Plus their own manager permissions
Permission Inheritance via implied_groups
Section titled “Permission Inheritance via implied_groups”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_internalA 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 File Organization
Section titled “Security File Organization”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.yamlThe manifest declares metadata and dependencies, not data files:
name: My Moduleidentifier: my_moduledependencies:- coreUpgrades & Customization
Section titled “Upgrades & Customization”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
ModelAccesswith your ownidentifiertargeting 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’ssecurity/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: trueso 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.
Complete Example
Section titled “Complete Example”- 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: falseChecking Access in Code
Section titled “Checking Access in Code”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 acceptedif 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_accessdoes not consult them:Truemeans “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.
Elevated Access (elevate())
Section titled “Elevated Access (elevate())”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 aContextVar, 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 bodycode: ENTERPRISE_LICENSE_REQUIRED(a specializedAccessError, so existingexcept AccessErrorhandlers 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 thatcode. - Licensing failures are only explained to staff. The
codeabove, 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 nocode. 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 oncodemust 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:
./fullfinity-server check --only security # exit 1 on any violation./fullfinity-server check --only security --module acme # scope to one moduleIt parses every module’s models/ and reads its security/, data/ and views/ YAML, then
applies four rules:
| Rule | What it requires |
|---|---|
| A1 | Every persistent model has at least one ModelAccess. Transient models and ManyToMany link tables are exempt — a grant on either is never consulted. |
| A2 | No 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. |
| R1 | A model granted to core_portal or core_public carries a record rule for that group. |
| R2 | A 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.
Read access decides what is SHOWN, too
Section titled “Read access decides what is SHOWN, too”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.
Best Practices
Section titled “Best Practices”- Start restrictive - Give minimal permissions, add more as needed
- Use group hierarchy - Managers inherit from users
- Document permissions - Comment why each access rule exists
- Test thoroughly - Verify users can/cannot do expected operations
- Pair every external grant with a record rule - a
core_portal/core_publicgrant with no rule reaches every row through the data API (see the gate above)
Next Steps
Section titled “Next Steps”- Field-Level Security - Column-level read/write restrictions
- Record Rules - Row-level security
- Groups and Users - Group management
- Menu and Settings Visibility - How access decides what is shown