Skip to content

Groups and Users

Security in Fullfinity is managed through groups that control access to features and data.

Groups define sets of permissions that can be assigned to users.

Create groups in your module’s security file:

- data_type: Group
identifier: sales_user_group
name: User
category: Sales
description: "Create and edit own quotations and sales orders; read-only pricelists and templates."

The data_type is Group (the model’s verbose name is “User Group”). A group has name, category (a plain text label, not a relation), an optional description, an optional exclusive flag, and implied_groups.

Set description to a short, plain-language summary of what the group actually grants — the rights and actions it allows. It is surfaced on an info tooltip beside the group in the access picker so an admin can tell the access tiers apart (e.g. User vs Manager). Keep it grounded in the group’s real ModelAccess/record-rule grants and, for a tiered group, describe what it adds over the group it implies (“Everything a User can do, plus …”).

Groups can inherit from other groups:

- data_type: Group
identifier: sales_manager_group
name: Manager
category: Sales
implied_groups:
- - link
- sales_user_group

Users in sales_manager_group automatically get all permissions from sales_user_group.

implied_groups is a Many-to-Many relation, so it uses the relation-command form [["link", "<identifier>"]] (link = link by identifier) — not a bare list of identifier strings.

When a user belongs to a group with implied_groups, they automatically receive:

  1. All ModelAccess permissions from implied groups (union of all permissions)
  2. All RecordRule access from implied groups
  3. All menu/view visibility controlled by implied groups

Example chain:

invoicing_admin → invoicing_bookkeeper → invoicing_user → core_internal

A user in invoicing_admin gets permissions from all four groups. If core_internal has read-only access to Currency and invoicing_admin has full CRUD, the user gets full CRUD (permissions are merged with OR logic).

A database links its administrator to exactly one group when it is created, so a module installed later grants it nothing — its app is invisible to whoever installed it unless the module says so. Declare the link beside the groups it defines:

- data_type: Group
identifier: core_admin
implied_groups:
- - link
- crm_manager_group

There is no admin bypass at the model level, so this is what actually grants an administrator access to a module’s data — and, because menu and settings visibility are derived from that access, what makes the module’s menus and its settings tab appear at all. See Menu and Settings Visibility. It stays a per-module declaration because keeping a group away from administrators is sometimes the point.

Important: When defining security for a module hierarchy:

  • Base group (e.g., mymodule_user) should have minimal permissions
  • Higher groups add permissions via their own ModelAccess rules
  • Users get the union of all permissions from their groups and all implied groups
- data_type: Group
identifier: crm_user_group
name: User
category: CRM
- data_type: Group
identifier: crm_manager_group
name: Manager
category: CRM
implied_groups:
- - link
- crm_user_group

The built-in User model stores user information:

FieldTypeDescription
nameCharDisplay name
emailCharEmail (login)
hashed_passwordCharHashed password
activeBooleanAccount active
groupsManyToManyAssigned groups
# In code: assign groups via the relation-command form on update()
User = get_model("User")
user = await User.filter(id=user_id).get()
await user.update(groups=[["link", sales_manager_group_id]])

update() on the User model automatically resolves implied groups (via sync_implied_groups) so the user also receives every group reachable through implied_groups.

Create default user groups in data files:

- data_type: User
identifier: demo_user
name: Demo User
email: demo@example.com
groups:
- - link
- core_internal
- - link
- sales_user_group

Group identifiers are bare (e.g. core_internal, sales_user_group) — there is no module.name dotted form. As a Many-to-Many relation, groups takes the [["link", "<identifier>"]] link-command form.

These core groups define the user type and control route-level access:

IdentifierNameCategoryDescription
core_internalInternalUser TypeBackend users with full system access
core_portalPortalUser TypeExternal users with limited access (customers, vendors)
core_publicPublicUser TypeGuest/anonymous users

Every user should belong to exactly one of these user type groups.

Internal users have access to the full backend application. Use for employees and administrators.

Portal users are external users (customers, vendors, contacts) with restricted access. They can only see their own records and portal-specific features.

Public/guest users have minimal access. Used for anonymous website visitors.

IdentifierNameDescription
core_adminSettingsAccess to system administration and settings

category is a plain text label on the group itself — there is no separate category model or record to define. Groups that share a category string are grouped together in the UI, and (when exclusive is true, the default) are treated as mutually exclusive within that category. Built-in categories include User Type, Administration, and Others; module groups typically use the app name (e.g. Sales, Invoicing).

- data_type: Group
identifier: sales_user_group
name: User
category: Sales

The boolean exclusive flag on a group controls how it is presented and selected within its category. It defaults to true.

  • exclusive: true (default) → radio. Exclusive groups in the same category are mutually exclusive — the user-access UI renders them as a radio set, and picking one unlinks the others. This is the access-tier pattern: a category like Sales offering User / Manager where you hold exactly one tier. (Tiers still stack permissions via implied_groups — Manager implies User — but you select only the highest.)
  • exclusive: false → switch. A non-exclusive group renders as an independent toggle, so it can be granted on its own without affecting the other groups in the category. Use it for optional add-on capabilities (e.g. a “Show Full Accounting Features” flag) that aren’t part of a one-of-N tier.
- data_type: Group
identifier: account_show_full_features
name: Show Full Accounting Features
category: Invoicing
exclusive: false

The current user’s resolved group identifiers (including all implied groups) are available on the environment as group_names. Check membership with a plain in:

from fullfinity.engine.context import env_ctx
env = env_ctx.get()
group_names = env.user_data.get("group_names", [])
# Check if user has a specific group
if "sales_manager_group" in group_names:
# Manager-only logic
pass
# Check multiple groups
if any(g in group_names for g in ["sales_manager_group", "sales_admin_group"]):
pass

Use a groups list (bare identifiers) under an element’s properties to show/hide it:

- type: field
name: company
properties:
widget: DataCombo
label: Company
groups:
- core_multi_company
- data_type: UiMenu
name: Setup
identifier: sales_config_menu
parent: core_crm_menu
action: configuration_sales_setup_action
sequence: 100
groups:
- - R
- - sales_manager_group

A built-in non-exclusive core_multi_company group gates multi-company features. Add it to a group’s implied_groups to grant a user multi-company access. Company fields don’t need hand-gating — a widget bound to a relation to Company is auto-gated to this group during view composition (see Multi-Company).

- data_type: Group
name: Multi Company
identifier: core_multi_company
category: Others
exclusive: false